Compare commits
92
Commits
539117b409
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70770236eb | ||
|
|
2179744ddf | ||
|
|
da7132d4ce | ||
|
|
c86803bbd6 | ||
|
|
528af07890 | ||
|
|
a2dcc4df65 | ||
|
|
86334c2752 | ||
|
|
3346b51b21 | ||
|
|
d1111983b0 | ||
|
|
27292470f6 | ||
|
|
47dff5a1ca | ||
|
|
93d6bd9127 | ||
|
|
213dd10dbd | ||
|
|
a31a235422 | ||
|
|
d320a4fd7a | ||
|
|
a0d8c62082 | ||
|
|
43d0fc5578 | ||
|
|
d7de0d92cf | ||
|
|
25bdb6f34b | ||
|
|
f4b57abd93 | ||
|
|
2fd606a3aa | ||
|
|
9f9e4c7cd0 | ||
|
|
7df9c1fb4e | ||
|
|
9b309bb942 | ||
|
|
7a52099a59 | ||
|
|
b244451c42 | ||
|
|
1d53b66e3f | ||
|
|
6cd7fcc079 | ||
|
|
3f6866e075 | ||
|
|
d24ee9bf91 | ||
|
|
a93e067e04 | ||
|
|
0f263c3a19 | ||
|
|
39f662d541 | ||
|
|
52b1726698 | ||
|
|
3f564c361f | ||
|
|
fb4dcf96fd | ||
|
|
c6a03c1a38 | ||
|
|
143d7efc97 | ||
|
|
d18c55eef5 | ||
|
|
064c872c30 | ||
|
|
4acd4a2d40 | ||
|
|
3e24910831 | ||
|
|
eec7a2fef2 | ||
|
|
dd6e1e77b0 | ||
|
|
ea7d9a4714 | ||
|
|
c16b105f00 | ||
|
|
bafa5e7e61 | ||
|
|
dd2277c803 | ||
|
|
ef5d0e1822 | ||
|
|
c4cb12b26e | ||
|
|
9871401fbc | ||
|
|
c9ab82e97a | ||
|
|
e64370aea0 | ||
|
|
6127d84d11 | ||
|
|
0551b13a86 | ||
|
|
f0869d8e33 | ||
|
|
5e6262b6a8 | ||
|
|
7630a35bf4 | ||
|
|
a02fd3e4a4 | ||
|
|
a74a7f5ee8 | ||
|
|
e40955fffe | ||
|
|
0416504d6a | ||
|
|
b6199623c5 | ||
|
|
cb799c7c66 | ||
|
|
d2330c81cd | ||
|
|
73d599f85b | ||
|
|
80da042cae | ||
|
|
d642814fb5 | ||
|
|
ade1fd0ed9 | ||
|
|
d08621b616 | ||
|
|
2e44bb208d | ||
|
|
cbe6547f3a | ||
|
|
8b0dcd2c70 | ||
|
|
d176619e1d | ||
|
|
3fc65812ef | ||
|
|
f60c0bb85e | ||
|
|
f3045dbab9 | ||
|
|
3917a11db0 | ||
|
|
54217d2acb | ||
|
|
7b517bcbf6 | ||
|
|
536ef2ead2 | ||
|
|
d5195e7f9f | ||
|
|
81dfff51a0 | ||
|
|
92e124753f | ||
|
|
4d5f32ae6d | ||
|
|
aeb687f42e | ||
|
|
dcc8dc3421 | ||
|
|
de3dcee7ec | ||
|
|
968a55f442 | ||
|
|
aa9444f076 | ||
|
|
078f28551d | ||
|
|
8688cc78db |
@@ -1,4 +1,8 @@
|
||||
.env.local
|
||||
env.local.php
|
||||
.local/
|
||||
uploads/
|
||||
var/
|
||||
.vscode/
|
||||
|
||||
sync_config.jsonc
|
||||
@@ -0,0 +1,64 @@
|
||||
# Nur *.php im Webroot, assets/ und versionierte Rechtstexte sollen direkt
|
||||
# aufrufbar sein. Interne
|
||||
# Verzeichnisse (Sessions, Mail-Logs, Migrationen, CLI-Skripte, Doku,
|
||||
# App-Bausteine, Legacy-DB-Shim, Vendor-Bibliotheken), die Git-Metadaten und
|
||||
# Konfigurationsdateien mit Zugangsdaten duerfen nicht direkt per URL
|
||||
# abrufbar sein.
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# Alles auf HTTPS. Session-Cookies werden nur ueber HTTPS mit dem
|
||||
# secure-Flag gesetzt (app_start_session()), und HSTS wird ebenfalls nur
|
||||
# dann gesendet - ohne Redirect kaeme ein Nutzer per http an und bekaeme
|
||||
# eine Session ohne secure-Flag. X-Forwarded-Proto deckt den Fall ab,
|
||||
# dass Apache hinter dem Plesk-nginx-Proxy laeuft.
|
||||
RewriteCond %{HTTPS} !=on
|
||||
RewriteCond %{HTTP:X-Forwarded-Proto} !=https
|
||||
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||
|
||||
RewriteCond %{REQUEST_URI} ^/(var|database|scripts|docs|app|lib)/ [NC]
|
||||
RewriteRule ^ - [F]
|
||||
|
||||
# Rechtstexte sind als speicherbare Textfassung absichtlich öffentlich;
|
||||
# alle anderen/versehentlich abgelegten Dateien im Ordner bleiben gesperrt.
|
||||
RewriteCond %{REQUEST_URI} ^/legal/ [NC]
|
||||
RewriteCond %{REQUEST_URI} !^/legal/(agb|datenschutz|avv|widerruf)-[0-9]{4}-[0-9]{2}-[0-9]{2}\.txt$ [NC]
|
||||
RewriteRule ^ - [F]
|
||||
|
||||
# Vendor-Verzeichnisse werden ausschliesslich serverseitig eingebunden
|
||||
# (TCPDF ueber exportKaffeeliste.php); direkte URL-Aufrufe ihrer PHP-
|
||||
# Dateien sind nie erwuenscht.
|
||||
RewriteCond %{REQUEST_URI} ^/(TCPDF|PHPMailer|DataTables)/ [NC]
|
||||
RewriteRule ^ - [F]
|
||||
|
||||
# .git waere sonst komplett herunterladbar, falls es je mit deployt wird.
|
||||
RewriteCond %{REQUEST_URI} ^/\.(git|claude|local)(/|$) [NC]
|
||||
RewriteRule ^ - [F]
|
||||
|
||||
# Konventionelle /sitemap.xml auf die dynamische, host-korrekte Sitemap.
|
||||
RewriteRule ^sitemap\.xml$ sitemap.php [L]
|
||||
</IfModule>
|
||||
|
||||
# Konfigurations- und Deploy-Dateien mit Zugangsdaten. sync_config.jsonc
|
||||
# enthaelt das FTP-Passwort im Klartext und darf niemals ausgeliefert werden,
|
||||
# auch nicht versehentlich mit hochgeladen.
|
||||
<FilesMatch "^(env\.local\.php|env\.local\.example\.php|\.env.*|sync_config\.jsonc|composer\.(json|lock))$">
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order allow,deny
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
|
||||
<FilesMatch "\.(sql|md|jsonc|yml|yaml|sh|log)$">
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order allow,deny
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
@@ -1,46 +0,0 @@
|
||||
GPL Cooperation Commitment
|
||||
Version 1.0
|
||||
|
||||
Before filing or continuing to prosecute any legal proceeding or claim
|
||||
(other than a Defensive Action) arising from termination of a Covered
|
||||
License, we commit to extend to the person or entity ('you') accused
|
||||
of violating the Covered License the following provisions regarding
|
||||
cure and reinstatement, taken from GPL version 3. As used here, the
|
||||
term 'this License' refers to the specific Covered License being
|
||||
enforced.
|
||||
|
||||
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.
|
||||
|
||||
We intend this Commitment to be irrevocable, and binding and
|
||||
enforceable against us and assignees of or successors to our
|
||||
copyrights.
|
||||
|
||||
Definitions
|
||||
|
||||
'Covered License' means the GNU General Public License, version 2
|
||||
(GPLv2), the GNU Lesser General Public License, version 2.1
|
||||
(LGPLv2.1), or the GNU Library General Public License, version 2
|
||||
(LGPLv2), all as published by the Free Software Foundation.
|
||||
|
||||
'Defensive Action' means a legal proceeding or claim that We bring
|
||||
against you in response to a prior proceeding or claim initiated by
|
||||
you or your affiliate.
|
||||
|
||||
'We' means each contributor to this repository as of the date of
|
||||
inclusion of this file, including subsidiaries of a corporate
|
||||
contributor.
|
||||
|
||||
This work is available under a Creative Commons Attribution-ShareAlike
|
||||
4.0 International license (https://creativecommons.org/licenses/by-sa/4.0/).
|
||||
@@ -1,502 +0,0 @@
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 2.1, February 1999
|
||||
|
||||
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
[This is the first released version of the Lesser GPL. It also counts
|
||||
as the successor of the GNU Library Public License, version 2, hence
|
||||
the version number 2.1.]
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
Licenses are intended to guarantee your freedom to share and change
|
||||
free software--to make sure the software is free for all its users.
|
||||
|
||||
This license, the Lesser General Public License, applies to some
|
||||
specially designated software packages--typically libraries--of the
|
||||
Free Software Foundation and other authors who decide to use it. You
|
||||
can use it too, but we suggest you first think carefully about whether
|
||||
this license or the ordinary General Public License is the better
|
||||
strategy to use in any particular case, based on the explanations below.
|
||||
|
||||
When we speak of free software, we are referring to freedom of use,
|
||||
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 this service if you wish); that you receive source code or can get
|
||||
it if you want it; that you can change the software and use pieces of
|
||||
it in new free programs; and that you are informed that you can do
|
||||
these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
distributors to deny you these rights or to ask you to surrender these
|
||||
rights. These restrictions translate to certain responsibilities for
|
||||
you if you distribute copies of the library or if you modify it.
|
||||
|
||||
For example, if you distribute copies of the library, whether gratis
|
||||
or for a fee, you must give the recipients all the rights that we gave
|
||||
you. You must make sure that they, too, receive or can get the source
|
||||
code. If you link other code with the library, you must provide
|
||||
complete object files to the recipients, so that they can relink them
|
||||
with the library after making changes to the library and recompiling
|
||||
it. And you must show them these terms so they know their rights.
|
||||
|
||||
We protect your rights with a two-step method: (1) we copyright the
|
||||
library, and (2) we offer you this license, which gives you legal
|
||||
permission to copy, distribute and/or modify the library.
|
||||
|
||||
To protect each distributor, we want to make it very clear that
|
||||
there is no warranty for the free library. Also, if the library is
|
||||
modified by someone else and passed on, the recipients should know
|
||||
that what they have is not the original version, so that the original
|
||||
author's reputation will not be affected by problems that might be
|
||||
introduced by others.
|
||||
|
||||
Finally, software patents pose a constant threat to the existence of
|
||||
any free program. We wish to make sure that a company cannot
|
||||
effectively restrict the users of a free program by obtaining a
|
||||
restrictive license from a patent holder. Therefore, we insist that
|
||||
any patent license obtained for a version of the library must be
|
||||
consistent with the full freedom of use specified in this license.
|
||||
|
||||
Most GNU software, including some libraries, is covered by the
|
||||
ordinary GNU General Public License. This license, the GNU Lesser
|
||||
General Public License, applies to certain designated libraries, and
|
||||
is quite different from the ordinary General Public License. We use
|
||||
this license for certain libraries in order to permit linking those
|
||||
libraries into non-free programs.
|
||||
|
||||
When a program is linked with a library, whether statically or using
|
||||
a shared library, the combination of the two is legally speaking a
|
||||
combined work, a derivative of the original library. The ordinary
|
||||
General Public License therefore permits such linking only if the
|
||||
entire combination fits its criteria of freedom. The Lesser General
|
||||
Public License permits more lax criteria for linking other code with
|
||||
the library.
|
||||
|
||||
We call this license the "Lesser" General Public License because it
|
||||
does Less to protect the user's freedom than the ordinary General
|
||||
Public License. It also provides other free software developers Less
|
||||
of an advantage over competing non-free programs. These disadvantages
|
||||
are the reason we use the ordinary General Public License for many
|
||||
libraries. However, the Lesser license provides advantages in certain
|
||||
special circumstances.
|
||||
|
||||
For example, on rare occasions, there may be a special need to
|
||||
encourage the widest possible use of a certain library, so that it becomes
|
||||
a de-facto standard. To achieve this, non-free programs must be
|
||||
allowed to use the library. A more frequent case is that a free
|
||||
library does the same job as widely used non-free libraries. In this
|
||||
case, there is little to gain by limiting the free library to free
|
||||
software only, so we use the Lesser General Public License.
|
||||
|
||||
In other cases, permission to use a particular library in non-free
|
||||
programs enables a greater number of people to use a large body of
|
||||
free software. For example, permission to use the GNU C Library in
|
||||
non-free programs enables many more people to use the whole GNU
|
||||
operating system, as well as its variant, the GNU/Linux operating
|
||||
system.
|
||||
|
||||
Although the Lesser General Public License is Less protective of the
|
||||
users' freedom, it does ensure that the user of a program that is
|
||||
linked with the Library has the freedom and the wherewithal to run
|
||||
that program using a modified version of the Library.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow. Pay close attention to the difference between a
|
||||
"work based on the library" and a "work that uses the library". The
|
||||
former contains code derived from the library, whereas the latter must
|
||||
be combined with the library in order to run.
|
||||
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License Agreement applies to any software library or other
|
||||
program which contains a notice placed by the copyright holder or
|
||||
other authorized party saying it may be distributed under the terms of
|
||||
this Lesser General Public License (also called "this License").
|
||||
Each licensee is addressed as "you".
|
||||
|
||||
A "library" means a collection of software functions and/or data
|
||||
prepared so as to be conveniently linked with application programs
|
||||
(which use some of those functions and data) to form executables.
|
||||
|
||||
The "Library", below, refers to any such software library or work
|
||||
which has been distributed under these terms. A "work based on the
|
||||
Library" means either the Library or any derivative work under
|
||||
copyright law: that is to say, a work containing the Library or a
|
||||
portion of it, either verbatim or with modifications and/or translated
|
||||
straightforwardly into another language. (Hereinafter, translation is
|
||||
included without limitation in the term "modification".)
|
||||
|
||||
"Source code" for a work means the preferred form of the work for
|
||||
making modifications to it. For a library, complete source code means
|
||||
all the source code for all modules it contains, plus any associated
|
||||
interface definition files, plus the scripts used to control compilation
|
||||
and installation of the library.
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running a program using the Library is not restricted, and output from
|
||||
such a program is covered only if its contents constitute a work based
|
||||
on the Library (independent of the use of the Library in a tool for
|
||||
writing it). Whether that is true depends on what the Library does
|
||||
and what the program that uses the Library does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Library's
|
||||
complete source code as you receive it, in any medium, provided that
|
||||
you conspicuously and appropriately publish on each copy an
|
||||
appropriate copyright notice and disclaimer of warranty; keep intact
|
||||
all the notices that refer to this License and to the absence of any
|
||||
warranty; and distribute a copy of this License along with the
|
||||
Library.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy,
|
||||
and you may at your option offer warranty protection in exchange for a
|
||||
fee.
|
||||
|
||||
2. You may modify your copy or copies of the Library or any portion
|
||||
of it, thus forming a work based on the Library, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) The modified work must itself be a software library.
|
||||
|
||||
b) You must cause the files modified to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
c) You must cause the whole of the work to be licensed at no
|
||||
charge to all third parties under the terms of this License.
|
||||
|
||||
d) If a facility in the modified Library refers to a function or a
|
||||
table of data to be supplied by an application program that uses
|
||||
the facility, other than as an argument passed when the facility
|
||||
is invoked, then you must make a good faith effort to ensure that,
|
||||
in the event an application does not supply such function or
|
||||
table, the facility still operates, and performs whatever part of
|
||||
its purpose remains meaningful.
|
||||
|
||||
(For example, a function in a library to compute square roots has
|
||||
a purpose that is entirely well-defined independent of the
|
||||
application. Therefore, Subsection 2d requires that any
|
||||
application-supplied function or table used by this function must
|
||||
be optional: if the application does not supply it, the square
|
||||
root function must still compute square roots.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Library,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Library, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote
|
||||
it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Library.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Library
|
||||
with the Library (or with a work based on the Library) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may opt to apply the terms of the ordinary GNU General Public
|
||||
License instead of this License to a given copy of the Library. To do
|
||||
this, you must alter all the notices that refer to this License, so
|
||||
that they refer to the ordinary GNU General Public License, version 2,
|
||||
instead of to this License. (If a newer version than version 2 of the
|
||||
ordinary GNU General Public License has appeared, then you can specify
|
||||
that version instead if you wish.) Do not make any other change in
|
||||
these notices.
|
||||
|
||||
Once this change is made in a given copy, it is irreversible for
|
||||
that copy, so the ordinary GNU General Public License applies to all
|
||||
subsequent copies and derivative works made from that copy.
|
||||
|
||||
This option is useful when you wish to copy part of the code of
|
||||
the Library into a program that is not a library.
|
||||
|
||||
4. You may copy and distribute the Library (or a portion or
|
||||
derivative of it, under Section 2) in object code or executable form
|
||||
under the terms of Sections 1 and 2 above provided that you accompany
|
||||
it with the complete corresponding machine-readable source code, which
|
||||
must be distributed under the terms of Sections 1 and 2 above on a
|
||||
medium customarily used for software interchange.
|
||||
|
||||
If distribution of object code is made by offering access to copy
|
||||
from a designated place, then offering equivalent access to copy the
|
||||
source code from the same place satisfies the requirement to
|
||||
distribute the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
5. A program that contains no derivative of any portion of the
|
||||
Library, but is designed to work with the Library by being compiled or
|
||||
linked with it, is called a "work that uses the Library". Such a
|
||||
work, in isolation, is not a derivative work of the Library, and
|
||||
therefore falls outside the scope of this License.
|
||||
|
||||
However, linking a "work that uses the Library" with the Library
|
||||
creates an executable that is a derivative of the Library (because it
|
||||
contains portions of the Library), rather than a "work that uses the
|
||||
library". The executable is therefore covered by this License.
|
||||
Section 6 states terms for distribution of such executables.
|
||||
|
||||
When a "work that uses the Library" uses material from a header file
|
||||
that is part of the Library, the object code for the work may be a
|
||||
derivative work of the Library even though the source code is not.
|
||||
Whether this is true is especially significant if the work can be
|
||||
linked without the Library, or if the work is itself a library. The
|
||||
threshold for this to be true is not precisely defined by law.
|
||||
|
||||
If such an object file uses only numerical parameters, data
|
||||
structure layouts and accessors, and small macros and small inline
|
||||
functions (ten lines or less in length), then the use of the object
|
||||
file is unrestricted, regardless of whether it is legally a derivative
|
||||
work. (Executables containing this object code plus portions of the
|
||||
Library will still fall under Section 6.)
|
||||
|
||||
Otherwise, if the work is a derivative of the Library, you may
|
||||
distribute the object code for the work under the terms of Section 6.
|
||||
Any executables containing that work also fall under Section 6,
|
||||
whether or not they are linked directly with the Library itself.
|
||||
|
||||
6. As an exception to the Sections above, you may also combine or
|
||||
link a "work that uses the Library" with the Library to produce a
|
||||
work containing portions of the Library, and distribute that work
|
||||
under terms of your choice, provided that the terms permit
|
||||
modification of the work for the customer's own use and reverse
|
||||
engineering for debugging such modifications.
|
||||
|
||||
You must give prominent notice with each copy of the work that the
|
||||
Library is used in it and that the Library and its use are covered by
|
||||
this License. You must supply a copy of this License. If the work
|
||||
during execution displays copyright notices, you must include the
|
||||
copyright notice for the Library among them, as well as a reference
|
||||
directing the user to the copy of this License. Also, you must do one
|
||||
of these things:
|
||||
|
||||
a) Accompany the work with the complete corresponding
|
||||
machine-readable source code for the Library including whatever
|
||||
changes were used in the work (which must be distributed under
|
||||
Sections 1 and 2 above); and, if the work is an executable linked
|
||||
with the Library, with the complete machine-readable "work that
|
||||
uses the Library", as object code and/or source code, so that the
|
||||
user can modify the Library and then relink to produce a modified
|
||||
executable containing the modified Library. (It is understood
|
||||
that the user who changes the contents of definitions files in the
|
||||
Library will not necessarily be able to recompile the application
|
||||
to use the modified definitions.)
|
||||
|
||||
b) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (1) uses at run time a
|
||||
copy of the library already present on the user's computer system,
|
||||
rather than copying library functions into the executable, and (2)
|
||||
will operate properly with a modified version of the library, if
|
||||
the user installs one, as long as the modified version is
|
||||
interface-compatible with the version that the work was made with.
|
||||
|
||||
c) Accompany the work with a written offer, valid for at
|
||||
least three years, to give the same user the materials
|
||||
specified in Subsection 6a, above, for a charge no more
|
||||
than the cost of performing this distribution.
|
||||
|
||||
d) If distribution of the work is made by offering access to copy
|
||||
from a designated place, offer equivalent access to copy the above
|
||||
specified materials from the same place.
|
||||
|
||||
e) Verify that the user has already received a copy of these
|
||||
materials or that you have already sent this user a copy.
|
||||
|
||||
For an executable, the required form of the "work that uses the
|
||||
Library" must include any data and utility programs needed for
|
||||
reproducing the executable from it. However, as a special exception,
|
||||
the materials to be distributed need not include anything that is
|
||||
normally distributed (in either source or binary form) with the major
|
||||
components (compiler, kernel, and so on) of the operating system on
|
||||
which the executable runs, unless that component itself accompanies
|
||||
the executable.
|
||||
|
||||
It may happen that this requirement contradicts the license
|
||||
restrictions of other proprietary libraries that do not normally
|
||||
accompany the operating system. Such a contradiction means you cannot
|
||||
use both them and the Library together in an executable that you
|
||||
distribute.
|
||||
|
||||
7. You may place library facilities that are a work based on the
|
||||
Library side-by-side in a single library together with other library
|
||||
facilities not covered by this License, and distribute such a combined
|
||||
library, provided that the separate distribution of the work based on
|
||||
the Library and of the other library facilities is otherwise
|
||||
permitted, and provided that you do these two things:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work
|
||||
based on the Library, uncombined with any other library
|
||||
facilities. This must be distributed under the terms of the
|
||||
Sections above.
|
||||
|
||||
b) Give prominent notice with the combined library of the fact
|
||||
that part of it is a work based on the Library, and explaining
|
||||
where to find the accompanying uncombined form of the same work.
|
||||
|
||||
8. You may not copy, modify, sublicense, link with, or distribute
|
||||
the Library except as expressly provided under this License. Any
|
||||
attempt otherwise to copy, modify, sublicense, link with, or
|
||||
distribute the Library is void, and will automatically terminate your
|
||||
rights under this License. However, parties who have received copies,
|
||||
or rights, from you under this License will not have their licenses
|
||||
terminated so long as such parties remain in full compliance.
|
||||
|
||||
9. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Library or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Library (or any work based on the
|
||||
Library), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Library or works based on it.
|
||||
|
||||
10. Each time you redistribute the Library (or any work based on the
|
||||
Library), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute, link with or modify the Library
|
||||
subject to these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties with
|
||||
this License.
|
||||
|
||||
11. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
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
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Library at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Library by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Library.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under any
|
||||
particular circumstance, the balance of the section is intended to apply,
|
||||
and the section as a whole is intended to apply in other circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
12. If the distribution and/or use of the Library is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Library under this License may add
|
||||
an explicit geographical distribution limitation excluding those countries,
|
||||
so that distribution is permitted only in or among countries not thus
|
||||
excluded. In such case, this License incorporates the limitation as if
|
||||
written in the body of this License.
|
||||
|
||||
13. The Free Software Foundation may publish revised and/or new
|
||||
versions of the Lesser 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 Library
|
||||
specifies a version number of this License which applies to it and
|
||||
"any later version", you have the option of following the terms and
|
||||
conditions either of that version or of any later version published by
|
||||
the Free Software Foundation. If the Library does not specify a
|
||||
license version number, you may choose any version ever published by
|
||||
the Free Software Foundation.
|
||||
|
||||
14. If you wish to incorporate parts of the Library into other free
|
||||
programs whose distribution conditions are incompatible with these,
|
||||
write to the author to ask for permission. For software which is
|
||||
copyrighted by the Free Software Foundation, write to the Free
|
||||
Software Foundation; we sometimes make exceptions for this. Our
|
||||
decision will be guided by the two goals of preserving the free status
|
||||
of all derivatives of our free software and of promoting the sharing
|
||||
and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
|
||||
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
|
||||
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
|
||||
OTHER PARTIES PROVIDE THE LIBRARY "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
|
||||
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
|
||||
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
|
||||
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
|
||||
AND/OR REDISTRIBUTE THE LIBRARY 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
|
||||
LIBRARY (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 LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
|
||||
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
|
||||
DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Libraries
|
||||
|
||||
If you develop a new library, and you want it to be of the greatest
|
||||
possible use to the public, we recommend making it free software that
|
||||
everyone can redistribute and change. You can do so by permitting
|
||||
redistribution under these terms (or, alternatively, under the terms of the
|
||||
ordinary General Public License).
|
||||
|
||||
To apply these terms, attach the following notices to the library. It is
|
||||
safest to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least the
|
||||
"copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the library's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library 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
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the library, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the
|
||||
library `Frob' (a library for tweaking knobs) written by James Random Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1990
|
||||
Ty Coon, President of Vice
|
||||
|
||||
That's all there is to it!
|
||||
@@ -1,79 +0,0 @@
|
||||
{
|
||||
"name": "phpmailer/phpmailer",
|
||||
"type": "library",
|
||||
"description": "PHPMailer is a full-featured email creation and transfer class for PHP",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Marcus Bointon",
|
||||
"email": "phpmailer@synchromedia.co.uk"
|
||||
},
|
||||
{
|
||||
"name": "Jim Jagielski",
|
||||
"email": "jimjag@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Andy Prevost",
|
||||
"email": "codeworxtech@users.sourceforge.net"
|
||||
},
|
||||
{
|
||||
"name": "Brent R. Matzelle"
|
||||
}
|
||||
],
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Synchro",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": true
|
||||
}
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.5.0",
|
||||
"ext-ctype": "*",
|
||||
"ext-filter": "*",
|
||||
"ext-hash": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
|
||||
"doctrine/annotations": "^1.2.6 || ^1.13.3",
|
||||
"php-parallel-lint/php-console-highlighter": "^1.0.0",
|
||||
"php-parallel-lint/php-parallel-lint": "^1.3.2",
|
||||
"phpcompatibility/php-compatibility": "^9.3.5",
|
||||
"roave/security-advisories": "dev-latest",
|
||||
"squizlabs/php_codesniffer": "^3.7.2",
|
||||
"yoast/phpunit-polyfills": "^1.0.4"
|
||||
},
|
||||
"suggest": {
|
||||
"decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication",
|
||||
"ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
|
||||
"ext-openssl": "Needed for secure SMTP sending and DKIM signing",
|
||||
"greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication",
|
||||
"hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication",
|
||||
"league/oauth2-google": "Needed for Google XOAUTH2 authentication",
|
||||
"psr/log": "For optional PSR-3 debug logging",
|
||||
"thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication",
|
||||
"symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PHPMailer\\PHPMailer\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"PHPMailer\\Test\\": "test/"
|
||||
}
|
||||
},
|
||||
"license": "LGPL-2.1-only",
|
||||
"scripts": {
|
||||
"check": "./vendor/bin/phpcs",
|
||||
"test": "./vendor/bin/phpunit --no-coverage",
|
||||
"coverage": "./vendor/bin/phpunit",
|
||||
"lint": [
|
||||
"@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . --show-deprecated -e php,phps --exclude vendor --exclude .git --exclude build"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* PHPMailer - PHP email creation and transport class.
|
||||
* PHP Version 5.5
|
||||
* @package PHPMailer
|
||||
* @see https://github.com/PHPMailer/PHPMailer/ The PHPMailer GitHub project
|
||||
* @author Marcus Bointon (Synchro/coolbru) <phpmailer@synchromedia.co.uk>
|
||||
* @author Jim Jagielski (jimjag) <jimjag@gmail.com>
|
||||
* @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
|
||||
* @author Brent R. Matzelle (original founder)
|
||||
* @copyright 2012 - 2020 Marcus Bointon
|
||||
* @copyright 2010 - 2012 Jim Jagielski
|
||||
* @copyright 2004 - 2009 Andy Prevost
|
||||
* @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
|
||||
* @note This program is distributed in the hope that it will be useful - WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get an OAuth2 token from an OAuth2 provider.
|
||||
* * Install this script on your server so that it's accessible
|
||||
* as [https/http]://<yourdomain>/<folder>/get_oauth_token.php
|
||||
* e.g.: http://localhost/phpmailer/get_oauth_token.php
|
||||
* * Ensure dependencies are installed with 'composer install'
|
||||
* * Set up an app in your Google/Yahoo/Microsoft account
|
||||
* * Set the script address as the app's redirect URL
|
||||
* If no refresh token is obtained when running this file,
|
||||
* revoke access to your app and run the script again.
|
||||
*/
|
||||
|
||||
namespace PHPMailer\PHPMailer;
|
||||
|
||||
/**
|
||||
* Aliases for League Provider Classes
|
||||
* Make sure you have added these to your composer.json and run `composer install`
|
||||
* Plenty to choose from here:
|
||||
* @see http://oauth2-client.thephpleague.com/providers/thirdparty/
|
||||
*/
|
||||
//@see https://github.com/thephpleague/oauth2-google
|
||||
use League\OAuth2\Client\Provider\Google;
|
||||
//@see https://packagist.org/packages/hayageek/oauth2-yahoo
|
||||
use Hayageek\OAuth2\Client\Provider\Yahoo;
|
||||
//@see https://github.com/stevenmaguire/oauth2-microsoft
|
||||
use Stevenmaguire\OAuth2\Client\Provider\Microsoft;
|
||||
//@see https://github.com/greew/oauth2-azure-provider
|
||||
use Greew\OAuth2\Client\Provider\Azure;
|
||||
|
||||
if (!isset($_GET['code']) && !isset($_POST['provider'])) {
|
||||
?>
|
||||
<html>
|
||||
<body>
|
||||
<form method="post">
|
||||
<h1>Select Provider</h1>
|
||||
<input type="radio" name="provider" value="Google" id="providerGoogle">
|
||||
<label for="providerGoogle">Google</label><br>
|
||||
<input type="radio" name="provider" value="Yahoo" id="providerYahoo">
|
||||
<label for="providerYahoo">Yahoo</label><br>
|
||||
<input type="radio" name="provider" value="Microsoft" id="providerMicrosoft">
|
||||
<label for="providerMicrosoft">Microsoft</label><br>
|
||||
<input type="radio" name="provider" value="Azure" id="providerAzure">
|
||||
<label for="providerAzure">Azure</label><br>
|
||||
<h1>Enter id and secret</h1>
|
||||
<p>These details are obtained by setting up an app in your provider's developer console.
|
||||
</p>
|
||||
<p>ClientId: <input type="text" name="clientId"><p>
|
||||
<p>ClientSecret: <input type="text" name="clientSecret"></p>
|
||||
<p>TenantID (only relevant for Azure): <input type="text" name="tenantId"></p>
|
||||
<input type="submit" value="Continue">
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
exit;
|
||||
}
|
||||
|
||||
require 'vendor/autoload.php';
|
||||
|
||||
session_start();
|
||||
|
||||
$providerName = '';
|
||||
$clientId = '';
|
||||
$clientSecret = '';
|
||||
$tenantId = '';
|
||||
|
||||
if (array_key_exists('provider', $_POST)) {
|
||||
$providerName = $_POST['provider'];
|
||||
$clientId = $_POST['clientId'];
|
||||
$clientSecret = $_POST['clientSecret'];
|
||||
$tenantId = $_POST['tenantId'];
|
||||
$_SESSION['provider'] = $providerName;
|
||||
$_SESSION['clientId'] = $clientId;
|
||||
$_SESSION['clientSecret'] = $clientSecret;
|
||||
$_SESSION['tenantId'] = $tenantId;
|
||||
} elseif (array_key_exists('provider', $_SESSION)) {
|
||||
$providerName = $_SESSION['provider'];
|
||||
$clientId = $_SESSION['clientId'];
|
||||
$clientSecret = $_SESSION['clientSecret'];
|
||||
$tenantId = $_SESSION['tenantId'];
|
||||
}
|
||||
|
||||
//If you don't want to use the built-in form, set your client id and secret here
|
||||
//$clientId = 'RANDOMCHARS-----duv1n2.apps.googleusercontent.com';
|
||||
//$clientSecret = 'RANDOMCHARS-----lGyjPcRtvP';
|
||||
|
||||
//If this automatic URL doesn't work, set it yourself manually to the URL of this script
|
||||
$redirectUri = (isset($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['PHP_SELF'];
|
||||
//$redirectUri = 'http://localhost/PHPMailer/redirect';
|
||||
|
||||
$params = [
|
||||
'clientId' => $clientId,
|
||||
'clientSecret' => $clientSecret,
|
||||
'redirectUri' => $redirectUri,
|
||||
'accessType' => 'offline'
|
||||
];
|
||||
|
||||
$options = [];
|
||||
$provider = null;
|
||||
|
||||
switch ($providerName) {
|
||||
case 'Google':
|
||||
$provider = new Google($params);
|
||||
$options = [
|
||||
'scope' => [
|
||||
'https://mail.google.com/'
|
||||
]
|
||||
];
|
||||
break;
|
||||
case 'Yahoo':
|
||||
$provider = new Yahoo($params);
|
||||
break;
|
||||
case 'Microsoft':
|
||||
$provider = new Microsoft($params);
|
||||
$options = [
|
||||
'scope' => [
|
||||
'wl.imap',
|
||||
'wl.offline_access'
|
||||
]
|
||||
];
|
||||
break;
|
||||
case 'Azure':
|
||||
$params['tenantId'] = $tenantId;
|
||||
|
||||
$provider = new Azure($params);
|
||||
$options = [
|
||||
'scope' => [
|
||||
'https://outlook.office.com/SMTP.Send',
|
||||
'offline_access'
|
||||
]
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
if (null === $provider) {
|
||||
exit('Provider missing');
|
||||
}
|
||||
|
||||
if (!isset($_GET['code'])) {
|
||||
//If we don't have an authorization code then get one
|
||||
$authUrl = $provider->getAuthorizationUrl($options);
|
||||
$_SESSION['oauth2state'] = $provider->getState();
|
||||
header('Location: ' . $authUrl);
|
||||
exit;
|
||||
//Check given state against previously stored one to mitigate CSRF attack
|
||||
} elseif (empty($_GET['state']) || ($_GET['state'] !== $_SESSION['oauth2state'])) {
|
||||
unset($_SESSION['oauth2state']);
|
||||
unset($_SESSION['provider']);
|
||||
exit('Invalid state');
|
||||
} else {
|
||||
unset($_SESSION['provider']);
|
||||
//Try to get an access token (using the authorization code grant)
|
||||
$token = $provider->getAccessToken(
|
||||
'authorization_code',
|
||||
[
|
||||
'code' => $_GET['code']
|
||||
]
|
||||
);
|
||||
//Use this to interact with an API on the users behalf
|
||||
//Use this to get a new access token if the old one expires
|
||||
echo 'Refresh Token: ', $token->getRefreshToken();
|
||||
}
|
||||
@@ -1,25 +1,37 @@
|
||||
# Legacy App Archiv
|
||||
# Kaffeeliste
|
||||
|
||||
Dieses Verzeichnis enthält den bisherigen PHP-Bestand der Kaffeeliste.
|
||||
Dieses Repository enthält den laufenden Umbau der Kaffeelisten-App von einer
|
||||
Windows/IIS/LDAP-gebundenen Legacy-App zu einer mehrkundenfähigen SaaS-
|
||||
Anwendung. Der Umbau erfolgt schrittweise im selben Repository, nicht in
|
||||
einem getrennten Zielverzeichnis: Legacy-Seiten und neue SaaS-Bausteine
|
||||
liegen nebeneinander, bis eine Seite vollständig auf das neue Modell
|
||||
umgestellt ist.
|
||||
|
||||
Es bleibt aus drei Gründen im Repository:
|
||||
Den vollständigen Plan mit Zielbild, Datenmodell, Rollenmodell und
|
||||
Meilensteinen beschreibt `docs/saas-umstrukturierungsplan.md`. Der
|
||||
Fortschritt je Meilenstein steht in `docs/m2-technical-foundation.md` bis
|
||||
`docs/m5-app-kern.md`.
|
||||
|
||||
- als Referenz für die bestehende Fachlogik
|
||||
- als Quelle für Datenmigrationen in die SaaS-Version
|
||||
- als Rückfalloption während der Übergangsphase
|
||||
## Struktur
|
||||
|
||||
## Wichtige Legacy-Bereiche
|
||||
- Legacy- und SaaS-Seiten liegen als flache PHP-Dateien im Webroot, zum
|
||||
Beispiel `index.php`, `stricheintragen.php`, `kaffeeliste.php`,
|
||||
`mitarbeiterverwalten.php`.
|
||||
- `app/`: zentrale Bausteine für Bootstrap, DB-Zugriff, Auth, Mail und das
|
||||
neue Ledger-Modell (`app/ledger.php`).
|
||||
- `database/migrations/`: versionierte Schemaänderungen, anzuwenden über
|
||||
`scripts/migrate.php`.
|
||||
- `scripts/`: Migrations-, Backfill- und Prüfskripte (Golden Master,
|
||||
HTTP-Smoke, M3/M4-Checks).
|
||||
- `docs/`: Planungs- und Meilensteindokumentation. Ausrollen und
|
||||
Inbetriebnahme auf dem Webspace beschreibt `docs/deployment.md`.
|
||||
|
||||
- `index.php`: persönliches Dashboard
|
||||
- `stricheintragen.php`: Sammelerfassung für Kaffee-Striche
|
||||
- `einzahlung.php`: Sammelerfassung für Einzahlungen
|
||||
- `kaffeeliste.php`: operative Gesamtübersicht
|
||||
- `mitarbeiterverwalten.php`: Mitglieder- und Rollenpflege
|
||||
- `letzteneintraege.php`: Korrektur letzter Buchungen
|
||||
- `hinweise.php`: Banner/Hinweise
|
||||
## Umgang mit Legacy-Seiten
|
||||
|
||||
## Umgang Mit Dem Archiv
|
||||
|
||||
- Keine neuen Produktfunktionen mehr hier entwickeln.
|
||||
- Nur noch für Referenz, Datenabgleich oder Notfallbetrieb verwenden.
|
||||
- Neue Arbeit findet ausschließlich in `../saas-app/` und `../docs/` statt.
|
||||
- Seiten, die noch direkt auf `kl_*`-Tabellen schreiben, werden schrittweise
|
||||
auf tenant-sicheres Lesen/Schreiben über `app/ledger.php` und
|
||||
`app/saas-auth.php` umgestellt; siehe die Meilensteindokumente für den
|
||||
aktuellen Stand je Seite.
|
||||
- Neue Produktfunktionen entstehen gegen das neue Modell (Tenants, Users,
|
||||
Participants, Ledger), nicht mehr direkt gegen die `kl_*`-Tabellen.
|
||||
- Lokale Entwicklung gegen MySQL ist in `docs/dev-mysql.md` beschrieben.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
// TCPDF FONT FILE DESCRIPTION
|
||||
$type='core';
|
||||
$name='Courier';
|
||||
$up=-100;
|
||||
$ut=50;
|
||||
$dw=600;
|
||||
$diff='';
|
||||
$enc='';
|
||||
$desc=array('Flags'=>33,'FontBBox'=>'[-23 -250 715 805]','ItalicAngle'=>0,'Ascent'=>805,'Descent'=>-250,'Leading'=>0,'CapHeight'=>562,'XHeight'=>426,'StemV'=>51,'StemH'=>51,'AvgWidth'=>600,'MaxWidth'=>600,'MissingWidth'=>600);
|
||||
$cw=array(0=>600,1=>600,2=>600,3=>600,4=>600,5=>600,6=>600,7=>600,8=>600,9=>600,10=>600,11=>600,12=>600,13=>600,14=>600,15=>600,16=>600,17=>600,18=>600,19=>600,20=>600,21=>600,22=>600,23=>600,24=>600,25=>600,26=>600,27=>600,28=>600,29=>600,30=>600,31=>600,32=>600,33=>600,34=>600,35=>600,36=>600,37=>600,38=>600,39=>600,40=>600,41=>600,42=>600,43=>600,44=>600,45=>600,46=>600,47=>600,48=>600,49=>600,50=>600,51=>600,52=>600,53=>600,54=>600,55=>600,56=>600,57=>600,58=>600,59=>600,60=>600,61=>600,62=>600,63=>600,64=>600,65=>600,66=>600,67=>600,68=>600,69=>600,70=>600,71=>600,72=>600,73=>600,74=>600,75=>600,76=>600,77=>600,78=>600,79=>600,80=>600,81=>600,82=>600,83=>600,84=>600,85=>600,86=>600,87=>600,88=>600,89=>600,90=>600,91=>600,92=>600,93=>600,94=>600,95=>600,96=>600,97=>600,98=>600,99=>600,100=>600,101=>600,102=>600,103=>600,104=>600,105=>600,106=>600,107=>600,108=>600,109=>600,110=>600,111=>600,112=>600,113=>600,114=>600,115=>600,116=>600,117=>600,118=>600,119=>600,120=>600,121=>600,122=>600,123=>600,124=>600,125=>600,126=>600,127=>600,128=>600,129=>600,130=>600,131=>600,132=>600,133=>600,134=>600,135=>600,136=>600,137=>600,138=>600,139=>600,140=>600,141=>600,142=>600,143=>600,144=>600,145=>600,146=>600,147=>600,148=>600,149=>600,150=>600,151=>600,152=>600,153=>600,154=>600,155=>600,156=>600,157=>600,158=>600,159=>600,160=>600,161=>600,162=>600,163=>600,164=>600,165=>600,166=>600,167=>600,168=>600,169=>600,170=>600,171=>600,172=>600,173=>600,174=>600,175=>600,176=>600,177=>600,178=>600,179=>600,180=>600,181=>600,182=>600,183=>600,184=>600,185=>600,186=>600,187=>600,188=>600,189=>600,190=>600,191=>600,192=>600,193=>600,194=>600,195=>600,196=>600,197=>600,198=>600,199=>600,200=>600,201=>600,202=>600,203=>600,204=>600,205=>600,206=>600,207=>600,208=>600,209=>600,210=>600,211=>600,212=>600,213=>600,214=>600,215=>600,216=>600,217=>600,218=>600,219=>600,220=>600,221=>600,222=>600,223=>600,224=>600,225=>600,226=>600,227=>600,228=>600,229=>600,230=>600,231=>600,232=>600,233=>600,234=>600,235=>600,236=>600,237=>600,238=>600,239=>600,240=>600,241=>600,242=>600,243=>600,244=>600,245=>600,246=>600,247=>600,248=>600,249=>600,250=>600,251=>600,252=>600,253=>600,254=>600,255=>600);
|
||||
// --- EOF ---
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
// TCPDF FONT FILE DESCRIPTION
|
||||
$type='core';
|
||||
$name='Courier-Bold';
|
||||
$up=-100;
|
||||
$ut=50;
|
||||
$dw=600;
|
||||
$diff='';
|
||||
$enc='';
|
||||
$desc=array('Flags'=>33,'FontBBox'=>'[-113 -250 749 801]','ItalicAngle'=>0,'Ascent'=>801,'Descent'=>-250,'Leading'=>0,'CapHeight'=>562,'XHeight'=>439,'StemV'=>106,'StemH'=>84,'AvgWidth'=>600,'MaxWidth'=>600,'MissingWidth'=>600);
|
||||
$cw=array(0=>600,1=>600,2=>600,3=>600,4=>600,5=>600,6=>600,7=>600,8=>600,9=>600,10=>600,11=>600,12=>600,13=>600,14=>600,15=>600,16=>600,17=>600,18=>600,19=>600,20=>600,21=>600,22=>600,23=>600,24=>600,25=>600,26=>600,27=>600,28=>600,29=>600,30=>600,31=>600,32=>600,33=>600,34=>600,35=>600,36=>600,37=>600,38=>600,39=>600,40=>600,41=>600,42=>600,43=>600,44=>600,45=>600,46=>600,47=>600,48=>600,49=>600,50=>600,51=>600,52=>600,53=>600,54=>600,55=>600,56=>600,57=>600,58=>600,59=>600,60=>600,61=>600,62=>600,63=>600,64=>600,65=>600,66=>600,67=>600,68=>600,69=>600,70=>600,71=>600,72=>600,73=>600,74=>600,75=>600,76=>600,77=>600,78=>600,79=>600,80=>600,81=>600,82=>600,83=>600,84=>600,85=>600,86=>600,87=>600,88=>600,89=>600,90=>600,91=>600,92=>600,93=>600,94=>600,95=>600,96=>600,97=>600,98=>600,99=>600,100=>600,101=>600,102=>600,103=>600,104=>600,105=>600,106=>600,107=>600,108=>600,109=>600,110=>600,111=>600,112=>600,113=>600,114=>600,115=>600,116=>600,117=>600,118=>600,119=>600,120=>600,121=>600,122=>600,123=>600,124=>600,125=>600,126=>600,127=>600,128=>600,129=>600,130=>600,131=>600,132=>600,133=>600,134=>600,135=>600,136=>600,137=>600,138=>600,139=>600,140=>600,141=>600,142=>600,143=>600,144=>600,145=>600,146=>600,147=>600,148=>600,149=>600,150=>600,151=>600,152=>600,153=>600,154=>600,155=>600,156=>600,157=>600,158=>600,159=>600,160=>600,161=>600,162=>600,163=>600,164=>600,165=>600,166=>600,167=>600,168=>600,169=>600,170=>600,171=>600,172=>600,173=>600,174=>600,175=>600,176=>600,177=>600,178=>600,179=>600,180=>600,181=>600,182=>600,183=>600,184=>600,185=>600,186=>600,187=>600,188=>600,189=>600,190=>600,191=>600,192=>600,193=>600,194=>600,195=>600,196=>600,197=>600,198=>600,199=>600,200=>600,201=>600,202=>600,203=>600,204=>600,205=>600,206=>600,207=>600,208=>600,209=>600,210=>600,211=>600,212=>600,213=>600,214=>600,215=>600,216=>600,217=>600,218=>600,219=>600,220=>600,221=>600,222=>600,223=>600,224=>600,225=>600,226=>600,227=>600,228=>600,229=>600,230=>600,231=>600,232=>600,233=>600,234=>600,235=>600,236=>600,237=>600,238=>600,239=>600,240=>600,241=>600,242=>600,243=>600,244=>600,245=>600,246=>600,247=>600,248=>600,249=>600,250=>600,251=>600,252=>600,253=>600,254=>600,255=>600);
|
||||
// --- EOF ---
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
// TCPDF FONT FILE DESCRIPTION
|
||||
$type='core';
|
||||
$name='Courier-BoldOblique';
|
||||
$up=-100;
|
||||
$ut=50;
|
||||
$dw=600;
|
||||
$diff='';
|
||||
$enc='';
|
||||
$desc=array('Flags'=>97,'FontBBox'=>'[-57 -250 869 801]','ItalicAngle'=>-12,'Ascent'=>801,'Descent'=>-250,'Leading'=>0,'CapHeight'=>562,'XHeight'=>439,'StemV'=>106,'StemH'=>84,'AvgWidth'=>600,'MaxWidth'=>600,'MissingWidth'=>600);
|
||||
$cw=array(0=>600,1=>600,2=>600,3=>600,4=>600,5=>600,6=>600,7=>600,8=>600,9=>600,10=>600,11=>600,12=>600,13=>600,14=>600,15=>600,16=>600,17=>600,18=>600,19=>600,20=>600,21=>600,22=>600,23=>600,24=>600,25=>600,26=>600,27=>600,28=>600,29=>600,30=>600,31=>600,32=>600,33=>600,34=>600,35=>600,36=>600,37=>600,38=>600,39=>600,40=>600,41=>600,42=>600,43=>600,44=>600,45=>600,46=>600,47=>600,48=>600,49=>600,50=>600,51=>600,52=>600,53=>600,54=>600,55=>600,56=>600,57=>600,58=>600,59=>600,60=>600,61=>600,62=>600,63=>600,64=>600,65=>600,66=>600,67=>600,68=>600,69=>600,70=>600,71=>600,72=>600,73=>600,74=>600,75=>600,76=>600,77=>600,78=>600,79=>600,80=>600,81=>600,82=>600,83=>600,84=>600,85=>600,86=>600,87=>600,88=>600,89=>600,90=>600,91=>600,92=>600,93=>600,94=>600,95=>600,96=>600,97=>600,98=>600,99=>600,100=>600,101=>600,102=>600,103=>600,104=>600,105=>600,106=>600,107=>600,108=>600,109=>600,110=>600,111=>600,112=>600,113=>600,114=>600,115=>600,116=>600,117=>600,118=>600,119=>600,120=>600,121=>600,122=>600,123=>600,124=>600,125=>600,126=>600,127=>600,128=>600,129=>600,130=>600,131=>600,132=>600,133=>600,134=>600,135=>600,136=>600,137=>600,138=>600,139=>600,140=>600,141=>600,142=>600,143=>600,144=>600,145=>600,146=>600,147=>600,148=>600,149=>600,150=>600,151=>600,152=>600,153=>600,154=>600,155=>600,156=>600,157=>600,158=>600,159=>600,160=>600,161=>600,162=>600,163=>600,164=>600,165=>600,166=>600,167=>600,168=>600,169=>600,170=>600,171=>600,172=>600,173=>600,174=>600,175=>600,176=>600,177=>600,178=>600,179=>600,180=>600,181=>600,182=>600,183=>600,184=>600,185=>600,186=>600,187=>600,188=>600,189=>600,190=>600,191=>600,192=>600,193=>600,194=>600,195=>600,196=>600,197=>600,198=>600,199=>600,200=>600,201=>600,202=>600,203=>600,204=>600,205=>600,206=>600,207=>600,208=>600,209=>600,210=>600,211=>600,212=>600,213=>600,214=>600,215=>600,216=>600,217=>600,218=>600,219=>600,220=>600,221=>600,222=>600,223=>600,224=>600,225=>600,226=>600,227=>600,228=>600,229=>600,230=>600,231=>600,232=>600,233=>600,234=>600,235=>600,236=>600,237=>600,238=>600,239=>600,240=>600,241=>600,242=>600,243=>600,244=>600,245=>600,246=>600,247=>600,248=>600,249=>600,250=>600,251=>600,252=>600,253=>600,254=>600,255=>600);
|
||||
// --- EOF ---
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
// TCPDF FONT FILE DESCRIPTION
|
||||
$type='core';
|
||||
$name='Courier-Oblique';
|
||||
$up=-100;
|
||||
$ut=50;
|
||||
$dw=600;
|
||||
$diff='';
|
||||
$enc='';
|
||||
$desc=array('Flags'=>97,'FontBBox'=>'[-27 -250 849 805]','ItalicAngle'=>-12,'Ascent'=>805,'Descent'=>-250,'Leading'=>0,'CapHeight'=>562,'XHeight'=>426,'StemV'=>51,'StemH'=>51,'AvgWidth'=>600,'MaxWidth'=>600,'MissingWidth'=>600);
|
||||
$cw=array(0=>600,1=>600,2=>600,3=>600,4=>600,5=>600,6=>600,7=>600,8=>600,9=>600,10=>600,11=>600,12=>600,13=>600,14=>600,15=>600,16=>600,17=>600,18=>600,19=>600,20=>600,21=>600,22=>600,23=>600,24=>600,25=>600,26=>600,27=>600,28=>600,29=>600,30=>600,31=>600,32=>600,33=>600,34=>600,35=>600,36=>600,37=>600,38=>600,39=>600,40=>600,41=>600,42=>600,43=>600,44=>600,45=>600,46=>600,47=>600,48=>600,49=>600,50=>600,51=>600,52=>600,53=>600,54=>600,55=>600,56=>600,57=>600,58=>600,59=>600,60=>600,61=>600,62=>600,63=>600,64=>600,65=>600,66=>600,67=>600,68=>600,69=>600,70=>600,71=>600,72=>600,73=>600,74=>600,75=>600,76=>600,77=>600,78=>600,79=>600,80=>600,81=>600,82=>600,83=>600,84=>600,85=>600,86=>600,87=>600,88=>600,89=>600,90=>600,91=>600,92=>600,93=>600,94=>600,95=>600,96=>600,97=>600,98=>600,99=>600,100=>600,101=>600,102=>600,103=>600,104=>600,105=>600,106=>600,107=>600,108=>600,109=>600,110=>600,111=>600,112=>600,113=>600,114=>600,115=>600,116=>600,117=>600,118=>600,119=>600,120=>600,121=>600,122=>600,123=>600,124=>600,125=>600,126=>600,127=>600,128=>600,129=>600,130=>600,131=>600,132=>600,133=>600,134=>600,135=>600,136=>600,137=>600,138=>600,139=>600,140=>600,141=>600,142=>600,143=>600,144=>600,145=>600,146=>600,147=>600,148=>600,149=>600,150=>600,151=>600,152=>600,153=>600,154=>600,155=>600,156=>600,157=>600,158=>600,159=>600,160=>600,161=>600,162=>600,163=>600,164=>600,165=>600,166=>600,167=>600,168=>600,169=>600,170=>600,171=>600,172=>600,173=>600,174=>600,175=>600,176=>600,177=>600,178=>600,179=>600,180=>600,181=>600,182=>600,183=>600,184=>600,185=>600,186=>600,187=>600,188=>600,189=>600,190=>600,191=>600,192=>600,193=>600,194=>600,195=>600,196=>600,197=>600,198=>600,199=>600,200=>600,201=>600,202=>600,203=>600,204=>600,205=>600,206=>600,207=>600,208=>600,209=>600,210=>600,211=>600,212=>600,213=>600,214=>600,215=>600,216=>600,217=>600,218=>600,219=>600,220=>600,221=>600,222=>600,223=>600,224=>600,225=>600,226=>600,227=>600,228=>600,229=>600,230=>600,231=>600,232=>600,233=>600,234=>600,235=>600,236=>600,237=>600,238=>600,239=>600,240=>600,241=>600,242=>600,243=>600,244=>600,245=>600,246=>600,247=>600,248=>600,249=>600,250=>600,251=>600,252=>600,253=>600,254=>600,255=>600);
|
||||
// --- EOF ---
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
// TCPDF FONT FILE DESCRIPTION
|
||||
$type='core';
|
||||
$name='Helvetica';
|
||||
$up=-100;
|
||||
$ut=50;
|
||||
$dw=513;
|
||||
$diff='';
|
||||
$enc='';
|
||||
$desc=array('Flags'=>32,'FontBBox'=>'[-166 -225 1000 931]','ItalicAngle'=>0,'Ascent'=>931,'Descent'=>-225,'Leading'=>0,'CapHeight'=>718,'XHeight'=>523,'StemV'=>88,'StemH'=>76,'AvgWidth'=>513,'MaxWidth'=>1015,'MissingWidth'=>513);
|
||||
$cw=array(0=>500,1=>500,2=>500,3=>500,4=>500,5=>500,6=>500,7=>500,8=>500,9=>500,10=>500,11=>500,12=>500,13=>500,14=>500,15=>500,16=>500,17=>500,18=>500,19=>500,20=>500,21=>500,22=>500,23=>500,24=>500,25=>500,26=>500,27=>500,28=>500,29=>500,30=>500,31=>500,32=>278,33=>278,34=>355,35=>556,36=>556,37=>889,38=>667,39=>191,40=>333,41=>333,42=>389,43=>584,44=>278,45=>333,46=>278,47=>278,48=>556,49=>556,50=>556,51=>556,52=>556,53=>556,54=>556,55=>556,56=>556,57=>556,58=>278,59=>278,60=>584,61=>584,62=>584,63=>556,64=>1015,65=>667,66=>667,67=>722,68=>722,69=>667,70=>611,71=>778,72=>722,73=>278,74=>500,75=>667,76=>556,77=>833,78=>722,79=>778,80=>667,81=>778,82=>722,83=>667,84=>611,85=>722,86=>667,87=>944,88=>667,89=>667,90=>611,91=>278,92=>278,93=>277,94=>469,95=>556,96=>333,97=>556,98=>556,99=>500,100=>556,101=>556,102=>278,103=>556,104=>556,105=>222,106=>222,107=>500,108=>222,109=>833,110=>556,111=>556,112=>556,113=>556,114=>333,115=>500,116=>278,117=>556,118=>500,119=>722,120=>500,121=>500,122=>500,123=>334,124=>260,125=>334,126=>584,127=>500,128=>655,129=>500,130=>222,131=>278,132=>333,133=>1000,134=>556,135=>556,136=>333,137=>1000,138=>667,139=>250,140=>1000,141=>500,142=>611,143=>500,144=>500,145=>222,146=>221,147=>333,148=>333,149=>350,150=>556,151=>1000,152=>333,153=>1000,154=>500,155=>250,156=>938,157=>500,158=>500,159=>667,160=>278,161=>278,162=>556,163=>556,164=>556,165=>556,166=>260,167=>556,168=>333,169=>737,170=>370,171=>448,172=>584,173=>333,174=>737,175=>333,176=>606,177=>584,178=>350,179=>350,180=>333,181=>556,182=>537,183=>278,184=>333,185=>350,186=>365,187=>448,188=>869,189=>869,190=>879,191=>556,192=>667,193=>667,194=>667,195=>667,196=>667,197=>667,198=>1000,199=>722,200=>667,201=>667,202=>667,203=>667,204=>278,205=>278,206=>278,207=>278,208=>722,209=>722,210=>778,211=>778,212=>778,213=>778,214=>778,215=>584,216=>778,217=>722,218=>722,219=>722,220=>722,221=>667,222=>666,223=>611,224=>556,225=>556,226=>556,227=>556,228=>556,229=>556,230=>896,231=>500,232=>556,233=>556,234=>556,235=>556,236=>251,237=>251,238=>251,239=>251,240=>556,241=>556,242=>556,243=>556,244=>556,245=>556,246=>556,247=>584,248=>611,249=>556,250=>556,251=>556,252=>556,253=>500,254=>555,255=>500);
|
||||
|
||||
// --- EOF ---
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
// TCPDF FONT FILE DESCRIPTION
|
||||
$type='core';
|
||||
$name='Helvetica-Bold';
|
||||
$up=-100;
|
||||
$ut=50;
|
||||
$dw=535;
|
||||
$diff='';
|
||||
$enc='';
|
||||
$desc=array('Flags'=>32,'FontBBox'=>'[-170 -228 1003 962]','ItalicAngle'=>0,'Ascent'=>962,'Descent'=>-228,'Leading'=>0,'CapHeight'=>718,'XHeight'=>532,'StemV'=>140,'StemH'=>118,'AvgWidth'=>535,'MaxWidth'=>1000,'MissingWidth'=>535);
|
||||
$cw=array(0=>278,1=>278,2=>278,3=>278,4=>278,5=>278,6=>278,7=>278,8=>278,9=>278,10=>278,11=>278,12=>278,13=>278,14=>278,15=>278,16=>278,17=>278,18=>278,19=>278,20=>278,21=>278,22=>278,23=>278,24=>278,25=>278,26=>278,27=>278,28=>278,29=>278,30=>278,31=>278,32=>278,33=>333,34=>474,35=>556,36=>556,37=>889,38=>722,39=>238,40=>333,41=>333,42=>389,43=>584,44=>278,45=>333,46=>278,47=>278,48=>556,49=>556,50=>556,51=>556,52=>556,53=>556,54=>556,55=>556,56=>556,57=>556,58=>333,59=>333,60=>584,61=>584,62=>584,63=>611,64=>975,65=>722,66=>722,67=>722,68=>722,69=>667,70=>611,71=>778,72=>722,73=>278,74=>556,75=>722,76=>611,77=>833,78=>722,79=>778,80=>667,81=>778,82=>722,83=>667,84=>611,85=>722,86=>667,87=>944,88=>667,89=>667,90=>611,91=>333,92=>278,93=>333,94=>584,95=>556,96=>333,97=>556,98=>611,99=>556,100=>611,101=>556,102=>333,103=>611,104=>611,105=>278,106=>278,107=>556,108=>278,109=>889,110=>611,111=>611,112=>611,113=>611,114=>389,115=>556,116=>333,117=>611,118=>556,119=>778,120=>556,121=>556,122=>500,123=>389,124=>280,125=>389,126=>584,127=>350,128=>556,129=>350,130=>278,131=>556,132=>500,133=>1000,134=>556,135=>556,136=>333,137=>1000,138=>667,139=>333,140=>1000,141=>350,142=>611,143=>350,144=>350,145=>278,146=>278,147=>500,148=>500,149=>350,150=>556,151=>1000,152=>333,153=>1000,154=>556,155=>333,156=>944,157=>350,158=>500,159=>667,160=>278,161=>333,162=>556,163=>556,164=>556,165=>556,166=>280,167=>556,168=>333,169=>737,170=>370,171=>556,172=>584,173=>333,174=>737,175=>333,176=>400,177=>584,178=>333,179=>333,180=>333,181=>611,182=>556,183=>278,184=>333,185=>333,186=>365,187=>556,188=>834,189=>834,190=>834,191=>611,192=>722,193=>722,194=>722,195=>722,196=>722,197=>722,198=>1000,199=>722,200=>667,201=>667,202=>667,203=>667,204=>278,205=>278,206=>278,207=>278,208=>722,209=>722,210=>778,211=>778,212=>778,213=>778,214=>778,215=>584,216=>778,217=>722,218=>722,219=>722,220=>722,221=>667,222=>667,223=>611,224=>556,225=>556,226=>556,227=>556,228=>556,229=>556,230=>889,231=>556,232=>556,233=>556,234=>556,235=>556,236=>278,237=>278,238=>278,239=>278,240=>611,241=>611,242=>611,243=>611,244=>611,245=>611,246=>611,247=>584,248=>611,249=>611,250=>611,251=>611,252=>611,253=>556,254=>611,255=>556);
|
||||
// --- EOF ---
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
// TCPDF FONT FILE DESCRIPTION
|
||||
$type='core';
|
||||
$name='Helvetica-BoldOblique';
|
||||
$up=-100;
|
||||
$ut=50;
|
||||
$dw=535;
|
||||
$diff='';
|
||||
$enc='';
|
||||
$desc=array('Flags'=>96,'FontBBox'=>'[-174 -228 1114 962]','ItalicAngle'=>-12,'Ascent'=>962,'Descent'=>-228,'Leading'=>0,'CapHeight'=>718,'XHeight'=>532,'StemV'=>140,'StemH'=>118,'AvgWidth'=>535,'MaxWidth'=>1000,'MissingWidth'=>535);
|
||||
$cw=array(0=>278,1=>278,2=>278,3=>278,4=>278,5=>278,6=>278,7=>278,8=>278,9=>278,10=>278,11=>278,12=>278,13=>278,14=>278,15=>278,16=>278,17=>278,18=>278,19=>278,20=>278,21=>278,22=>278,23=>278,24=>278,25=>278,26=>278,27=>278,28=>278,29=>278,30=>278,31=>278,32=>278,33=>333,34=>474,35=>556,36=>556,37=>889,38=>722,39=>238,40=>333,41=>333,42=>389,43=>584,44=>278,45=>333,46=>278,47=>278,48=>556,49=>556,50=>556,51=>556,52=>556,53=>556,54=>556,55=>556,56=>556,57=>556,58=>333,59=>333,60=>584,61=>584,62=>584,63=>611,64=>975,65=>722,66=>722,67=>722,68=>722,69=>667,70=>611,71=>778,72=>722,73=>278,74=>556,75=>722,76=>611,77=>833,78=>722,79=>778,80=>667,81=>778,82=>722,83=>667,84=>611,85=>722,86=>667,87=>944,88=>667,89=>667,90=>611,91=>333,92=>278,93=>333,94=>584,95=>556,96=>333,97=>556,98=>611,99=>556,100=>611,101=>556,102=>333,103=>611,104=>611,105=>278,106=>278,107=>556,108=>278,109=>889,110=>611,111=>611,112=>611,113=>611,114=>389,115=>556,116=>333,117=>611,118=>556,119=>778,120=>556,121=>556,122=>500,123=>389,124=>280,125=>389,126=>584,127=>350,128=>556,129=>350,130=>278,131=>556,132=>500,133=>1000,134=>556,135=>556,136=>333,137=>1000,138=>667,139=>333,140=>1000,141=>350,142=>611,143=>350,144=>350,145=>278,146=>278,147=>500,148=>500,149=>350,150=>556,151=>1000,152=>333,153=>1000,154=>556,155=>333,156=>944,157=>350,158=>500,159=>667,160=>278,161=>333,162=>556,163=>556,164=>556,165=>556,166=>280,167=>556,168=>333,169=>737,170=>370,171=>556,172=>584,173=>333,174=>737,175=>333,176=>400,177=>584,178=>333,179=>333,180=>333,181=>611,182=>556,183=>278,184=>333,185=>333,186=>365,187=>556,188=>834,189=>834,190=>834,191=>611,192=>722,193=>722,194=>722,195=>722,196=>722,197=>722,198=>1000,199=>722,200=>667,201=>667,202=>667,203=>667,204=>278,205=>278,206=>278,207=>278,208=>722,209=>722,210=>778,211=>778,212=>778,213=>778,214=>778,215=>584,216=>778,217=>722,218=>722,219=>722,220=>722,221=>667,222=>667,223=>611,224=>556,225=>556,226=>556,227=>556,228=>556,229=>556,230=>889,231=>556,232=>556,233=>556,234=>556,235=>556,236=>278,237=>278,238=>278,239=>278,240=>611,241=>611,242=>611,243=>611,244=>611,245=>611,246=>611,247=>584,248=>611,249=>611,250=>611,251=>611,252=>611,253=>556,254=>611,255=>556);
|
||||
// --- EOF ---
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
// TCPDF FONT FILE DESCRIPTION
|
||||
$type='core';
|
||||
$name='Helvetica-Oblique';
|
||||
$up=-100;
|
||||
$ut=50;
|
||||
$dw=513;
|
||||
$diff='';
|
||||
$enc='';
|
||||
$desc=array('Flags'=>96,'FontBBox'=>'[-170 -225 1116 931]','ItalicAngle'=>-12,'Ascent'=>931,'Descent'=>-225,'Leading'=>0,'CapHeight'=>718,'XHeight'=>523,'StemV'=>88,'StemH'=>76,'AvgWidth'=>513,'MaxWidth'=>1015,'MissingWidth'=>513);
|
||||
$cw=array(0=>278,1=>278,2=>278,3=>278,4=>278,5=>278,6=>278,7=>278,8=>278,9=>278,10=>278,11=>278,12=>278,13=>278,14=>278,15=>278,16=>278,17=>278,18=>278,19=>278,20=>278,21=>278,22=>278,23=>278,24=>278,25=>278,26=>278,27=>278,28=>278,29=>278,30=>278,31=>278,32=>278,33=>278,34=>355,35=>556,36=>556,37=>889,38=>667,39=>191,40=>333,41=>333,42=>389,43=>584,44=>278,45=>333,46=>278,47=>278,48=>556,49=>556,50=>556,51=>556,52=>556,53=>556,54=>556,55=>556,56=>556,57=>556,58=>278,59=>278,60=>584,61=>584,62=>584,63=>556,64=>1015,65=>667,66=>667,67=>722,68=>722,69=>667,70=>611,71=>778,72=>722,73=>278,74=>500,75=>667,76=>556,77=>833,78=>722,79=>778,80=>667,81=>778,82=>722,83=>667,84=>611,85=>722,86=>667,87=>944,88=>667,89=>667,90=>611,91=>278,92=>278,93=>278,94=>469,95=>556,96=>333,97=>556,98=>556,99=>500,100=>556,101=>556,102=>278,103=>556,104=>556,105=>222,106=>222,107=>500,108=>222,109=>833,110=>556,111=>556,112=>556,113=>556,114=>333,115=>500,116=>278,117=>556,118=>500,119=>722,120=>500,121=>500,122=>500,123=>334,124=>260,125=>334,126=>584,127=>350,128=>556,129=>350,130=>222,131=>556,132=>333,133=>1000,134=>556,135=>556,136=>333,137=>1000,138=>667,139=>333,140=>1000,141=>350,142=>611,143=>350,144=>350,145=>222,146=>222,147=>333,148=>333,149=>350,150=>556,151=>1000,152=>333,153=>1000,154=>500,155=>333,156=>944,157=>350,158=>500,159=>667,160=>278,161=>333,162=>556,163=>556,164=>556,165=>556,166=>260,167=>556,168=>333,169=>737,170=>370,171=>556,172=>584,173=>333,174=>737,175=>333,176=>400,177=>584,178=>333,179=>333,180=>333,181=>556,182=>537,183=>278,184=>333,185=>333,186=>365,187=>556,188=>834,189=>834,190=>834,191=>611,192=>667,193=>667,194=>667,195=>667,196=>667,197=>667,198=>1000,199=>722,200=>667,201=>667,202=>667,203=>667,204=>278,205=>278,206=>278,207=>278,208=>722,209=>722,210=>778,211=>778,212=>778,213=>778,214=>778,215=>584,216=>778,217=>722,218=>722,219=>722,220=>722,221=>667,222=>667,223=>611,224=>556,225=>556,226=>556,227=>556,228=>556,229=>556,230=>889,231=>500,232=>556,233=>556,234=>556,235=>556,236=>278,237=>278,238=>278,239=>278,240=>556,241=>556,242=>556,243=>556,244=>556,245=>556,246=>556,247=>584,248=>611,249=>556,250=>556,251=>556,252=>556,253=>500,254=>556,255=>500);
|
||||
// --- EOF ---
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
// TCPDF FONT FILE DESCRIPTION
|
||||
$type='core';
|
||||
$name='Symbol';
|
||||
$up=-100;
|
||||
$ut=50;
|
||||
$dw=587;
|
||||
$diff='';
|
||||
$enc='';
|
||||
$desc=array('Flags'=>4,'FontBBox'=>'[-180 -293 1090 1010]','ItalicAngle'=>0,'Ascent'=>1010,'Descent'=>-293,'Leading'=>0,'CapHeight'=>1010,'StemV'=>85,'StemH'=>92,'AvgWidth'=>587,'MaxWidth'=>1042,'MissingWidth'=>587);
|
||||
$cw=array(0=>587,1=>587,2=>587,3=>587,4=>587,5=>587,6=>587,7=>587,8=>587,9=>587,10=>587,11=>587,12=>587,13=>587,14=>587,15=>587,16=>587,17=>587,18=>587,19=>587,20=>587,21=>587,22=>587,23=>587,24=>587,25=>587,26=>587,27=>587,28=>587,29=>587,30=>587,31=>587,32=>250,33=>333,34=>713,35=>500,36=>549,37=>833,38=>778,39=>439,40=>333,41=>333,42=>500,43=>549,44=>250,45=>549,46=>250,47=>278,48=>500,49=>500,50=>500,51=>500,52=>500,53=>500,54=>500,55=>500,56=>500,57=>500,58=>278,59=>278,60=>549,61=>549,62=>549,63=>444,64=>549,65=>722,66=>667,67=>722,68=>612,69=>611,70=>763,71=>603,72=>722,73=>333,74=>631,75=>722,76=>686,77=>889,78=>722,79=>722,80=>768,81=>741,82=>556,83=>592,84=>611,85=>690,86=>439,87=>768,88=>645,89=>795,90=>611,91=>333,92=>863,93=>333,94=>658,95=>500,96=>500,97=>631,98=>549,99=>549,100=>494,101=>439,102=>521,103=>411,104=>603,105=>329,106=>603,107=>549,108=>549,109=>576,110=>521,111=>549,112=>549,113=>521,114=>549,115=>603,116=>439,117=>576,118=>713,119=>686,120=>493,121=>686,122=>494,123=>480,124=>200,125=>480,126=>549,127=>587,128=>587,129=>587,130=>587,131=>587,132=>587,133=>587,134=>587,135=>587,136=>587,137=>587,138=>587,139=>587,140=>587,141=>587,142=>587,143=>587,144=>587,145=>587,146=>587,147=>587,148=>587,149=>587,150=>587,151=>587,152=>587,153=>587,154=>587,155=>587,156=>587,157=>587,158=>587,159=>587,160=>750,161=>620,162=>247,163=>549,164=>167,165=>713,166=>500,167=>753,168=>753,169=>753,170=>753,171=>1042,172=>987,173=>603,174=>987,175=>603,176=>400,177=>549,178=>411,179=>549,180=>549,181=>713,182=>494,183=>460,184=>549,185=>549,186=>549,187=>549,188=>1000,189=>603,190=>1000,191=>658,192=>823,193=>686,194=>795,195=>987,196=>768,197=>768,198=>823,199=>768,200=>768,201=>713,202=>713,203=>713,204=>713,205=>713,206=>713,207=>713,208=>768,209=>713,210=>790,211=>790,212=>890,213=>823,214=>549,215=>250,216=>713,217=>603,218=>603,219=>1042,220=>987,221=>603,222=>987,223=>603,224=>494,225=>329,226=>790,227=>790,228=>786,229=>713,230=>384,231=>384,232=>384,233=>384,234=>384,235=>384,236=>494,237=>494,238=>494,239=>494,240=>587,241=>329,242=>274,243=>686,244=>686,245=>686,246=>384,247=>384,248=>384,249=>384,250=>384,251=>384,252=>494,253=>494,254=>494,255=>587);
|
||||
// --- EOF ---
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
// TCPDF FONT FILE DESCRIPTION
|
||||
$type='core';
|
||||
$name='Times-Roman';
|
||||
$up=-100;
|
||||
$ut=50;
|
||||
$dw=495;
|
||||
$diff='';
|
||||
$enc='';
|
||||
$desc=array('Flags'=>32,'FontBBox'=>'[-168 -218 1000 898]','ItalicAngle'=>0,'Ascent'=>898,'Descent'=>-218,'Leading'=>0,'CapHeight'=>662,'XHeight'=>450,'StemV'=>84,'StemH'=>28,'AvgWidth'=>495,'MaxWidth'=>1000,'MissingWidth'=>495);
|
||||
$cw=array(0=>250,1=>250,2=>250,3=>250,4=>250,5=>250,6=>250,7=>250,8=>250,9=>250,10=>250,11=>250,12=>250,13=>250,14=>250,15=>250,16=>250,17=>250,18=>250,19=>250,20=>250,21=>250,22=>250,23=>250,24=>250,25=>250,26=>250,27=>250,28=>250,29=>250,30=>250,31=>250,32=>250,33=>333,34=>408,35=>500,36=>500,37=>833,38=>778,39=>180,40=>333,41=>333,42=>500,43=>564,44=>250,45=>333,46=>250,47=>278,48=>500,49=>500,50=>500,51=>500,52=>500,53=>500,54=>500,55=>500,56=>500,57=>500,58=>278,59=>278,60=>564,61=>564,62=>564,63=>444,64=>921,65=>722,66=>667,67=>667,68=>722,69=>611,70=>556,71=>722,72=>722,73=>333,74=>389,75=>722,76=>611,77=>889,78=>722,79=>722,80=>556,81=>722,82=>667,83=>556,84=>611,85=>722,86=>722,87=>944,88=>722,89=>722,90=>611,91=>333,92=>278,93=>333,94=>469,95=>500,96=>333,97=>444,98=>500,99=>444,100=>500,101=>444,102=>333,103=>500,104=>500,105=>278,106=>278,107=>500,108=>278,109=>778,110=>500,111=>500,112=>500,113=>500,114=>333,115=>389,116=>278,117=>500,118=>500,119=>722,120=>500,121=>500,122=>444,123=>480,124=>200,125=>480,126=>541,127=>350,128=>500,129=>350,130=>333,131=>500,132=>444,133=>1000,134=>500,135=>500,136=>333,137=>1000,138=>556,139=>333,140=>889,141=>350,142=>611,143=>350,144=>350,145=>333,146=>333,147=>444,148=>444,149=>350,150=>500,151=>1000,152=>333,153=>980,154=>389,155=>333,156=>722,157=>350,158=>444,159=>722,160=>250,161=>333,162=>500,163=>500,164=>500,165=>500,166=>200,167=>500,168=>333,169=>760,170=>276,171=>500,172=>564,173=>333,174=>760,175=>333,176=>400,177=>564,178=>300,179=>300,180=>333,181=>500,182=>453,183=>250,184=>333,185=>300,186=>310,187=>500,188=>750,189=>750,190=>750,191=>444,192=>722,193=>722,194=>722,195=>722,196=>722,197=>722,198=>889,199=>667,200=>611,201=>611,202=>611,203=>611,204=>333,205=>333,206=>333,207=>333,208=>722,209=>722,210=>722,211=>722,212=>722,213=>722,214=>722,215=>564,216=>722,217=>722,218=>722,219=>722,220=>722,221=>722,222=>556,223=>500,224=>444,225=>444,226=>444,227=>444,228=>444,229=>444,230=>667,231=>444,232=>444,233=>444,234=>444,235=>444,236=>278,237=>278,238=>278,239=>278,240=>500,241=>500,242=>500,243=>500,244=>500,245=>500,246=>500,247=>564,248=>500,249=>500,250=>500,251=>500,252=>500,253=>500,254=>500,255=>500);
|
||||
// --- EOF ---
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
// TCPDF FONT FILE DESCRIPTION
|
||||
$type='core';
|
||||
$name='Times-Bold';
|
||||
$up=-100;
|
||||
$ut=50;
|
||||
$dw=516;
|
||||
$diff='';
|
||||
$enc='';
|
||||
$desc=array('Flags'=>32,'FontBBox'=>'[-168 -218 1000 935]','ItalicAngle'=>0,'Ascent'=>935,'Descent'=>-218,'Leading'=>0,'CapHeight'=>676,'XHeight'=>461,'StemV'=>139,'StemH'=>44,'AvgWidth'=>516,'MaxWidth'=>1000,'MissingWidth'=>516);
|
||||
$cw=array(0=>250,1=>250,2=>250,3=>250,4=>250,5=>250,6=>250,7=>250,8=>250,9=>250,10=>250,11=>250,12=>250,13=>250,14=>250,15=>250,16=>250,17=>250,18=>250,19=>250,20=>250,21=>250,22=>250,23=>250,24=>250,25=>250,26=>250,27=>250,28=>250,29=>250,30=>250,31=>250,32=>250,33=>333,34=>555,35=>500,36=>500,37=>1000,38=>833,39=>278,40=>333,41=>333,42=>500,43=>570,44=>250,45=>333,46=>250,47=>278,48=>500,49=>500,50=>500,51=>500,52=>500,53=>500,54=>500,55=>500,56=>500,57=>500,58=>333,59=>333,60=>570,61=>570,62=>570,63=>500,64=>930,65=>722,66=>667,67=>722,68=>722,69=>667,70=>611,71=>778,72=>778,73=>389,74=>500,75=>778,76=>667,77=>944,78=>722,79=>778,80=>611,81=>778,82=>722,83=>556,84=>667,85=>722,86=>722,87=>1000,88=>722,89=>722,90=>667,91=>333,92=>278,93=>333,94=>581,95=>500,96=>333,97=>500,98=>556,99=>444,100=>556,101=>444,102=>333,103=>500,104=>556,105=>278,106=>333,107=>556,108=>278,109=>833,110=>556,111=>500,112=>556,113=>556,114=>444,115=>389,116=>333,117=>556,118=>500,119=>722,120=>500,121=>500,122=>444,123=>394,124=>220,125=>394,126=>520,127=>350,128=>500,129=>350,130=>333,131=>500,132=>500,133=>1000,134=>500,135=>500,136=>333,137=>1000,138=>556,139=>333,140=>1000,141=>350,142=>667,143=>350,144=>350,145=>333,146=>333,147=>500,148=>500,149=>350,150=>500,151=>1000,152=>333,153=>1000,154=>389,155=>333,156=>722,157=>350,158=>444,159=>722,160=>250,161=>333,162=>500,163=>500,164=>500,165=>500,166=>220,167=>500,168=>333,169=>747,170=>300,171=>500,172=>570,173=>333,174=>747,175=>333,176=>400,177=>570,178=>300,179=>300,180=>333,181=>556,182=>540,183=>250,184=>333,185=>300,186=>330,187=>500,188=>750,189=>750,190=>750,191=>500,192=>722,193=>722,194=>722,195=>722,196=>722,197=>722,198=>1000,199=>722,200=>667,201=>667,202=>667,203=>667,204=>389,205=>389,206=>389,207=>389,208=>722,209=>722,210=>778,211=>778,212=>778,213=>778,214=>778,215=>570,216=>778,217=>722,218=>722,219=>722,220=>722,221=>722,222=>611,223=>556,224=>500,225=>500,226=>500,227=>500,228=>500,229=>500,230=>722,231=>444,232=>444,233=>444,234=>444,235=>444,236=>278,237=>278,238=>278,239=>278,240=>500,241=>556,242=>500,243=>500,244=>500,245=>500,246=>500,247=>570,248=>500,249=>556,250=>556,251=>556,252=>556,253=>500,254=>556,255=>500);
|
||||
// --- EOF ---
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
// TCPDF FONT FILE DESCRIPTION
|
||||
$type='core';
|
||||
$name='Times-BoldItalic';
|
||||
$up=-100;
|
||||
$ut=50;
|
||||
$dw=501;
|
||||
$diff='';
|
||||
$enc='';
|
||||
$desc=array('Flags'=>96,'FontBBox'=>'[-200 -218 996 921]','ItalicAngle'=>-15,'Ascent'=>921,'Descent'=>-218,'Leading'=>0,'CapHeight'=>669,'XHeight'=>462,'StemV'=>121,'StemH'=>42,'AvgWidth'=>501,'MaxWidth'=>1000,'MissingWidth'=>501);
|
||||
$cw=array(0=>250,1=>250,2=>250,3=>250,4=>250,5=>250,6=>250,7=>250,8=>250,9=>250,10=>250,11=>250,12=>250,13=>250,14=>250,15=>250,16=>250,17=>250,18=>250,19=>250,20=>250,21=>250,22=>250,23=>250,24=>250,25=>250,26=>250,27=>250,28=>250,29=>250,30=>250,31=>250,32=>250,33=>389,34=>555,35=>500,36=>500,37=>833,38=>778,39=>278,40=>333,41=>333,42=>500,43=>570,44=>250,45=>333,46=>250,47=>278,48=>500,49=>500,50=>500,51=>500,52=>500,53=>500,54=>500,55=>500,56=>500,57=>500,58=>333,59=>333,60=>570,61=>570,62=>570,63=>500,64=>832,65=>667,66=>667,67=>667,68=>722,69=>667,70=>667,71=>722,72=>778,73=>389,74=>500,75=>667,76=>611,77=>889,78=>722,79=>722,80=>611,81=>722,82=>667,83=>556,84=>611,85=>722,86=>667,87=>889,88=>667,89=>611,90=>611,91=>333,92=>278,93=>333,94=>570,95=>500,96=>333,97=>500,98=>500,99=>444,100=>500,101=>444,102=>333,103=>500,104=>556,105=>278,106=>278,107=>500,108=>278,109=>778,110=>556,111=>500,112=>500,113=>500,114=>389,115=>389,116=>278,117=>556,118=>444,119=>667,120=>500,121=>444,122=>389,123=>348,124=>220,125=>348,126=>570,127=>350,128=>500,129=>350,130=>333,131=>500,132=>500,133=>1000,134=>500,135=>500,136=>333,137=>1000,138=>556,139=>333,140=>944,141=>350,142=>611,143=>350,144=>350,145=>333,146=>333,147=>500,148=>500,149=>350,150=>500,151=>1000,152=>333,153=>1000,154=>389,155=>333,156=>722,157=>350,158=>389,159=>611,160=>250,161=>389,162=>500,163=>500,164=>500,165=>500,166=>220,167=>500,168=>333,169=>747,170=>266,171=>500,172=>606,173=>333,174=>747,175=>333,176=>400,177=>570,178=>300,179=>300,180=>333,181=>576,182=>500,183=>250,184=>333,185=>300,186=>300,187=>500,188=>750,189=>750,190=>750,191=>500,192=>667,193=>667,194=>667,195=>667,196=>667,197=>667,198=>944,199=>667,200=>667,201=>667,202=>667,203=>667,204=>389,205=>389,206=>389,207=>389,208=>722,209=>722,210=>722,211=>722,212=>722,213=>722,214=>722,215=>570,216=>722,217=>722,218=>722,219=>722,220=>722,221=>611,222=>611,223=>500,224=>500,225=>500,226=>500,227=>500,228=>500,229=>500,230=>722,231=>444,232=>444,233=>444,234=>444,235=>444,236=>278,237=>278,238=>278,239=>278,240=>500,241=>556,242=>500,243=>500,244=>500,245=>500,246=>500,247=>570,248=>500,249=>556,250=>556,251=>556,252=>556,253=>444,254=>500,255=>444);
|
||||
// --- EOF ---
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
// TCPDF FONT FILE DESCRIPTION
|
||||
$type='core';
|
||||
$name='Times-Italic';
|
||||
$up=-100;
|
||||
$ut=50;
|
||||
$dw=491;
|
||||
$diff='';
|
||||
$enc='';
|
||||
$desc=array('Flags'=>96,'FontBBox'=>'[-169 -217 1010 883]','ItalicAngle'=>-15.5,'Ascent'=>883,'Descent'=>-217,'Leading'=>0,'CapHeight'=>653,'XHeight'=>441,'StemV'=>76,'StemH'=>32,'AvgWidth'=>491,'MaxWidth'=>1000,'MissingWidth'=>491);
|
||||
$cw=array(0=>250,1=>250,2=>250,3=>250,4=>250,5=>250,6=>250,7=>250,8=>250,9=>250,10=>250,11=>250,12=>250,13=>250,14=>250,15=>250,16=>250,17=>250,18=>250,19=>250,20=>250,21=>250,22=>250,23=>250,24=>250,25=>250,26=>250,27=>250,28=>250,29=>250,30=>250,31=>250,32=>250,33=>333,34=>420,35=>500,36=>500,37=>833,38=>778,39=>214,40=>333,41=>333,42=>500,43=>675,44=>250,45=>333,46=>250,47=>278,48=>500,49=>500,50=>500,51=>500,52=>500,53=>500,54=>500,55=>500,56=>500,57=>500,58=>333,59=>333,60=>675,61=>675,62=>675,63=>500,64=>920,65=>611,66=>611,67=>667,68=>722,69=>611,70=>611,71=>722,72=>722,73=>333,74=>444,75=>667,76=>556,77=>833,78=>667,79=>722,80=>611,81=>722,82=>611,83=>500,84=>556,85=>722,86=>611,87=>833,88=>611,89=>556,90=>556,91=>389,92=>278,93=>389,94=>422,95=>500,96=>333,97=>500,98=>500,99=>444,100=>500,101=>444,102=>278,103=>500,104=>500,105=>278,106=>278,107=>444,108=>278,109=>722,110=>500,111=>500,112=>500,113=>500,114=>389,115=>389,116=>278,117=>500,118=>444,119=>667,120=>444,121=>444,122=>389,123=>400,124=>275,125=>400,126=>541,127=>350,128=>500,129=>350,130=>333,131=>500,132=>556,133=>889,134=>500,135=>500,136=>333,137=>1000,138=>500,139=>333,140=>944,141=>350,142=>556,143=>350,144=>350,145=>333,146=>333,147=>556,148=>556,149=>350,150=>500,151=>889,152=>333,153=>980,154=>389,155=>333,156=>667,157=>350,158=>389,159=>556,160=>250,161=>389,162=>500,163=>500,164=>500,165=>500,166=>275,167=>500,168=>333,169=>760,170=>276,171=>500,172=>675,173=>333,174=>760,175=>333,176=>400,177=>675,178=>300,179=>300,180=>333,181=>500,182=>523,183=>250,184=>333,185=>300,186=>310,187=>500,188=>750,189=>750,190=>750,191=>500,192=>611,193=>611,194=>611,195=>611,196=>611,197=>611,198=>889,199=>667,200=>611,201=>611,202=>611,203=>611,204=>333,205=>333,206=>333,207=>333,208=>722,209=>667,210=>722,211=>722,212=>722,213=>722,214=>722,215=>675,216=>722,217=>722,218=>722,219=>722,220=>722,221=>556,222=>611,223=>500,224=>500,225=>500,226=>500,227=>500,228=>500,229=>500,230=>667,231=>444,232=>444,233=>444,234=>444,235=>444,236=>278,237=>278,238=>278,239=>278,240=>500,241=>500,242=>500,243=>500,244=>500,245=>500,246=>500,247=>675,248=>500,249=>500,250=>500,251=>500,252=>500,253=>444,254=>500,255=>444);
|
||||
// --- EOF ---
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
// TCPDF FONT FILE DESCRIPTION
|
||||
$type='core';
|
||||
$name='ZapfDingbats';
|
||||
$up=-100;
|
||||
$ut=50;
|
||||
$dw=746;
|
||||
$diff='';
|
||||
$enc='';
|
||||
$desc=array('Flags'=>4,'FontBBox'=>'[-1 -143 981 820]','ItalicAngle'=>0,'Ascent'=>820,'Descent'=>-143,'Leading'=>0,'CapHeight'=>820,'StemV'=>90,'StemH'=>28,'AvgWidth'=>746,'MaxWidth'=>1016,'MissingWidth'=>746);
|
||||
$cw=array(0=>746,1=>746,2=>746,3=>746,4=>746,5=>746,6=>746,7=>746,8=>746,9=>746,10=>746,11=>746,12=>746,13=>746,14=>746,15=>746,16=>746,17=>746,18=>746,19=>746,20=>746,21=>746,22=>746,23=>746,24=>746,25=>746,26=>746,27=>746,28=>746,29=>746,30=>746,31=>746,32=>278,33=>974,34=>961,35=>974,36=>980,37=>719,38=>789,39=>790,40=>791,41=>690,42=>960,43=>939,44=>549,45=>855,46=>911,47=>933,48=>911,49=>945,50=>974,51=>755,52=>846,53=>762,54=>761,55=>571,56=>677,57=>763,58=>760,59=>759,60=>754,61=>494,62=>552,63=>537,64=>577,65=>692,66=>786,67=>788,68=>788,69=>790,70=>793,71=>794,72=>816,73=>823,74=>789,75=>841,76=>823,77=>833,78=>816,79=>831,80=>923,81=>744,82=>723,83=>749,84=>790,85=>792,86=>695,87=>776,88=>768,89=>792,90=>759,91=>707,92=>708,93=>682,94=>701,95=>826,96=>815,97=>789,98=>789,99=>707,100=>687,101=>696,102=>689,103=>786,104=>787,105=>713,106=>791,107=>785,108=>791,109=>873,110=>761,111=>762,112=>762,113=>759,114=>759,115=>892,116=>892,117=>788,118=>784,119=>438,120=>138,121=>277,122=>415,123=>392,124=>392,125=>668,126=>668,127=>746,128=>390,129=>390,130=>317,131=>317,132=>276,133=>276,134=>509,135=>509,136=>410,137=>410,138=>234,139=>234,140=>334,141=>334,142=>746,143=>746,144=>746,145=>746,146=>746,147=>746,148=>746,149=>746,150=>746,151=>746,152=>746,153=>746,154=>746,155=>746,156=>746,157=>746,158=>746,159=>746,160=>746,161=>732,162=>544,163=>544,164=>910,165=>667,166=>760,167=>760,168=>776,169=>595,170=>694,171=>626,172=>788,173=>788,174=>788,175=>788,176=>788,177=>788,178=>788,179=>788,180=>788,181=>788,182=>788,183=>788,184=>788,185=>788,186=>788,187=>788,188=>788,189=>788,190=>788,191=>788,192=>788,193=>788,194=>788,195=>788,196=>788,197=>788,198=>788,199=>788,200=>788,201=>788,202=>788,203=>788,204=>788,205=>788,206=>788,207=>788,208=>788,209=>788,210=>788,211=>788,212=>894,213=>838,214=>1016,215=>458,216=>748,217=>924,218=>748,219=>918,220=>927,221=>928,222=>928,223=>834,224=>873,225=>828,226=>924,227=>924,228=>917,229=>930,230=>931,231=>463,232=>883,233=>836,234=>836,235=>867,236=>867,237=>696,238=>696,239=>874,240=>746,241=>874,242=>760,243=>946,244=>771,245=>865,246=>771,247=>888,248=>967,249=>888,250=>831,251=>873,252=>927,253=>970,254=>918,255=>746);
|
||||
// --- EOF ---
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,996 @@
|
||||
<?php
|
||||
//============================================================+
|
||||
// File name : pdf417.php
|
||||
// Version : 1.0.005
|
||||
// Begin : 2010-06-03
|
||||
// Last Update : 2014-04-25
|
||||
// Author : Nicola Asuni - Tecnick.com LTD - www.tecnick.com - info@tecnick.com
|
||||
// License : GNU-LGPL v3 (http://www.gnu.org/copyleft/lesser.html)
|
||||
// -------------------------------------------------------------------
|
||||
// Copyright (C) 2010-2013 Nicola Asuni - Tecnick.com LTD
|
||||
//
|
||||
// This file is part of TCPDF software library.
|
||||
//
|
||||
// TCPDF is free software: you can redistribute it and/or modify it
|
||||
// under the terms of the GNU Lesser General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// TCPDF 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 Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License
|
||||
// along with TCPDF. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// See LICENSE.TXT file for more information.
|
||||
// -------------------------------------------------------------------
|
||||
//
|
||||
// DESCRIPTION :
|
||||
//
|
||||
// Class to create PDF417 barcode arrays for TCPDF class.
|
||||
// PDF417 (ISO/IEC 15438:2006) is a 2-dimensional stacked bar code created by Symbol Technologies in 1991.
|
||||
// It is one of the most popular 2D codes because of its ability to be read with slightly modified handheld laser or linear CCD scanners.
|
||||
// TECHNICAL DATA / FEATURES OF PDF417:
|
||||
// Encodable Character Set: All 128 ASCII Characters (including extended)
|
||||
// Code Type: Continuous, Multi-Row
|
||||
// Symbol Height: 3 - 90 Rows
|
||||
// Symbol Width: 90X - 583X
|
||||
// Bidirectional Decoding: Yes
|
||||
// Error Correction Characters: 2 - 512
|
||||
// Maximum Data Characters: 1850 text, 2710 digits, 1108 bytes
|
||||
//
|
||||
//============================================================+
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Class to create PDF417 barcode arrays for TCPDF class.
|
||||
* PDF417 (ISO/IEC 15438:2006) is a 2-dimensional stacked bar code created by Symbol Technologies in 1991.
|
||||
* (requires PHP bcmath extension)
|
||||
* @package com.tecnick.tcpdf
|
||||
* @author Nicola Asuni
|
||||
* @version 1.0.005
|
||||
*/
|
||||
|
||||
// definitions
|
||||
if (!defined('PDF417DEFS')) {
|
||||
|
||||
/**
|
||||
* Indicate that definitions for this class are set
|
||||
*/
|
||||
define('PDF417DEFS', true);
|
||||
|
||||
// -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Row height respect X dimension of single module
|
||||
*/
|
||||
define('ROWHEIGHT', 4);
|
||||
|
||||
/**
|
||||
* Horizontal quiet zone in modules
|
||||
*/
|
||||
define('QUIETH', 2);
|
||||
|
||||
/**
|
||||
* Vertical quiet zone in modules
|
||||
*/
|
||||
define('QUIETV', 2);
|
||||
|
||||
} // end of definitions
|
||||
|
||||
// #*#*#*#*#*#*#*#*#*#*#*#*#*#*#*#*#*#*#*#*#*#*#*#*#*#*#*#*#
|
||||
|
||||
/**
|
||||
* @class PDF417
|
||||
* Class to create PDF417 barcode arrays for TCPDF class.
|
||||
* PDF417 (ISO/IEC 15438:2006) is a 2-dimensional stacked bar code created by Symbol Technologies in 1991.
|
||||
* @package com.tecnick.tcpdf
|
||||
* @author Nicola Asuni
|
||||
* @version 1.0.003
|
||||
*/
|
||||
class PDF417 {
|
||||
|
||||
/**
|
||||
* Barcode array to be returned which is readable by TCPDF.
|
||||
* @protected
|
||||
*/
|
||||
protected $barcode_array = array();
|
||||
|
||||
/**
|
||||
* Start pattern.
|
||||
* @protected
|
||||
*/
|
||||
protected $start_pattern = '11111111010101000';
|
||||
|
||||
/**
|
||||
* Stop pattern.
|
||||
* @protected
|
||||
*/
|
||||
protected $stop_pattern = '111111101000101001';
|
||||
|
||||
/**
|
||||
* Array of text Compaction Sub-Modes (values 0xFB - 0xFF are used for submode changers).
|
||||
* @protected
|
||||
*/
|
||||
protected $textsubmodes = array(
|
||||
array(0x41,0x42,0x43,0x44,0x45,0x46,0x47,0x48,0x49,0x4a,0x4b,0x4c,0x4d,0x4e,0x4f,0x50,0x51,0x52,0x53,0x54,0x55,0x56,0x57,0x58,0x59,0x5a,0x20,0xFD,0xFE,0xFF), // Alpha
|
||||
array(0x61,0x62,0x63,0x64,0x65,0x66,0x67,0x68,0x69,0x6a,0x6b,0x6c,0x6d,0x6e,0x6f,0x70,0x71,0x72,0x73,0x74,0x75,0x76,0x77,0x78,0x79,0x7a,0x20,0xFD,0xFE,0xFF), // Lower
|
||||
array(0x30,0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39,0x26,0x0d,0x09,0x2c,0x3a,0x23,0x2d,0x2e,0x24,0x2f,0x2b,0x25,0x2a,0x3d,0x5e,0xFB,0x20,0xFD,0xFE,0xFF), // Mixed
|
||||
array(0x3b,0x3c,0x3e,0x40,0x5b,0x5c,0x5d,0x5f,0x60,0x7e,0x21,0x0d,0x09,0x2c,0x3a,0x0a,0x2d,0x2e,0x24,0x2f,0x22,0x7c,0x2a,0x28,0x29,0x3f,0x7b,0x7d,0x27,0xFF) // Puntuaction
|
||||
);
|
||||
|
||||
/**
|
||||
* Array of switching codes for Text Compaction Sub-Modes.
|
||||
* @protected
|
||||
*/
|
||||
protected $textlatch = array(
|
||||
'01' => array(27), '02' => array(28), '03' => array(28,25), //
|
||||
'10' => array(28,28), '12' => array(28), '13' => array(28,25), //
|
||||
'20' => array(28), '21' => array(27), '23' => array(25), //
|
||||
'30' => array(29), '31' => array(29,27), '32' => array(29,28) //
|
||||
);
|
||||
|
||||
/**
|
||||
* Clusters of codewords (0, 3, 6)<br/>
|
||||
* Values are hex equivalents of binary representation of bars (1 = bar, 0 = space).<br/>
|
||||
* The codewords numbered from 900 to 928 have special meaning, some enable to switch between modes in order to optimise the code:<ul>
|
||||
* <li>900 : Switch to "Text" mode</li>
|
||||
* <li>901 : Switch to "Byte" mode</li>
|
||||
* <li>902 : Switch to "Numeric" mode</li>
|
||||
* <li>903 - 912 : Reserved</li>
|
||||
* <li>913 : Switch to "Octet" only for the next codeword</li>
|
||||
* <li>914 - 920 : Reserved</li>
|
||||
* <li>921 : Initialization</li>
|
||||
* <li>922 : Terminator codeword for Macro PDF control block</li>
|
||||
* <li>923 : Sequence tag to identify the beginning of optional fields in the Macro PDF control block</li>
|
||||
* <li>924 : Switch to "Byte" mode (If the total number of byte is multiple of 6)</li>
|
||||
* <li>925 : Identifier for a user defined Extended Channel Interpretation (ECI)</li>
|
||||
* <li>926 : Identifier for a general purpose ECI format</li>
|
||||
* <li>927 : Identifier for an ECI of a character set or code page</li>
|
||||
* <li>928 : Macro marker codeword to indicate the beginning of a Macro PDF Control Block</li>
|
||||
* </ul>
|
||||
* @protected
|
||||
*/
|
||||
protected $clusters = array(
|
||||
array( // cluster 0 -----------------------------------------------------------------------
|
||||
0x1d5c0,0x1eaf0,0x1f57c,0x1d4e0,0x1ea78,0x1f53e,0x1a8c0,0x1d470,0x1a860,0x15040, // 10
|
||||
0x1a830,0x15020,0x1adc0,0x1d6f0,0x1eb7c,0x1ace0,0x1d678,0x1eb3e,0x158c0,0x1ac70, // 20
|
||||
0x15860,0x15dc0,0x1aef0,0x1d77c,0x15ce0,0x1ae78,0x1d73e,0x15c70,0x1ae3c,0x15ef0, // 30
|
||||
0x1af7c,0x15e78,0x1af3e,0x15f7c,0x1f5fa,0x1d2e0,0x1e978,0x1f4be,0x1a4c0,0x1d270, // 40
|
||||
0x1e93c,0x1a460,0x1d238,0x14840,0x1a430,0x1d21c,0x14820,0x1a418,0x14810,0x1a6e0, // 50
|
||||
0x1d378,0x1e9be,0x14cc0,0x1a670,0x1d33c,0x14c60,0x1a638,0x1d31e,0x14c30,0x1a61c, // 60
|
||||
0x14ee0,0x1a778,0x1d3be,0x14e70,0x1a73c,0x14e38,0x1a71e,0x14f78,0x1a7be,0x14f3c, // 70
|
||||
0x14f1e,0x1a2c0,0x1d170,0x1e8bc,0x1a260,0x1d138,0x1e89e,0x14440,0x1a230,0x1d11c, // 80
|
||||
0x14420,0x1a218,0x14410,0x14408,0x146c0,0x1a370,0x1d1bc,0x14660,0x1a338,0x1d19e, // 90
|
||||
0x14630,0x1a31c,0x14618,0x1460c,0x14770,0x1a3bc,0x14738,0x1a39e,0x1471c,0x147bc, // 100
|
||||
0x1a160,0x1d0b8,0x1e85e,0x14240,0x1a130,0x1d09c,0x14220,0x1a118,0x1d08e,0x14210, // 110
|
||||
0x1a10c,0x14208,0x1a106,0x14360,0x1a1b8,0x1d0de,0x14330,0x1a19c,0x14318,0x1a18e, // 120
|
||||
0x1430c,0x14306,0x1a1de,0x1438e,0x14140,0x1a0b0,0x1d05c,0x14120,0x1a098,0x1d04e, // 130
|
||||
0x14110,0x1a08c,0x14108,0x1a086,0x14104,0x141b0,0x14198,0x1418c,0x140a0,0x1d02e, // 140
|
||||
0x1a04c,0x1a046,0x14082,0x1cae0,0x1e578,0x1f2be,0x194c0,0x1ca70,0x1e53c,0x19460, // 150
|
||||
0x1ca38,0x1e51e,0x12840,0x19430,0x12820,0x196e0,0x1cb78,0x1e5be,0x12cc0,0x19670, // 160
|
||||
0x1cb3c,0x12c60,0x19638,0x12c30,0x12c18,0x12ee0,0x19778,0x1cbbe,0x12e70,0x1973c, // 170
|
||||
0x12e38,0x12e1c,0x12f78,0x197be,0x12f3c,0x12fbe,0x1dac0,0x1ed70,0x1f6bc,0x1da60, // 180
|
||||
0x1ed38,0x1f69e,0x1b440,0x1da30,0x1ed1c,0x1b420,0x1da18,0x1ed0e,0x1b410,0x1da0c, // 190
|
||||
0x192c0,0x1c970,0x1e4bc,0x1b6c0,0x19260,0x1c938,0x1e49e,0x1b660,0x1db38,0x1ed9e, // 200
|
||||
0x16c40,0x12420,0x19218,0x1c90e,0x16c20,0x1b618,0x16c10,0x126c0,0x19370,0x1c9bc, // 210
|
||||
0x16ec0,0x12660,0x19338,0x1c99e,0x16e60,0x1b738,0x1db9e,0x16e30,0x12618,0x16e18, // 220
|
||||
0x12770,0x193bc,0x16f70,0x12738,0x1939e,0x16f38,0x1b79e,0x16f1c,0x127bc,0x16fbc, // 230
|
||||
0x1279e,0x16f9e,0x1d960,0x1ecb8,0x1f65e,0x1b240,0x1d930,0x1ec9c,0x1b220,0x1d918, // 240
|
||||
0x1ec8e,0x1b210,0x1d90c,0x1b208,0x1b204,0x19160,0x1c8b8,0x1e45e,0x1b360,0x19130, // 250
|
||||
0x1c89c,0x16640,0x12220,0x1d99c,0x1c88e,0x16620,0x12210,0x1910c,0x16610,0x1b30c, // 260
|
||||
0x19106,0x12204,0x12360,0x191b8,0x1c8de,0x16760,0x12330,0x1919c,0x16730,0x1b39c, // 270
|
||||
0x1918e,0x16718,0x1230c,0x12306,0x123b8,0x191de,0x167b8,0x1239c,0x1679c,0x1238e, // 280
|
||||
0x1678e,0x167de,0x1b140,0x1d8b0,0x1ec5c,0x1b120,0x1d898,0x1ec4e,0x1b110,0x1d88c, // 290
|
||||
0x1b108,0x1d886,0x1b104,0x1b102,0x12140,0x190b0,0x1c85c,0x16340,0x12120,0x19098, // 300
|
||||
0x1c84e,0x16320,0x1b198,0x1d8ce,0x16310,0x12108,0x19086,0x16308,0x1b186,0x16304, // 310
|
||||
0x121b0,0x190dc,0x163b0,0x12198,0x190ce,0x16398,0x1b1ce,0x1638c,0x12186,0x16386, // 320
|
||||
0x163dc,0x163ce,0x1b0a0,0x1d858,0x1ec2e,0x1b090,0x1d84c,0x1b088,0x1d846,0x1b084, // 330
|
||||
0x1b082,0x120a0,0x19058,0x1c82e,0x161a0,0x12090,0x1904c,0x16190,0x1b0cc,0x19046, // 340
|
||||
0x16188,0x12084,0x16184,0x12082,0x120d8,0x161d8,0x161cc,0x161c6,0x1d82c,0x1d826, // 350
|
||||
0x1b042,0x1902c,0x12048,0x160c8,0x160c4,0x160c2,0x18ac0,0x1c570,0x1e2bc,0x18a60, // 360
|
||||
0x1c538,0x11440,0x18a30,0x1c51c,0x11420,0x18a18,0x11410,0x11408,0x116c0,0x18b70, // 370
|
||||
0x1c5bc,0x11660,0x18b38,0x1c59e,0x11630,0x18b1c,0x11618,0x1160c,0x11770,0x18bbc, // 380
|
||||
0x11738,0x18b9e,0x1171c,0x117bc,0x1179e,0x1cd60,0x1e6b8,0x1f35e,0x19a40,0x1cd30, // 390
|
||||
0x1e69c,0x19a20,0x1cd18,0x1e68e,0x19a10,0x1cd0c,0x19a08,0x1cd06,0x18960,0x1c4b8, // 400
|
||||
0x1e25e,0x19b60,0x18930,0x1c49c,0x13640,0x11220,0x1cd9c,0x1c48e,0x13620,0x19b18, // 410
|
||||
0x1890c,0x13610,0x11208,0x13608,0x11360,0x189b8,0x1c4de,0x13760,0x11330,0x1cdde, // 420
|
||||
0x13730,0x19b9c,0x1898e,0x13718,0x1130c,0x1370c,0x113b8,0x189de,0x137b8,0x1139c, // 430
|
||||
0x1379c,0x1138e,0x113de,0x137de,0x1dd40,0x1eeb0,0x1f75c,0x1dd20,0x1ee98,0x1f74e, // 440
|
||||
0x1dd10,0x1ee8c,0x1dd08,0x1ee86,0x1dd04,0x19940,0x1ccb0,0x1e65c,0x1bb40,0x19920, // 450
|
||||
0x1eedc,0x1e64e,0x1bb20,0x1dd98,0x1eece,0x1bb10,0x19908,0x1cc86,0x1bb08,0x1dd86, // 460
|
||||
0x19902,0x11140,0x188b0,0x1c45c,0x13340,0x11120,0x18898,0x1c44e,0x17740,0x13320, // 470
|
||||
0x19998,0x1ccce,0x17720,0x1bb98,0x1ddce,0x18886,0x17710,0x13308,0x19986,0x17708, // 480
|
||||
0x11102,0x111b0,0x188dc,0x133b0,0x11198,0x188ce,0x177b0,0x13398,0x199ce,0x17798, // 490
|
||||
0x1bbce,0x11186,0x13386,0x111dc,0x133dc,0x111ce,0x177dc,0x133ce,0x1dca0,0x1ee58, // 500
|
||||
0x1f72e,0x1dc90,0x1ee4c,0x1dc88,0x1ee46,0x1dc84,0x1dc82,0x198a0,0x1cc58,0x1e62e, // 510
|
||||
0x1b9a0,0x19890,0x1ee6e,0x1b990,0x1dccc,0x1cc46,0x1b988,0x19884,0x1b984,0x19882, // 520
|
||||
0x1b982,0x110a0,0x18858,0x1c42e,0x131a0,0x11090,0x1884c,0x173a0,0x13190,0x198cc, // 530
|
||||
0x18846,0x17390,0x1b9cc,0x11084,0x17388,0x13184,0x11082,0x13182,0x110d8,0x1886e, // 540
|
||||
0x131d8,0x110cc,0x173d8,0x131cc,0x110c6,0x173cc,0x131c6,0x110ee,0x173ee,0x1dc50, // 550
|
||||
0x1ee2c,0x1dc48,0x1ee26,0x1dc44,0x1dc42,0x19850,0x1cc2c,0x1b8d0,0x19848,0x1cc26, // 560
|
||||
0x1b8c8,0x1dc66,0x1b8c4,0x19842,0x1b8c2,0x11050,0x1882c,0x130d0,0x11048,0x18826, // 570
|
||||
0x171d0,0x130c8,0x19866,0x171c8,0x1b8e6,0x11042,0x171c4,0x130c2,0x171c2,0x130ec, // 580
|
||||
0x171ec,0x171e6,0x1ee16,0x1dc22,0x1cc16,0x19824,0x19822,0x11028,0x13068,0x170e8, // 590
|
||||
0x11022,0x13062,0x18560,0x10a40,0x18530,0x10a20,0x18518,0x1c28e,0x10a10,0x1850c, // 600
|
||||
0x10a08,0x18506,0x10b60,0x185b8,0x1c2de,0x10b30,0x1859c,0x10b18,0x1858e,0x10b0c, // 610
|
||||
0x10b06,0x10bb8,0x185de,0x10b9c,0x10b8e,0x10bde,0x18d40,0x1c6b0,0x1e35c,0x18d20, // 620
|
||||
0x1c698,0x18d10,0x1c68c,0x18d08,0x1c686,0x18d04,0x10940,0x184b0,0x1c25c,0x11b40, // 630
|
||||
0x10920,0x1c6dc,0x1c24e,0x11b20,0x18d98,0x1c6ce,0x11b10,0x10908,0x18486,0x11b08, // 640
|
||||
0x18d86,0x10902,0x109b0,0x184dc,0x11bb0,0x10998,0x184ce,0x11b98,0x18dce,0x11b8c, // 650
|
||||
0x10986,0x109dc,0x11bdc,0x109ce,0x11bce,0x1cea0,0x1e758,0x1f3ae,0x1ce90,0x1e74c, // 660
|
||||
0x1ce88,0x1e746,0x1ce84,0x1ce82,0x18ca0,0x1c658,0x19da0,0x18c90,0x1c64c,0x19d90, // 670
|
||||
0x1cecc,0x1c646,0x19d88,0x18c84,0x19d84,0x18c82,0x19d82,0x108a0,0x18458,0x119a0, // 680
|
||||
0x10890,0x1c66e,0x13ba0,0x11990,0x18ccc,0x18446,0x13b90,0x19dcc,0x10884,0x13b88, // 690
|
||||
0x11984,0x10882,0x11982,0x108d8,0x1846e,0x119d8,0x108cc,0x13bd8,0x119cc,0x108c6, // 700
|
||||
0x13bcc,0x119c6,0x108ee,0x119ee,0x13bee,0x1ef50,0x1f7ac,0x1ef48,0x1f7a6,0x1ef44, // 710
|
||||
0x1ef42,0x1ce50,0x1e72c,0x1ded0,0x1ef6c,0x1e726,0x1dec8,0x1ef66,0x1dec4,0x1ce42, // 720
|
||||
0x1dec2,0x18c50,0x1c62c,0x19cd0,0x18c48,0x1c626,0x1bdd0,0x19cc8,0x1ce66,0x1bdc8, // 730
|
||||
0x1dee6,0x18c42,0x1bdc4,0x19cc2,0x1bdc2,0x10850,0x1842c,0x118d0,0x10848,0x18426, // 740
|
||||
0x139d0,0x118c8,0x18c66,0x17bd0,0x139c8,0x19ce6,0x10842,0x17bc8,0x1bde6,0x118c2, // 750
|
||||
0x17bc4,0x1086c,0x118ec,0x10866,0x139ec,0x118e6,0x17bec,0x139e6,0x17be6,0x1ef28, // 760
|
||||
0x1f796,0x1ef24,0x1ef22,0x1ce28,0x1e716,0x1de68,0x1ef36,0x1de64,0x1ce22,0x1de62, // 770
|
||||
0x18c28,0x1c616,0x19c68,0x18c24,0x1bce8,0x19c64,0x18c22,0x1bce4,0x19c62,0x1bce2, // 780
|
||||
0x10828,0x18416,0x11868,0x18c36,0x138e8,0x11864,0x10822,0x179e8,0x138e4,0x11862, // 790
|
||||
0x179e4,0x138e2,0x179e2,0x11876,0x179f6,0x1ef12,0x1de34,0x1de32,0x19c34,0x1bc74, // 800
|
||||
0x1bc72,0x11834,0x13874,0x178f4,0x178f2,0x10540,0x10520,0x18298,0x10510,0x10508, // 810
|
||||
0x10504,0x105b0,0x10598,0x1058c,0x10586,0x105dc,0x105ce,0x186a0,0x18690,0x1c34c, // 820
|
||||
0x18688,0x1c346,0x18684,0x18682,0x104a0,0x18258,0x10da0,0x186d8,0x1824c,0x10d90, // 830
|
||||
0x186cc,0x10d88,0x186c6,0x10d84,0x10482,0x10d82,0x104d8,0x1826e,0x10dd8,0x186ee, // 840
|
||||
0x10dcc,0x104c6,0x10dc6,0x104ee,0x10dee,0x1c750,0x1c748,0x1c744,0x1c742,0x18650, // 850
|
||||
0x18ed0,0x1c76c,0x1c326,0x18ec8,0x1c766,0x18ec4,0x18642,0x18ec2,0x10450,0x10cd0, // 860
|
||||
0x10448,0x18226,0x11dd0,0x10cc8,0x10444,0x11dc8,0x10cc4,0x10442,0x11dc4,0x10cc2, // 870
|
||||
0x1046c,0x10cec,0x10466,0x11dec,0x10ce6,0x11de6,0x1e7a8,0x1e7a4,0x1e7a2,0x1c728, // 880
|
||||
0x1cf68,0x1e7b6,0x1cf64,0x1c722,0x1cf62,0x18628,0x1c316,0x18e68,0x1c736,0x19ee8, // 890
|
||||
0x18e64,0x18622,0x19ee4,0x18e62,0x19ee2,0x10428,0x18216,0x10c68,0x18636,0x11ce8, // 900
|
||||
0x10c64,0x10422,0x13de8,0x11ce4,0x10c62,0x13de4,0x11ce2,0x10436,0x10c76,0x11cf6, // 910
|
||||
0x13df6,0x1f7d4,0x1f7d2,0x1e794,0x1efb4,0x1e792,0x1efb2,0x1c714,0x1cf34,0x1c712, // 920
|
||||
0x1df74,0x1cf32,0x1df72,0x18614,0x18e34,0x18612,0x19e74,0x18e32,0x1bef4), // 929
|
||||
array( // cluster 3 -----------------------------------------------------------------------
|
||||
0x1f560,0x1fab8,0x1ea40,0x1f530,0x1fa9c,0x1ea20,0x1f518,0x1fa8e,0x1ea10,0x1f50c, // 10
|
||||
0x1ea08,0x1f506,0x1ea04,0x1eb60,0x1f5b8,0x1fade,0x1d640,0x1eb30,0x1f59c,0x1d620, // 20
|
||||
0x1eb18,0x1f58e,0x1d610,0x1eb0c,0x1d608,0x1eb06,0x1d604,0x1d760,0x1ebb8,0x1f5de, // 30
|
||||
0x1ae40,0x1d730,0x1eb9c,0x1ae20,0x1d718,0x1eb8e,0x1ae10,0x1d70c,0x1ae08,0x1d706, // 40
|
||||
0x1ae04,0x1af60,0x1d7b8,0x1ebde,0x15e40,0x1af30,0x1d79c,0x15e20,0x1af18,0x1d78e, // 50
|
||||
0x15e10,0x1af0c,0x15e08,0x1af06,0x15f60,0x1afb8,0x1d7de,0x15f30,0x1af9c,0x15f18, // 60
|
||||
0x1af8e,0x15f0c,0x15fb8,0x1afde,0x15f9c,0x15f8e,0x1e940,0x1f4b0,0x1fa5c,0x1e920, // 70
|
||||
0x1f498,0x1fa4e,0x1e910,0x1f48c,0x1e908,0x1f486,0x1e904,0x1e902,0x1d340,0x1e9b0, // 80
|
||||
0x1f4dc,0x1d320,0x1e998,0x1f4ce,0x1d310,0x1e98c,0x1d308,0x1e986,0x1d304,0x1d302, // 90
|
||||
0x1a740,0x1d3b0,0x1e9dc,0x1a720,0x1d398,0x1e9ce,0x1a710,0x1d38c,0x1a708,0x1d386, // 100
|
||||
0x1a704,0x1a702,0x14f40,0x1a7b0,0x1d3dc,0x14f20,0x1a798,0x1d3ce,0x14f10,0x1a78c, // 110
|
||||
0x14f08,0x1a786,0x14f04,0x14fb0,0x1a7dc,0x14f98,0x1a7ce,0x14f8c,0x14f86,0x14fdc, // 120
|
||||
0x14fce,0x1e8a0,0x1f458,0x1fa2e,0x1e890,0x1f44c,0x1e888,0x1f446,0x1e884,0x1e882, // 130
|
||||
0x1d1a0,0x1e8d8,0x1f46e,0x1d190,0x1e8cc,0x1d188,0x1e8c6,0x1d184,0x1d182,0x1a3a0, // 140
|
||||
0x1d1d8,0x1e8ee,0x1a390,0x1d1cc,0x1a388,0x1d1c6,0x1a384,0x1a382,0x147a0,0x1a3d8, // 150
|
||||
0x1d1ee,0x14790,0x1a3cc,0x14788,0x1a3c6,0x14784,0x14782,0x147d8,0x1a3ee,0x147cc, // 160
|
||||
0x147c6,0x147ee,0x1e850,0x1f42c,0x1e848,0x1f426,0x1e844,0x1e842,0x1d0d0,0x1e86c, // 170
|
||||
0x1d0c8,0x1e866,0x1d0c4,0x1d0c2,0x1a1d0,0x1d0ec,0x1a1c8,0x1d0e6,0x1a1c4,0x1a1c2, // 180
|
||||
0x143d0,0x1a1ec,0x143c8,0x1a1e6,0x143c4,0x143c2,0x143ec,0x143e6,0x1e828,0x1f416, // 190
|
||||
0x1e824,0x1e822,0x1d068,0x1e836,0x1d064,0x1d062,0x1a0e8,0x1d076,0x1a0e4,0x1a0e2, // 200
|
||||
0x141e8,0x1a0f6,0x141e4,0x141e2,0x1e814,0x1e812,0x1d034,0x1d032,0x1a074,0x1a072, // 210
|
||||
0x1e540,0x1f2b0,0x1f95c,0x1e520,0x1f298,0x1f94e,0x1e510,0x1f28c,0x1e508,0x1f286, // 220
|
||||
0x1e504,0x1e502,0x1cb40,0x1e5b0,0x1f2dc,0x1cb20,0x1e598,0x1f2ce,0x1cb10,0x1e58c, // 230
|
||||
0x1cb08,0x1e586,0x1cb04,0x1cb02,0x19740,0x1cbb0,0x1e5dc,0x19720,0x1cb98,0x1e5ce, // 240
|
||||
0x19710,0x1cb8c,0x19708,0x1cb86,0x19704,0x19702,0x12f40,0x197b0,0x1cbdc,0x12f20, // 250
|
||||
0x19798,0x1cbce,0x12f10,0x1978c,0x12f08,0x19786,0x12f04,0x12fb0,0x197dc,0x12f98, // 260
|
||||
0x197ce,0x12f8c,0x12f86,0x12fdc,0x12fce,0x1f6a0,0x1fb58,0x16bf0,0x1f690,0x1fb4c, // 270
|
||||
0x169f8,0x1f688,0x1fb46,0x168fc,0x1f684,0x1f682,0x1e4a0,0x1f258,0x1f92e,0x1eda0, // 280
|
||||
0x1e490,0x1fb6e,0x1ed90,0x1f6cc,0x1f246,0x1ed88,0x1e484,0x1ed84,0x1e482,0x1ed82, // 290
|
||||
0x1c9a0,0x1e4d8,0x1f26e,0x1dba0,0x1c990,0x1e4cc,0x1db90,0x1edcc,0x1e4c6,0x1db88, // 300
|
||||
0x1c984,0x1db84,0x1c982,0x1db82,0x193a0,0x1c9d8,0x1e4ee,0x1b7a0,0x19390,0x1c9cc, // 310
|
||||
0x1b790,0x1dbcc,0x1c9c6,0x1b788,0x19384,0x1b784,0x19382,0x1b782,0x127a0,0x193d8, // 320
|
||||
0x1c9ee,0x16fa0,0x12790,0x193cc,0x16f90,0x1b7cc,0x193c6,0x16f88,0x12784,0x16f84, // 330
|
||||
0x12782,0x127d8,0x193ee,0x16fd8,0x127cc,0x16fcc,0x127c6,0x16fc6,0x127ee,0x1f650, // 340
|
||||
0x1fb2c,0x165f8,0x1f648,0x1fb26,0x164fc,0x1f644,0x1647e,0x1f642,0x1e450,0x1f22c, // 350
|
||||
0x1ecd0,0x1e448,0x1f226,0x1ecc8,0x1f666,0x1ecc4,0x1e442,0x1ecc2,0x1c8d0,0x1e46c, // 360
|
||||
0x1d9d0,0x1c8c8,0x1e466,0x1d9c8,0x1ece6,0x1d9c4,0x1c8c2,0x1d9c2,0x191d0,0x1c8ec, // 370
|
||||
0x1b3d0,0x191c8,0x1c8e6,0x1b3c8,0x1d9e6,0x1b3c4,0x191c2,0x1b3c2,0x123d0,0x191ec, // 380
|
||||
0x167d0,0x123c8,0x191e6,0x167c8,0x1b3e6,0x167c4,0x123c2,0x167c2,0x123ec,0x167ec, // 390
|
||||
0x123e6,0x167e6,0x1f628,0x1fb16,0x162fc,0x1f624,0x1627e,0x1f622,0x1e428,0x1f216, // 400
|
||||
0x1ec68,0x1f636,0x1ec64,0x1e422,0x1ec62,0x1c868,0x1e436,0x1d8e8,0x1c864,0x1d8e4, // 410
|
||||
0x1c862,0x1d8e2,0x190e8,0x1c876,0x1b1e8,0x1d8f6,0x1b1e4,0x190e2,0x1b1e2,0x121e8, // 420
|
||||
0x190f6,0x163e8,0x121e4,0x163e4,0x121e2,0x163e2,0x121f6,0x163f6,0x1f614,0x1617e, // 430
|
||||
0x1f612,0x1e414,0x1ec34,0x1e412,0x1ec32,0x1c834,0x1d874,0x1c832,0x1d872,0x19074, // 440
|
||||
0x1b0f4,0x19072,0x1b0f2,0x120f4,0x161f4,0x120f2,0x161f2,0x1f60a,0x1e40a,0x1ec1a, // 450
|
||||
0x1c81a,0x1d83a,0x1903a,0x1b07a,0x1e2a0,0x1f158,0x1f8ae,0x1e290,0x1f14c,0x1e288, // 460
|
||||
0x1f146,0x1e284,0x1e282,0x1c5a0,0x1e2d8,0x1f16e,0x1c590,0x1e2cc,0x1c588,0x1e2c6, // 470
|
||||
0x1c584,0x1c582,0x18ba0,0x1c5d8,0x1e2ee,0x18b90,0x1c5cc,0x18b88,0x1c5c6,0x18b84, // 480
|
||||
0x18b82,0x117a0,0x18bd8,0x1c5ee,0x11790,0x18bcc,0x11788,0x18bc6,0x11784,0x11782, // 490
|
||||
0x117d8,0x18bee,0x117cc,0x117c6,0x117ee,0x1f350,0x1f9ac,0x135f8,0x1f348,0x1f9a6, // 500
|
||||
0x134fc,0x1f344,0x1347e,0x1f342,0x1e250,0x1f12c,0x1e6d0,0x1e248,0x1f126,0x1e6c8, // 510
|
||||
0x1f366,0x1e6c4,0x1e242,0x1e6c2,0x1c4d0,0x1e26c,0x1cdd0,0x1c4c8,0x1e266,0x1cdc8, // 520
|
||||
0x1e6e6,0x1cdc4,0x1c4c2,0x1cdc2,0x189d0,0x1c4ec,0x19bd0,0x189c8,0x1c4e6,0x19bc8, // 530
|
||||
0x1cde6,0x19bc4,0x189c2,0x19bc2,0x113d0,0x189ec,0x137d0,0x113c8,0x189e6,0x137c8, // 540
|
||||
0x19be6,0x137c4,0x113c2,0x137c2,0x113ec,0x137ec,0x113e6,0x137e6,0x1fba8,0x175f0, // 550
|
||||
0x1bafc,0x1fba4,0x174f8,0x1ba7e,0x1fba2,0x1747c,0x1743e,0x1f328,0x1f996,0x132fc, // 560
|
||||
0x1f768,0x1fbb6,0x176fc,0x1327e,0x1f764,0x1f322,0x1767e,0x1f762,0x1e228,0x1f116, // 570
|
||||
0x1e668,0x1e224,0x1eee8,0x1f776,0x1e222,0x1eee4,0x1e662,0x1eee2,0x1c468,0x1e236, // 580
|
||||
0x1cce8,0x1c464,0x1dde8,0x1cce4,0x1c462,0x1dde4,0x1cce2,0x1dde2,0x188e8,0x1c476, // 590
|
||||
0x199e8,0x188e4,0x1bbe8,0x199e4,0x188e2,0x1bbe4,0x199e2,0x1bbe2,0x111e8,0x188f6, // 600
|
||||
0x133e8,0x111e4,0x177e8,0x133e4,0x111e2,0x177e4,0x133e2,0x177e2,0x111f6,0x133f6, // 610
|
||||
0x1fb94,0x172f8,0x1b97e,0x1fb92,0x1727c,0x1723e,0x1f314,0x1317e,0x1f734,0x1f312, // 620
|
||||
0x1737e,0x1f732,0x1e214,0x1e634,0x1e212,0x1ee74,0x1e632,0x1ee72,0x1c434,0x1cc74, // 630
|
||||
0x1c432,0x1dcf4,0x1cc72,0x1dcf2,0x18874,0x198f4,0x18872,0x1b9f4,0x198f2,0x1b9f2, // 640
|
||||
0x110f4,0x131f4,0x110f2,0x173f4,0x131f2,0x173f2,0x1fb8a,0x1717c,0x1713e,0x1f30a, // 650
|
||||
0x1f71a,0x1e20a,0x1e61a,0x1ee3a,0x1c41a,0x1cc3a,0x1dc7a,0x1883a,0x1987a,0x1b8fa, // 660
|
||||
0x1107a,0x130fa,0x171fa,0x170be,0x1e150,0x1f0ac,0x1e148,0x1f0a6,0x1e144,0x1e142, // 670
|
||||
0x1c2d0,0x1e16c,0x1c2c8,0x1e166,0x1c2c4,0x1c2c2,0x185d0,0x1c2ec,0x185c8,0x1c2e6, // 680
|
||||
0x185c4,0x185c2,0x10bd0,0x185ec,0x10bc8,0x185e6,0x10bc4,0x10bc2,0x10bec,0x10be6, // 690
|
||||
0x1f1a8,0x1f8d6,0x11afc,0x1f1a4,0x11a7e,0x1f1a2,0x1e128,0x1f096,0x1e368,0x1e124, // 700
|
||||
0x1e364,0x1e122,0x1e362,0x1c268,0x1e136,0x1c6e8,0x1c264,0x1c6e4,0x1c262,0x1c6e2, // 710
|
||||
0x184e8,0x1c276,0x18de8,0x184e4,0x18de4,0x184e2,0x18de2,0x109e8,0x184f6,0x11be8, // 720
|
||||
0x109e4,0x11be4,0x109e2,0x11be2,0x109f6,0x11bf6,0x1f9d4,0x13af8,0x19d7e,0x1f9d2, // 730
|
||||
0x13a7c,0x13a3e,0x1f194,0x1197e,0x1f3b4,0x1f192,0x13b7e,0x1f3b2,0x1e114,0x1e334, // 740
|
||||
0x1e112,0x1e774,0x1e332,0x1e772,0x1c234,0x1c674,0x1c232,0x1cef4,0x1c672,0x1cef2, // 750
|
||||
0x18474,0x18cf4,0x18472,0x19df4,0x18cf2,0x19df2,0x108f4,0x119f4,0x108f2,0x13bf4, // 760
|
||||
0x119f2,0x13bf2,0x17af0,0x1bd7c,0x17a78,0x1bd3e,0x17a3c,0x17a1e,0x1f9ca,0x1397c, // 770
|
||||
0x1fbda,0x17b7c,0x1393e,0x17b3e,0x1f18a,0x1f39a,0x1f7ba,0x1e10a,0x1e31a,0x1e73a, // 780
|
||||
0x1ef7a,0x1c21a,0x1c63a,0x1ce7a,0x1defa,0x1843a,0x18c7a,0x19cfa,0x1bdfa,0x1087a, // 790
|
||||
0x118fa,0x139fa,0x17978,0x1bcbe,0x1793c,0x1791e,0x138be,0x179be,0x178bc,0x1789e, // 800
|
||||
0x1785e,0x1e0a8,0x1e0a4,0x1e0a2,0x1c168,0x1e0b6,0x1c164,0x1c162,0x182e8,0x1c176, // 810
|
||||
0x182e4,0x182e2,0x105e8,0x182f6,0x105e4,0x105e2,0x105f6,0x1f0d4,0x10d7e,0x1f0d2, // 820
|
||||
0x1e094,0x1e1b4,0x1e092,0x1e1b2,0x1c134,0x1c374,0x1c132,0x1c372,0x18274,0x186f4, // 830
|
||||
0x18272,0x186f2,0x104f4,0x10df4,0x104f2,0x10df2,0x1f8ea,0x11d7c,0x11d3e,0x1f0ca, // 840
|
||||
0x1f1da,0x1e08a,0x1e19a,0x1e3ba,0x1c11a,0x1c33a,0x1c77a,0x1823a,0x1867a,0x18efa, // 850
|
||||
0x1047a,0x10cfa,0x11dfa,0x13d78,0x19ebe,0x13d3c,0x13d1e,0x11cbe,0x13dbe,0x17d70, // 860
|
||||
0x1bebc,0x17d38,0x1be9e,0x17d1c,0x17d0e,0x13cbc,0x17dbc,0x13c9e,0x17d9e,0x17cb8, // 870
|
||||
0x1be5e,0x17c9c,0x17c8e,0x13c5e,0x17cde,0x17c5c,0x17c4e,0x17c2e,0x1c0b4,0x1c0b2, // 880
|
||||
0x18174,0x18172,0x102f4,0x102f2,0x1e0da,0x1c09a,0x1c1ba,0x1813a,0x1837a,0x1027a, // 890
|
||||
0x106fa,0x10ebe,0x11ebc,0x11e9e,0x13eb8,0x19f5e,0x13e9c,0x13e8e,0x11e5e,0x13ede, // 900
|
||||
0x17eb0,0x1bf5c,0x17e98,0x1bf4e,0x17e8c,0x17e86,0x13e5c,0x17edc,0x13e4e,0x17ece, // 910
|
||||
0x17e58,0x1bf2e,0x17e4c,0x17e46,0x13e2e,0x17e6e,0x17e2c,0x17e26,0x10f5e,0x11f5c, // 920
|
||||
0x11f4e,0x13f58,0x19fae,0x13f4c,0x13f46,0x11f2e,0x13f6e,0x13f2c,0x13f26), // 929
|
||||
array( // cluster 6 -----------------------------------------------------------------------
|
||||
0x1abe0,0x1d5f8,0x153c0,0x1a9f0,0x1d4fc,0x151e0,0x1a8f8,0x1d47e,0x150f0,0x1a87c, // 10
|
||||
0x15078,0x1fad0,0x15be0,0x1adf8,0x1fac8,0x159f0,0x1acfc,0x1fac4,0x158f8,0x1ac7e, // 20
|
||||
0x1fac2,0x1587c,0x1f5d0,0x1faec,0x15df8,0x1f5c8,0x1fae6,0x15cfc,0x1f5c4,0x15c7e, // 30
|
||||
0x1f5c2,0x1ebd0,0x1f5ec,0x1ebc8,0x1f5e6,0x1ebc4,0x1ebc2,0x1d7d0,0x1ebec,0x1d7c8, // 40
|
||||
0x1ebe6,0x1d7c4,0x1d7c2,0x1afd0,0x1d7ec,0x1afc8,0x1d7e6,0x1afc4,0x14bc0,0x1a5f0, // 50
|
||||
0x1d2fc,0x149e0,0x1a4f8,0x1d27e,0x148f0,0x1a47c,0x14878,0x1a43e,0x1483c,0x1fa68, // 60
|
||||
0x14df0,0x1a6fc,0x1fa64,0x14cf8,0x1a67e,0x1fa62,0x14c7c,0x14c3e,0x1f4e8,0x1fa76, // 70
|
||||
0x14efc,0x1f4e4,0x14e7e,0x1f4e2,0x1e9e8,0x1f4f6,0x1e9e4,0x1e9e2,0x1d3e8,0x1e9f6, // 80
|
||||
0x1d3e4,0x1d3e2,0x1a7e8,0x1d3f6,0x1a7e4,0x1a7e2,0x145e0,0x1a2f8,0x1d17e,0x144f0, // 90
|
||||
0x1a27c,0x14478,0x1a23e,0x1443c,0x1441e,0x1fa34,0x146f8,0x1a37e,0x1fa32,0x1467c, // 100
|
||||
0x1463e,0x1f474,0x1477e,0x1f472,0x1e8f4,0x1e8f2,0x1d1f4,0x1d1f2,0x1a3f4,0x1a3f2, // 110
|
||||
0x142f0,0x1a17c,0x14278,0x1a13e,0x1423c,0x1421e,0x1fa1a,0x1437c,0x1433e,0x1f43a, // 120
|
||||
0x1e87a,0x1d0fa,0x14178,0x1a0be,0x1413c,0x1411e,0x141be,0x140bc,0x1409e,0x12bc0, // 130
|
||||
0x195f0,0x1cafc,0x129e0,0x194f8,0x1ca7e,0x128f0,0x1947c,0x12878,0x1943e,0x1283c, // 140
|
||||
0x1f968,0x12df0,0x196fc,0x1f964,0x12cf8,0x1967e,0x1f962,0x12c7c,0x12c3e,0x1f2e8, // 150
|
||||
0x1f976,0x12efc,0x1f2e4,0x12e7e,0x1f2e2,0x1e5e8,0x1f2f6,0x1e5e4,0x1e5e2,0x1cbe8, // 160
|
||||
0x1e5f6,0x1cbe4,0x1cbe2,0x197e8,0x1cbf6,0x197e4,0x197e2,0x1b5e0,0x1daf8,0x1ed7e, // 170
|
||||
0x169c0,0x1b4f0,0x1da7c,0x168e0,0x1b478,0x1da3e,0x16870,0x1b43c,0x16838,0x1b41e, // 180
|
||||
0x1681c,0x125e0,0x192f8,0x1c97e,0x16de0,0x124f0,0x1927c,0x16cf0,0x1b67c,0x1923e, // 190
|
||||
0x16c78,0x1243c,0x16c3c,0x1241e,0x16c1e,0x1f934,0x126f8,0x1937e,0x1fb74,0x1f932, // 200
|
||||
0x16ef8,0x1267c,0x1fb72,0x16e7c,0x1263e,0x16e3e,0x1f274,0x1277e,0x1f6f4,0x1f272, // 210
|
||||
0x16f7e,0x1f6f2,0x1e4f4,0x1edf4,0x1e4f2,0x1edf2,0x1c9f4,0x1dbf4,0x1c9f2,0x1dbf2, // 220
|
||||
0x193f4,0x193f2,0x165c0,0x1b2f0,0x1d97c,0x164e0,0x1b278,0x1d93e,0x16470,0x1b23c, // 230
|
||||
0x16438,0x1b21e,0x1641c,0x1640e,0x122f0,0x1917c,0x166f0,0x12278,0x1913e,0x16678, // 240
|
||||
0x1b33e,0x1663c,0x1221e,0x1661e,0x1f91a,0x1237c,0x1fb3a,0x1677c,0x1233e,0x1673e, // 250
|
||||
0x1f23a,0x1f67a,0x1e47a,0x1ecfa,0x1c8fa,0x1d9fa,0x191fa,0x162e0,0x1b178,0x1d8be, // 260
|
||||
0x16270,0x1b13c,0x16238,0x1b11e,0x1621c,0x1620e,0x12178,0x190be,0x16378,0x1213c, // 270
|
||||
0x1633c,0x1211e,0x1631e,0x121be,0x163be,0x16170,0x1b0bc,0x16138,0x1b09e,0x1611c, // 280
|
||||
0x1610e,0x120bc,0x161bc,0x1209e,0x1619e,0x160b8,0x1b05e,0x1609c,0x1608e,0x1205e, // 290
|
||||
0x160de,0x1605c,0x1604e,0x115e0,0x18af8,0x1c57e,0x114f0,0x18a7c,0x11478,0x18a3e, // 300
|
||||
0x1143c,0x1141e,0x1f8b4,0x116f8,0x18b7e,0x1f8b2,0x1167c,0x1163e,0x1f174,0x1177e, // 310
|
||||
0x1f172,0x1e2f4,0x1e2f2,0x1c5f4,0x1c5f2,0x18bf4,0x18bf2,0x135c0,0x19af0,0x1cd7c, // 320
|
||||
0x134e0,0x19a78,0x1cd3e,0x13470,0x19a3c,0x13438,0x19a1e,0x1341c,0x1340e,0x112f0, // 330
|
||||
0x1897c,0x136f0,0x11278,0x1893e,0x13678,0x19b3e,0x1363c,0x1121e,0x1361e,0x1f89a, // 340
|
||||
0x1137c,0x1f9ba,0x1377c,0x1133e,0x1373e,0x1f13a,0x1f37a,0x1e27a,0x1e6fa,0x1c4fa, // 350
|
||||
0x1cdfa,0x189fa,0x1bae0,0x1dd78,0x1eebe,0x174c0,0x1ba70,0x1dd3c,0x17460,0x1ba38, // 360
|
||||
0x1dd1e,0x17430,0x1ba1c,0x17418,0x1ba0e,0x1740c,0x132e0,0x19978,0x1ccbe,0x176e0, // 370
|
||||
0x13270,0x1993c,0x17670,0x1bb3c,0x1991e,0x17638,0x1321c,0x1761c,0x1320e,0x1760e, // 380
|
||||
0x11178,0x188be,0x13378,0x1113c,0x17778,0x1333c,0x1111e,0x1773c,0x1331e,0x1771e, // 390
|
||||
0x111be,0x133be,0x177be,0x172c0,0x1b970,0x1dcbc,0x17260,0x1b938,0x1dc9e,0x17230, // 400
|
||||
0x1b91c,0x17218,0x1b90e,0x1720c,0x17206,0x13170,0x198bc,0x17370,0x13138,0x1989e, // 410
|
||||
0x17338,0x1b99e,0x1731c,0x1310e,0x1730e,0x110bc,0x131bc,0x1109e,0x173bc,0x1319e, // 420
|
||||
0x1739e,0x17160,0x1b8b8,0x1dc5e,0x17130,0x1b89c,0x17118,0x1b88e,0x1710c,0x17106, // 430
|
||||
0x130b8,0x1985e,0x171b8,0x1309c,0x1719c,0x1308e,0x1718e,0x1105e,0x130de,0x171de, // 440
|
||||
0x170b0,0x1b85c,0x17098,0x1b84e,0x1708c,0x17086,0x1305c,0x170dc,0x1304e,0x170ce, // 450
|
||||
0x17058,0x1b82e,0x1704c,0x17046,0x1302e,0x1706e,0x1702c,0x17026,0x10af0,0x1857c, // 460
|
||||
0x10a78,0x1853e,0x10a3c,0x10a1e,0x10b7c,0x10b3e,0x1f0ba,0x1e17a,0x1c2fa,0x185fa, // 470
|
||||
0x11ae0,0x18d78,0x1c6be,0x11a70,0x18d3c,0x11a38,0x18d1e,0x11a1c,0x11a0e,0x10978, // 480
|
||||
0x184be,0x11b78,0x1093c,0x11b3c,0x1091e,0x11b1e,0x109be,0x11bbe,0x13ac0,0x19d70, // 490
|
||||
0x1cebc,0x13a60,0x19d38,0x1ce9e,0x13a30,0x19d1c,0x13a18,0x19d0e,0x13a0c,0x13a06, // 500
|
||||
0x11970,0x18cbc,0x13b70,0x11938,0x18c9e,0x13b38,0x1191c,0x13b1c,0x1190e,0x13b0e, // 510
|
||||
0x108bc,0x119bc,0x1089e,0x13bbc,0x1199e,0x13b9e,0x1bd60,0x1deb8,0x1ef5e,0x17a40, // 520
|
||||
0x1bd30,0x1de9c,0x17a20,0x1bd18,0x1de8e,0x17a10,0x1bd0c,0x17a08,0x1bd06,0x17a04, // 530
|
||||
0x13960,0x19cb8,0x1ce5e,0x17b60,0x13930,0x19c9c,0x17b30,0x1bd9c,0x19c8e,0x17b18, // 540
|
||||
0x1390c,0x17b0c,0x13906,0x17b06,0x118b8,0x18c5e,0x139b8,0x1189c,0x17bb8,0x1399c, // 550
|
||||
0x1188e,0x17b9c,0x1398e,0x17b8e,0x1085e,0x118de,0x139de,0x17bde,0x17940,0x1bcb0, // 560
|
||||
0x1de5c,0x17920,0x1bc98,0x1de4e,0x17910,0x1bc8c,0x17908,0x1bc86,0x17904,0x17902, // 570
|
||||
0x138b0,0x19c5c,0x179b0,0x13898,0x19c4e,0x17998,0x1bcce,0x1798c,0x13886,0x17986, // 580
|
||||
0x1185c,0x138dc,0x1184e,0x179dc,0x138ce,0x179ce,0x178a0,0x1bc58,0x1de2e,0x17890, // 590
|
||||
0x1bc4c,0x17888,0x1bc46,0x17884,0x17882,0x13858,0x19c2e,0x178d8,0x1384c,0x178cc, // 600
|
||||
0x13846,0x178c6,0x1182e,0x1386e,0x178ee,0x17850,0x1bc2c,0x17848,0x1bc26,0x17844, // 610
|
||||
0x17842,0x1382c,0x1786c,0x13826,0x17866,0x17828,0x1bc16,0x17824,0x17822,0x13816, // 620
|
||||
0x17836,0x10578,0x182be,0x1053c,0x1051e,0x105be,0x10d70,0x186bc,0x10d38,0x1869e, // 630
|
||||
0x10d1c,0x10d0e,0x104bc,0x10dbc,0x1049e,0x10d9e,0x11d60,0x18eb8,0x1c75e,0x11d30, // 640
|
||||
0x18e9c,0x11d18,0x18e8e,0x11d0c,0x11d06,0x10cb8,0x1865e,0x11db8,0x10c9c,0x11d9c, // 650
|
||||
0x10c8e,0x11d8e,0x1045e,0x10cde,0x11dde,0x13d40,0x19eb0,0x1cf5c,0x13d20,0x19e98, // 660
|
||||
0x1cf4e,0x13d10,0x19e8c,0x13d08,0x19e86,0x13d04,0x13d02,0x11cb0,0x18e5c,0x13db0, // 670
|
||||
0x11c98,0x18e4e,0x13d98,0x19ece,0x13d8c,0x11c86,0x13d86,0x10c5c,0x11cdc,0x10c4e, // 680
|
||||
0x13ddc,0x11cce,0x13dce,0x1bea0,0x1df58,0x1efae,0x1be90,0x1df4c,0x1be88,0x1df46, // 690
|
||||
0x1be84,0x1be82,0x13ca0,0x19e58,0x1cf2e,0x17da0,0x13c90,0x19e4c,0x17d90,0x1becc, // 700
|
||||
0x19e46,0x17d88,0x13c84,0x17d84,0x13c82,0x17d82,0x11c58,0x18e2e,0x13cd8,0x11c4c, // 710
|
||||
0x17dd8,0x13ccc,0x11c46,0x17dcc,0x13cc6,0x17dc6,0x10c2e,0x11c6e,0x13cee,0x17dee, // 720
|
||||
0x1be50,0x1df2c,0x1be48,0x1df26,0x1be44,0x1be42,0x13c50,0x19e2c,0x17cd0,0x13c48, // 730
|
||||
0x19e26,0x17cc8,0x1be66,0x17cc4,0x13c42,0x17cc2,0x11c2c,0x13c6c,0x11c26,0x17cec, // 740
|
||||
0x13c66,0x17ce6,0x1be28,0x1df16,0x1be24,0x1be22,0x13c28,0x19e16,0x17c68,0x13c24, // 750
|
||||
0x17c64,0x13c22,0x17c62,0x11c16,0x13c36,0x17c76,0x1be14,0x1be12,0x13c14,0x17c34, // 760
|
||||
0x13c12,0x17c32,0x102bc,0x1029e,0x106b8,0x1835e,0x1069c,0x1068e,0x1025e,0x106de, // 770
|
||||
0x10eb0,0x1875c,0x10e98,0x1874e,0x10e8c,0x10e86,0x1065c,0x10edc,0x1064e,0x10ece, // 780
|
||||
0x11ea0,0x18f58,0x1c7ae,0x11e90,0x18f4c,0x11e88,0x18f46,0x11e84,0x11e82,0x10e58, // 790
|
||||
0x1872e,0x11ed8,0x18f6e,0x11ecc,0x10e46,0x11ec6,0x1062e,0x10e6e,0x11eee,0x19f50, // 800
|
||||
0x1cfac,0x19f48,0x1cfa6,0x19f44,0x19f42,0x11e50,0x18f2c,0x13ed0,0x19f6c,0x18f26, // 810
|
||||
0x13ec8,0x11e44,0x13ec4,0x11e42,0x13ec2,0x10e2c,0x11e6c,0x10e26,0x13eec,0x11e66, // 820
|
||||
0x13ee6,0x1dfa8,0x1efd6,0x1dfa4,0x1dfa2,0x19f28,0x1cf96,0x1bf68,0x19f24,0x1bf64, // 830
|
||||
0x19f22,0x1bf62,0x11e28,0x18f16,0x13e68,0x11e24,0x17ee8,0x13e64,0x11e22,0x17ee4, // 840
|
||||
0x13e62,0x17ee2,0x10e16,0x11e36,0x13e76,0x17ef6,0x1df94,0x1df92,0x19f14,0x1bf34, // 850
|
||||
0x19f12,0x1bf32,0x11e14,0x13e34,0x11e12,0x17e74,0x13e32,0x17e72,0x1df8a,0x19f0a, // 860
|
||||
0x1bf1a,0x11e0a,0x13e1a,0x17e3a,0x1035c,0x1034e,0x10758,0x183ae,0x1074c,0x10746, // 870
|
||||
0x1032e,0x1076e,0x10f50,0x187ac,0x10f48,0x187a6,0x10f44,0x10f42,0x1072c,0x10f6c, // 880
|
||||
0x10726,0x10f66,0x18fa8,0x1c7d6,0x18fa4,0x18fa2,0x10f28,0x18796,0x11f68,0x18fb6, // 890
|
||||
0x11f64,0x10f22,0x11f62,0x10716,0x10f36,0x11f76,0x1cfd4,0x1cfd2,0x18f94,0x19fb4, // 900
|
||||
0x18f92,0x19fb2,0x10f14,0x11f34,0x10f12,0x13f74,0x11f32,0x13f72,0x1cfca,0x18f8a, // 910
|
||||
0x19f9a,0x10f0a,0x11f1a,0x13f3a,0x103ac,0x103a6,0x107a8,0x183d6,0x107a4,0x107a2, // 920
|
||||
0x10396,0x107b6,0x187d4,0x187d2,0x10794,0x10fb4,0x10792,0x10fb2,0x1c7ea) // 929
|
||||
); // end of $clusters array
|
||||
|
||||
/**
|
||||
* Array of factors of the Reed-Solomon polynomial equations used for error correction; one sub array for each correction level (0-8).
|
||||
* @protected
|
||||
*/
|
||||
protected $rsfactors = array(
|
||||
array( // ECL 0 (2 factors) -------------------------------------------------------------------------------
|
||||
0x01b,0x395), // 2
|
||||
array( // ECL 1 (4 factors) -------------------------------------------------------------------------------
|
||||
0x20a,0x238,0x2d3,0x329), // 4
|
||||
array( // ECL 2 (8 factors) -------------------------------------------------------------------------------
|
||||
0x0ed,0x134,0x1b4,0x11c,0x286,0x28d,0x1ac,0x17b), // 8
|
||||
array( // ECL 3 (16 factors) ------------------------------------------------------------------------------
|
||||
0x112,0x232,0x0e8,0x2f3,0x257,0x20c,0x321,0x084,0x127,0x074,0x1ba,0x1ac,0x127,0x02a,0x0b0,0x041),// 16
|
||||
array( // ECL 4 (32 factors) ------------------------------------------------------------------------------
|
||||
0x169,0x23f,0x39a,0x20d,0x0b0,0x24a,0x280,0x141,0x218,0x2e6,0x2a5,0x2e6,0x2af,0x11c,0x0c1,0x205, // 16
|
||||
0x111,0x1ee,0x107,0x093,0x251,0x320,0x23b,0x140,0x323,0x085,0x0e7,0x186,0x2ad,0x14a,0x03f,0x19a),// 32
|
||||
array( // ECL 5 (64 factors) ------------------------------------------------------------------------------
|
||||
0x21b,0x1a6,0x006,0x05d,0x35e,0x303,0x1c5,0x06a,0x262,0x11f,0x06b,0x1f9,0x2dd,0x36d,0x17d,0x264, // 16
|
||||
0x2d3,0x1dc,0x1ce,0x0ac,0x1ae,0x261,0x35a,0x336,0x21f,0x178,0x1ff,0x190,0x2a0,0x2fa,0x11b,0x0b8, // 32
|
||||
0x1b8,0x023,0x207,0x01f,0x1cc,0x252,0x0e1,0x217,0x205,0x160,0x25d,0x09e,0x28b,0x0c9,0x1e8,0x1f6, // 48
|
||||
0x288,0x2dd,0x2cd,0x053,0x194,0x061,0x118,0x303,0x348,0x275,0x004,0x17d,0x34b,0x26f,0x108,0x21f),// 64
|
||||
array( // ECL 6 (128 factors) -----------------------------------------------------------------------------
|
||||
0x209,0x136,0x360,0x223,0x35a,0x244,0x128,0x17b,0x035,0x30b,0x381,0x1bc,0x190,0x39d,0x2ed,0x19f, // 16
|
||||
0x336,0x05d,0x0d9,0x0d0,0x3a0,0x0f4,0x247,0x26c,0x0f6,0x094,0x1bf,0x277,0x124,0x38c,0x1ea,0x2c0, // 32
|
||||
0x204,0x102,0x1c9,0x38b,0x252,0x2d3,0x2a2,0x124,0x110,0x060,0x2ac,0x1b0,0x2ae,0x25e,0x35c,0x239, // 48
|
||||
0x0c1,0x0db,0x081,0x0ba,0x0ec,0x11f,0x0c0,0x307,0x116,0x0ad,0x028,0x17b,0x2c8,0x1cf,0x286,0x308, // 64
|
||||
0x0ab,0x1eb,0x129,0x2fb,0x09c,0x2dc,0x05f,0x10e,0x1bf,0x05a,0x1fb,0x030,0x0e4,0x335,0x328,0x382, // 80
|
||||
0x310,0x297,0x273,0x17a,0x17e,0x106,0x17c,0x25a,0x2f2,0x150,0x059,0x266,0x057,0x1b0,0x29e,0x268, // 96
|
||||
0x09d,0x176,0x0f2,0x2d6,0x258,0x10d,0x177,0x382,0x34d,0x1c6,0x162,0x082,0x32e,0x24b,0x324,0x022, // 112
|
||||
0x0d3,0x14a,0x21b,0x129,0x33b,0x361,0x025,0x205,0x342,0x13b,0x226,0x056,0x321,0x004,0x06c,0x21b),// 128
|
||||
array( // ECL 7 (256 factors) -----------------------------------------------------------------------------
|
||||
0x20c,0x37e,0x04b,0x2fe,0x372,0x359,0x04a,0x0cc,0x052,0x24a,0x2c4,0x0fa,0x389,0x312,0x08a,0x2d0, // 16
|
||||
0x35a,0x0c2,0x137,0x391,0x113,0x0be,0x177,0x352,0x1b6,0x2dd,0x0c2,0x118,0x0c9,0x118,0x33c,0x2f5, // 32
|
||||
0x2c6,0x32e,0x397,0x059,0x044,0x239,0x00b,0x0cc,0x31c,0x25d,0x21c,0x391,0x321,0x2bc,0x31f,0x089, // 48
|
||||
0x1b7,0x1a2,0x250,0x29c,0x161,0x35b,0x172,0x2b6,0x145,0x0f0,0x0d8,0x101,0x11c,0x225,0x0d1,0x374, // 64
|
||||
0x13b,0x046,0x149,0x319,0x1ea,0x112,0x36d,0x0a2,0x2ed,0x32c,0x2ac,0x1cd,0x14e,0x178,0x351,0x209, // 80
|
||||
0x133,0x123,0x323,0x2c8,0x013,0x166,0x18f,0x38c,0x067,0x1ff,0x033,0x008,0x205,0x0e1,0x121,0x1d6, // 96
|
||||
0x27d,0x2db,0x042,0x0ff,0x395,0x10d,0x1cf,0x33e,0x2da,0x1b1,0x350,0x249,0x088,0x21a,0x38a,0x05a, // 112
|
||||
0x002,0x122,0x2e7,0x0c7,0x28f,0x387,0x149,0x031,0x322,0x244,0x163,0x24c,0x0bc,0x1ce,0x00a,0x086, // 128
|
||||
0x274,0x140,0x1df,0x082,0x2e3,0x047,0x107,0x13e,0x176,0x259,0x0c0,0x25d,0x08e,0x2a1,0x2af,0x0ea, // 144
|
||||
0x2d2,0x180,0x0b1,0x2f0,0x25f,0x280,0x1c7,0x0c1,0x2b1,0x2c3,0x325,0x281,0x030,0x03c,0x2dc,0x26d, // 160
|
||||
0x37f,0x220,0x105,0x354,0x28f,0x135,0x2b9,0x2f3,0x2f4,0x03c,0x0e7,0x305,0x1b2,0x1a5,0x2d6,0x210, // 176
|
||||
0x1f7,0x076,0x031,0x31b,0x020,0x090,0x1f4,0x0ee,0x344,0x18a,0x118,0x236,0x13f,0x009,0x287,0x226, // 192
|
||||
0x049,0x392,0x156,0x07e,0x020,0x2a9,0x14b,0x318,0x26c,0x03c,0x261,0x1b9,0x0b4,0x317,0x37d,0x2f2, // 208
|
||||
0x25d,0x17f,0x0e4,0x2ed,0x2f8,0x0d5,0x036,0x129,0x086,0x036,0x342,0x12b,0x39a,0x0bf,0x38e,0x214, // 224
|
||||
0x261,0x33d,0x0bd,0x014,0x0a7,0x01d,0x368,0x1c1,0x053,0x192,0x029,0x290,0x1f9,0x243,0x1e1,0x0ad, // 240
|
||||
0x194,0x0fb,0x2b0,0x05f,0x1f1,0x22b,0x282,0x21f,0x133,0x09f,0x39c,0x22e,0x288,0x037,0x1f1,0x00a),// 256
|
||||
array( // ECL 8 (512 factors) -----------------------------------------------------------------------------
|
||||
0x160,0x04d,0x175,0x1f8,0x023,0x257,0x1ac,0x0cf,0x199,0x23e,0x076,0x1f2,0x11d,0x17c,0x15e,0x1ec, // 16
|
||||
0x0c5,0x109,0x398,0x09b,0x392,0x12b,0x0e5,0x283,0x126,0x367,0x132,0x058,0x057,0x0c1,0x160,0x30d, // 32
|
||||
0x34e,0x04b,0x147,0x208,0x1b3,0x21f,0x0cb,0x29a,0x0f9,0x15a,0x30d,0x26d,0x280,0x10c,0x31a,0x216, // 48
|
||||
0x21b,0x30d,0x198,0x186,0x284,0x066,0x1dc,0x1f3,0x122,0x278,0x221,0x025,0x35a,0x394,0x228,0x029, // 64
|
||||
0x21e,0x121,0x07a,0x110,0x17f,0x320,0x1e5,0x062,0x2f0,0x1d8,0x2f9,0x06b,0x310,0x35c,0x292,0x2e5, // 80
|
||||
0x122,0x0cc,0x2a9,0x197,0x357,0x055,0x063,0x03e,0x1e2,0x0b4,0x014,0x129,0x1c3,0x251,0x391,0x08e, // 96
|
||||
0x328,0x2ac,0x11f,0x218,0x231,0x04c,0x28d,0x383,0x2d9,0x237,0x2e8,0x186,0x201,0x0c0,0x204,0x102, // 112
|
||||
0x0f0,0x206,0x31a,0x18b,0x300,0x350,0x033,0x262,0x180,0x0a8,0x0be,0x33a,0x148,0x254,0x312,0x12f, // 128
|
||||
0x23a,0x17d,0x19f,0x281,0x09c,0x0ed,0x097,0x1ad,0x213,0x0cf,0x2a4,0x2c6,0x059,0x0a8,0x130,0x192, // 144
|
||||
0x028,0x2c4,0x23f,0x0a2,0x360,0x0e5,0x041,0x35d,0x349,0x200,0x0a4,0x1dd,0x0dd,0x05c,0x166,0x311, // 160
|
||||
0x120,0x165,0x352,0x344,0x33b,0x2e0,0x2c3,0x05e,0x008,0x1ee,0x072,0x209,0x002,0x1f3,0x353,0x21f, // 176
|
||||
0x098,0x2d9,0x303,0x05f,0x0f8,0x169,0x242,0x143,0x358,0x31d,0x121,0x033,0x2ac,0x1d2,0x215,0x334, // 192
|
||||
0x29d,0x02d,0x386,0x1c4,0x0a7,0x156,0x0f4,0x0ad,0x023,0x1cf,0x28b,0x033,0x2bb,0x24f,0x1c4,0x242, // 208
|
||||
0x025,0x07c,0x12a,0x14c,0x228,0x02b,0x1ab,0x077,0x296,0x309,0x1db,0x352,0x2fc,0x16c,0x242,0x38f, // 224
|
||||
0x11b,0x2c7,0x1d8,0x1a4,0x0f5,0x120,0x252,0x18a,0x1ff,0x147,0x24d,0x309,0x2bb,0x2b0,0x02b,0x198, // 240
|
||||
0x34a,0x17f,0x2d1,0x209,0x230,0x284,0x2ca,0x22f,0x03e,0x091,0x369,0x297,0x2c9,0x09f,0x2a0,0x2d9, // 256
|
||||
0x270,0x03b,0x0c1,0x1a1,0x09e,0x0d1,0x233,0x234,0x157,0x2b5,0x06d,0x260,0x233,0x16d,0x0b5,0x304, // 272
|
||||
0x2a5,0x136,0x0f8,0x161,0x2c4,0x19a,0x243,0x366,0x269,0x349,0x278,0x35c,0x121,0x218,0x023,0x309, // 288
|
||||
0x26a,0x24a,0x1a8,0x341,0x04d,0x255,0x15a,0x10d,0x2f5,0x278,0x2b7,0x2ef,0x14b,0x0f7,0x0b8,0x02d, // 304
|
||||
0x313,0x2a8,0x012,0x042,0x197,0x171,0x036,0x1ec,0x0e4,0x265,0x33e,0x39a,0x1b5,0x207,0x284,0x389, // 320
|
||||
0x315,0x1a4,0x131,0x1b9,0x0cf,0x12c,0x37c,0x33b,0x08d,0x219,0x17d,0x296,0x201,0x038,0x0fc,0x155, // 336
|
||||
0x0f2,0x31d,0x346,0x345,0x2d0,0x0e0,0x133,0x277,0x03d,0x057,0x230,0x136,0x2f4,0x299,0x18d,0x328, // 352
|
||||
0x353,0x135,0x1d9,0x31b,0x17a,0x01f,0x287,0x393,0x1cb,0x326,0x24e,0x2db,0x1a9,0x0d8,0x224,0x0f9, // 368
|
||||
0x141,0x371,0x2bb,0x217,0x2a1,0x30e,0x0d2,0x32f,0x389,0x12f,0x34b,0x39a,0x119,0x049,0x1d5,0x317, // 384
|
||||
0x294,0x0a2,0x1f2,0x134,0x09b,0x1a6,0x38b,0x331,0x0bb,0x03e,0x010,0x1a9,0x217,0x150,0x11e,0x1b5, // 400
|
||||
0x177,0x111,0x262,0x128,0x0b7,0x39b,0x074,0x29b,0x2ef,0x161,0x03e,0x16e,0x2b3,0x17b,0x2af,0x34a, // 416
|
||||
0x025,0x165,0x2d0,0x2e6,0x14a,0x005,0x027,0x39b,0x137,0x1a8,0x0f2,0x2ed,0x141,0x036,0x29d,0x13c, // 432
|
||||
0x156,0x12b,0x216,0x069,0x29b,0x1e8,0x280,0x2a0,0x240,0x21c,0x13c,0x1e6,0x2d1,0x262,0x02e,0x290, // 448
|
||||
0x1bf,0x0ab,0x268,0x1d0,0x0be,0x213,0x129,0x141,0x2fa,0x2f0,0x215,0x0af,0x086,0x00e,0x17d,0x1b1, // 464
|
||||
0x2cd,0x02d,0x06f,0x014,0x254,0x11c,0x2e0,0x08a,0x286,0x19b,0x36d,0x29d,0x08d,0x397,0x02d,0x30c, // 480
|
||||
0x197,0x0a4,0x14c,0x383,0x0a5,0x2d6,0x258,0x145,0x1f2,0x28f,0x165,0x2f0,0x300,0x0df,0x351,0x287, // 496
|
||||
0x03f,0x136,0x35f,0x0fb,0x16e,0x130,0x11a,0x2e2,0x2a3,0x19a,0x185,0x0f4,0x01f,0x079,0x12f,0x107) // 512
|
||||
);
|
||||
|
||||
/**
|
||||
* This is the class constructor.
|
||||
* Creates a PDF417 object
|
||||
* @param string $code code to represent using PDF417
|
||||
* @param int $ecl error correction level (0-8); default -1 = automatic correction level
|
||||
* @param float $aspectratio the width to height of the symbol (excluding quiet zones)
|
||||
* @param array $macro information for macro block
|
||||
* @public
|
||||
*/
|
||||
public function __construct($code, $ecl=-1, $aspectratio=2, $macro=array()) {
|
||||
$barcode_array = array();
|
||||
if ((is_null($code)) OR ($code == '\0') OR ($code == '')) {
|
||||
return false;
|
||||
}
|
||||
// get the input sequence array
|
||||
$sequence = $this->getInputSequences($code);
|
||||
$codewords = array(); // array of code-words
|
||||
foreach($sequence as $seq) {
|
||||
$cw = $this->getCompaction($seq[0], $seq[1], true);
|
||||
$codewords = array_merge($codewords, $cw);
|
||||
}
|
||||
if ($codewords[0] == 900) {
|
||||
// Text Alpha is the default mode, so remove the first code
|
||||
array_shift($codewords);
|
||||
}
|
||||
// count number of codewords
|
||||
$numcw = count($codewords);
|
||||
if ($numcw > 925) {
|
||||
// reached maximum data codeword capacity
|
||||
return false;
|
||||
}
|
||||
// build macro control block codewords
|
||||
if (!empty($macro)) {
|
||||
$macrocw = array();
|
||||
// beginning of macro control block
|
||||
$macrocw[] = 928;
|
||||
// segment index
|
||||
$cw = $this->getCompaction(902, sprintf('%05d', $macro['segment_index']), false);
|
||||
$macrocw = array_merge($macrocw, $cw);
|
||||
// file ID
|
||||
$cw = $this->getCompaction(900, $macro['file_id'], false);
|
||||
$macrocw = array_merge($macrocw, $cw);
|
||||
// optional fields
|
||||
$optmodes = array(900,902,902,900,900,902,902);
|
||||
$optsize = array(-1,2,4,-1,-1,-1,2);
|
||||
foreach ($optmodes as $k => $omode) {
|
||||
if (isset($macro['option_'.$k])) {
|
||||
$macrocw[] = 923;
|
||||
$macrocw[] = $k;
|
||||
if ($optsize[$k] == 2) {
|
||||
$macro['option_'.$k] = sprintf('%05d', $macro['option_'.$k]);
|
||||
} elseif ($optsize[$k] == 4) {
|
||||
$macro['option_'.$k] = sprintf('%010d', $macro['option_'.$k]);
|
||||
}
|
||||
$cw = $this->getCompaction($omode, $macro['option_'.$k], false);
|
||||
$macrocw = array_merge($macrocw, $cw);
|
||||
}
|
||||
}
|
||||
if ($macro['segment_index'] == ($macro['segment_total'] - 1)) {
|
||||
// end of control block
|
||||
$macrocw[] = 922;
|
||||
}
|
||||
// update total codewords
|
||||
$numcw += count($macrocw);
|
||||
}
|
||||
// set error correction level
|
||||
$ecl = $this->getErrorCorrectionLevel($ecl, $numcw);
|
||||
// number of codewords for error correction
|
||||
$errsize = (2 << $ecl);
|
||||
// calculate number of columns (number of codewords per row) and rows
|
||||
$nce = ($numcw + $errsize + 1);
|
||||
$cols = round((sqrt(4761 + (68 * $aspectratio * ROWHEIGHT * $nce)) - 69) / 34);
|
||||
// adjust cols
|
||||
if ($cols < 1) {
|
||||
$cols = 1;
|
||||
} elseif ($cols > 30) {
|
||||
$cols = 30;
|
||||
}
|
||||
$rows = ceil($nce / $cols);
|
||||
$size = ($cols * $rows);
|
||||
// adjust rows
|
||||
if (($rows < 3) OR ($rows > 90)) {
|
||||
if ($rows < 3) {
|
||||
$rows = 3;
|
||||
} elseif ($rows > 90) {
|
||||
$rows = 90;
|
||||
}
|
||||
$cols = ceil($size / $rows);
|
||||
$size = ($cols * $rows);
|
||||
}
|
||||
if ($size > 928) {
|
||||
// set dimensions to get maximum capacity
|
||||
if (abs($aspectratio - (17 * 29 / 32)) < abs($aspectratio - (17 * 16 / 58))) {
|
||||
$cols = 29;
|
||||
$rows = 32;
|
||||
} else {
|
||||
$cols = 16;
|
||||
$rows = 58;
|
||||
}
|
||||
$size = 928;
|
||||
}
|
||||
// calculate padding
|
||||
$pad = ($size - $nce);
|
||||
if ($pad > 0) {
|
||||
if (($size - $rows) == $nce) {
|
||||
--$rows;
|
||||
$size -= $rows;
|
||||
} else {
|
||||
// add pading
|
||||
$codewords = array_merge($codewords, array_fill(0, $pad, 900));
|
||||
}
|
||||
}
|
||||
if (!empty($macro)) {
|
||||
// add macro section
|
||||
$codewords = array_merge($codewords, $macrocw);
|
||||
}
|
||||
// Symbol Length Descriptor (number of data codewords including Symbol Length Descriptor and pad codewords)
|
||||
$sld = $size - $errsize;
|
||||
// add symbol length description
|
||||
array_unshift($codewords, $sld);
|
||||
// calculate error correction
|
||||
$ecw = $this->getErrorCorrection($codewords, $ecl);
|
||||
// add error correction codewords
|
||||
$codewords = array_merge($codewords, $ecw);
|
||||
// add horizontal quiet zones to start and stop patterns
|
||||
$pstart = str_repeat('0', QUIETH).$this->start_pattern;
|
||||
$pstop = $this->stop_pattern.str_repeat('0', QUIETH);
|
||||
$barcode_array['num_rows'] = ($rows * ROWHEIGHT) + (2 * QUIETV);
|
||||
$barcode_array['num_cols'] = (($cols + 2) * 17) + 35 + (2 * QUIETH);
|
||||
$barcode_array['bcode'] = array();
|
||||
// build rows for vertical quiet zone
|
||||
if (QUIETV > 0) {
|
||||
$empty_row = array_fill(0, $barcode_array['num_cols'], 0);
|
||||
for ($i = 0; $i < QUIETV; ++$i) {
|
||||
// add vertical quiet rows
|
||||
$barcode_array['bcode'][] = $empty_row;
|
||||
}
|
||||
}
|
||||
$k = 0; // codeword index
|
||||
$cid = 0; // initial cluster
|
||||
// for each row
|
||||
for ($r = 0; $r < $rows; ++$r) {
|
||||
// row start code
|
||||
$row = $pstart;
|
||||
switch ($cid) {
|
||||
case 0: {
|
||||
$L = ((30 * intval($r / 3)) + intval(($rows - 1) / 3));
|
||||
break;
|
||||
}
|
||||
case 1: {
|
||||
$L = ((30 * intval($r / 3)) + ($ecl * 3) + (($rows - 1) % 3));
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
$L = ((30 * intval($r / 3)) + ($cols - 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
// left row indicator
|
||||
$row .= sprintf('%17b', $this->clusters[$cid][$L]);
|
||||
// for each column
|
||||
for ($c = 0; $c < $cols; ++$c) {
|
||||
$row .= sprintf('%17b', $this->clusters[$cid][$codewords[$k]]);
|
||||
++$k;
|
||||
}
|
||||
switch ($cid) {
|
||||
case 0: {
|
||||
$L = ((30 * intval($r / 3)) + ($cols - 1));
|
||||
break;
|
||||
}
|
||||
case 1: {
|
||||
$L = ((30 * intval($r / 3)) + intval(($rows - 1) / 3));
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
$L = ((30 * intval($r / 3)) + ($ecl * 3) + (($rows - 1) % 3));
|
||||
break;
|
||||
}
|
||||
}
|
||||
// right row indicator
|
||||
$row .= sprintf('%17b', $this->clusters[$cid][$L]);
|
||||
// row stop code
|
||||
$row .= $pstop;
|
||||
// convert the string to array
|
||||
$arow = preg_split('//', $row, -1, PREG_SPLIT_NO_EMPTY);
|
||||
// duplicate row to get the desired height
|
||||
for ($h = 0; $h < ROWHEIGHT; ++$h) {
|
||||
$barcode_array['bcode'][] = $arow;
|
||||
}
|
||||
++$cid;
|
||||
if ($cid > 2) {
|
||||
$cid = 0;
|
||||
}
|
||||
}
|
||||
if (QUIETV > 0) {
|
||||
for ($i = 0; $i < QUIETV; ++$i) {
|
||||
// add vertical quiet rows
|
||||
$barcode_array['bcode'][] = $empty_row;
|
||||
}
|
||||
}
|
||||
$this->barcode_array = $barcode_array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a barcode array which is readable by TCPDF
|
||||
* @return array barcode array readable by TCPDF;
|
||||
* @public
|
||||
*/
|
||||
public function getBarcodeArray() {
|
||||
return $this->barcode_array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the error correction level (0-8) to be used
|
||||
* @param int $ecl error correction level
|
||||
* @param int $numcw number of data codewords
|
||||
* @return int error correction level
|
||||
* @protected
|
||||
*/
|
||||
protected function getErrorCorrectionLevel($ecl, $numcw) {
|
||||
$maxecl = 8; // starting error level
|
||||
// check for automatic levels
|
||||
if (($ecl < 0) OR ($ecl > 8)) {
|
||||
if ($numcw < 41) {
|
||||
$ecl = 2;
|
||||
} elseif ($numcw < 161) {
|
||||
$ecl = 3;
|
||||
} elseif ($numcw < 321) {
|
||||
$ecl = 4;
|
||||
} elseif ($numcw < 864) {
|
||||
$ecl = 5;
|
||||
} else {
|
||||
$ecl = $maxecl;
|
||||
}
|
||||
}
|
||||
// get maximum correction level
|
||||
$maxerrsize = (928 - $numcw); // available codewords for error
|
||||
while ($maxecl > 0) {
|
||||
$errsize = (2 << $ecl);
|
||||
if ($maxerrsize >= $errsize) {
|
||||
break;
|
||||
}
|
||||
--$maxecl;
|
||||
}
|
||||
if ($ecl > $maxecl) {
|
||||
$ecl = $maxecl;
|
||||
}
|
||||
return $ecl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the error correction codewords
|
||||
* @param array $cw array of codewords including Symbol Length Descriptor and pad
|
||||
* @param int $ecl error correction level 0-8
|
||||
* @return array of error correction codewords
|
||||
* @protected
|
||||
*/
|
||||
protected function getErrorCorrection($cw, $ecl) {
|
||||
// get error correction coefficients
|
||||
$ecc = $this->rsfactors[$ecl];
|
||||
// number of error correction factors
|
||||
$eclsize = (2 << $ecl);
|
||||
// maximum index for $rsfactors[$ecl]
|
||||
$eclmaxid = ($eclsize - 1);
|
||||
// initialize array of error correction codewords
|
||||
$ecw = array_fill(0, $eclsize, 0);
|
||||
// for each data codeword
|
||||
foreach($cw as $k => $d) {
|
||||
$t1 = ($d + $ecw[$eclmaxid]) % 929;
|
||||
for ($j = $eclmaxid; $j > 0; --$j) {
|
||||
$t2 = ($t1 * $ecc[$j]) % 929;
|
||||
$t3 = 929 - $t2;
|
||||
$ecw[$j] = ($ecw[($j - 1)] + $t3) % 929;
|
||||
}
|
||||
$t2 = ($t1 * $ecc[0]) % 929;
|
||||
$t3 = 929 - $t2;
|
||||
$ecw[0] = $t3 % 929;
|
||||
}
|
||||
foreach($ecw as $j => $e) {
|
||||
if ($e != 0) {
|
||||
$ecw[$j] = 929 - $e;
|
||||
}
|
||||
}
|
||||
$ecw = array_reverse($ecw);
|
||||
return $ecw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create array of sequences from input
|
||||
* @param string $code code
|
||||
* @return array bi-dimensional array containing characters and classification
|
||||
* @protected
|
||||
*/
|
||||
protected function getInputSequences($code) {
|
||||
$sequence_array = array(); // array to be returned
|
||||
$numseq = array();
|
||||
// get numeric sequences
|
||||
preg_match_all('/([0-9]{13,44})/', $code, $numseq, PREG_OFFSET_CAPTURE);
|
||||
$numseq[1][] = array('', strlen($code));
|
||||
$offset = 0;
|
||||
foreach($numseq[1] as $seq) {
|
||||
$seqlen = strlen($seq[0]);
|
||||
if ($seq[1] > 0) {
|
||||
// extract text sequence before the number sequence
|
||||
$prevseq = substr($code, $offset, ($seq[1] - $offset));
|
||||
$textseq = array();
|
||||
// get text sequences
|
||||
preg_match_all('/([\x09\x0a\x0d\x20-\x7e]{5,})/', $prevseq, $textseq, PREG_OFFSET_CAPTURE);
|
||||
$textseq[1][] = array('', strlen($prevseq));
|
||||
$txtoffset = 0;
|
||||
foreach($textseq[1] as $txtseq) {
|
||||
$txtseqlen = strlen($txtseq[0]);
|
||||
if ($txtseq[1] > 0) {
|
||||
// extract byte sequence before the text sequence
|
||||
$prevtxtseq = substr($prevseq, $txtoffset, ($txtseq[1] - $txtoffset));
|
||||
if (strlen($prevtxtseq) > 0) {
|
||||
// add BYTE sequence
|
||||
if ((strlen($prevtxtseq) == 1) AND ((count($sequence_array) > 0) AND ($sequence_array[(count($sequence_array) - 1)][0] == 900))) {
|
||||
$sequence_array[] = array(913, $prevtxtseq);
|
||||
} elseif ((strlen($prevtxtseq) % 6) == 0) {
|
||||
$sequence_array[] = array(924, $prevtxtseq);
|
||||
} else {
|
||||
$sequence_array[] = array(901, $prevtxtseq);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($txtseqlen > 0) {
|
||||
// add numeric sequence
|
||||
$sequence_array[] = array(900, $txtseq[0]);
|
||||
}
|
||||
$txtoffset = $txtseq[1] + $txtseqlen;
|
||||
}
|
||||
}
|
||||
if ($seqlen > 0) {
|
||||
// add numeric sequence
|
||||
$sequence_array[] = array(902, $seq[0]);
|
||||
}
|
||||
$offset = $seq[1] + $seqlen;
|
||||
}
|
||||
return $sequence_array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact data by mode.
|
||||
* @param int $mode compaction mode number
|
||||
* @param string $code data to compact
|
||||
* @param boolean $addmode if true add the mode codeword at first position
|
||||
* @return array of codewords
|
||||
* @protected
|
||||
*/
|
||||
protected function getCompaction($mode, $code, $addmode=true) {
|
||||
$cw = array(); // array of codewords to return
|
||||
switch($mode) {
|
||||
case 900: { // Text Compaction mode latch
|
||||
$submode = 0; // default Alpha sub-mode
|
||||
$txtarr = array(); // array of characters and sub-mode switching characters
|
||||
$codelen = strlen($code);
|
||||
for ($i = 0; $i < $codelen; ++$i) {
|
||||
$chval = ord($code[$i]);
|
||||
if (($k = array_search($chval, $this->textsubmodes[$submode])) !== false) {
|
||||
// we are on the same sub-mode
|
||||
$txtarr[] = $k;
|
||||
} else {
|
||||
// the sub-mode is changed
|
||||
for ($s = 0; $s < 4; ++$s) {
|
||||
// search new sub-mode
|
||||
if (($s != $submode) AND (($k = array_search($chval, $this->textsubmodes[$s])) !== false)) {
|
||||
// $s is the new submode
|
||||
if (((($i + 1) == $codelen) OR ((($i + 1) < $codelen) AND (array_search(ord($code[($i + 1)]), $this->textsubmodes[$submode]) !== false))) AND (($s == 3) OR (($s == 0) AND ($submode == 1)))) {
|
||||
// shift (temporary change only for this char)
|
||||
if ($s == 3) {
|
||||
// shift to puntuaction
|
||||
$txtarr[] = 29;
|
||||
} else {
|
||||
// shift from lower to alpha
|
||||
$txtarr[] = 27;
|
||||
}
|
||||
} else {
|
||||
// latch
|
||||
$txtarr = array_merge($txtarr, $this->textlatch[''.$submode.$s]);
|
||||
// set new submode
|
||||
$submode = $s;
|
||||
}
|
||||
// add characted code to array
|
||||
$txtarr[] = $k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$txtarrlen = count($txtarr);
|
||||
if (($txtarrlen % 2) != 0) {
|
||||
// add padding
|
||||
$txtarr[] = 29;
|
||||
++$txtarrlen;
|
||||
}
|
||||
// calculate codewords
|
||||
for ($i = 0; $i < $txtarrlen; $i += 2) {
|
||||
$cw[] = (30 * $txtarr[$i]) + $txtarr[($i + 1)];
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 901:
|
||||
case 924: { // Byte Compaction mode latch
|
||||
while (($codelen = strlen($code)) > 0) {
|
||||
if ($codelen > 6) {
|
||||
$rest = substr($code, 6);
|
||||
$code = substr($code, 0, 6);
|
||||
$sublen = 6;
|
||||
} else {
|
||||
$rest = '';
|
||||
$sublen = strlen($code);
|
||||
}
|
||||
if ($sublen == 6) {
|
||||
$t = bcmul(''.ord($code[0]), '1099511627776');
|
||||
$t = bcadd($t, bcmul(''.ord($code[1]), '4294967296'));
|
||||
$t = bcadd($t, bcmul(''.ord($code[2]), '16777216'));
|
||||
$t = bcadd($t, bcmul(''.ord($code[3]), '65536'));
|
||||
$t = bcadd($t, bcmul(''.ord($code[4]), '256'));
|
||||
$t = bcadd($t, ''.ord($code[5]));
|
||||
// tmp array for the 6 bytes block
|
||||
$cw6 = array();
|
||||
do {
|
||||
$d = bcmod($t, '900', 0);
|
||||
$t = bcdiv($t, '900', 0);
|
||||
// prepend the value to the beginning of the array
|
||||
array_unshift($cw6, $d);
|
||||
} while ($t != '0');
|
||||
// append the result array at the end
|
||||
$cw = array_merge($cw, $cw6);
|
||||
} else {
|
||||
for ($i = 0; $i < $sublen; ++$i) {
|
||||
$cw[] = ord($code[$i]);
|
||||
}
|
||||
}
|
||||
$code = $rest;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 902: { // Numeric Compaction mode latch
|
||||
while (($codelen = strlen($code)) > 0) {
|
||||
if ($codelen > 44) {
|
||||
$rest = substr($code, 44);
|
||||
$code = substr($code, 0, 44);
|
||||
} else {
|
||||
$rest = '';
|
||||
}
|
||||
$t = '1'.$code;
|
||||
do {
|
||||
$d = bcmod($t, '900', 0);
|
||||
$t = bcdiv($t, '900', 0);
|
||||
array_unshift($cw, $d);
|
||||
} while ($t != '0');
|
||||
$code = $rest;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 913: { // Byte Compaction mode shift
|
||||
$cw[] = ord($code);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($addmode) {
|
||||
// add the compaction mode codeword at the beginning
|
||||
array_unshift($cw, $mode);
|
||||
}
|
||||
return $cw;
|
||||
}
|
||||
|
||||
} // end PDF417 class
|
||||
|
||||
//============================================================+
|
||||
// END OF FILE
|
||||
//============================================================+
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,462 @@
|
||||
<?php
|
||||
//============================================================+
|
||||
// File name : tcpdf_colors.php
|
||||
// Version : 1.0.004
|
||||
// Begin : 2002-04-09
|
||||
// Last Update : 2014-04-25
|
||||
// Author : Nicola Asuni - Tecnick.com LTD - www.tecnick.com - info@tecnick.com
|
||||
// License : GNU-LGPL v3 (http://www.gnu.org/copyleft/lesser.html)
|
||||
// -------------------------------------------------------------------
|
||||
// Copyright (C) 2002-2013 Nicola Asuni - Tecnick.com LTD
|
||||
//
|
||||
// This file is part of TCPDF software library.
|
||||
//
|
||||
// TCPDF is free software: you can redistribute it and/or modify it
|
||||
// under the terms of the GNU Lesser General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// TCPDF 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 Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License
|
||||
// along with TCPDF. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// See LICENSE.TXT file for more information.
|
||||
// -------------------------------------------------------------------
|
||||
//
|
||||
// Description : Array of WEB safe colors
|
||||
//
|
||||
//============================================================+
|
||||
|
||||
/**
|
||||
* @file
|
||||
* PHP color class for TCPDF
|
||||
* @author Nicola Asuni
|
||||
* @package com.tecnick.tcpdf
|
||||
*/
|
||||
|
||||
/**
|
||||
* @class TCPDF_COLORS
|
||||
* PHP color class for TCPDF
|
||||
* @package com.tecnick.tcpdf
|
||||
* @version 1.0.004
|
||||
* @author Nicola Asuni - info@tecnick.com
|
||||
*/
|
||||
class TCPDF_COLORS {
|
||||
|
||||
/**
|
||||
* Array of WEB safe colors
|
||||
* @public static
|
||||
*/
|
||||
public static $webcolor = array (
|
||||
'aliceblue' => 'f0f8ff',
|
||||
'antiquewhite' => 'faebd7',
|
||||
'aqua' => '00ffff',
|
||||
'aquamarine' => '7fffd4',
|
||||
'azure' => 'f0ffff',
|
||||
'beige' => 'f5f5dc',
|
||||
'bisque' => 'ffe4c4',
|
||||
'black' => '000000',
|
||||
'blanchedalmond' => 'ffebcd',
|
||||
'blue' => '0000ff',
|
||||
'blueviolet' => '8a2be2',
|
||||
'brown' => 'a52a2a',
|
||||
'burlywood' => 'deb887',
|
||||
'cadetblue' => '5f9ea0',
|
||||
'chartreuse' => '7fff00',
|
||||
'chocolate' => 'd2691e',
|
||||
'coral' => 'ff7f50',
|
||||
'cornflowerblue' => '6495ed',
|
||||
'cornsilk' => 'fff8dc',
|
||||
'crimson' => 'dc143c',
|
||||
'cyan' => '00ffff',
|
||||
'darkblue' => '00008b',
|
||||
'darkcyan' => '008b8b',
|
||||
'darkgoldenrod' => 'b8860b',
|
||||
'dkgray' => 'a9a9a9',
|
||||
'darkgray' => 'a9a9a9',
|
||||
'darkgrey' => 'a9a9a9',
|
||||
'darkgreen' => '006400',
|
||||
'darkkhaki' => 'bdb76b',
|
||||
'darkmagenta' => '8b008b',
|
||||
'darkolivegreen' => '556b2f',
|
||||
'darkorange' => 'ff8c00',
|
||||
'darkorchid' => '9932cc',
|
||||
'darkred' => '8b0000',
|
||||
'darksalmon' => 'e9967a',
|
||||
'darkseagreen' => '8fbc8f',
|
||||
'darkslateblue' => '483d8b',
|
||||
'darkslategray' => '2f4f4f',
|
||||
'darkslategrey' => '2f4f4f',
|
||||
'darkturquoise' => '00ced1',
|
||||
'darkviolet' => '9400d3',
|
||||
'deeppink' => 'ff1493',
|
||||
'deepskyblue' => '00bfff',
|
||||
'dimgray' => '696969',
|
||||
'dimgrey' => '696969',
|
||||
'dodgerblue' => '1e90ff',
|
||||
'firebrick' => 'b22222',
|
||||
'floralwhite' => 'fffaf0',
|
||||
'forestgreen' => '228b22',
|
||||
'fuchsia' => 'ff00ff',
|
||||
'gainsboro' => 'dcdcdc',
|
||||
'ghostwhite' => 'f8f8ff',
|
||||
'gold' => 'ffd700',
|
||||
'goldenrod' => 'daa520',
|
||||
'gray' => '808080',
|
||||
'grey' => '808080',
|
||||
'green' => '008000',
|
||||
'greenyellow' => 'adff2f',
|
||||
'honeydew' => 'f0fff0',
|
||||
'hotpink' => 'ff69b4',
|
||||
'indianred' => 'cd5c5c',
|
||||
'indigo' => '4b0082',
|
||||
'ivory' => 'fffff0',
|
||||
'khaki' => 'f0e68c',
|
||||
'lavender' => 'e6e6fa',
|
||||
'lavenderblush' => 'fff0f5',
|
||||
'lawngreen' => '7cfc00',
|
||||
'lemonchiffon' => 'fffacd',
|
||||
'lightblue' => 'add8e6',
|
||||
'lightcoral' => 'f08080',
|
||||
'lightcyan' => 'e0ffff',
|
||||
'lightgoldenrodyellow' => 'fafad2',
|
||||
'ltgray' => 'd3d3d3',
|
||||
'lightgray' => 'd3d3d3',
|
||||
'lightgrey' => 'd3d3d3',
|
||||
'lightgreen' => '90ee90',
|
||||
'lightpink' => 'ffb6c1',
|
||||
'lightsalmon' => 'ffa07a',
|
||||
'lightseagreen' => '20b2aa',
|
||||
'lightskyblue' => '87cefa',
|
||||
'lightslategray' => '778899',
|
||||
'lightslategrey' => '778899',
|
||||
'lightsteelblue' => 'b0c4de',
|
||||
'lightyellow' => 'ffffe0',
|
||||
'lime' => '00ff00',
|
||||
'limegreen' => '32cd32',
|
||||
'linen' => 'faf0e6',
|
||||
'magenta' => 'ff00ff',
|
||||
'maroon' => '800000',
|
||||
'mediumaquamarine' => '66cdaa',
|
||||
'mediumblue' => '0000cd',
|
||||
'mediumorchid' => 'ba55d3',
|
||||
'mediumpurple' => '9370d8',
|
||||
'mediumseagreen' => '3cb371',
|
||||
'mediumslateblue' => '7b68ee',
|
||||
'mediumspringgreen' => '00fa9a',
|
||||
'mediumturquoise' => '48d1cc',
|
||||
'mediumvioletred' => 'c71585',
|
||||
'midnightblue' => '191970',
|
||||
'mintcream' => 'f5fffa',
|
||||
'mistyrose' => 'ffe4e1',
|
||||
'moccasin' => 'ffe4b5',
|
||||
'navajowhite' => 'ffdead',
|
||||
'navy' => '000080',
|
||||
'oldlace' => 'fdf5e6',
|
||||
'olive' => '808000',
|
||||
'olivedrab' => '6b8e23',
|
||||
'orange' => 'ffa500',
|
||||
'orangered' => 'ff4500',
|
||||
'orchid' => 'da70d6',
|
||||
'palegoldenrod' => 'eee8aa',
|
||||
'palegreen' => '98fb98',
|
||||
'paleturquoise' => 'afeeee',
|
||||
'palevioletred' => 'd87093',
|
||||
'papayawhip' => 'ffefd5',
|
||||
'peachpuff' => 'ffdab9',
|
||||
'peru' => 'cd853f',
|
||||
'pink' => 'ffc0cb',
|
||||
'plum' => 'dda0dd',
|
||||
'powderblue' => 'b0e0e6',
|
||||
'purple' => '800080',
|
||||
'red' => 'ff0000',
|
||||
'rosybrown' => 'bc8f8f',
|
||||
'royalblue' => '4169e1',
|
||||
'saddlebrown' => '8b4513',
|
||||
'salmon' => 'fa8072',
|
||||
'sandybrown' => 'f4a460',
|
||||
'seagreen' => '2e8b57',
|
||||
'seashell' => 'fff5ee',
|
||||
'sienna' => 'a0522d',
|
||||
'silver' => 'c0c0c0',
|
||||
'skyblue' => '87ceeb',
|
||||
'slateblue' => '6a5acd',
|
||||
'slategray' => '708090',
|
||||
'slategrey' => '708090',
|
||||
'snow' => 'fffafa',
|
||||
'springgreen' => '00ff7f',
|
||||
'steelblue' => '4682b4',
|
||||
'tan' => 'd2b48c',
|
||||
'teal' => '008080',
|
||||
'thistle' => 'd8bfd8',
|
||||
'tomato' => 'ff6347',
|
||||
'turquoise' => '40e0d0',
|
||||
'violet' => 'ee82ee',
|
||||
'wheat' => 'f5deb3',
|
||||
'white' => 'ffffff',
|
||||
'whitesmoke' => 'f5f5f5',
|
||||
'yellow' => 'ffff00',
|
||||
'yellowgreen' => '9acd32'
|
||||
); // end of web colors
|
||||
|
||||
/**
|
||||
* Array of valid JavaScript color names
|
||||
* @public static
|
||||
*/
|
||||
public static $jscolor = array ('transparent', 'black', 'white', 'red', 'green', 'blue', 'cyan', 'magenta', 'yellow', 'dkGray', 'gray', 'ltGray');
|
||||
|
||||
/**
|
||||
* Array of Spot colors (C,M,Y,K,name)
|
||||
* Color keys must be in lowercase and without spaces.
|
||||
* As long as no open standard for spot colours exists, you have to buy a colour book by one of the colour manufacturers and insert the values and names of spot colours directly.
|
||||
* Common industry standard spot colors are: ANPA-COLOR, DIC, FOCOLTONE, GCMI, HKS, PANTONE, TOYO, TRUMATCH.
|
||||
* @public static
|
||||
*/
|
||||
public static $spotcolor = array (
|
||||
// special registration colors
|
||||
'none' => array( 0, 0, 0, 0, 'None'),
|
||||
'all' => array(100, 100, 100, 100, 'All'),
|
||||
// standard CMYK colors
|
||||
'cyan' => array(100, 0, 0, 0, 'Cyan'),
|
||||
'magenta' => array( 0, 100, 0, 0, 'Magenta'),
|
||||
'yellow' => array( 0, 0, 100, 0, 'Yellow'),
|
||||
'key' => array( 0, 0, 0, 100, 'Key'),
|
||||
// alias
|
||||
'white' => array( 0, 0, 0, 0, 'White'),
|
||||
'black' => array( 0, 0, 0, 100, 'Black'),
|
||||
// standard RGB colors
|
||||
'red' => array( 0, 100, 100, 0, 'Red'),
|
||||
'green' => array(100, 0, 100, 0, 'Green'),
|
||||
'blue' => array(100, 100, 0, 0, 'Blue'),
|
||||
// Add here standard spot colors or dynamically define them with AddSpotColor()
|
||||
// ...
|
||||
); // end of spot colors
|
||||
|
||||
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
/**
|
||||
* Return the Spot color array.
|
||||
* @param string $name Name of the spot color.
|
||||
* @param array $spotc Reference to an array of spot colors.
|
||||
* @return array|false Spot color array or false if not defined.
|
||||
* @since 5.9.125 (2011-10-03)
|
||||
* @public static
|
||||
*/
|
||||
public static function getSpotColor($name, &$spotc) {
|
||||
if (isset($spotc[$name])) {
|
||||
return $spotc[$name];
|
||||
}
|
||||
$color = preg_replace('/[\s]*/', '', $name); // remove extra spaces
|
||||
$color = strtolower($color);
|
||||
if (isset(self::$spotcolor[$color])) {
|
||||
if (!isset($spotc[$name])) {
|
||||
$i = (1 + count($spotc));
|
||||
$spotc[$name] = array('C' => self::$spotcolor[$color][0], 'M' => self::$spotcolor[$color][1], 'Y' => self::$spotcolor[$color][2], 'K' => self::$spotcolor[$color][3], 'name' => self::$spotcolor[$color][4], 'i' => $i);
|
||||
}
|
||||
return $spotc[self::$spotcolor[$color][4]];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array (RGB or CMYK) from an html color name, or a six-digit (i.e. #3FE5AA), or three-digit (i.e. #7FF) hexadecimal color, or a javascript color array, or javascript color name.
|
||||
* @param string $hcolor HTML color.
|
||||
* @param array $spotc Reference to an array of spot colors.
|
||||
* @param array $defcol Color to return in case of error.
|
||||
* @return array|false RGB or CMYK color, or false in case of error.
|
||||
* @public static
|
||||
*/
|
||||
public static function convertHTMLColorToDec($hcolor, &$spotc, $defcol=array('R'=>128,'G'=>128,'B'=>128)) {
|
||||
$color = preg_replace('/[\s]*/', '', $hcolor); // remove extra spaces
|
||||
$color = strtolower($color);
|
||||
// check for javascript color array syntax
|
||||
if (strpos($color, '[') !== false) {
|
||||
if (preg_match('/[\[][\"\'](t|g|rgb|cmyk)[\"\'][\,]?([0-9\.]*)[\,]?([0-9\.]*)[\,]?([0-9\.]*)[\,]?([0-9\.]*)[\]]/', $color, $m) > 0) {
|
||||
$returncolor = array();
|
||||
switch ($m[1]) {
|
||||
case 'cmyk': {
|
||||
// RGB
|
||||
$returncolor['C'] = max(0, min(100, (floatval($m[2]) * 100)));
|
||||
$returncolor['M'] = max(0, min(100, (floatval($m[3]) * 100)));
|
||||
$returncolor['Y'] = max(0, min(100, (floatval($m[4]) * 100)));
|
||||
$returncolor['K'] = max(0, min(100, (floatval($m[5]) * 100)));
|
||||
break;
|
||||
}
|
||||
case 'rgb': {
|
||||
// RGB
|
||||
$returncolor['R'] = max(0, min(255, (floatval($m[2]) * 255)));
|
||||
$returncolor['G'] = max(0, min(255, (floatval($m[3]) * 255)));
|
||||
$returncolor['B'] = max(0, min(255, (floatval($m[4]) * 255)));
|
||||
break;
|
||||
}
|
||||
case 'g': {
|
||||
// grayscale
|
||||
$returncolor['G'] = max(0, min(255, (floatval($m[2]) * 255)));
|
||||
break;
|
||||
}
|
||||
case 't':
|
||||
default: {
|
||||
// transparent (empty array)
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $returncolor;
|
||||
}
|
||||
} elseif ((substr($color, 0, 4) != 'cmyk') AND (substr($color, 0, 3) != 'rgb') AND (($dotpos = strpos($color, '.')) !== false)) {
|
||||
// remove class parent (i.e.: color.red)
|
||||
$color = substr($color, ($dotpos + 1));
|
||||
if ($color == 'transparent') {
|
||||
// transparent (empty array)
|
||||
return array();
|
||||
}
|
||||
}
|
||||
if (strlen($color) == 0) {
|
||||
return $defcol;
|
||||
}
|
||||
// RGB ARRAY
|
||||
if (substr($color, 0, 3) == 'rgb') {
|
||||
$codes = substr($color, 4);
|
||||
$codes = str_replace(')', '', $codes);
|
||||
$returncolor = explode(',', $codes);
|
||||
foreach ($returncolor as $key => $val) {
|
||||
if (strpos($val, '%') > 0) {
|
||||
// percentage
|
||||
$returncolor[$key] = (255 * intval($val) / 100);
|
||||
} else {
|
||||
$returncolor[$key] = intval($val);
|
||||
}
|
||||
// normalize value
|
||||
$returncolor[$key] = max(0, min(255, $returncolor[$key]));
|
||||
}
|
||||
return $returncolor;
|
||||
}
|
||||
// CMYK ARRAY
|
||||
if (substr($color, 0, 4) == 'cmyk') {
|
||||
$codes = substr($color, 5);
|
||||
$codes = str_replace(')', '', $codes);
|
||||
$returncolor = explode(',', $codes);
|
||||
foreach ($returncolor as $key => $val) {
|
||||
if (strpos($val, '%') !== false) {
|
||||
// percentage
|
||||
$returncolor[$key] = (100 * intval($val) / 100);
|
||||
} else {
|
||||
$returncolor[$key] = intval($val);
|
||||
}
|
||||
// normalize value
|
||||
$returncolor[$key] = max(0, min(100, $returncolor[$key]));
|
||||
}
|
||||
return $returncolor;
|
||||
}
|
||||
if ($color[0] != '#') {
|
||||
// COLOR NAME
|
||||
if (isset(self::$webcolor[$color])) {
|
||||
// web color
|
||||
$color_code = self::$webcolor[$color];
|
||||
} else {
|
||||
// spot color
|
||||
$returncolor = self::getSpotColor($hcolor, $spotc);
|
||||
if ($returncolor === false) {
|
||||
$returncolor = $defcol;
|
||||
}
|
||||
return $returncolor;
|
||||
}
|
||||
} else {
|
||||
$color_code = substr($color, 1);
|
||||
}
|
||||
// HEXADECIMAL REPRESENTATION
|
||||
switch (strlen($color_code)) {
|
||||
case 3: {
|
||||
// 3-digit RGB hexadecimal representation
|
||||
$r = substr($color_code, 0, 1);
|
||||
$g = substr($color_code, 1, 1);
|
||||
$b = substr($color_code, 2, 1);
|
||||
$returncolor = array();
|
||||
$returncolor['R'] = max(0, min(255, hexdec($r.$r)));
|
||||
$returncolor['G'] = max(0, min(255, hexdec($g.$g)));
|
||||
$returncolor['B'] = max(0, min(255, hexdec($b.$b)));
|
||||
break;
|
||||
}
|
||||
case 6: {
|
||||
// 6-digit RGB hexadecimal representation
|
||||
$returncolor = array();
|
||||
$returncolor['R'] = max(0, min(255, hexdec(substr($color_code, 0, 2))));
|
||||
$returncolor['G'] = max(0, min(255, hexdec(substr($color_code, 2, 2))));
|
||||
$returncolor['B'] = max(0, min(255, hexdec(substr($color_code, 4, 2))));
|
||||
break;
|
||||
}
|
||||
case 8: {
|
||||
// 8-digit CMYK hexadecimal representation
|
||||
$returncolor = array();
|
||||
$returncolor['C'] = max(0, min(100, round(hexdec(substr($color_code, 0, 2)) / 2.55)));
|
||||
$returncolor['M'] = max(0, min(100, round(hexdec(substr($color_code, 2, 2)) / 2.55)));
|
||||
$returncolor['Y'] = max(0, min(100, round(hexdec(substr($color_code, 4, 2)) / 2.55)));
|
||||
$returncolor['K'] = max(0, min(100, round(hexdec(substr($color_code, 6, 2)) / 2.55)));
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
$returncolor = $defcol;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $returncolor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a color array into a string representation.
|
||||
* @param array $c Array of colors.
|
||||
* @return string The color array representation.
|
||||
* @since 5.9.137 (2011-12-01)
|
||||
* @public static
|
||||
*/
|
||||
public static function getColorStringFromArray($c) {
|
||||
$c = array_values($c);
|
||||
$color = '[';
|
||||
switch (count($c)) {
|
||||
case 4: {
|
||||
// CMYK
|
||||
$color .= sprintf('%F %F %F %F', (max(0, min(100, floatval($c[0]))) / 100), (max(0, min(100, floatval($c[1]))) / 100), (max(0, min(100, floatval($c[2]))) / 100), (max(0, min(100, floatval($c[3]))) / 100));
|
||||
break;
|
||||
}
|
||||
case 3: {
|
||||
// RGB
|
||||
$color .= sprintf('%F %F %F', (max(0, min(255, floatval($c[0]))) / 255), (max(0, min(255, floatval($c[1]))) / 255), (max(0, min(255, floatval($c[2]))) / 255));
|
||||
break;
|
||||
}
|
||||
case 1: {
|
||||
// grayscale
|
||||
$color .= sprintf('%F', (max(0, min(255, floatval($c[0]))) / 255));
|
||||
break;
|
||||
}
|
||||
}
|
||||
$color .= ']';
|
||||
return $color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert color to javascript color.
|
||||
* @param string $color color name or "#RRGGBB"
|
||||
* @protected
|
||||
* @since 2.1.002 (2008-02-12)
|
||||
* @public static
|
||||
*/
|
||||
public static function _JScolor($color) {
|
||||
if (substr($color, 0, 1) == '#') {
|
||||
return sprintf("['RGB',%F,%F,%F]", (hexdec(substr($color, 1, 2)) / 255), (hexdec(substr($color, 3, 2)) / 255), (hexdec(substr($color, 5, 2)) / 255));
|
||||
}
|
||||
if (!in_array($color, self::$jscolor)) {
|
||||
// default transparent color
|
||||
$color = self::$jscolor[0];
|
||||
}
|
||||
return 'color.'.$color;
|
||||
}
|
||||
|
||||
|
||||
} // END OF TCPDF_COLORS CLASS
|
||||
|
||||
//============================================================+
|
||||
// END OF FILE
|
||||
//============================================================+
|
||||
@@ -0,0 +1,481 @@
|
||||
<?php
|
||||
//============================================================+
|
||||
// File name : tcpdf_filters.php
|
||||
// Version : 1.0.001
|
||||
// Begin : 2011-05-23
|
||||
// Last Update : 2014-04-25
|
||||
// Author : Nicola Asuni - Tecnick.com LTD - www.tecnick.com - info@tecnick.com
|
||||
// License : GNU-LGPL v3 (http://www.gnu.org/copyleft/lesser.html)
|
||||
// -------------------------------------------------------------------
|
||||
// Copyright (C) 2011-2013 Nicola Asuni - Tecnick.com LTD
|
||||
//
|
||||
// This file is part of TCPDF software library.
|
||||
//
|
||||
// TCPDF is free software: you can redistribute it and/or modify it
|
||||
// under the terms of the GNU Lesser General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// TCPDF 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 Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the License
|
||||
// along with TCPDF. If not, see
|
||||
// <http://www.tecnick.com/pagefiles/tcpdf/LICENSE.TXT>.
|
||||
//
|
||||
// See LICENSE.TXT file for more information.
|
||||
// -------------------------------------------------------------------
|
||||
//
|
||||
// Description : This is a PHP class for decoding common PDF filters (PDF 32000-2008 - 7.4 Filters).
|
||||
//
|
||||
//============================================================+
|
||||
|
||||
/**
|
||||
* @file
|
||||
* This is a PHP class for decoding common PDF filters (PDF 32000-2008 - 7.4 Filters).<br>
|
||||
* @package com.tecnick.tcpdf
|
||||
* @author Nicola Asuni
|
||||
* @version 1.0.001
|
||||
*/
|
||||
|
||||
/**
|
||||
* @class TCPDF_FILTERS
|
||||
* This is a PHP class for decoding common PDF filters (PDF 32000-2008 - 7.4 Filters).<br>
|
||||
* @package com.tecnick.tcpdf
|
||||
* @brief This is a PHP class for decoding common PDF filters.
|
||||
* @version 1.0.001
|
||||
* @author Nicola Asuni - info@tecnick.com
|
||||
*/
|
||||
class TCPDF_FILTERS {
|
||||
|
||||
/**
|
||||
* Define a list of available filter decoders.
|
||||
* @private static
|
||||
*/
|
||||
private static $available_filters = array('ASCIIHexDecode', 'ASCII85Decode', 'LZWDecode', 'FlateDecode', 'RunLengthDecode');
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get a list of available decoding filters.
|
||||
* @return array Array of available filter decoders.
|
||||
* @since 1.0.000 (2011-05-23)
|
||||
* @public static
|
||||
*/
|
||||
public static function getAvailableFilters() {
|
||||
return self::$available_filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode data using the specified filter type.
|
||||
* @param string $filter Filter name.
|
||||
* @param string $data Data to decode.
|
||||
* @return string Decoded data string.
|
||||
* @since 1.0.000 (2011-05-23)
|
||||
* @public static
|
||||
*/
|
||||
public static function decodeFilter($filter, $data) {
|
||||
switch ($filter) {
|
||||
case 'ASCIIHexDecode': {
|
||||
return self::decodeFilterASCIIHexDecode($data);
|
||||
break;
|
||||
}
|
||||
case 'ASCII85Decode': {
|
||||
return self::decodeFilterASCII85Decode($data);
|
||||
break;
|
||||
}
|
||||
case 'LZWDecode': {
|
||||
return self::decodeFilterLZWDecode($data);
|
||||
break;
|
||||
}
|
||||
case 'FlateDecode': {
|
||||
return self::decodeFilterFlateDecode($data);
|
||||
break;
|
||||
}
|
||||
case 'RunLengthDecode': {
|
||||
return self::decodeFilterRunLengthDecode($data);
|
||||
break;
|
||||
}
|
||||
case 'CCITTFaxDecode': {
|
||||
return self::decodeFilterCCITTFaxDecode($data);
|
||||
break;
|
||||
}
|
||||
case 'JBIG2Decode': {
|
||||
return self::decodeFilterJBIG2Decode($data);
|
||||
break;
|
||||
}
|
||||
case 'DCTDecode': {
|
||||
return self::decodeFilterDCTDecode($data);
|
||||
break;
|
||||
}
|
||||
case 'JPXDecode': {
|
||||
return self::decodeFilterJPXDecode($data);
|
||||
break;
|
||||
}
|
||||
case 'Crypt': {
|
||||
return self::decodeFilterCrypt($data);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
return self::decodeFilterStandard($data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- FILTERS (PDF 32000-2008 - 7.4 Filters) ------------------------------
|
||||
|
||||
/**
|
||||
* Standard
|
||||
* Default decoding filter (leaves data unchanged).
|
||||
* @param string $data Data to decode.
|
||||
* @return string Decoded data string.
|
||||
* @since 1.0.000 (2011-05-23)
|
||||
* @public static
|
||||
*/
|
||||
public static function decodeFilterStandard($data) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* ASCIIHexDecode
|
||||
* Decodes data encoded in an ASCII hexadecimal representation, reproducing the original binary data.
|
||||
* @param string $data Data to decode.
|
||||
* @return string Decoded data string.
|
||||
* @since 1.0.000 (2011-05-23)
|
||||
* @public static
|
||||
*/
|
||||
public static function decodeFilterASCIIHexDecode($data) {
|
||||
// initialize string to return
|
||||
$decoded = '';
|
||||
// all white-space characters shall be ignored
|
||||
$data = preg_replace('/[\s]/', '', $data);
|
||||
// check for EOD character: GREATER-THAN SIGN (3Eh)
|
||||
$eod = strpos($data, '>');
|
||||
if ($eod !== false) {
|
||||
// remove EOD and extra data (if any)
|
||||
$data = substr($data, 0, $eod);
|
||||
$eod = true;
|
||||
}
|
||||
// get data length
|
||||
$data_length = strlen($data);
|
||||
if (($data_length % 2) != 0) {
|
||||
// odd number of hexadecimal digits
|
||||
if ($eod) {
|
||||
// EOD shall behave as if a 0 (zero) followed the last digit
|
||||
$data = substr($data, 0, -1).'0'.substr($data, -1);
|
||||
} else {
|
||||
self::Error('decodeFilterASCIIHexDecode: invalid code');
|
||||
}
|
||||
}
|
||||
// check for invalid characters
|
||||
if (preg_match('/[^a-fA-F\d]/', $data) > 0) {
|
||||
self::Error('decodeFilterASCIIHexDecode: invalid code');
|
||||
}
|
||||
// get one byte of binary data for each pair of ASCII hexadecimal digits
|
||||
$decoded = pack('H*', $data);
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* ASCII85Decode
|
||||
* Decodes data encoded in an ASCII base-85 representation, reproducing the original binary data.
|
||||
* @param string $data Data to decode.
|
||||
* @return string Decoded data string.
|
||||
* @since 1.0.000 (2011-05-23)
|
||||
* @public static
|
||||
*/
|
||||
public static function decodeFilterASCII85Decode($data) {
|
||||
// initialize string to return
|
||||
$decoded = '';
|
||||
// all white-space characters shall be ignored
|
||||
$data = preg_replace('/[\s]/', '', $data);
|
||||
// remove start sequence 2-character sequence <~ (3Ch)(7Eh)
|
||||
if (strpos($data, '<~') !== false) {
|
||||
// remove EOD and extra data (if any)
|
||||
$data = substr($data, 2);
|
||||
}
|
||||
// check for EOD: 2-character sequence ~> (7Eh)(3Eh)
|
||||
$eod = strpos($data, '~>');
|
||||
if ($eod !== false) {
|
||||
// remove EOD and extra data (if any)
|
||||
$data = substr($data, 0, $eod);
|
||||
}
|
||||
// data length
|
||||
$data_length = strlen($data);
|
||||
// check for invalid characters
|
||||
if (preg_match('/[^\x21-\x75,\x74]/', $data) > 0) {
|
||||
self::Error('decodeFilterASCII85Decode: invalid code');
|
||||
}
|
||||
// z sequence
|
||||
$zseq = chr(0).chr(0).chr(0).chr(0);
|
||||
// position inside a group of 4 bytes (0-3)
|
||||
$group_pos = 0;
|
||||
$tuple = 0;
|
||||
$pow85 = array((85*85*85*85), (85*85*85), (85*85), 85, 1);
|
||||
$last_pos = ($data_length - 1);
|
||||
// for each byte
|
||||
for ($i = 0; $i < $data_length; ++$i) {
|
||||
// get char value
|
||||
$char = ord($data[$i]);
|
||||
if ($char == 122) { // 'z'
|
||||
if ($group_pos == 0) {
|
||||
$decoded .= $zseq;
|
||||
} else {
|
||||
self::Error('decodeFilterASCII85Decode: invalid code');
|
||||
}
|
||||
} else {
|
||||
// the value represented by a group of 5 characters should never be greater than 2^32 - 1
|
||||
$tuple += (($char - 33) * $pow85[$group_pos]);
|
||||
if ($group_pos == 4) {
|
||||
$decoded .= chr($tuple >> 24).chr($tuple >> 16).chr($tuple >> 8).chr($tuple);
|
||||
$tuple = 0;
|
||||
$group_pos = 0;
|
||||
} else {
|
||||
++$group_pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($group_pos > 1) {
|
||||
$tuple += $pow85[($group_pos - 1)];
|
||||
}
|
||||
// last tuple (if any)
|
||||
switch ($group_pos) {
|
||||
case 4: {
|
||||
$decoded .= chr($tuple >> 24).chr($tuple >> 16).chr($tuple >> 8);
|
||||
break;
|
||||
}
|
||||
case 3: {
|
||||
$decoded .= chr($tuple >> 24).chr($tuple >> 16);
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
$decoded .= chr($tuple >> 24);
|
||||
break;
|
||||
}
|
||||
case 1: {
|
||||
self::Error('decodeFilterASCII85Decode: invalid code');
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* LZWDecode
|
||||
* Decompresses data encoded using the LZW (Lempel-Ziv-Welch) adaptive compression method, reproducing the original text or binary data.
|
||||
* @param string $data Data to decode.
|
||||
* @return string Decoded data string.
|
||||
* @since 1.0.000 (2011-05-23)
|
||||
* @public static
|
||||
*/
|
||||
public static function decodeFilterLZWDecode($data) {
|
||||
// initialize string to return
|
||||
$decoded = '';
|
||||
// data length
|
||||
$data_length = strlen($data);
|
||||
// convert string to binary string
|
||||
$bitstring = '';
|
||||
for ($i = 0; $i < $data_length; ++$i) {
|
||||
$bitstring .= sprintf('%08b', ord($data[$i]));
|
||||
}
|
||||
// get the number of bits
|
||||
$data_length = strlen($bitstring);
|
||||
// initialize code length in bits
|
||||
$bitlen = 9;
|
||||
// initialize dictionary index
|
||||
$dix = 258;
|
||||
// initialize the dictionary (with the first 256 entries).
|
||||
$dictionary = array();
|
||||
for ($i = 0; $i < 256; ++$i) {
|
||||
$dictionary[$i] = chr($i);
|
||||
}
|
||||
// previous val
|
||||
$prev_index = 0;
|
||||
// while we encounter EOD marker (257), read code_length bits
|
||||
while (($data_length > 0) AND (($index = bindec(substr($bitstring, 0, $bitlen))) != 257)) {
|
||||
// remove read bits from string
|
||||
$bitstring = substr($bitstring, $bitlen);
|
||||
// update number of bits
|
||||
$data_length -= $bitlen;
|
||||
if ($index == 256) { // clear-table marker
|
||||
// reset code length in bits
|
||||
$bitlen = 9;
|
||||
// reset dictionary index
|
||||
$dix = 258;
|
||||
$prev_index = 256;
|
||||
// reset the dictionary (with the first 256 entries).
|
||||
$dictionary = array();
|
||||
for ($i = 0; $i < 256; ++$i) {
|
||||
$dictionary[$i] = chr($i);
|
||||
}
|
||||
} elseif ($prev_index == 256) {
|
||||
// first entry
|
||||
$decoded .= $dictionary[$index];
|
||||
$prev_index = $index;
|
||||
} else {
|
||||
// check if index exist in the dictionary
|
||||
if ($index < $dix) {
|
||||
// index exist on dictionary
|
||||
$decoded .= $dictionary[$index];
|
||||
$dic_val = $dictionary[$prev_index].$dictionary[$index][0];
|
||||
// store current index
|
||||
$prev_index = $index;
|
||||
} else {
|
||||
// index do not exist on dictionary
|
||||
$dic_val = $dictionary[$prev_index].$dictionary[$prev_index][0];
|
||||
$decoded .= $dic_val;
|
||||
}
|
||||
// update dictionary
|
||||
$dictionary[$dix] = $dic_val;
|
||||
++$dix;
|
||||
// change bit length by case
|
||||
if ($dix == 2047) {
|
||||
$bitlen = 12;
|
||||
} elseif ($dix == 1023) {
|
||||
$bitlen = 11;
|
||||
} elseif ($dix == 511) {
|
||||
$bitlen = 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* FlateDecode
|
||||
* Decompresses data encoded using the zlib/deflate compression method, reproducing the original text or binary data.
|
||||
* @param string $data Data to decode.
|
||||
* @return string Decoded data string.
|
||||
* @since 1.0.000 (2011-05-23)
|
||||
* @public static
|
||||
*/
|
||||
public static function decodeFilterFlateDecode($data) {
|
||||
// initialize string to return
|
||||
$decoded = @gzuncompress($data);
|
||||
if ($decoded === false) {
|
||||
self::Error('decodeFilterFlateDecode: invalid code');
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* RunLengthDecode
|
||||
* Decompresses data encoded using a byte-oriented run-length encoding algorithm.
|
||||
* @param string $data Data to decode.
|
||||
* @since 1.0.000 (2011-05-23)
|
||||
* @public static
|
||||
*/
|
||||
public static function decodeFilterRunLengthDecode($data) {
|
||||
// initialize string to return
|
||||
$decoded = '';
|
||||
// data length
|
||||
$data_length = strlen($data);
|
||||
$i = 0;
|
||||
while($i < $data_length) {
|
||||
// get current byte value
|
||||
$byte = ord($data[$i]);
|
||||
if ($byte == 128) {
|
||||
// a length value of 128 denote EOD
|
||||
break;
|
||||
} elseif ($byte < 128) {
|
||||
// if the length byte is in the range 0 to 127
|
||||
// the following length + 1 (1 to 128) bytes shall be copied literally during decompression
|
||||
$decoded .= substr($data, ($i + 1), ($byte + 1));
|
||||
// move to next block
|
||||
$i += ($byte + 2);
|
||||
} else {
|
||||
// if length is in the range 129 to 255,
|
||||
// the following single byte shall be copied 257 - length (2 to 128) times during decompression
|
||||
$decoded .= str_repeat($data[($i + 1)], (257 - $byte));
|
||||
// move to next block
|
||||
$i += 2;
|
||||
}
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* CCITTFaxDecode (NOT IMPLEMETED - RETURN AN EXCEPTION)
|
||||
* Decompresses data encoded using the CCITT facsimile standard, reproducing the original data (typically monochrome image data at 1 bit per pixel).
|
||||
* @param string $data Data to decode.
|
||||
* @return string Decoded data string.
|
||||
* @since 1.0.000 (2011-05-23)
|
||||
* @public static
|
||||
*/
|
||||
public static function decodeFilterCCITTFaxDecode($data) {
|
||||
self::Error('~decodeFilterCCITTFaxDecode: this method has not been yet implemented');
|
||||
//return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* JBIG2Decode (NOT IMPLEMETED - RETURN AN EXCEPTION)
|
||||
* Decompresses data encoded using the JBIG2 standard, reproducing the original monochrome (1 bit per pixel) image data (or an approximation of that data).
|
||||
* @param string $data Data to decode.
|
||||
* @return string Decoded data string.
|
||||
* @since 1.0.000 (2011-05-23)
|
||||
* @public static
|
||||
*/
|
||||
public static function decodeFilterJBIG2Decode($data) {
|
||||
self::Error('~decodeFilterJBIG2Decode: this method has not been yet implemented');
|
||||
//return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* DCTDecode (NOT IMPLEMETED - RETURN AN EXCEPTION)
|
||||
* Decompresses data encoded using a DCT (discrete cosine transform) technique based on the JPEG standard, reproducing image sample data that approximates the original data.
|
||||
* @param string $data Data to decode.
|
||||
* @return string Decoded data string.
|
||||
* @since 1.0.000 (2011-05-23)
|
||||
* @public static
|
||||
*/
|
||||
public static function decodeFilterDCTDecode($data) {
|
||||
self::Error('~decodeFilterDCTDecode: this method has not been yet implemented');
|
||||
//return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* JPXDecode (NOT IMPLEMETED - RETURN AN EXCEPTION)
|
||||
* Decompresses data encoded using the wavelet-based JPEG2000 standard, reproducing the original image data.
|
||||
* @param string $data Data to decode.
|
||||
* @return string Decoded data string.
|
||||
* @since 1.0.000 (2011-05-23)
|
||||
* @public static
|
||||
*/
|
||||
public static function decodeFilterJPXDecode($data) {
|
||||
self::Error('~decodeFilterJPXDecode: this method has not been yet implemented');
|
||||
//return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crypt (NOT IMPLEMETED - RETURN AN EXCEPTION)
|
||||
* Decrypts data encrypted by a security handler, reproducing the data as it was before encryption.
|
||||
* @param string $data Data to decode.
|
||||
* @return string Decoded data string.
|
||||
* @since 1.0.000 (2011-05-23)
|
||||
* @public static
|
||||
*/
|
||||
public static function decodeFilterCrypt($data) {
|
||||
self::Error('~decodeFilterCrypt: this method has not been yet implemented');
|
||||
//return $data;
|
||||
}
|
||||
|
||||
// --- END FILTERS SECTION -------------------------------------------------
|
||||
|
||||
/**
|
||||
* Throw an exception.
|
||||
* @param string $msg The error message
|
||||
* @since 1.0.000 (2011-05-23)
|
||||
* @public static
|
||||
*/
|
||||
public static function Error($msg) {
|
||||
throw new Exception('TCPDF_PARSER ERROR: '.$msg);
|
||||
}
|
||||
|
||||
} // END OF TCPDF_FILTERS CLASS
|
||||
|
||||
//============================================================+
|
||||
// END OF FILE
|
||||
//============================================================+
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,359 @@
|
||||
<?php
|
||||
//============================================================+
|
||||
// File name : tcpdf_images.php
|
||||
// Version : 1.0.005
|
||||
// Begin : 2002-08-03
|
||||
// Last Update : 2014-11-15
|
||||
// Author : Nicola Asuni - Tecnick.com LTD - www.tecnick.com - info@tecnick.com
|
||||
// License : GNU-LGPL v3 (http://www.gnu.org/copyleft/lesser.html)
|
||||
// -------------------------------------------------------------------
|
||||
// Copyright (C) 2002-2014 Nicola Asuni - Tecnick.com LTD
|
||||
//
|
||||
// This file is part of TCPDF software library.
|
||||
//
|
||||
// TCPDF is free software: you can redistribute it and/or modify it
|
||||
// under the terms of the GNU Lesser General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// TCPDF 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 Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the License
|
||||
// along with TCPDF. If not, see
|
||||
// <http://www.tecnick.com/pagefiles/tcpdf/LICENSE.TXT>.
|
||||
//
|
||||
// See LICENSE.TXT file for more information.
|
||||
// -------------------------------------------------------------------
|
||||
//
|
||||
// Description :
|
||||
// Static image methods used by the TCPDF class.
|
||||
//
|
||||
//============================================================+
|
||||
|
||||
/**
|
||||
* @file
|
||||
* This is a PHP class that contains static image methods for the TCPDF class.<br>
|
||||
* @package com.tecnick.tcpdf
|
||||
* @author Nicola Asuni
|
||||
* @version 1.0.005
|
||||
*/
|
||||
|
||||
/**
|
||||
* @class TCPDF_IMAGES
|
||||
* Static image methods used by the TCPDF class.
|
||||
* @package com.tecnick.tcpdf
|
||||
* @brief PHP class for generating PDF documents without requiring external extensions.
|
||||
* @version 1.0.005
|
||||
* @author Nicola Asuni - info@tecnick.com
|
||||
*/
|
||||
class TCPDF_IMAGES {
|
||||
|
||||
/**
|
||||
* Array of hinheritable SVG properties.
|
||||
* @since 5.0.000 (2010-05-02)
|
||||
* @public static
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
public static $svginheritprop = array('clip-rule', 'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile', 'color-rendering', 'cursor', 'direction', 'display', 'fill', 'fill-opacity', 'fill-rule', 'font', 'font-family', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'glyph-orientation-horizontal', 'glyph-orientation-vertical', 'image-rendering', 'kerning', 'letter-spacing', 'marker', 'marker-end', 'marker-mid', 'marker-start', 'pointer-events', 'shape-rendering', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'text-anchor', 'text-rendering', 'visibility', 'word-spacing', 'writing-mode');
|
||||
|
||||
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
/**
|
||||
* Return the image type given the file name or array returned by getimagesize() function.
|
||||
* @param string $imgfile image file name
|
||||
* @param array $iminfo array of image information returned by getimagesize() function.
|
||||
* @return string image type
|
||||
* @since 4.8.017 (2009-11-27)
|
||||
* @public static
|
||||
*/
|
||||
public static function getImageFileType($imgfile, $iminfo=array()) {
|
||||
$type = '';
|
||||
if (isset($iminfo['mime']) AND !empty($iminfo['mime'])) {
|
||||
$mime = explode('/', $iminfo['mime']);
|
||||
if ((count($mime) > 1) AND ($mime[0] == 'image') AND (!empty($mime[1]))) {
|
||||
$type = strtolower(trim($mime[1]));
|
||||
}
|
||||
}
|
||||
if (empty($type)) {
|
||||
$type = strtolower(trim(pathinfo(parse_url($imgfile, PHP_URL_PATH), PATHINFO_EXTENSION)));
|
||||
}
|
||||
if ($type == 'jpg') {
|
||||
$type = 'jpeg';
|
||||
}
|
||||
return $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the transparency for the given GD image.
|
||||
* @param resource $new_image GD image object
|
||||
* @param resource $image GD image object.
|
||||
* @return resource GD image object $new_image
|
||||
* @since 4.9.016 (2010-04-20)
|
||||
* @public static
|
||||
*/
|
||||
public static function setGDImageTransparency($new_image, $image) {
|
||||
// default transparency color (white)
|
||||
$tcol = array('red' => 255, 'green' => 255, 'blue' => 255);
|
||||
// transparency index
|
||||
$tid = imagecolortransparent($image);
|
||||
$palletsize = imagecolorstotal($image);
|
||||
if (($tid >= 0) AND ($tid < $palletsize)) {
|
||||
// get the colors for the transparency index
|
||||
$tcol = imagecolorsforindex($image, $tid);
|
||||
}
|
||||
$tid = imagecolorallocate($new_image, $tcol['red'], $tcol['green'], $tcol['blue']);
|
||||
imagefill($new_image, 0, 0, $tid);
|
||||
imagecolortransparent($new_image, $tid);
|
||||
return $new_image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the loaded image to a PNG and then return a structure for the PDF creator.
|
||||
* This function requires GD library and write access to the directory defined on K_PATH_CACHE constant.
|
||||
* @param resource $image Image object.
|
||||
* @param string $tempfile Temporary file name.
|
||||
* return image PNG image object.
|
||||
* @since 4.9.016 (2010-04-20)
|
||||
* @public static
|
||||
*/
|
||||
public static function _toPNG($image, $tempfile) {
|
||||
// turn off interlaced mode
|
||||
imageinterlace($image, 0);
|
||||
// create temporary PNG image
|
||||
imagepng($image, $tempfile);
|
||||
// remove image from memory
|
||||
imagedestroy($image);
|
||||
// get PNG image data
|
||||
$retvars = self::_parsepng($tempfile);
|
||||
// tidy up by removing temporary image
|
||||
unlink($tempfile);
|
||||
return $retvars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the loaded image to a JPEG and then return a structure for the PDF creator.
|
||||
* This function requires GD library and write access to the directory defined on K_PATH_CACHE constant.
|
||||
* @param resource $image Image object.
|
||||
* @param int $quality JPEG quality.
|
||||
* @param string $tempfile Temporary file name.
|
||||
* return array|false image JPEG image object.
|
||||
* @public static
|
||||
*/
|
||||
public static function _toJPEG($image, $quality, $tempfile) {
|
||||
imagejpeg($image, $tempfile, $quality);
|
||||
imagedestroy($image);
|
||||
$retvars = self::_parsejpeg($tempfile);
|
||||
// tidy up by removing temporary image
|
||||
unlink($tempfile);
|
||||
return $retvars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract info from a JPEG file without using the GD library.
|
||||
* @param string $file image file to parse
|
||||
* @return array|false structure containing the image data
|
||||
* @public static
|
||||
*/
|
||||
public static function _parsejpeg($file) {
|
||||
// check if is a local file
|
||||
if (!@TCPDF_STATIC::file_exists($file)) {
|
||||
return false;
|
||||
}
|
||||
$a = getimagesize($file);
|
||||
if (empty($a)) {
|
||||
//Missing or incorrect image file
|
||||
return false;
|
||||
}
|
||||
if ($a[2] != 2) {
|
||||
// Not a JPEG file
|
||||
return false;
|
||||
}
|
||||
// bits per pixel
|
||||
$bpc = isset($a['bits']) ? intval($a['bits']) : 8;
|
||||
// number of image channels
|
||||
if (!isset($a['channels'])) {
|
||||
$channels = 3;
|
||||
} else {
|
||||
$channels = intval($a['channels']);
|
||||
}
|
||||
// default colour space
|
||||
switch ($channels) {
|
||||
case 1: {
|
||||
$colspace = 'DeviceGray';
|
||||
break;
|
||||
}
|
||||
case 3: {
|
||||
$colspace = 'DeviceRGB';
|
||||
break;
|
||||
}
|
||||
case 4: {
|
||||
$colspace = 'DeviceCMYK';
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
$channels = 3;
|
||||
$colspace = 'DeviceRGB';
|
||||
break;
|
||||
}
|
||||
}
|
||||
// get file content
|
||||
$data = file_get_contents($file);
|
||||
// check for embedded ICC profile
|
||||
$icc = array();
|
||||
$offset = 0;
|
||||
while (($pos = strpos($data, "ICC_PROFILE\0", $offset)) !== false) {
|
||||
// get ICC sequence length
|
||||
$length = (TCPDF_STATIC::_getUSHORT($data, ($pos - 2)) - 16);
|
||||
// marker sequence number
|
||||
$msn = max(1, ord($data[($pos + 12)]));
|
||||
// number of markers (total of APP2 used)
|
||||
$nom = max(1, ord($data[($pos + 13)]));
|
||||
// get sequence segment
|
||||
$icc[($msn - 1)] = substr($data, ($pos + 14), $length);
|
||||
// move forward to next sequence
|
||||
$offset = ($pos + 14 + $length);
|
||||
}
|
||||
// order and compact ICC segments
|
||||
if (count($icc) > 0) {
|
||||
ksort($icc);
|
||||
$icc = implode('', $icc);
|
||||
if ((ord($icc[36]) != 0x61) OR (ord($icc[37]) != 0x63) OR (ord($icc[38]) != 0x73) OR (ord($icc[39]) != 0x70)) {
|
||||
// invalid ICC profile
|
||||
$icc = false;
|
||||
}
|
||||
} else {
|
||||
$icc = false;
|
||||
}
|
||||
return array('w' => $a[0], 'h' => $a[1], 'ch' => $channels, 'icc' => $icc, 'cs' => $colspace, 'bpc' => $bpc, 'f' => 'DCTDecode', 'data' => $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract info from a PNG file without using the GD library.
|
||||
* @param string $file image file to parse
|
||||
* @return array|false structure containing the image data
|
||||
* @public static
|
||||
*/
|
||||
public static function _parsepng($file) {
|
||||
$f = @fopen($file, 'rb');
|
||||
if ($f === false) {
|
||||
// Can't open image file
|
||||
return false;
|
||||
}
|
||||
//Check signature
|
||||
if (fread($f, 8) != chr(137).'PNG'.chr(13).chr(10).chr(26).chr(10)) {
|
||||
// Not a PNG file
|
||||
return false;
|
||||
}
|
||||
//Read header chunk
|
||||
fread($f, 4);
|
||||
if (fread($f, 4) != 'IHDR') {
|
||||
//Incorrect PNG file
|
||||
return false;
|
||||
}
|
||||
$w = TCPDF_STATIC::_freadint($f);
|
||||
$h = TCPDF_STATIC::_freadint($f);
|
||||
$bpc = ord(fread($f, 1));
|
||||
$ct = ord(fread($f, 1));
|
||||
if ($ct == 0) {
|
||||
$colspace = 'DeviceGray';
|
||||
} elseif ($ct == 2) {
|
||||
$colspace = 'DeviceRGB';
|
||||
} elseif ($ct == 3) {
|
||||
$colspace = 'Indexed';
|
||||
} else {
|
||||
// alpha channel
|
||||
fclose($f);
|
||||
return 'pngalpha';
|
||||
}
|
||||
if (ord(fread($f, 1)) != 0) {
|
||||
// Unknown compression method
|
||||
fclose($f);
|
||||
return false;
|
||||
}
|
||||
if (ord(fread($f, 1)) != 0) {
|
||||
// Unknown filter method
|
||||
fclose($f);
|
||||
return false;
|
||||
}
|
||||
if (ord(fread($f, 1)) != 0) {
|
||||
// Interlacing not supported
|
||||
fclose($f);
|
||||
return false;
|
||||
}
|
||||
fread($f, 4);
|
||||
$channels = ($ct == 2 ? 3 : 1);
|
||||
$parms = '/DecodeParms << /Predictor 15 /Colors '.$channels.' /BitsPerComponent '.$bpc.' /Columns '.$w.' >>';
|
||||
//Scan chunks looking for palette, transparency and image data
|
||||
$pal = '';
|
||||
$trns = '';
|
||||
$data = '';
|
||||
$icc = false;
|
||||
$n = TCPDF_STATIC::_freadint($f);
|
||||
do {
|
||||
$type = fread($f, 4);
|
||||
if ($type == 'PLTE') {
|
||||
// read palette
|
||||
$pal = TCPDF_STATIC::rfread($f, $n);
|
||||
fread($f, 4);
|
||||
} elseif ($type == 'tRNS') {
|
||||
// read transparency info
|
||||
$t = TCPDF_STATIC::rfread($f, $n);
|
||||
if ($ct == 0) { // DeviceGray
|
||||
$trns = array(ord($t[1]));
|
||||
} elseif ($ct == 2) { // DeviceRGB
|
||||
$trns = array(ord($t[1]), ord($t[3]), ord($t[5]));
|
||||
} else { // Indexed
|
||||
if ($n > 0) {
|
||||
$trns = array();
|
||||
for ($i = 0; $i < $n; ++ $i) {
|
||||
$trns[] = ord($t[$i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
fread($f, 4);
|
||||
} elseif ($type == 'IDAT') {
|
||||
// read image data block
|
||||
$data .= TCPDF_STATIC::rfread($f, $n);
|
||||
fread($f, 4);
|
||||
} elseif ($type == 'iCCP') {
|
||||
// skip profile name
|
||||
$len = 0;
|
||||
while ((ord(fread($f, 1)) != 0) AND ($len < 80)) {
|
||||
++$len;
|
||||
}
|
||||
// get compression method
|
||||
if (ord(fread($f, 1)) != 0) {
|
||||
// Unknown filter method
|
||||
fclose($f);
|
||||
return false;
|
||||
}
|
||||
// read ICC Color Profile
|
||||
$icc = TCPDF_STATIC::rfread($f, ($n - $len - 2));
|
||||
// decompress profile
|
||||
$icc = gzuncompress($icc);
|
||||
fread($f, 4);
|
||||
} elseif ($type == 'IEND') {
|
||||
break;
|
||||
} else {
|
||||
TCPDF_STATIC::rfread($f, $n + 4);
|
||||
}
|
||||
$n = TCPDF_STATIC::_freadint($f);
|
||||
} while ($n);
|
||||
if (($colspace == 'Indexed') AND (empty($pal))) {
|
||||
// Missing palette
|
||||
fclose($f);
|
||||
return false;
|
||||
}
|
||||
fclose($f);
|
||||
return array('w' => $w, 'h' => $h, 'ch' => $channels, 'icc' => $icc, 'cs' => $colspace, 'bpc' => $bpc, 'f' => 'FlateDecode', 'parms' => $parms, 'pal' => $pal, 'trns' => $trns, 'data' => $data);
|
||||
}
|
||||
|
||||
} // END OF TCPDF_IMAGES CLASS
|
||||
|
||||
//============================================================+
|
||||
// END OF FILE
|
||||
//============================================================+
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/billing.php';
|
||||
require_once __DIR__ . '/app/legal.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = saas_require_login();
|
||||
if (!saas_can_manage_tenant_settings($user)) {
|
||||
http_response_code(403);
|
||||
exit('Kein Zugriff.');
|
||||
}
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Location: mandant-einstellungen.php');
|
||||
exit;
|
||||
}
|
||||
app_require_csrf();
|
||||
|
||||
$planCode = (string)($_POST['plan_code'] ?? '');
|
||||
$plans = billing_plans();
|
||||
if (!isset($plans[$planCode]) || $plans[$planCode]['stripe_lookup_key'] === null) {
|
||||
http_response_code(400);
|
||||
exit('Ungültiger kostenpflichtiger Tarif.');
|
||||
}
|
||||
if (!billing_plan_selectable_for($pdo, (int)$user['tenant_id'], $planCode)) {
|
||||
http_response_code(400);
|
||||
exit('Dieser Tarif deckt eure aktuelle Teilnehmerzahl nicht ab.');
|
||||
}
|
||||
if (!in_array((string)($user['customer_type'] ?? ''), ['business', 'consumer'], true)) {
|
||||
http_response_code(400);
|
||||
exit('Bitte wähle zuerst in den Mandant-Einstellungen, ob der Vertrag geschäftlich oder als Verbraucher geführt wird.');
|
||||
}
|
||||
if (!empty($user['contract_ends_at'])) {
|
||||
http_response_code(409);
|
||||
exit('Für diesen Vertrag liegt bereits eine Kündigung vor. Bitte nutze das Ticketsystem, wenn du sie zurücknehmen möchtest.');
|
||||
}
|
||||
|
||||
$plan = $plans[$planCode];
|
||||
$current = billing_fetch_or_init($pdo, (int)$user['tenant_id']);
|
||||
$isSwitch = !empty($current['stripe_subscription_id']);
|
||||
?>
|
||||
<!DOCTYPE HTML>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<title>Tarif kostenpflichtig bestellen – Kaffeeliste</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="assets/css/main.css">
|
||||
<link rel="stylesheet" href="assets/css/public.css">
|
||||
<?php echo app_analytics_head_html(); ?>
|
||||
</head>
|
||||
<body class="is-preload public-page">
|
||||
<div class="public-shell">
|
||||
<section class="public-section public-legal">
|
||||
<nav class="public-nav" aria-label="Hauptnavigation"><strong><a href="landing.php">Kaffeeliste</a></strong></nav>
|
||||
<header class="major"><h1>Bestellung prüfen</h1></header>
|
||||
|
||||
<div class="public-panel order-summary">
|
||||
<h2><?php echo saas_html($plan['label']); ?></h2>
|
||||
<p class="public-price"><?php echo saas_html(saas_format_money_cents($plan['price_cents'])); ?> € <span>pro Monat</span></p>
|
||||
<ul>
|
||||
<li>Webbasierte Kaffeeliste für bis zu <?php echo (int)$plan['max_participants']; ?> aktive Teilnehmer</li>
|
||||
<li>Monatliche Abrechnung im Voraus über Stripe; keine Versand- oder Zusatzkosten</li>
|
||||
<li>Unbefristeter Vertrag, keine Mindestlaufzeit über die laufende Monatsperiode hinaus</li>
|
||||
<li>Jederzeit kündbar zum Ende der laufenden Abrechnungsperiode</li>
|
||||
<li>Erforderlich: aktueller Browser, Internetzugang und erreichbare E-Mail-Adresse</li>
|
||||
<li>Gesetzliche Mängelrechte und Aktualisierungsansprüche bleiben bestehen</li>
|
||||
</ul>
|
||||
<?php if ($isSwitch): ?>
|
||||
<p><b>Tarifwechsel:</b> Die neue Teilnehmergrenze gilt sofort. Für die laufende Periode gibt es keine zusätzliche Belastung oder Gutschrift; ab der nächsten Verlängerung werden <?php echo saas_html(saas_format_money_cents($plan['price_cents'])); ?> € monatlich berechnet.</p>
|
||||
<?php else: ?>
|
||||
<p><b>Neubuchung:</b> Nach dieser Bestätigung wirst du zur sicheren Stripe-Zahlungsseite weitergeleitet. Dort wählst du das Zahlungsmittel und schließt die Bestellung abschließend ab.</p>
|
||||
<?php endif; ?>
|
||||
<p>Anbieter: Clemens Creutzburg, CTB-IT, In den Sieben Stücken 9d, 30655 Hannover. Kontakt siehe <a href="impressum.php" target="_blank">Impressum</a>.</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="abo-upgrade.php">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="plan_code" value="<?php echo saas_html($planCode); ?>">
|
||||
<div>
|
||||
<input type="checkbox" name="accept_terms" id="accept_terms" value="1" required>
|
||||
<label for="accept_terms">Ich akzeptiere die <a href="agb.php" target="_blank" rel="noopener">AGB</a>.</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="checkbox" name="acknowledge_withdrawal" id="acknowledge_withdrawal" value="1" required>
|
||||
<label for="acknowledge_withdrawal">Ich habe die <a href="widerruf.php" target="_blank" rel="noopener">Widerrufsbelehrung samt Musterformular</a> erhalten und gelesen.</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="checkbox" name="request_early_performance" id="request_early_performance" value="1" required>
|
||||
<label for="request_early_performance">Ich verlange ausdrücklich, dass die kostenpflichtige Leistung vor Ablauf der 14-tägigen Widerrufsfrist beginnt. Mir ist bekannt, dass ich bei Widerruf Wertersatz für die bis dahin erbrachte Leistung schulden kann.</label>
|
||||
</div>
|
||||
<p><small>Gesamtpreis: <?php echo saas_html(saas_format_money_cents($plan['price_cents'])); ?> € je monatlicher Abrechnungsperiode. Es findet keine personalisierte Preisbildung statt.</small></p>
|
||||
<ul class="actions">
|
||||
<li><button type="submit" class="primary">zahlungspflichtig bestellen</button></li>
|
||||
<li><a href="mandant-einstellungen.php" class="button">Abbrechen</a></li>
|
||||
</ul>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<?php echo app_public_legal_footer(); ?>
|
||||
<?php echo app_analytics_body_html(); ?>
|
||||
<script src="assets/js/jquery.min.js"></script>
|
||||
<script src="assets/js/browser.min.js"></script>
|
||||
<script src="assets/js/breakpoints.min.js"></script>
|
||||
<script src="assets/js/util.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/billing.php';
|
||||
require_once __DIR__ . '/app/audit.php';
|
||||
require_once __DIR__ . '/app/saas-mail.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = saas_require_login();
|
||||
|
||||
if (!saas_can_manage_tenant_settings($user)) {
|
||||
http_response_code(403);
|
||||
exit('Kein Zugriff.');
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Location: mandant-einstellungen.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
app_require_csrf();
|
||||
|
||||
$tenantId = (int)$user['tenant_id'];
|
||||
$billing = billing_fetch_or_init($pdo, $tenantId);
|
||||
|
||||
if ($billing['stripe_customer_id'] === null) {
|
||||
http_response_code(400);
|
||||
exit('Für diesen Mandanten gibt es noch keine Zahlungsmethode zu verwalten.');
|
||||
}
|
||||
|
||||
$result = stripe_create_billing_portal_session($billing['stripe_customer_id'], saas_app_url('mandant-einstellungen.php'));
|
||||
|
||||
if (!$result['ok']) {
|
||||
http_response_code(502);
|
||||
exit('Das Kundenportal konnte nicht geöffnet werden: ' . saas_html((string)$result['error']));
|
||||
}
|
||||
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.portal_opened', 'tenant', $tenantId);
|
||||
|
||||
header('Location: ' . $result['url']);
|
||||
exit;
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/billing.php';
|
||||
require_once __DIR__ . '/app/audit.php';
|
||||
require_once __DIR__ . '/app/saas-mail.php';
|
||||
require_once __DIR__ . '/app/legal.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = saas_require_login();
|
||||
|
||||
if (!saas_can_manage_tenant_settings($user)) {
|
||||
http_response_code(403);
|
||||
exit('Kein Zugriff.');
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Location: mandant-einstellungen.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
app_require_csrf();
|
||||
|
||||
$tenantId = (int)$user['tenant_id'];
|
||||
$planCode = (string)($_POST['plan_code'] ?? '');
|
||||
$plans = billing_plans();
|
||||
|
||||
if (!isset($plans[$planCode])) {
|
||||
http_response_code(400);
|
||||
exit('Ungültiger Tarif.');
|
||||
}
|
||||
|
||||
if (!billing_plan_selectable_for($pdo, $tenantId, $planCode)) {
|
||||
http_response_code(400);
|
||||
exit('Dieser Tarif deckt eure aktuelle Teilnehmerzahl nicht ab. Bitte einen passenden Tarif wählen.');
|
||||
}
|
||||
|
||||
if ($planCode !== 'free' && !in_array((string)($user['customer_type'] ?? ''), ['business', 'consumer'], true)) {
|
||||
http_response_code(400);
|
||||
exit('Bitte wähle zuerst in den Mandant-Einstellungen, ob der Vertrag geschäftlich oder als Verbraucher geführt wird.');
|
||||
}
|
||||
|
||||
$billing = billing_fetch_or_init($pdo, $tenantId);
|
||||
$baseUrl = saas_app_url('mandant-einstellungen.php');
|
||||
|
||||
if ($planCode !== 'free' && !empty($user['contract_ends_at'])) {
|
||||
http_response_code(409);
|
||||
exit('Für diesen Vertrag liegt bereits eine Kündigung vor. Bitte nutze das Ticketsystem, wenn du sie zurücknehmen möchtest.');
|
||||
}
|
||||
|
||||
try {
|
||||
// Wechsel auf "free": die bezahlte Subscription wird zum Ende der bereits
|
||||
// bezahlten Periode gekündigt. Bis dahin bleiben Tarif und Funktionen aktiv.
|
||||
if ($plans[$planCode]['stripe_lookup_key'] === null) {
|
||||
if ($planCode !== 'free') {
|
||||
// enterprise hat ebenfalls keinen Stripe-Preis, ist aber kein
|
||||
// Selbstbedienungs-Downgrade-Ziel - manuelle Absprache erforderlich.
|
||||
http_response_code(400);
|
||||
exit('Für diesen Tarif meldet euch bitte per E-Mail bei uns.');
|
||||
}
|
||||
|
||||
if ($billing['stripe_subscription_id'] !== null) {
|
||||
$cancelResult = stripe_schedule_subscription_cancellation((string)$billing['stripe_subscription_id']);
|
||||
if (!$cancelResult['ok']) {
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.downgrade_failed', 'tenant', $tenantId, ['plan_code' => $planCode, 'error' => $cancelResult['error']]);
|
||||
http_response_code(502);
|
||||
exit('Das Abo konnte nicht gekündigt werden: ' . saas_html((string)$cancelResult['error']));
|
||||
}
|
||||
}
|
||||
|
||||
if ($billing['stripe_subscription_id'] === null) {
|
||||
billing_update($pdo, $tenantId, [
|
||||
'plan_code' => 'free',
|
||||
'subscription_status' => 'active',
|
||||
]);
|
||||
} else {
|
||||
billing_update($pdo, $tenantId, [
|
||||
'subscription_status' => 'canceling',
|
||||
'current_period_end' => $cancelResult['current_period_end'],
|
||||
]);
|
||||
}
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.cancellation_scheduled', 'tenant', $tenantId, [
|
||||
'current_period_end' => $cancelResult['current_period_end'] ?? null,
|
||||
]);
|
||||
|
||||
header('Location: ' . $baseUrl . '?upgrade=success');
|
||||
exit;
|
||||
}
|
||||
|
||||
$priceId = billing_stripe_price_id($planCode);
|
||||
if ($priceId === null) {
|
||||
http_response_code(502);
|
||||
exit('Der Tarif ist bei Stripe aktuell nicht verfügbar. Bitte später erneut versuchen.');
|
||||
}
|
||||
|
||||
if (empty($_POST['accept_terms']) || empty($_POST['acknowledge_withdrawal']) || empty($_POST['request_early_performance'])) {
|
||||
http_response_code(400);
|
||||
exit('Bitte bestätige AGB, Widerrufsbelehrung und den gewünschten sofortigen Leistungsbeginn auf der Bestellseite.');
|
||||
}
|
||||
|
||||
$orderMetadata = [
|
||||
'plan_code' => $planCode,
|
||||
'monthly_price_cents' => (int)$plans[$planCode]['price_cents'],
|
||||
'customer_type' => (string)($user['customer_type'] ?? 'business'),
|
||||
];
|
||||
app_record_legal_acceptance($pdo, $tenantId, (int)$user['user_id'], 'terms', 'paid_order', $orderMetadata);
|
||||
app_record_legal_acceptance($pdo, $tenantId, (int)$user['user_id'], 'withdrawal_information', 'paid_order', $orderMetadata);
|
||||
app_record_legal_acceptance($pdo, $tenantId, (int)$user['user_id'], 'early_performance', 'paid_order', $orderMetadata);
|
||||
|
||||
// Fall 2: Es gibt bereits eine aktive/bezahlte Subscription bei Stripe ->
|
||||
// Preis in-place wechseln (Up- oder Downgrade zwischen bezahlten Stufen),
|
||||
// statt eine weitere Checkout-Session zu erzeugen. Stripe rechnet die
|
||||
// neue Preis gilt ohne Zwischenbelastung ab der nächsten Verlängerung.
|
||||
if ($billing['stripe_subscription_id'] !== null) {
|
||||
$subscription = stripe_get_subscription((string)$billing['stripe_subscription_id']);
|
||||
|
||||
if ($subscription['ok'] && in_array($subscription['status'], ['active', 'trialing', 'past_due'], true) && $subscription['item_id'] !== null) {
|
||||
$switchResult = stripe_update_subscription_price(
|
||||
(string)$billing['stripe_subscription_id'],
|
||||
(string)$subscription['item_id'],
|
||||
$priceId
|
||||
);
|
||||
|
||||
if (!$switchResult['ok']) {
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.plan_switch_failed', 'tenant', $tenantId, ['plan_code' => $planCode, 'error' => $switchResult['error']]);
|
||||
http_response_code(502);
|
||||
exit('Der Tarifwechsel konnte nicht durchgeführt werden: ' . saas_html((string)$switchResult['error']));
|
||||
}
|
||||
|
||||
// Lokal sofort spiegeln statt auf den Webhook zu warten, damit die
|
||||
// UI direkt den neuen Tarif zeigt; der Webhook (subscription.updated)
|
||||
// bestaetigt denselben Stand nochmal redundant.
|
||||
billing_update($pdo, $tenantId, [
|
||||
'plan_code' => $planCode,
|
||||
'subscription_status' => 'active',
|
||||
]);
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.plan_switched', 'tenant', $tenantId, ['plan_code' => $planCode]);
|
||||
|
||||
$confirmation = saas_send_paid_contract_confirmation($pdo, $tenantId, $planCode);
|
||||
if (empty($confirmation['ok'])) {
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.contract_confirmation_failed', 'tenant', $tenantId, [
|
||||
'error' => $confirmation['error'] ?? 'unknown',
|
||||
]);
|
||||
header('Location: ' . $baseUrl . '?upgrade=success&contract_mail_failed=1');
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Location: ' . $baseUrl . '?upgrade=success');
|
||||
exit;
|
||||
}
|
||||
// Falls die bestehende Subscription nicht mehr aktiv abrufbar ist
|
||||
// (z. B. bereits gekuendigt), faellt der Ablauf unten auf eine neue
|
||||
// Checkout-Session zurueck.
|
||||
}
|
||||
|
||||
// Fall 3: Kein aktives Abo bisher (Neu-Abschluss) -> Stripe Checkout Session.
|
||||
$result = stripe_create_checkout_session(
|
||||
$priceId,
|
||||
(string)$user['email'],
|
||||
$billing['stripe_customer_id'],
|
||||
$baseUrl . '?upgrade=success',
|
||||
$baseUrl . '?upgrade=cancelled',
|
||||
['tenant_id' => $tenantId, 'plan_code' => $planCode]
|
||||
);
|
||||
|
||||
if (!$result['ok']) {
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.checkout_failed', 'tenant', $tenantId, ['plan_code' => $planCode, 'error' => $result['error']]);
|
||||
http_response_code(502);
|
||||
exit('Der Bezahlvorgang konnte nicht gestartet werden: ' . saas_html((string)$result['error']));
|
||||
}
|
||||
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.checkout_started', 'tenant', $tenantId, ['plan_code' => $planCode]);
|
||||
|
||||
header('Location: ' . $result['url']);
|
||||
exit;
|
||||
} catch (RuntimeException $e) {
|
||||
// Stripe ist in dieser Umgebung nicht konfiguriert (fehlender
|
||||
// STRIPE_SECRET_KEY, z. B. lokale Dev-Umgebung ohne echte Zugangsdaten)
|
||||
// oder eine andere Konfigurationsvoraussetzung fehlt - sauber als
|
||||
// Dienst-nicht-verfuegbar melden statt eine Fatal-Error-Seite zu zeigen.
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.stripe_unavailable', 'tenant', $tenantId, ['plan_code' => $planCode, 'error' => $e->getMessage()]);
|
||||
http_response_code(502);
|
||||
exit('Der Bezahldienst ist aktuell nicht verfügbar. Bitte später erneut versuchen oder uns kontaktieren.');
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/app/legal.php';
|
||||
require_once __DIR__ . '/app/public-page-views.php';
|
||||
app_record_public_page_view('terms');
|
||||
?>
|
||||
<!DOCTYPE HTML>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<title>AGB – Kaffeeliste</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="assets/css/main.css">
|
||||
<link rel="stylesheet" href="assets/css/public.css">
|
||||
<?php echo app_analytics_head_html(); ?>
|
||||
</head>
|
||||
<body class="is-preload public-page">
|
||||
<div class="public-shell">
|
||||
<section class="public-section public-legal">
|
||||
<nav class="public-nav" aria-label="Hauptnavigation">
|
||||
<strong><a href="landing.php">Kaffeeliste</a></strong>
|
||||
<ul class="actions"><li><a href="login.php" class="button">Login</a></li></ul>
|
||||
</nav>
|
||||
<header class="major"><h1>Allgemeine Geschäftsbedingungen</h1></header>
|
||||
<?php echo app_render_legal_document('terms'); ?>
|
||||
<p><a href="legal/agb-<?php echo htmlspecialchars(APP_TERMS_VERSION, ENT_QUOTES, 'UTF-8'); ?>.txt" download>AGB als Textdatei speichern</a></p>
|
||||
<p><a href="landing.php">← Zurück zur Startseite</a></p>
|
||||
</section>
|
||||
</div>
|
||||
<?php echo app_public_legal_footer(); ?>
|
||||
<?php echo app_analytics_body_html(); ?>
|
||||
<script src="assets/js/jquery.min.js"></script>
|
||||
<script src="assets/js/browser.min.js"></script>
|
||||
<script src="assets/js/breakpoints.min.js"></script>
|
||||
<script src="assets/js/util.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$saasUser = saas_current_user($pdo);
|
||||
|
||||
// Die Anleitung ist bewusst fuer alle angemeldeten Rollen lesbar - auch
|
||||
// Mitglieder sollen nachschlagen koennen. Die Verwaltungsabschnitte werden
|
||||
// nur eingeblendet, wenn die Rolle sie auch nutzen kann; sonst waere die
|
||||
// Seite voller Hinweise auf Menuepunkte, die es fuer diesen Nutzer nicht gibt.
|
||||
$istKassenwart = $saasUser !== null && saas_user_has_role(['owner', 'admin', 'treasurer'], $saasUser);
|
||||
$istAdmin = $saasUser !== null && saas_user_has_role(['owner', 'admin'], $saasUser);
|
||||
|
||||
// Einstellungen fuer die Hinweise zu den aktivierten Zahlungswegen.
|
||||
$settings = $saasUser !== null ? saas_fetch_tenant_settings($pdo, (int)$saasUser['tenant_id']) : null;
|
||||
|
||||
// Gleiche Logik wie im Menue: was der Betreiber gesperrt oder der Mandant
|
||||
// selbst abgeschaltet hat, wird hier auch nicht erklaert.
|
||||
$anleitungTenantId = $saasUser !== null ? (int)$saasUser['tenant_id'] : 0;
|
||||
$kann = static function (string $feature) use ($pdo, $anleitungTenantId): bool {
|
||||
return $anleitungTenantId <= 0 || app_feature_available($pdo, $anleitungTenantId, $feature);
|
||||
};
|
||||
|
||||
include "header.php";
|
||||
include "headerline.php";
|
||||
include "nav.php";
|
||||
?>
|
||||
|
||||
<section id="banner">
|
||||
<div class="content">
|
||||
|
||||
<h2>Anleitung</h2>
|
||||
<p>Kurzüberblick über die Funktionen. Oben steht, was jedes Mitglied betrifft,
|
||||
darunter die Verwaltung.</p>
|
||||
|
||||
<?php if ($saasUser === null): ?>
|
||||
<div class="hint-box">
|
||||
<p>Du bist nicht angemeldet. Nach der Anmeldung siehst du hier zusätzlich die
|
||||
Anleitung zu den Funktionen deiner Rolle.</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>Für alle Mitglieder</h2>
|
||||
|
||||
<h3>Meine Kaffeeliste</h3>
|
||||
<p>Deine Startseite zeigt den aktuellen Stand, die Jahres- und Monatsübersicht sowie
|
||||
die letzten Buchungen. Positiver Betrag = Guthaben, negativer Betrag = offener Betrag.</p>
|
||||
|
||||
<?php if ($kann('self_entry')): ?>
|
||||
<h3>Striche selbst eintragen</h3>
|
||||
<p>Wenn die Verwaltung das freigeschaltet hat, trägst du deinen Kaffee direkt auf der
|
||||
Startseite ein. Vertippt? Mit „Letzten Strich stornieren" nimmst du deinen jüngsten
|
||||
<b>selbst</b> eingetragenen Strich zurück – vom Kassenwart erfasste Striche nicht.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3>Bezahlen</h3>
|
||||
<p>Welche Wege offen stehen, legt die Verwaltung fest: Barzahlung, PayPal oder Überweisung.</p>
|
||||
<?php if ($settings !== null): ?>
|
||||
<p><small>Bei euch aktiv:
|
||||
<?php
|
||||
$wege = [];
|
||||
if ((int)$settings['cash_enabled'] === 1) { $wege[] = 'Barzahlung'; }
|
||||
if ((int)$settings['paypal_enabled'] === 1) { $wege[] = 'PayPal'; }
|
||||
if ((int)$settings['bank_transfer_enabled'] === 1) { $wege[] = 'Überweisung'; }
|
||||
echo $wege === [] ? '<b>noch kein Zahlungsweg</b>' : '<b>' . saas_html(implode(', ', $wege)) . '</b>';
|
||||
?>.</small></p>
|
||||
<?php endif; ?>
|
||||
<div class="hint-box">
|
||||
<p><b>Wichtig bei PayPal:</b> „Geld an Freunde senden" nutzen. Bei „Waren und
|
||||
Dienstleistungen" zieht PayPal Gebühren ab – gutgeschrieben wird nur, was ankommt.</p>
|
||||
</div>
|
||||
|
||||
<h3>Eigene Angaben</h3>
|
||||
<p>Unter <b>Namensanpassung</b> legst du fest, wie dein Name in der Liste und im Ausdruck
|
||||
erscheint (max. 20 Zeichen). Unter <b>Kundenkonto</b> änderst du Passwort und E-Mail.</p>
|
||||
|
||||
<?php if ($istKassenwart): ?>
|
||||
<hr>
|
||||
|
||||
<h2>Für Kassenwart und Verwaltung</h2>
|
||||
|
||||
<h3>Buchen</h3>
|
||||
<ul>
|
||||
<li><b>Einzahlung eintragen</b>: mehrere Mitglieder in einem Durchgang; leere Felder
|
||||
werden übersprungen. Negativer Betrag = Abzug, dann ist eine Bemerkung Pflicht.</li>
|
||||
<li><b>Striche eintragen</b>: Sammelerfassung für die Papierliste. Der Preis pro Strich
|
||||
kommt aus den Einstellungen und ist pro Vorgang überschreibbar.</li>
|
||||
<li><b>Letzte Einträge</b>: falsche Buchungen hier <b>stornieren</b>, nicht per
|
||||
Gegenbuchung ausgleichen – sonst zählen beide als Umsatz in der Auswertung.</li>
|
||||
</ul>
|
||||
<div class="hint-box">
|
||||
<p>Zum Schutz vor Vertippern werden Beträge über 1.000 € abgelehnt. Schlägt eine
|
||||
Zeile fehl, wird <b>nichts</b> gebucht und deine Eingaben bleiben stehen.</p>
|
||||
</div>
|
||||
|
||||
<?php if ($kann('paypal_inbox')): ?>
|
||||
<h3>PayPal-Zahlungen automatisch verbuchen</h3>
|
||||
<p>Auf der Seite <b>PayPal-Zahlungen</b> steht eure Eingangsadresse. Leitet die
|
||||
PayPal-Benachrichtigungen aus eurem Postfach automatisch dorthin weiter, dann werden
|
||||
erkannte Zahlungen selbstständig gutgeschrieben – immer der tatsächlich angekommene
|
||||
Betrag. Nicht eindeutig zuordenbare Zahlungen landen in der Warteschlange „Offene
|
||||
Zahlungen". Ein hinterlegter <b>PayPal-Name</b> beim Mitglied verbessert die Trefferquote.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3>Auswertung und Ausdruck</h3>
|
||||
<ul>
|
||||
<li><b>Kaffeeliste anzeigen</b>: Gesamtübersicht; ein Klick auf den Namen öffnet die
|
||||
Einzelauswertung.</li>
|
||||
<?php if ($kann('pdf_export')): ?>
|
||||
<li><b>PDF-Export</b>: die Liste zum Aufhängen. Bis 50 Mitglieder eine Seite, darüber
|
||||
Vorder- und Rückseite.</li>
|
||||
<?php endif; ?>
|
||||
<li><b>Journal-Vorschau</b>: alle Buchungen mit Herkunft.</li>
|
||||
<?php if ($kann('csv_import')): ?>
|
||||
<li><b>CSV Upload</b>: Einzahlungen aus einer Datei einlesen – erst Vorschau, dann
|
||||
buchen. Zeilen ohne eindeutige Zuordnung werden nicht verbucht.</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
|
||||
<?php if ($kann('mail_dispatch') || $kann('year_closing')): ?>
|
||||
<h3>Mails und Jahresabschluss</h3>
|
||||
<ul>
|
||||
<?php if ($kann('mail_dispatch')): ?>
|
||||
<li><b>Info-Mail versenden</b>: informiert Mitglieder über ihren Stand. Der Dry-Run
|
||||
zeigt vorher, wer eine Mail bekäme.</li>
|
||||
<?php endif; ?>
|
||||
<?php if ($kann('year_closing')): ?>
|
||||
<li><b>Jahresabschluss</b>: verteilt einen frei wählbaren Betrag anteilig zu den
|
||||
Strichen des Jahres als Guthaben. Auch hier erst Dry-Run, dann buchen.</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($istAdmin): ?>
|
||||
<hr>
|
||||
|
||||
<h2>Für Administratoren</h2>
|
||||
|
||||
<h3>Mitglieder verwalten</h3>
|
||||
<ul>
|
||||
<li><b>Deaktivieren</b> statt löschen: das Mitglied verschwindet aus den Listen, die
|
||||
Buchungshistorie bleibt.</li>
|
||||
<li><b>Anonymisieren</b>: ersetzt Name und E-Mail durch einen Platzhalter, die Buchungen
|
||||
der Kasse bleiben bestehen.</li>
|
||||
<li><b>Zugang gewähren / entziehen</b> steuert, ob sich jemand anmelden kann.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Hinweise</h3>
|
||||
<p>Ein Hinweis erscheint als gelber Balken oben auf jeder Seite und verschwindet nach dem
|
||||
<b>Gültig bis</b>-Datum automatisch.</p>
|
||||
|
||||
<h3>Mandant-Einstellungen</h3>
|
||||
<p>Preis pro Strich, Listenfenster, Warnschwelle, Bezahloptionen, Web-Striche, Gestaltung
|
||||
des Ausdrucks<?php echo $kann('branding') ? ', eigenes Design (Logo und Akzentfarbe)' : ''; ?>
|
||||
und Zahlungserinnerungen. Unten steht das Protokoll aller Admin-Änderungen.</p>
|
||||
|
||||
<h3>Konto, Abo und Daten</h3>
|
||||
<ul>
|
||||
<li><b>Kundenkonto</b>: Tarif, Rechnungen und Zahlungsdaten.</li>
|
||||
<?php if ($kann('data_export')): ?>
|
||||
<li><b>Datenexport</b>: alle Daten des Mandanten als Datei.</li>
|
||||
<?php endif; ?>
|
||||
<li><b>Mandant löschen</b>: entfernt alles endgültig<?php echo $kann('data_export') ? ' – vorher Datenexport ziehen' : ''; ?>.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Rollen im Überblick</h3>
|
||||
<table>
|
||||
<tr><th>Rolle</th><th>Darf</th></tr>
|
||||
<tr><td><b>Inhaber</b></td><td>alles, inklusive Abo, Datenexport und Löschen des Mandanten</td></tr>
|
||||
<tr><td><b>Administrator</b></td><td>alle Verwaltungsfunktionen inklusive Mitglieder, Hinweise und Einstellungen</td></tr>
|
||||
<tr><td><b>Kassenwart</b></td><td>Buchen: Einzahlungen, Striche, Stornos, Import, Ausdruck, Mails, Jahresabschluss</td></tr>
|
||||
<tr><td><b>Mitglied</b></td><td>eigene Kaffeeliste, Striche selbst eintragen, eigenen Namen anpassen</td></tr>
|
||||
<tr><td><b>Betrachter</b></td><td>nur die eigene Übersicht ansehen</td></tr>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($kann('faq')): ?>
|
||||
<hr>
|
||||
<p>Weitere Fragen beantwortet eure Verwaltung unter <a href="faq.php">FAQ Kaffeeliste</a>.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php include "footer.php"; ?>
|
||||
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/analytics.php';
|
||||
|
||||
/**
|
||||
* Der Browser spricht fuer die Analyse nur mit der bereits besuchten
|
||||
* Kaffeeliste-Domain. Dieser Baustein validiert die wenigen erlaubten Werte
|
||||
* und sendet sie anschliessend an die fest konfigurierte Matomo-Site.
|
||||
* Insbesondere werden weder die Client-IP noch Benutzer- oder Formulardaten
|
||||
* uebernommen.
|
||||
*/
|
||||
|
||||
/** @param array<string, string> $cookies */
|
||||
function app_analytics_consent_is_granted(array $cookies): bool
|
||||
{
|
||||
return hash_equals(
|
||||
APP_ANALYTICS_CONSENT_VERSION . ':granted',
|
||||
(string)($cookies[APP_ANALYTICS_CONSENT_COOKIE] ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $server */
|
||||
function app_analytics_request_origin_is_valid(array $server): bool
|
||||
{
|
||||
$fetchSite = strtolower(trim((string)($server['HTTP_SEC_FETCH_SITE'] ?? '')));
|
||||
if ($fetchSite !== '' && $fetchSite !== 'same-origin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$origin = trim((string)($server['HTTP_ORIGIN'] ?? ''));
|
||||
$requestHost = trim((string)($server['HTTP_HOST'] ?? ''));
|
||||
if ($origin === '' || $requestHost === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$originParts = parse_url($origin);
|
||||
$requestParts = parse_url('http://' . $requestHost);
|
||||
if (!is_array($originParts) || !is_array($requestParts)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$originScheme = strtolower((string)($originParts['scheme'] ?? ''));
|
||||
$expectedScheme = (!empty($server['HTTPS']) && $server['HTTPS'] !== 'off')
|
||||
|| (string)($server['SERVER_PORT'] ?? '') === '443'
|
||||
? 'https'
|
||||
: 'http';
|
||||
|
||||
return $originScheme === $expectedScheme
|
||||
&& strtolower((string)($originParts['host'] ?? '')) === strtolower((string)($requestParts['host'] ?? ''))
|
||||
&& (int)($originParts['port'] ?? ($originScheme === 'https' ? 443 : 80))
|
||||
=== (int)($requestParts['port'] ?? ($expectedScheme === 'https' ? 443 : 80));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
function app_analytics_allowed_hosts(string $requestHost): array
|
||||
{
|
||||
$hosts = app_is_dev() ? [$requestHost] : [];
|
||||
$appHost = trim((string)app_primary_host());
|
||||
if ($appHost !== '') {
|
||||
$hosts[] = strtolower($appHost);
|
||||
}
|
||||
$marketingHost = strtolower((string)parse_url((string)app_env('APP_MARKETING_URL', ''), PHP_URL_HOST));
|
||||
if ($marketingHost !== '') {
|
||||
$hosts[] = $marketingHost;
|
||||
}
|
||||
|
||||
return array_values(array_unique($hosts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt Query und Fragment nochmals serverseitig und erlaubt nur die
|
||||
* konfigurierten Kaffeeliste-Hosts. Bei der aktuellen Seite muss der Host
|
||||
* zusaetzlich dem Host des First-Party-Requests entsprechen.
|
||||
*/
|
||||
function app_analytics_clean_url(string $value, string $requestHost, bool $isCurrentPage): ?string
|
||||
{
|
||||
if ($value === '' || strlen($value) > 2048 || preg_match('/[\x00-\x1F\x7F]/', $value) === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = parse_url($value);
|
||||
if (
|
||||
!is_array($parts)
|
||||
|| !in_array(strtolower((string)($parts['scheme'] ?? '')), ['http', 'https'], true)
|
||||
|| trim((string)($parts['host'] ?? '')) === ''
|
||||
|| isset($parts['user'])
|
||||
|| isset($parts['pass'])
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$host = strtolower((string)$parts['host']);
|
||||
$requestParts = parse_url('http://' . $requestHost);
|
||||
$normalizedRequestHost = strtolower((string)($requestParts['host'] ?? ''));
|
||||
if ($normalizedRequestHost === '') {
|
||||
return null;
|
||||
}
|
||||
if ($isCurrentPage && $host !== $normalizedRequestHost) {
|
||||
return null;
|
||||
}
|
||||
if (!in_array($host, app_analytics_allowed_hosts($normalizedRequestHost), true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$scheme = strtolower((string)$parts['scheme']);
|
||||
if (!app_is_dev() && $scheme !== 'https') {
|
||||
return null;
|
||||
}
|
||||
if ($isCurrentPage) {
|
||||
$pagePort = (int)($parts['port'] ?? ($scheme === 'https' ? 443 : 80));
|
||||
$requestPort = (int)($requestParts['port'] ?? ($scheme === 'https' ? 443 : 80));
|
||||
if ($pagePort !== $requestPort) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
$port = isset($parts['port']) ? ':' . (int)$parts['port'] : '';
|
||||
$path = (string)($parts['path'] ?? '/');
|
||||
if ($path === '' || !str_starts_with($path, '/')) {
|
||||
$path = '/' . $path;
|
||||
}
|
||||
|
||||
return $scheme . '://' . $host . $port . $path;
|
||||
}
|
||||
|
||||
function app_analytics_clean_text(string $value, int $maximumLength): string
|
||||
{
|
||||
$value = trim(preg_replace('/[\x00-\x1F\x7F]+/u', ' ', strip_tags($value)) ?? '');
|
||||
return mb_substr($value, 0, $maximumLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<string, mixed> $server
|
||||
* @param array<string, string> $cookies
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function app_matomo_page_view_parameters(array $payload, array $server, array $cookies): array
|
||||
{
|
||||
if (!app_analytics_consent_is_granted($cookies)) {
|
||||
throw new RuntimeException('Keine Analyse-Einwilligung.');
|
||||
}
|
||||
if (!app_analytics_request_origin_is_valid($server)) {
|
||||
throw new RuntimeException('Ungültiger Anfrageursprung.');
|
||||
}
|
||||
|
||||
$visitorId = strtolower(trim((string)($payload['visitorId'] ?? '')));
|
||||
$visitorCookie = strtolower(trim((string)($cookies[APP_ANALYTICS_VISITOR_COOKIE] ?? '')));
|
||||
if (preg_match('/^[0-9a-f]{16}$/', $visitorId) !== 1 || !hash_equals($visitorCookie, $visitorId)) {
|
||||
throw new RuntimeException('Ungültige Analyse-Besucherkennung.');
|
||||
}
|
||||
|
||||
$requestHost = trim((string)($server['HTTP_HOST'] ?? ''));
|
||||
$pageUrl = app_analytics_clean_url((string)($payload['pageUrl'] ?? ''), $requestHost, true);
|
||||
if ($pageUrl === null) {
|
||||
throw new RuntimeException('Ungültige Seiten-URL.');
|
||||
}
|
||||
|
||||
$pageTitle = app_analytics_clean_text((string)($payload['pageTitle'] ?? ''), 200);
|
||||
if ($pageTitle === '') {
|
||||
$pageTitle = 'Kaffeeliste';
|
||||
}
|
||||
|
||||
$parameters = [
|
||||
'rec' => '1',
|
||||
'apiv' => '1',
|
||||
'send_image' => '0',
|
||||
'action_name' => $pageTitle,
|
||||
'url' => $pageUrl,
|
||||
'_id' => $visitorId,
|
||||
'rand' => (string)random_int(100000, 999999999),
|
||||
'cookie' => '1',
|
||||
];
|
||||
|
||||
$referrer = app_analytics_clean_url((string)($payload['referrerUrl'] ?? ''), $requestHost, false);
|
||||
if ($referrer !== null) {
|
||||
$parameters['urlref'] = $referrer;
|
||||
}
|
||||
|
||||
$resolution = trim((string)($payload['resolution'] ?? ''));
|
||||
if (preg_match('/^[1-9][0-9]{0,4}x[1-9][0-9]{0,4}$/', $resolution) === 1) {
|
||||
$parameters['res'] = $resolution;
|
||||
}
|
||||
|
||||
$language = app_analytics_clean_text((string)($payload['language'] ?? ''), 35);
|
||||
if ($language !== '' && preg_match('/^[A-Za-z0-9._-]+$/', $language) === 1) {
|
||||
$parameters['lang'] = $language;
|
||||
}
|
||||
|
||||
foreach (['h' => 23, 'm' => 59, 's' => 59] as $name => $maximum) {
|
||||
$value = filter_var($payload[$name] ?? null, FILTER_VALIDATE_INT, [
|
||||
'options' => ['min_range' => 0, 'max_range' => $maximum],
|
||||
]);
|
||||
if ($value !== false) {
|
||||
$parameters[$name] = (string)$value;
|
||||
}
|
||||
}
|
||||
|
||||
// Der User-Agent ist vom Einwilligungstext umfasst. Die Client-IP wird
|
||||
// bewusst nicht als Tracking-IP an Matomo weitergegeben.
|
||||
$userAgent = app_analytics_clean_text((string)($server['HTTP_USER_AGENT'] ?? ''), 500);
|
||||
if ($userAgent !== '') {
|
||||
$parameters['ua'] = $userAgent;
|
||||
}
|
||||
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $parameters
|
||||
* @param null|callable(string, array<string, string>): array{ok: bool, status: int, error: ?string} $transport
|
||||
* @return array{ok: bool, status: int, error: ?string}
|
||||
*/
|
||||
function app_matomo_forward_page_view(array $parameters, ?callable $transport = null): array
|
||||
{
|
||||
$configuration = app_matomo_configuration();
|
||||
if ($configuration === null) {
|
||||
return ['ok' => false, 'status' => 0, 'error' => 'Matomo ist nicht konfiguriert.'];
|
||||
}
|
||||
|
||||
$parameters['idsite'] = $configuration['siteId'];
|
||||
$trackerUrl = $configuration['matomoUrl'] . 'matomo.php';
|
||||
if ($transport !== null) {
|
||||
return $transport($trackerUrl, $parameters);
|
||||
}
|
||||
|
||||
$body = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
|
||||
if (function_exists('curl_init')) {
|
||||
$curl = curl_init($trackerUrl);
|
||||
if ($curl === false) {
|
||||
return ['ok' => false, 'status' => 0, 'error' => 'cURL konnte nicht initialisiert werden.'];
|
||||
}
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_TIMEOUT => 6,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
$response = curl_exec($curl);
|
||||
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$error = $response === false ? curl_error($curl) : null;
|
||||
curl_close($curl);
|
||||
|
||||
return [
|
||||
'ok' => $response !== false && $status >= 200 && $status < 300,
|
||||
'status' => $status,
|
||||
'error' => $error,
|
||||
];
|
||||
}
|
||||
|
||||
$context = stream_context_create(['http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/x-www-form-urlencoded\r\nConnection: close",
|
||||
'content' => $body,
|
||||
'timeout' => 6,
|
||||
'ignore_errors' => true,
|
||||
]]);
|
||||
$response = @file_get_contents($trackerUrl, false, $context);
|
||||
$status = 0;
|
||||
foreach ($http_response_header ?? [] as $header) {
|
||||
if (preg_match('~^HTTP/\S+\s+(\d{3})~', $header, $matches) === 1) {
|
||||
$status = (int)$matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => $response !== false && $status >= 200 && $status < 300,
|
||||
'status' => $status,
|
||||
'error' => $response === false ? 'Keine Antwort vom Matomo-Endpunkt.' : null,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
const APP_ANALYTICS_CONSENT_COOKIE = 'kaffeeliste_analytics_consent';
|
||||
const APP_ANALYTICS_VISITOR_COOKIE = 'kaffeeliste_analytics_visitor';
|
||||
const APP_ANALYTICS_CONSENT_VERSION = '2';
|
||||
const APP_ANALYTICS_CONSENT_MAX_AGE = 15552000; // 180 Tage
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
function app_matomo_configuration_errors(): array
|
||||
{
|
||||
$url = trim((string)app_env('MATOMO_URL', ''));
|
||||
$siteId = trim((string)app_env('MATOMO_SITE_ID', ''));
|
||||
$privacyConfirmed = trim((string)app_env('MATOMO_PRIVACY_CONFIRMED', ''));
|
||||
$cookieDomain = trim((string)app_env('MATOMO_CONSENT_COOKIE_DOMAIN', ''));
|
||||
$hasAnySetting = $url !== '' || $siteId !== '' || $cookieDomain !== '';
|
||||
|
||||
if (!$hasAnySetting) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$urlParts = $url !== '' ? parse_url($url) : false;
|
||||
if (
|
||||
$url === ''
|
||||
|| filter_var($url, FILTER_VALIDATE_URL) === false
|
||||
|| !is_array($urlParts)
|
||||
|| !in_array(strtolower((string)($urlParts['scheme'] ?? '')), ['http', 'https'], true)
|
||||
|| trim((string)($urlParts['host'] ?? '')) === ''
|
||||
|| isset($urlParts['user'])
|
||||
|| isset($urlParts['pass'])
|
||||
|| isset($urlParts['query'])
|
||||
|| isset($urlParts['fragment'])
|
||||
) {
|
||||
$errors[] = 'MATOMO_URL muss eine vollständige HTTP(S)-Basis-URL ohne Query oder Fragment sein.';
|
||||
} elseif (!app_is_dev() && strtolower((string)$urlParts['scheme']) !== 'https') {
|
||||
$errors[] = 'MATOMO_URL muss außerhalb der Entwicklungsumgebung HTTPS verwenden.';
|
||||
}
|
||||
|
||||
if (preg_match('/^[1-9][0-9]*$/', $siteId) !== 1) {
|
||||
$errors[] = 'MATOMO_SITE_ID muss eine positive ganze Zahl sein.';
|
||||
}
|
||||
|
||||
if ($privacyConfirmed !== '1') {
|
||||
$errors[] = 'MATOMO_PRIVACY_CONFIRMED muss nach Prüfung der Matomo-Datenschutzeinstellungen auf 1 gesetzt werden.';
|
||||
}
|
||||
|
||||
$cookieDomainHostname = ltrim(strtolower($cookieDomain), '.');
|
||||
if ($cookieDomain !== '' && filter_var($cookieDomainHostname, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) === false) {
|
||||
$errors[] = 'MATOMO_CONSENT_COOKIE_DOMAIN ist keine gültige Cookie-Domain.';
|
||||
} elseif ($cookieDomain !== '') {
|
||||
$hostBelongsToCookieDomain = static function (string $host) use ($cookieDomainHostname): bool {
|
||||
$host = strtolower($host);
|
||||
return $host === $cookieDomainHostname || str_ends_with($host, '.' . $cookieDomainHostname);
|
||||
};
|
||||
$appHost = trim((string)app_primary_host());
|
||||
$marketingHost = (string)parse_url((string)app_env('APP_MARKETING_URL', ''), PHP_URL_HOST);
|
||||
if ($appHost !== '' && !$hostBelongsToCookieDomain($appHost)) {
|
||||
$errors[] = 'MATOMO_CONSENT_COOKIE_DOMAIN passt nicht zu APP_HOST.';
|
||||
}
|
||||
if ($marketingHost !== '' && !$hostBelongsToCookieDomain($marketingHost)) {
|
||||
$errors[] = 'MATOMO_CONSENT_COOKIE_DOMAIN passt nicht zu APP_MARKETING_URL.';
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{matomoUrl: string, siteId: string, consentCookie: string, visitorCookie: string, consentVersion: string, consentMaxAge: int, cookieDomain: string, secureCookies: bool}|null
|
||||
*/
|
||||
function app_matomo_configuration(): ?array
|
||||
{
|
||||
$url = trim((string)app_env('MATOMO_URL', ''));
|
||||
$siteId = trim((string)app_env('MATOMO_SITE_ID', ''));
|
||||
$privacyConfirmed = trim((string)app_env('MATOMO_PRIVACY_CONFIRMED', ''));
|
||||
|
||||
if ($url === '' && $siteId === '') {
|
||||
return null;
|
||||
}
|
||||
if (app_matomo_configuration_errors() !== []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'matomoUrl' => rtrim($url, '/') . '/',
|
||||
'siteId' => $siteId,
|
||||
'consentCookie' => APP_ANALYTICS_CONSENT_COOKIE,
|
||||
'visitorCookie' => APP_ANALYTICS_VISITOR_COOKIE,
|
||||
'consentVersion' => APP_ANALYTICS_CONSENT_VERSION,
|
||||
'consentMaxAge' => APP_ANALYTICS_CONSENT_MAX_AGE,
|
||||
'cookieDomain' => trim((string)app_env('MATOMO_CONSENT_COOKIE_DOMAIN', '')),
|
||||
'secureCookies' => app_is_https(),
|
||||
];
|
||||
}
|
||||
|
||||
function app_analytics_head_html(): string
|
||||
{
|
||||
if (app_matomo_configuration() === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<link rel="stylesheet" href="assets/css/privacy-settings.css">' . "\n";
|
||||
}
|
||||
|
||||
function app_analytics_footer_settings_button_html(): string
|
||||
{
|
||||
if (app_matomo_configuration() === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<button type="button" class="footer-cookie-settings" data-analytics-settings>'
|
||||
. 'Cookie-Einstellungen</button>';
|
||||
}
|
||||
|
||||
function app_analytics_body_html(): string
|
||||
{
|
||||
$configuration = app_matomo_configuration();
|
||||
if ($configuration === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Matomo-Host und Site-ID bleiben serverseitig. Der Browser sendet nach
|
||||
// Einwilligung nur an den First-Party-Endpunkt; dieser leitet die eng
|
||||
// validierte Seitenansicht an die fest konfigurierte Matomo-Site weiter.
|
||||
$browserConfiguration = [
|
||||
'consentCookie' => $configuration['consentCookie'],
|
||||
'visitorCookie' => $configuration['visitorCookie'],
|
||||
'consentVersion' => $configuration['consentVersion'],
|
||||
'consentMaxAge' => $configuration['consentMaxAge'],
|
||||
'cookieDomain' => $configuration['cookieDomain'],
|
||||
'secureCookies' => $configuration['secureCookies'],
|
||||
'trackingEndpoint' => 'nutzungsanalyse.php',
|
||||
];
|
||||
|
||||
$json = json_encode(
|
||||
$browserConfiguration,
|
||||
JSON_UNESCAPED_SLASHES
|
||||
| JSON_UNESCAPED_UNICODE
|
||||
| JSON_HEX_TAG
|
||||
| JSON_HEX_AMP
|
||||
| JSON_HEX_APOS
|
||||
| JSON_HEX_QUOT
|
||||
);
|
||||
if (!is_string($json)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return <<<'HTML'
|
||||
<section class="analytics-consent" id="analytics-consent" aria-labelledby="analytics-consent-title" aria-describedby="analytics-consent-description" hidden>
|
||||
<div class="analytics-consent__inner">
|
||||
<div class="analytics-consent__copy">
|
||||
<h2 id="analytics-consent-title">Cookie-Zustimmung</h2>
|
||||
<p id="analytics-consent-description">Wir möchten mit Matomo verstehen, wie unsere Website und App genutzt werden. Erst nach deiner Zustimmung übermitteln wir Seitenaufrufe an unser selbst betriebenes Matomo und setzen ein Analyse-Cookie. Notwendige Cookies funktionieren immer. Mehr dazu steht im <a href="datenschutz.php">Datenschutz</a>.</p>
|
||||
</div>
|
||||
<div class="analytics-consent__actions">
|
||||
<button type="button" data-analytics-consent="denied">Nur notwendige Cookies</button>
|
||||
<button type="button" data-analytics-consent="granted">Zustimmen</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
HTML
|
||||
. "\n<script type=\"application/json\" id=\"analytics-configuration\">{$json}</script>\n"
|
||||
. '<script src="assets/js/privacy-settings.js" defer></script>' . "\n";
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
/**
|
||||
* Records one admin/security-relevant action. Keep $action as a short,
|
||||
* stable slug (e.g. "participant.access_granted") so entries stay
|
||||
* filterable; put anything variable in $metadata.
|
||||
*/
|
||||
function app_audit_log(
|
||||
PDO $pdo,
|
||||
?int $tenantId,
|
||||
?int $actorUserId,
|
||||
string $action,
|
||||
string $subjectType,
|
||||
?int $subjectId,
|
||||
array $metadata = []
|
||||
): void {
|
||||
$pdo->exec('DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL 180 DAY)');
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO audit_log (tenant_id, actor_user_id, action, subject_type, subject_id, metadata_json, ip)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$tenantId,
|
||||
$actorUserId,
|
||||
$action,
|
||||
$subjectType,
|
||||
$subjectId,
|
||||
$metadata !== [] ? json_encode($metadata, JSON_UNESCAPED_UNICODE) : null,
|
||||
$_SERVER['REMOTE_ADDR'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int, actor_user_id: ?int, actor_name: ?string, action: string, subject_type: string, subject_id: ?int, metadata_json: ?string, ip: ?string, created_at: string}>
|
||||
*/
|
||||
function app_fetch_audit_log(PDO $pdo, int $tenantId, int $limit = 100): array
|
||||
{
|
||||
$limit = max(1, min($limit, 500));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT a.id, a.actor_user_id, u.display_name AS actor_name, a.action, a.subject_type, a.subject_id, a.metadata_json, a.ip, a.created_at
|
||||
FROM audit_log a
|
||||
LEFT JOIN users u ON u.id = a.actor_user_id
|
||||
WHERE a.tenant_id = ?
|
||||
ORDER BY a.created_at DESC, a.id DESC
|
||||
LIMIT {$limit}"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/stripe.php';
|
||||
|
||||
/**
|
||||
* Single source of truth for the pricing tiers, matching preise.php and
|
||||
* agb.php. max_participants = null means "no upper limit defined, contact
|
||||
* us" (the "auf Anfrage" tier). stripe_lookup_key is null for tiers that
|
||||
* have no Stripe Price (free and enterprise are never checked out through
|
||||
* Stripe: free needs no payment, enterprise is arranged manually).
|
||||
*
|
||||
* @return array<string, array{label: string, max_participants: ?int, price_cents: int, stripe_lookup_key: ?string}>
|
||||
*/
|
||||
function billing_plans(): array
|
||||
{
|
||||
return [
|
||||
'free' => ['label' => 'Kostenlos', 'max_participants' => 10, 'price_cents' => 0, 'stripe_lookup_key' => null],
|
||||
'basic' => ['label' => 'Basic', 'max_participants' => 25, 'price_cents' => 399, 'stripe_lookup_key' => 'kaffeeliste_basic'],
|
||||
'plus' => ['label' => 'Plus', 'max_participants' => 50, 'price_cents' => 799, 'stripe_lookup_key' => 'kaffeeliste_plus'],
|
||||
'pro' => ['label' => 'Pro', 'max_participants' => 150, 'price_cents' => 1299, 'stripe_lookup_key' => 'kaffeeliste_pro'],
|
||||
'enterprise' => ['label' => 'Enterprise (auf Anfrage)', 'max_participants' => null, 'price_cents' => 0, 'stripe_lookup_key' => null],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Stripe Price id for a plan by its lookup_key. Returns null
|
||||
* for plans without a Stripe price (free, enterprise), on API failure, or
|
||||
* when Stripe isn't configured at all (missing STRIPE_SECRET_KEY, e.g. in
|
||||
* a local/dev environment) - callers already treat null as "not available
|
||||
* right now", so this degrades gracefully instead of a fatal error.
|
||||
*/
|
||||
function billing_stripe_price_id(string $planCode): ?string
|
||||
{
|
||||
$plans = billing_plans();
|
||||
$lookupKey = $plans[$planCode]['stripe_lookup_key'] ?? null;
|
||||
if ($lookupKey === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = stripe_request('GET', 'prices', ['lookup_keys' => [$lookupKey], 'active' => 'true']);
|
||||
} catch (RuntimeException $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($result['ok'] && !empty($result['data']['data'][0]['id'])) {
|
||||
return (string)$result['data']['data'][0]['id'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cheapest plan whose participant limit still covers
|
||||
* $activeParticipantCount. Used both to show customers which plan they
|
||||
* need and, once Stripe is wired up, to detect required upgrades.
|
||||
*/
|
||||
function billing_required_plan_code(int $activeParticipantCount): string
|
||||
{
|
||||
foreach (billing_plans() as $code => $plan) {
|
||||
if ($plan['max_participants'] === null || $activeParticipantCount <= $plan['max_participants']) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
|
||||
return 'enterprise';
|
||||
}
|
||||
|
||||
function billing_count_active_participants(PDO $pdo, int $tenantId): int
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM participants WHERE tenant_id = ? AND active = 1');
|
||||
$stmt->execute([$tenantId]);
|
||||
|
||||
return (int)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{tenant_id: int, plan_code: string, subscription_status: string, stripe_customer_id: ?string, stripe_subscription_id: ?string, current_period_end: ?string}
|
||||
*/
|
||||
function billing_fetch_or_init(PDO $pdo, int $tenantId): array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM tenant_billing WHERE tenant_id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if ($row === false) {
|
||||
$pdo->prepare("INSERT INTO tenant_billing (tenant_id, plan_code, subscription_status) VALUES (?, 'free', 'active')")
|
||||
->execute([$tenantId]);
|
||||
|
||||
return [
|
||||
'tenant_id' => $tenantId,
|
||||
'plan_code' => 'free',
|
||||
'subscription_status' => 'active',
|
||||
'stripe_customer_id' => null,
|
||||
'stripe_subscription_id' => null,
|
||||
'current_period_end' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
function billing_update(PDO $pdo, int $tenantId, array $fields): void
|
||||
{
|
||||
billing_fetch_or_init($pdo, $tenantId);
|
||||
|
||||
$setClauses = [];
|
||||
$params = [];
|
||||
foreach ($fields as $column => $value) {
|
||||
$setClauses[] = "{$column} = ?";
|
||||
$params[] = $value;
|
||||
}
|
||||
$params[] = $tenantId;
|
||||
|
||||
$pdo->prepare('UPDATE tenant_billing SET ' . implode(', ', $setClauses) . ' WHERE tenant_id = ?')
|
||||
->execute($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordnet einen Stripe-Preis (per lookup_key) wieder einem Plan-Code zu. Wird
|
||||
* gebraucht, um einen Tarifwechsel aus dem Stripe-Kundenportal lokal
|
||||
* nachzuziehen. Liefert null, wenn kein Plan zum lookup_key passt.
|
||||
*/
|
||||
function billing_plan_code_by_lookup_key(string $lookupKey): ?string
|
||||
{
|
||||
if ($lookupKey === '') {
|
||||
return null;
|
||||
}
|
||||
foreach (billing_plans() as $code => $plan) {
|
||||
if (($plan['stripe_lookup_key'] ?? null) === $lookupKey) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotenz-Schranke fuer den Stripe-Webhook: reserviert die Event-ID per
|
||||
* INSERT IGNORE. Liefert true, wenn das Event bereits verarbeitet wurde (also
|
||||
* uebersprungen werden soll), sonst false (frisch reserviert, darf verarbeitet
|
||||
* werden). Eine leere Event-ID gilt als "neu", damit ein unerwartetes Payload
|
||||
* nicht faelschlich als Duplikat durchrutscht.
|
||||
*/
|
||||
function billing_webhook_event_seen(PDO $pdo, string $eventId, string $eventType): bool
|
||||
{
|
||||
if ($eventId === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT IGNORE INTO stripe_webhook_events (event_id, event_type) VALUES (?, ?)'
|
||||
);
|
||||
$stmt->execute([$eventId, $eventType]);
|
||||
|
||||
return $stmt->rowCount() === 0;
|
||||
}
|
||||
|
||||
function billing_find_tenant_id_by_stripe_customer(PDO $pdo, string $stripeCustomerId): ?int
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT tenant_id FROM tenant_billing WHERE stripe_customer_id = ?');
|
||||
$stmt->execute([$stripeCustomerId]);
|
||||
$tenantId = $stmt->fetchColumn();
|
||||
|
||||
return $tenantId !== false ? (int)$tenantId : null;
|
||||
}
|
||||
|
||||
function billing_find_tenant_id_by_stripe_subscription(PDO $pdo, string $stripeSubscriptionId): ?int
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT tenant_id FROM tenant_billing WHERE stripe_subscription_id = ?');
|
||||
$stmt->execute([$stripeSubscriptionId]);
|
||||
$tenantId = $stmt->fetchColumn();
|
||||
|
||||
return $tenantId !== false ? (int)$tenantId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard cap enforcement: whether the tenant's current plan still has room
|
||||
* for $additional more active participants. There is no automatic plan
|
||||
* upgrade - tenants that outgrow their plan must upgrade themselves
|
||||
* (mandant-einstellungen.php) before adding/reactivating more members.
|
||||
*/
|
||||
function billing_has_capacity_for(PDO $pdo, int $tenantId, int $additional = 1): bool
|
||||
{
|
||||
$billing = billing_fetch_or_init($pdo, $tenantId);
|
||||
$max = billing_plans()[$billing['plan_code']]['max_participants'] ?? null;
|
||||
if ($max === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return billing_count_active_participants($pdo, $tenantId) + $additional <= $max;
|
||||
}
|
||||
|
||||
function billing_capacity_error_message(PDO $pdo, int $tenantId): string
|
||||
{
|
||||
$billing = billing_fetch_or_init($pdo, $tenantId);
|
||||
$plan = billing_plans()[$billing['plan_code']] ?? null;
|
||||
$label = $plan['label'] ?? $billing['plan_code'];
|
||||
$max = $plan['max_participants'] ?? null;
|
||||
|
||||
return "Euer aktueller Tarif „{$label}\" erlaubt maximal {$max} aktive Mitglieder. "
|
||||
. 'Bitte upgradet unter Mandant-Einstellungen, um mehr Mitglieder freizuschalten.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the tenant's currently booked plan against what their active
|
||||
* participant count actually requires. Purely informational until the
|
||||
* Stripe integration lands — nothing here blocks usage or auto-upgrades.
|
||||
*
|
||||
* @return array{current_plan: string, required_plan: string, active_participants: int, needs_upgrade: bool}
|
||||
*/
|
||||
function billing_check_tenant(PDO $pdo, int $tenantId): array
|
||||
{
|
||||
$billing = billing_fetch_or_init($pdo, $tenantId);
|
||||
$activeCount = billing_count_active_participants($pdo, $tenantId);
|
||||
$requiredPlan = billing_required_plan_code($activeCount);
|
||||
|
||||
$plans = array_keys(billing_plans());
|
||||
$currentRank = array_search($billing['plan_code'], $plans, true);
|
||||
$requiredRank = array_search($requiredPlan, $plans, true);
|
||||
|
||||
return [
|
||||
'current_plan' => $billing['plan_code'],
|
||||
'required_plan' => $requiredPlan,
|
||||
'active_participants' => $activeCount,
|
||||
'needs_upgrade' => $requiredRank !== false && $currentRank !== false && $requiredRank > $currentRank,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a tenant could self-service switch to $planCode given its current
|
||||
* active participant count - i.e. the plan's limit (if any) still covers
|
||||
* them. Used to grey out/reject plan selections that would immediately put
|
||||
* the tenant over their own member count (a downgrade or lateral move that
|
||||
* doesn't fit). Unknown plan codes are treated as not selectable.
|
||||
*/
|
||||
function billing_plan_selectable_for(PDO $pdo, int $tenantId, string $planCode): bool
|
||||
{
|
||||
$plans = billing_plans();
|
||||
if (!isset($plans[$planCode])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$max = $plans[$planCode]['max_participants'];
|
||||
if ($max === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return billing_count_active_participants($pdo, $tenantId) <= $max;
|
||||
}
|
||||
@@ -6,6 +6,17 @@ if (!defined('APP_ROOT')) {
|
||||
define('APP_ROOT', dirname(__DIR__));
|
||||
}
|
||||
|
||||
// Auf echtem Webhosting (PHP-FPM/mod_php) gibt es meist keine bequeme
|
||||
// Moeglichkeit, Prozess-Umgebungsvariablen zu setzen wie beim lokalen
|
||||
// `source .env.local` vor dem PHP-Dev-Server. env.local.php ist die
|
||||
// Produktions-Entsprechung: eine nicht eingecheckte Datei mit putenv()-
|
||||
// Aufrufen, siehe env.local.example.php. Existiert sie nicht (lokale
|
||||
// Entwicklung), passiert hier nichts und getenv() liest wie bisher aus
|
||||
// der Shell-Umgebung.
|
||||
if (is_file(APP_ROOT . '/env.local.php')) {
|
||||
require_once APP_ROOT . '/env.local.php';
|
||||
}
|
||||
|
||||
function app_env(string $name, ?string $default = null): ?string
|
||||
{
|
||||
$value = getenv($name);
|
||||
@@ -16,6 +27,16 @@ function app_env(string $name, ?string $default = null): ?string
|
||||
return $value;
|
||||
}
|
||||
|
||||
// PHP und MySQL muessen dieselbe Uhr sehen. Sonst laufen alle Stellen, die
|
||||
// einen in PHP berechneten Zeitstempel gegen MySQL NOW() vergleichen,
|
||||
// gegeneinander - z. B. Token-Ablaeufe (Passwort-Reset, E-Mail-Verifikation)
|
||||
// und die Gueltigkeit von Hinweisen. Wir pinnen die PHP-Zeitzone
|
||||
// deterministisch; app_db_pdo() setzt die DB-Session auf denselben Offset.
|
||||
$appTimezone = app_env('APP_TIMEZONE', 'Europe/Berlin');
|
||||
if (@date_default_timezone_set((string)$appTimezone) === false) {
|
||||
date_default_timezone_set('UTC');
|
||||
}
|
||||
|
||||
function app_is_dev(): bool
|
||||
{
|
||||
return app_env('APP_ENV', 'prod') === 'dev';
|
||||
@@ -27,6 +48,84 @@ function app_is_https(): bool
|
||||
|| (($_SERVER['SERVER_PORT'] ?? null) === '443');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends baseline security headers on every dynamic request. Deliberately
|
||||
* does not set a Content-Security-Policy: the existing templates rely on
|
||||
* inline style="" attributes throughout (banners, table cells, etc.), and
|
||||
* locking that down would need a broader template pass. Runs once per
|
||||
* request via the auto-invocation at the bottom of this file.
|
||||
*/
|
||||
function app_send_security_headers(): void
|
||||
{
|
||||
if (PHP_SAPI === 'cli' || headers_sent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Referrer-Policy: strict-origin-when-cross-origin');
|
||||
header('Permissions-Policy: geolocation=(), microphone=(), camera=()');
|
||||
|
||||
if (app_is_https()) {
|
||||
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The application's own primary hostname in production (e.g.
|
||||
* app.kaffeeliste.de), configured via APP_HOST. Unset in dev, where
|
||||
* index.php should keep behaving exactly as before (no host split).
|
||||
*/
|
||||
function app_primary_host(): ?string
|
||||
{
|
||||
return app_env('APP_HOST');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a URL that always points at the app's own host, even when the
|
||||
* current request came in on a different domain (e.g. the marketing
|
||||
* domain linking to /login.php). Falls back to a relative path when
|
||||
* APP_HOST isn't configured, so local dev is unaffected.
|
||||
*/
|
||||
function app_primary_url(string $path): string
|
||||
{
|
||||
$host = app_primary_host();
|
||||
if ($host === null) {
|
||||
return ltrim($path, '/');
|
||||
}
|
||||
|
||||
$scheme = app_is_https() ? 'https' : 'http';
|
||||
|
||||
return $scheme . '://' . $host . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse der Marketing-Domain (z. B. https://kaffeeliste.de) - Ziel des
|
||||
* Wasserzeichens im kostenlosen Tarif. Explizit ueber APP_MARKETING_URL
|
||||
* setzbar; sonst wird sie aus APP_HOST abgeleitet, indem die
|
||||
* App-Subdomain wegfaellt (app.kaffeeliste.de -> kaffeeliste.de). In der
|
||||
* lokalen Entwicklung ohne APP_HOST bleibt der aktuelle Host uebrig, im
|
||||
* CLI-Betrieb ein leerer String.
|
||||
*/
|
||||
function app_marketing_url(): string
|
||||
{
|
||||
$configured = app_env('APP_MARKETING_URL');
|
||||
if ($configured !== null) {
|
||||
return rtrim($configured, '/');
|
||||
}
|
||||
|
||||
$scheme = app_is_https() ? 'https' : 'http';
|
||||
$host = app_primary_host();
|
||||
|
||||
if ($host !== null) {
|
||||
return $scheme . '://' . preg_replace('/^app\./i', '', $host);
|
||||
}
|
||||
|
||||
$requestHost = (string)($_SERVER['HTTP_HOST'] ?? '');
|
||||
|
||||
return $requestHost !== '' ? $scheme . '://' . $requestHost : '';
|
||||
}
|
||||
|
||||
function app_start_session(): void
|
||||
{
|
||||
if (PHP_SAPI === 'cli' || session_status() === PHP_SESSION_ACTIVE) {
|
||||
@@ -102,3 +201,10 @@ function app_require_csrf(): void
|
||||
die('CSRF validation failed.');
|
||||
}
|
||||
}
|
||||
|
||||
app_send_security_headers();
|
||||
|
||||
// Der Analyse-Baustein wird zentral geladen, damit alle HTML-Templates nur
|
||||
// noch die beiden bewusst platzierten Head-/Body-Fragmente ausgeben muessen.
|
||||
// analytics.php bindet bootstrap.php seinerseits per require_once ein.
|
||||
require_once __DIR__ . '/analytics.php';
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/saas-auth.php';
|
||||
require_once __DIR__ . '/features.php';
|
||||
|
||||
/**
|
||||
* Logo und Akzentfarbe des Mandanten, an dem der angemeldete Nutzer haengt.
|
||||
* Ohne Anmeldung, ohne freigeschaltete Funktion „Eigenes Design" oder ohne
|
||||
* hinterlegte Werte kommt ueberall ein leerer String zurueck - der Aufrufer
|
||||
* bleibt dann beim Standarddesign.
|
||||
*
|
||||
* Das Ergebnis wird pro Request gecached: header.php laeuft auf jeder Seite
|
||||
* und soll dafuer nicht mehrfach die Einstellungen laden.
|
||||
*
|
||||
* @return array{color: string, logo_url: string, tenant_name: string}
|
||||
*/
|
||||
function app_branding_for_current_user(PDO $pdo): array
|
||||
{
|
||||
static $cached = null;
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$leer = ['color' => '', 'logo_url' => '', 'tenant_name' => ''];
|
||||
|
||||
try {
|
||||
$user = saas_current_user($pdo);
|
||||
if ($user === null) {
|
||||
return $cached = $leer;
|
||||
}
|
||||
|
||||
$tenantId = (int)$user['tenant_id'];
|
||||
if (!app_feature_enabled($pdo, $tenantId, 'branding')) {
|
||||
return $cached = $leer;
|
||||
}
|
||||
|
||||
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
|
||||
if ($settings === null) {
|
||||
return $cached = $leer;
|
||||
}
|
||||
|
||||
$color = (string)($settings['brand_color'] ?? '');
|
||||
// Zweite Pruefung neben der Formularvalidierung: was hier nicht wie
|
||||
// ein Hex-Wert aussieht, wird nie in ein Stylesheet geschrieben.
|
||||
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
|
||||
$color = '';
|
||||
}
|
||||
|
||||
return $cached = [
|
||||
'color' => strtolower($color),
|
||||
'logo_url' => trim((string)($settings['brand_logo'] ?? '')) !== '' ? 'tenant-logo-anzeigen.php' : '',
|
||||
'tenant_name' => (string)($settings['name'] ?? ''),
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
// Branding ist Beiwerk: faellt es aus (z. B. Migration noch nicht
|
||||
// eingespielt), laedt die Seite trotzdem im Standarddesign.
|
||||
return $cached = $leer;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt die Farbueberschreibungen fuer das Template-Stylesheet. main.css
|
||||
* verwendet durchgaengig genau einen Akzentton (#38761d) - hier werden die
|
||||
* sichtbaren Stellen davon auf die Mandantenfarbe umgestellt.
|
||||
*/
|
||||
function app_branding_css(string $color): string
|
||||
{
|
||||
$c = preg_match('/^#[0-9a-f]{6}$/', strtolower($color)) ? strtolower($color) : '#38761d';
|
||||
|
||||
return <<<CSS
|
||||
:root { --brand-color: {$c}; }
|
||||
a { color: var(--brand-color); }
|
||||
a:hover { border-bottom-color: var(--brand-color); color: var(--brand-color) !important; }
|
||||
header.major > :last-child { border-bottom-color: var(--brand-color); }
|
||||
input[type="text"]:focus, input[type="password"]:focus, input[type="email"]:focus,
|
||||
input[type="tel"]:focus, input[type="search"]:focus, input[type="url"]:focus,
|
||||
input[type="number"]:focus, input[type="date"]:focus, input[type="datetime-local"]:focus,
|
||||
select:focus, textarea:focus,
|
||||
input[type="checkbox"]:focus + label:before, input[type="radio"]:focus + label:before {
|
||||
border-color: var(--brand-color);
|
||||
box-shadow: 0 0 0 1px var(--brand-color);
|
||||
}
|
||||
input[type="checkbox"]:checked + label:before, input[type="radio"]:checked + label:before {
|
||||
background: var(--brand-color);
|
||||
border-color: var(--brand-color);
|
||||
}
|
||||
input[type="submit"], input[type="reset"], input[type="button"], button, .button {
|
||||
box-shadow: inset 0 0 0 2px var(--brand-color);
|
||||
color: var(--brand-color) !important;
|
||||
}
|
||||
input[type="submit"].primary, input[type="reset"].primary, input[type="button"].primary,
|
||||
button.primary, .button.primary {
|
||||
background-color: var(--brand-color);
|
||||
box-shadow: none;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
#header { border-bottom-color: var(--brand-color); }
|
||||
#menu ul a:hover, #menu ul span:hover { color: var(--brand-color); }
|
||||
.tenant-brand-line { border-bottom: solid 1px rgba(0, 0, 0, 0.08); }
|
||||
|
||||
CSS;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/tenant-logo.php';
|
||||
|
||||
/**
|
||||
* Assembles a full export of everything stored for one tenant (data
|
||||
* portability / GDPR Art. 20). Excludes password_hash and token hashes;
|
||||
* everything else that is tenant-scoped is included as-is.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function app_export_tenant_data(PDO $pdo, int $tenantId): array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT id, slug, paypal_inbox_token, name, customer_type, status, timezone, locale, currency_code, created_at, updated_at FROM tenants WHERE id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
$tenant = $stmt->fetch();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM tenant_settings WHERE tenant_id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
$settings = $stmt->fetch();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM participants WHERE tenant_id = ? ORDER BY id');
|
||||
$stmt->execute([$tenantId]);
|
||||
$participants = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT u.id, u.email, u.display_name, u.status, u.email_verified_at, u.last_login_at, u.created_at,
|
||||
tm.role, tm.status AS membership_status, tm.invited_at, tm.joined_at
|
||||
FROM tenant_memberships tm
|
||||
JOIN users u ON u.id = tm.user_id
|
||||
WHERE tm.tenant_id = ?
|
||||
ORDER BY u.id'
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$members = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM ledger_entries WHERE tenant_id = ? ORDER BY id');
|
||||
$stmt->execute([$tenantId]);
|
||||
$ledgerEntries = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id, message, valid_from, valid_until, deleted_at, created_at FROM notices WHERE tenant_id = ? ORDER BY id');
|
||||
$stmt->execute([$tenantId]);
|
||||
$notices = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id, original_filename, status, created_at, committed_at FROM payment_import_batches WHERE tenant_id = ? ORDER BY id');
|
||||
$stmt->execute([$tenantId]);
|
||||
$importBatches = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT r.id, r.batch_id, r.row_num, r.raw_name, r.amount_cents, r.booked_at, r.status
|
||||
FROM payment_import_rows r
|
||||
JOIN payment_import_batches b ON b.id = r.batch_id
|
||||
WHERE b.tenant_id = ?
|
||||
ORDER BY r.id'
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$importRows = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id, participant_id, template, subject, status, sent_at, error, created_at FROM outbound_emails WHERE tenant_id = ? ORDER BY id');
|
||||
$stmt->execute([$tenantId]);
|
||||
$outboundEmails = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id, actor_user_id, action, subject_type, subject_id, metadata_json, ip, created_at FROM audit_log WHERE tenant_id = ? ORDER BY id');
|
||||
$stmt->execute([$tenantId]);
|
||||
$auditLog = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM faq_entries WHERE tenant_id = ? ORDER BY id');
|
||||
$stmt->execute([$tenantId]);
|
||||
$faqEntries = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM paypal_payments WHERE tenant_id = ? ORDER BY id');
|
||||
$stmt->execute([$tenantId]);
|
||||
$paypalPayments = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM tenant_billing WHERE tenant_id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
$billing = $stmt->fetch();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM tenant_features WHERE tenant_id = ? ORDER BY id');
|
||||
$stmt->execute([$tenantId]);
|
||||
$features = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT document_type, document_version, context, metadata_json, accepted_at
|
||||
FROM legal_acceptances WHERE tenant_id = ? ORDER BY id'
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$legalAcceptances = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT reference_code, request_type, requester_name, requester_email,
|
||||
contract_reference, request_reason, requested_end, status,
|
||||
metadata_json, received_at, processed_at
|
||||
FROM legal_requests WHERE tenant_id = ? ORDER BY id'
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$legalRequests = $stmt->fetchAll();
|
||||
|
||||
$logos = [];
|
||||
foreach (['brand_logo', 'pdf_watermark_logo'] as $logoField) {
|
||||
$filename = (string)($settings[$logoField] ?? '');
|
||||
$path = tenant_logo_path($filename);
|
||||
if ($path !== null) {
|
||||
$logos[$logoField] = [
|
||||
'filename' => $filename,
|
||||
'mime_type' => mime_content_type($path) ?: 'application/octet-stream',
|
||||
'base64' => base64_encode((string)file_get_contents($path)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'exported_at' => date('c'),
|
||||
'tenant' => $tenant ?: null,
|
||||
'tenant_settings' => $settings ?: null,
|
||||
'participants' => $participants,
|
||||
'members' => $members,
|
||||
'ledger_entries' => $ledgerEntries,
|
||||
'notices' => $notices,
|
||||
'payment_import_batches' => $importBatches,
|
||||
'payment_import_rows' => $importRows,
|
||||
'outbound_emails' => $outboundEmails,
|
||||
'audit_log' => $auditLog,
|
||||
'faq_entries' => $faqEntries,
|
||||
'paypal_payments' => $paypalPayments,
|
||||
'billing' => $billing ?: null,
|
||||
'features' => $features,
|
||||
'legal_acceptances' => $legalAcceptances,
|
||||
'legal_requests' => $legalRequests,
|
||||
'logos' => $logos,
|
||||
];
|
||||
}
|
||||
+10
-1
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/prefixed-pdo.php';
|
||||
|
||||
function app_db_pdo(): PDO
|
||||
{
|
||||
@@ -23,11 +24,19 @@ function app_db_pdo(): PDO
|
||||
}
|
||||
|
||||
$dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $database);
|
||||
$pdo = new PDO($dsn, $user, $password ?? '', [
|
||||
$pdo = new AppPrefixedPDO($dsn, $user, $password ?? '', [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => true,
|
||||
]);
|
||||
$pdo->setTablePrefix(app_db_table_prefix());
|
||||
|
||||
// DB-Session auf denselben Offset wie die (in bootstrap.php gepinnte)
|
||||
// PHP-Zeitzone setzen, damit MySQL NOW() und PHP-Zeitstempel
|
||||
// uebereinstimmen. Numerischer Offset, damit keine MySQL-Zeitzonentabellen
|
||||
// noetig sind.
|
||||
$offset = (new DateTimeImmutable('now'))->format('P');
|
||||
$pdo->exec("SET time_zone = '{$offset}'");
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
function dolibarr_base_url(): string
|
||||
{
|
||||
$url = getenv('DOLIBARR_URL');
|
||||
if ($url === false || $url === '') {
|
||||
throw new RuntimeException('DOLIBARR_URL ist nicht gesetzt.');
|
||||
}
|
||||
|
||||
return rtrim($url, '/');
|
||||
}
|
||||
|
||||
function dolibarr_api_key(): string
|
||||
{
|
||||
$key = getenv('DOLIBARR_API_KEY');
|
||||
if ($key === false || $key === '') {
|
||||
throw new RuntimeException('DOLIBARR_API_KEY ist nicht gesetzt.');
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, status: int, data: array, error: ?string}
|
||||
*/
|
||||
function dolibarr_request(string $method, string $path, ?array $jsonBody = null): array
|
||||
{
|
||||
$headers = 'DOLAPIKEY: ' . dolibarr_api_key() . "\r\nAccept: application/json\r\n";
|
||||
$content = null;
|
||||
if ($jsonBody !== null) {
|
||||
$content = json_encode($jsonBody, JSON_THROW_ON_ERROR);
|
||||
$headers .= "Content-Type: application/json\r\n";
|
||||
}
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => $method,
|
||||
'timeout' => 20,
|
||||
'ignore_errors' => true,
|
||||
'header' => $headers,
|
||||
'content' => $content,
|
||||
],
|
||||
]);
|
||||
|
||||
$url = dolibarr_base_url() . '/api/index.php' . $path;
|
||||
$body = @file_get_contents($url, false, $context);
|
||||
|
||||
$status = 0;
|
||||
foreach ($http_response_header ?? [] as $header) {
|
||||
if (preg_match('~^HTTP/\S+\s+(\d{3})~', $header, $matches) === 1) {
|
||||
$status = (int)$matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
if ($body === false) {
|
||||
$error = error_get_last();
|
||||
return ['ok' => false, 'status' => $status, 'data' => [], 'error' => $error['message'] ?? 'Unbekannter HTTP-Fehler'];
|
||||
}
|
||||
|
||||
$decoded = json_decode($body, true);
|
||||
if ($status < 200 || $status >= 300) {
|
||||
$message = is_array($decoded) ? ($decoded['error']['message'] ?? $body) : $body;
|
||||
return ['ok' => false, 'status' => $status, 'data' => is_array($decoded) ? $decoded : [], 'error' => (string)$message];
|
||||
}
|
||||
|
||||
// Dolibarr's create-endpoints return a bare numeric id, not JSON.
|
||||
if (!is_array($decoded)) {
|
||||
return ['ok' => true, 'status' => $status, 'data' => ['id' => trim($body, "\" \n")], 'error' => null];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'status' => $status, 'data' => $decoded, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Dolibarr thirdparty (customer) id for a tenant, creating it
|
||||
* on first use and caching it in tenant_billing.dolibarr_thirdparty_id.
|
||||
*/
|
||||
function dolibarr_find_or_create_thirdparty(PDO $pdo, int $tenantId): ?int
|
||||
{
|
||||
$billing = billing_fetch_or_init($pdo, $tenantId);
|
||||
if (!empty($billing['dolibarr_thirdparty_id'])) {
|
||||
return (int)$billing['dolibarr_thirdparty_id'];
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT name FROM tenants WHERE id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
$tenantName = (string)($stmt->fetchColumn() ?: "Mandant #{$tenantId}");
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT u.email FROM users u
|
||||
INNER JOIN tenant_memberships tm ON tm.user_id = u.id
|
||||
WHERE tm.tenant_id = ? AND tm.role = 'owner' AND tm.status = 'active'
|
||||
ORDER BY tm.id ASC LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$ownerEmail = $stmt->fetchColumn();
|
||||
|
||||
$result = dolibarr_request('POST', '/thirdparties', [
|
||||
'name' => "Kaffeeliste - {$tenantName}",
|
||||
'client' => 1,
|
||||
'code_client' => 'auto',
|
||||
'email' => $ownerEmail !== false ? (string)$ownerEmail : '',
|
||||
'note_private' => "Automatisch angelegt durch die Kaffeeliste-App fuer Mandant #{$tenantId}.",
|
||||
]);
|
||||
|
||||
if (!$result['ok']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$thirdpartyId = (int)$result['data']['id'];
|
||||
billing_update($pdo, $tenantId, ['dolibarr_thirdparty_id' => (string)$thirdpartyId]);
|
||||
|
||||
return $thirdpartyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a single-line invoice for a thirdparty and validates it
|
||||
* immediately (assigns the permanent, sequential invoice number). Intended
|
||||
* to be called only for genuine, already-collected Stripe payments.
|
||||
*
|
||||
* @return array{ok: bool, invoice_id: ?int, ref: ?string, error: ?string}
|
||||
*/
|
||||
function dolibarr_create_and_validate_invoice(int $thirdpartyId, string $description, int $amountCents): array
|
||||
{
|
||||
$created = dolibarr_request('POST', '/invoices', [
|
||||
'socid' => $thirdpartyId,
|
||||
'type' => 0,
|
||||
'note_private' => 'Automatisch erzeugt durch die Kaffeeliste-App aus einer erfolgreichen Stripe-Zahlung.',
|
||||
]);
|
||||
if (!$created['ok']) {
|
||||
return ['ok' => false, 'invoice_id' => null, 'ref' => null, 'error' => 'Rechnung anlegen fehlgeschlagen: ' . $created['error']];
|
||||
}
|
||||
|
||||
$invoiceId = (int)$created['data']['id'];
|
||||
|
||||
$line = dolibarr_request('POST', "/invoices/{$invoiceId}/lines", [
|
||||
'desc' => $description,
|
||||
'qty' => 1,
|
||||
'subprice' => round($amountCents / 100, 2),
|
||||
// Kleinunternehmer nach § 19 UStG: keine Umsatzsteuer ausgewiesen.
|
||||
'tva_tx' => 0,
|
||||
]);
|
||||
if (!$line['ok']) {
|
||||
return ['ok' => false, 'invoice_id' => $invoiceId, 'ref' => null, 'error' => 'Rechnungsposition anlegen fehlgeschlagen: ' . $line['error']];
|
||||
}
|
||||
|
||||
$validated = dolibarr_request('POST', "/invoices/{$invoiceId}/validate", []);
|
||||
if (!$validated['ok']) {
|
||||
return ['ok' => false, 'invoice_id' => $invoiceId, 'ref' => null, 'error' => 'Rechnung validieren fehlgeschlagen: ' . $validated['error']];
|
||||
}
|
||||
|
||||
$fetched = dolibarr_request('GET', "/invoices/{$invoiceId}");
|
||||
$ref = $fetched['ok'] ? (string)($fetched['data']['ref'] ?? '') : null;
|
||||
|
||||
return ['ok' => true, 'invoice_id' => $invoiceId, 'ref' => $ref, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates the Dolibarr side of a successful Stripe payment: resolves
|
||||
* (or creates) the tenant's Dolibarr customer, then creates and validates
|
||||
* a mirrored invoice. Stripe remains the sole payment channel; Dolibarr is
|
||||
* populated purely as a bookkeeping mirror, never invoked to collect money.
|
||||
*
|
||||
* @return array{ok: bool, invoice_id: ?int, ref: ?string, error: ?string}
|
||||
*/
|
||||
function dolibarr_sync_invoice_paid(PDO $pdo, int $tenantId, string $planCode, int $amountCents): array
|
||||
{
|
||||
$thirdpartyId = dolibarr_find_or_create_thirdparty($pdo, $tenantId);
|
||||
if ($thirdpartyId === null) {
|
||||
return ['ok' => false, 'invoice_id' => null, 'ref' => null, 'error' => 'Dolibarr-Kunde konnte nicht ermittelt/angelegt werden.'];
|
||||
}
|
||||
|
||||
$plans = billing_plans();
|
||||
$planLabel = $plans[$planCode]['label'] ?? $planCode;
|
||||
$description = "Kaffeeliste SaaS-Abo - Tarif {$planLabel} - " . date('m/Y');
|
||||
|
||||
return dolibarr_create_and_validate_invoice($thirdpartyId, $description, $amountCents);
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
/**
|
||||
* @return list<array{id: int, question: string, answer: string, sort_order: int}>
|
||||
*/
|
||||
function faq_fetch_all(PDO $pdo, int $tenantId): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, question, answer, sort_order
|
||||
FROM faq_entries
|
||||
WHERE tenant_id = ?
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY sort_order, id'
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function faq_create(PDO $pdo, int $tenantId, string $question, string $answer): bool
|
||||
{
|
||||
$question = trim($question);
|
||||
$answer = trim($answer);
|
||||
if ($question === '' || $answer === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT COALESCE(MAX(sort_order), 0) + 1 FROM faq_entries WHERE tenant_id = ? AND deleted_at IS NULL');
|
||||
$stmt->execute([$tenantId]);
|
||||
$nextOrder = (int)$stmt->fetchColumn();
|
||||
|
||||
$stmt = $pdo->prepare('INSERT INTO faq_entries (tenant_id, question, answer, sort_order) VALUES (?, ?, ?, ?)');
|
||||
|
||||
return $stmt->execute([$tenantId, $question, $answer, $nextOrder]);
|
||||
}
|
||||
|
||||
function faq_update(PDO $pdo, int $tenantId, int $id, string $question, string $answer): bool
|
||||
{
|
||||
$question = trim($question);
|
||||
$answer = trim($answer);
|
||||
if ($question === '' || $answer === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE faq_entries SET question = ?, answer = ? WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL'
|
||||
);
|
||||
$stmt->execute([$question, $answer, $id, $tenantId]);
|
||||
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
function faq_soft_delete(PDO $pdo, int $tenantId, int $id): bool
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE faq_entries SET deleted_at = NOW() WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL'
|
||||
);
|
||||
$stmt->execute([$id, $tenantId]);
|
||||
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds two example questions for a brand-new tenant, damit die FAQ-Seite
|
||||
* nicht voellig leer startet und sofort erkennbar ist, wie ein Eintrag
|
||||
* aussieht. Bewusst nur zwei Beispiele statt eines fertigen Katalogs: die
|
||||
* Antworten sind Platzhalter, die jeder Mandant ohnehin auf seine eigene
|
||||
* Teekueche umschreibt. Beide Eintraege sind frei editier- und loeschbar.
|
||||
*/
|
||||
function faq_seed_default_entries(PDO $pdo, int $tenantId): void
|
||||
{
|
||||
$starters = [
|
||||
['Wie starten wir mit unserer Kaffeeliste?', 'Beispielantwort – bitte anpassen: Mitglieder unter „Mitglieder verwalten" anlegen, danach direkt Striche und Einzahlungen erfassen.'],
|
||||
['Wie erfahre ich meinen aktuellen Stand?', 'Beispielantwort – bitte anpassen: Unter „Meine Kaffeeliste" ist der aktuelle Stand jederzeit sichtbar.'],
|
||||
];
|
||||
|
||||
$stmt = $pdo->prepare('INSERT INTO faq_entries (tenant_id, question, answer, sort_order) VALUES (?, ?, ?, ?)');
|
||||
foreach ($starters as $index => [$question, $answer]) {
|
||||
$stmt->execute([$tenantId, $question, $answer, $index + 1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
/**
|
||||
* Funktions-Schalter je Mandant, gepflegt vom Betreiber im Back-Office
|
||||
* (backoffice-mandant.php). Sie liegen eine Ebene ueber den
|
||||
* Mandant-Einstellungen: der Kunde stellt dort ein, *wie* eine Funktion
|
||||
* arbeitet - hier entscheidet der Betreiber, ob sie ihm ueberhaupt zur
|
||||
* Verfuegung steht.
|
||||
*
|
||||
* Bewusst kein Bestandteil von saas_user_has_role(): Rollen regeln, wer
|
||||
* innerhalb eines Mandanten etwas darf, Features regeln den Funktionsumfang
|
||||
* des Mandanten als Ganzes. Beide Pruefungen stehen deshalb nebeneinander.
|
||||
*
|
||||
* @return array<string, array{label: string, description: string}>
|
||||
*/
|
||||
function app_feature_catalog(): array
|
||||
{
|
||||
return [
|
||||
'faq' => [
|
||||
'label' => 'FAQ-Seite',
|
||||
'description' => 'Eigene Fragen und Antworten für die Mitglieder des Mandanten.',
|
||||
],
|
||||
'self_entry' => [
|
||||
'label' => 'Striche selbst eintragen',
|
||||
'description' => 'Mitglieder tragen ihre Striche im eigenen Dashboard ein (zusätzlich zur Mandant-Einstellung „Web-Striche erlauben").',
|
||||
],
|
||||
'pdf_export' => [
|
||||
'label' => 'Kaffeeliste als PDF',
|
||||
'description' => 'Ausdruck der Strichliste zum Aufhängen.',
|
||||
],
|
||||
'paypal_inbox' => [
|
||||
'label' => 'PayPal-Zahlungseingang',
|
||||
'description' => 'Automatisches Verbuchen weitergeleiteter PayPal-Mails samt Zuordnungs-Warteschlange.',
|
||||
],
|
||||
'csv_import' => [
|
||||
'label' => 'CSV-Import',
|
||||
'description' => 'Einlesen von Einzahlungen und Mitgliedern aus einer Datei.',
|
||||
],
|
||||
'mail_dispatch' => [
|
||||
'label' => 'Info-Mails und Erinnerungen',
|
||||
'description' => 'Sammelmails an Mitglieder und automatische Zahlungserinnerungen.',
|
||||
],
|
||||
'year_closing' => [
|
||||
'label' => 'Jahresabschluss',
|
||||
'description' => 'Verteilen eines Gesamtbetrags als Guthaben auf alle Mitglieder.',
|
||||
],
|
||||
'data_export' => [
|
||||
'label' => 'Datenexport',
|
||||
'description' => 'Herunterladen aller Mandantendaten durch den Kunden selbst.',
|
||||
],
|
||||
'branding' => [
|
||||
'label' => 'Eigenes Design',
|
||||
'description' => 'Eigenes Logo und eigene Akzentfarbe in der App-Oberfläche des Mandanten.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard fuer jede Funktion, wenn der Betreiber nichts abweichend gesetzt
|
||||
* hat. Alles ist freigeschaltet - ein neuer Kunde soll den vollen
|
||||
* Funktionsumfang bekommen, ohne dass der Betreiber erst etwas einschalten
|
||||
* muss.
|
||||
*/
|
||||
function app_feature_default(string $featureKey): bool
|
||||
{
|
||||
return array_key_exists($featureKey, app_feature_catalog());
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktueller Stand aller bekannten Funktionen fuer einen Mandanten.
|
||||
*
|
||||
* @return array<string, bool>
|
||||
*/
|
||||
function app_features_for_tenant(PDO $pdo, int $tenantId): array
|
||||
{
|
||||
$state = [];
|
||||
foreach (array_keys(app_feature_catalog()) as $key) {
|
||||
$state[$key] = app_feature_default($key);
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare('SELECT feature_key, enabled FROM tenant_features WHERE tenant_id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$key = (string)$row['feature_key'];
|
||||
if (array_key_exists($key, $state)) {
|
||||
$state[$key] = (int)$row['enabled'] === 1;
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Fehlt die Tabelle (Migration noch nicht eingespielt), bleibt es bei
|
||||
// den Standardwerten - eine nicht ausgerollte Migration darf keine
|
||||
// Funktion sperren.
|
||||
return $state;
|
||||
}
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelne Funktion pruefen. Ergebnis wird pro Request zwischengespeichert,
|
||||
* damit Seiten mit mehreren Pruefungen (Navigation!) nicht je Aufruf erneut
|
||||
* die Datenbank befragen.
|
||||
*/
|
||||
function app_feature_enabled(PDO $pdo, int $tenantId, string $featureKey): bool
|
||||
{
|
||||
static $cache = [];
|
||||
|
||||
// Export und Rückgabe der eigenen Daten sind Bestandteil von Vertrag und
|
||||
// AVV und dürfen nicht versehentlich im Back-Office abgeschaltet werden.
|
||||
if ($featureKey === 'data_export') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($tenantId <= 0) {
|
||||
return app_feature_default($featureKey);
|
||||
}
|
||||
|
||||
if (!array_key_exists($tenantId, $cache)) {
|
||||
$cache[$tenantId] = app_features_for_tenant($pdo, $tenantId);
|
||||
}
|
||||
|
||||
return $cache[$tenantId][$featureKey] ?? app_feature_default($featureKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Schalter eines Mandanten neu. Erwartet die Formulardaten des
|
||||
* Back-Office-Formulars: jede angehakte Funktion steht als Schluessel in
|
||||
* $input['features'].
|
||||
*
|
||||
* @param array<string, mixed> $submittedFeatures
|
||||
* @return list<string> Liste der geaenderten Funktionsschluessel
|
||||
*/
|
||||
function app_features_update(PDO $pdo, int $tenantId, array $submittedFeatures): array
|
||||
{
|
||||
$before = app_features_for_tenant($pdo, $tenantId);
|
||||
$changed = [];
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO tenant_features (tenant_id, feature_key, enabled)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled)'
|
||||
);
|
||||
|
||||
foreach (array_keys(app_feature_catalog()) as $key) {
|
||||
$enabled = $key === 'data_export' || !empty($submittedFeatures[$key]);
|
||||
$stmt->execute([$tenantId, $key, $enabled ? 1 : 0]);
|
||||
if (($before[$key] ?? true) !== $enabled) {
|
||||
$changed[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seitenwaechter: bricht mit einer verstaendlichen Meldung ab, wenn der
|
||||
* Betreiber die Funktion fuer diesen Mandanten abgeschaltet hat. Die Meldung
|
||||
* wird bewusst in die bereits geoeffnete Seite geschrieben, damit sie im
|
||||
* gewohnten Layout erscheint statt als nackter Text.
|
||||
*/
|
||||
function app_feature_notice_html(string $featureKey): string
|
||||
{
|
||||
$label = app_feature_catalog()[$featureKey]['label'] ?? $featureKey;
|
||||
|
||||
return '<div class="hint-box error"><p><b>' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8')
|
||||
. ' ist für euren Zugang nicht freigeschaltet.</b><br>'
|
||||
. 'Wende dich an den Betreiber der Kaffeeliste, wenn ihr diese Funktion nutzen möchtet.</p></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Zuordnung Betreiber-Funktion -> Schalter in den Mandant-Einstellungen.
|
||||
* Manche Funktionen kann der Kunde zusaetzlich selbst abschalten; wer PayPal
|
||||
* gar nicht als Zahlungsweg anbietet, braucht auch keine PayPal-Zuordnung.
|
||||
* Funktionen ohne Eintrag kennen nur den Betreiber-Schalter.
|
||||
*/
|
||||
function app_feature_tenant_switch(string $featureKey): ?string
|
||||
{
|
||||
$switches = [
|
||||
'paypal_inbox' => 'paypal_enabled',
|
||||
'self_entry' => 'self_entry_enabled',
|
||||
];
|
||||
|
||||
return $switches[$featureKey] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist eine Funktion fuer den Mandanten tatsaechlich nutzbar? Prueft beide
|
||||
* Ebenen: erst die Freischaltung durch den Betreiber, dann - falls vorhanden -
|
||||
* den eigenen Schalter des Kunden in den Mandant-Einstellungen.
|
||||
*/
|
||||
function app_feature_available(PDO $pdo, int $tenantId, string $featureKey): bool
|
||||
{
|
||||
if (!app_feature_enabled($pdo, $tenantId, $featureKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$switch = app_feature_tenant_switch($featureKey);
|
||||
if ($switch === null || $tenantId <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return app_tenant_switch_enabled($pdo, $tenantId, $switch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelnen Schalter aus den Mandant-Einstellungen lesen. Wie bei den
|
||||
* Betreiber-Funktionen wird das Ergebnis pro Request gehalten, damit die
|
||||
* Navigation die Einstellungen nicht mehrfach laedt.
|
||||
*/
|
||||
function app_tenant_switch_enabled(PDO $pdo, int $tenantId, string $settingKey): bool
|
||||
{
|
||||
static $cache = [];
|
||||
|
||||
if (!array_key_exists($tenantId, $cache)) {
|
||||
try {
|
||||
require_once __DIR__ . '/saas-auth.php';
|
||||
$cache[$tenantId] = saas_fetch_tenant_settings($pdo, $tenantId) ?? [];
|
||||
} catch (Throwable $e) {
|
||||
// Lassen sich die Einstellungen nicht lesen, entscheidet allein die
|
||||
// Betreiber-Freischaltung - kein stiller Funktionsverlust.
|
||||
$cache[$tenantId] = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!array_key_exists($settingKey, $cache[$tenantId])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (int)$cache[$tenantId][$settingKey] === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gegenstueck zu app_feature_notice_html() fuer den Fall, dass nicht der
|
||||
* Betreiber, sondern der Mandant selbst die Funktion abgeschaltet hat. Hier
|
||||
* hilft kein Kontakt zum Betreiber, sondern der Weg in die eigenen
|
||||
* Einstellungen.
|
||||
*/
|
||||
function app_feature_tenant_notice_html(string $featureKey): string
|
||||
{
|
||||
$label = app_feature_catalog()[$featureKey]['label'] ?? $featureKey;
|
||||
|
||||
return '<div class="hint-box error"><p><b>' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8')
|
||||
. ' ist in euren Mandant-Einstellungen deaktiviert.</b><br>'
|
||||
. 'Ein Besitzer oder Administrator kann die Funktion unter '
|
||||
. '<a href="mandant-einstellungen.php">Mandant-Einstellungen</a> wieder einschalten.</p></div>';
|
||||
}
|
||||
+489
@@ -0,0 +1,489 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/ledger.php';
|
||||
|
||||
/**
|
||||
* Parses a decimal amount ("12,34" or "12.34") into cents. Returns null for
|
||||
* anything that is not a plausible positive amount.
|
||||
*/
|
||||
function imports_normalize_amount(string $raw): ?int
|
||||
{
|
||||
$normalized = str_replace(',', '.', trim($raw));
|
||||
if (!preg_match('/^-?[0-9]+(?:\.[0-9]{1,2})?$/', $normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cents = (int)round(((float)$normalized) * 100);
|
||||
|
||||
return $cents > 0 ? $cents : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a CSV name against a participant's paypal_name or display_name
|
||||
* (case-insensitive).
|
||||
*
|
||||
* @return array{participant_id: int, display_name: string}|null
|
||||
*/
|
||||
function imports_find_participant(PDO $pdo, int $tenantId, string $name): ?array
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, display_name
|
||||
FROM participants
|
||||
WHERE tenant_id = ?
|
||||
AND (LOWER(paypal_name) = LOWER(?) OR LOWER(display_name) = LOWER(?))
|
||||
ORDER BY id
|
||||
LIMIT 2'
|
||||
);
|
||||
$stmt->execute([$tenantId, $name, $name]);
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
if (count($rows) !== 1) {
|
||||
return null;
|
||||
}
|
||||
$row = $rows[0];
|
||||
|
||||
return [
|
||||
'participant_id' => (int)$row['id'],
|
||||
'display_name' => (string)$row['display_name'],
|
||||
];
|
||||
}
|
||||
|
||||
function imports_normalize_match_text(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
|
||||
|
||||
return function_exists('mb_strtolower')
|
||||
? mb_strtolower($value, 'UTF-8')
|
||||
: strtolower($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert wahrscheinliche Teilnehmer fuer eine manuelle Zuordnung. Die
|
||||
* automatische Buchung bleibt strenger; diese Vorschlaege sind nur eine Hilfe
|
||||
* fuer Admins/Kassenwarte in CSV- und PayPal-Warteschlangen.
|
||||
*
|
||||
* @return list<array{participant_id:int, display_name:string, email:?string, paypal_name:?string, score:int}>
|
||||
*/
|
||||
function imports_suggest_participants(PDO $pdo, int $tenantId, string $rawName, ?string $note = null, int $limit = 5): array
|
||||
{
|
||||
$haystack = imports_normalize_match_text(trim($rawName . ' ' . (string)$note));
|
||||
if ($haystack === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tokens = array_values(array_unique(array_filter(
|
||||
preg_split('/\s+/', $haystack) ?: [],
|
||||
static fn(string $token): bool => strlen($token) >= 3
|
||||
)));
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, display_name, email, paypal_name
|
||||
FROM participants
|
||||
WHERE tenant_id = ? AND active = 1
|
||||
ORDER BY display_name'
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
|
||||
$candidates = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$displayName = (string)$row['display_name'];
|
||||
$paypalName = (string)($row['paypal_name'] ?? '');
|
||||
$email = (string)($row['email'] ?? '');
|
||||
$displayNorm = imports_normalize_match_text($displayName);
|
||||
$paypalNorm = imports_normalize_match_text($paypalName);
|
||||
$emailNorm = imports_normalize_match_text($email);
|
||||
$score = 0;
|
||||
|
||||
if ($displayNorm !== '' && $displayNorm === imports_normalize_match_text($rawName)) {
|
||||
$score += 90;
|
||||
}
|
||||
if ($paypalNorm !== '' && $paypalNorm === imports_normalize_match_text($rawName)) {
|
||||
$score += 100;
|
||||
}
|
||||
if ($displayNorm !== '' && str_contains($haystack, $displayNorm)) {
|
||||
$score += 45;
|
||||
}
|
||||
if ($paypalNorm !== '' && str_contains($haystack, $paypalNorm)) {
|
||||
$score += 55;
|
||||
}
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if ($displayNorm !== '' && str_contains($displayNorm, $token)) {
|
||||
$score += 12;
|
||||
}
|
||||
if ($paypalNorm !== '' && str_contains($paypalNorm, $token)) {
|
||||
$score += 16;
|
||||
}
|
||||
if ($emailNorm !== '' && str_contains($emailNorm, $token)) {
|
||||
$score += 6;
|
||||
}
|
||||
}
|
||||
|
||||
if ($score <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidates[] = [
|
||||
'participant_id' => (int)$row['id'],
|
||||
'display_name' => $displayName,
|
||||
'email' => $row['email'] !== null ? (string)$row['email'] : null,
|
||||
'paypal_name' => $row['paypal_name'] !== null ? (string)$row['paypal_name'] : null,
|
||||
'score' => $score,
|
||||
];
|
||||
}
|
||||
|
||||
usort($candidates, static function (array $a, array $b): int {
|
||||
return $b['score'] <=> $a['score']
|
||||
?: strcasecmp((string)$a['display_name'], (string)$b['display_name']);
|
||||
});
|
||||
|
||||
return array_slice($candidates, 0, max(1, $limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* A payment counts as a duplicate if the same participant already has a
|
||||
* non-voided ledger payment of the same amount on the same day.
|
||||
*/
|
||||
function imports_is_duplicate(PDO $pdo, int $tenantId, int $participantId, int $amountCents, string $bookedAtDate): bool
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT COUNT(*)
|
||||
FROM ledger_entries
|
||||
WHERE tenant_id = ?
|
||||
AND participant_id = ?
|
||||
AND type = 'payment'
|
||||
AND amount_cents = ?
|
||||
AND DATE(booked_at) = ?
|
||||
AND voided_at IS NULL"
|
||||
);
|
||||
$stmt->execute([$tenantId, $participantId, $amountCents, $bookedAtDate]);
|
||||
|
||||
return (int)$stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a PayPal-style export CSV: column 0 is the booking date, column 3
|
||||
* the payer name, column 7 the gross amount. The first line is a header and
|
||||
* is skipped.
|
||||
*
|
||||
* @return list<array{row_number: int, raw_name: string, amount_raw: string, date_raw: string, raw: list<string>}>
|
||||
*/
|
||||
function imports_parse_csv(string $filePath): array
|
||||
{
|
||||
$handle = fopen($filePath, 'r');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('Die CSV-Datei konnte nicht gelesen werden.');
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$rowNumber = 0;
|
||||
fgetcsv($handle); // Header ueberspringen
|
||||
|
||||
while (($data = fgetcsv($handle, 1000, ',')) !== false) {
|
||||
$rowNumber++;
|
||||
$rows[] = [
|
||||
'row_number' => $rowNumber,
|
||||
'raw_name' => trim((string)($data[3] ?? '')),
|
||||
'amount_raw' => trim((string)($data[7] ?? '')),
|
||||
'date_raw' => trim((string)($data[0] ?? '')),
|
||||
'raw' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
function imports_create_batch(PDO $pdo, int $tenantId, string $originalFilename, string $checksum, ?int $createdByUserId): int
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO payment_import_batches (tenant_id, original_filename, checksum, status, created_by_user_id)
|
||||
VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([$tenantId, $originalFilename, $checksum, 'previewed', $createdByUserId]);
|
||||
|
||||
return (int)$pdo->lastInsertId();
|
||||
}
|
||||
|
||||
function imports_store_row(
|
||||
PDO $pdo,
|
||||
int $batchId,
|
||||
int $rowNumber,
|
||||
?int $participantId,
|
||||
string $rawName,
|
||||
int $amountCents,
|
||||
string $bookedAt,
|
||||
string $status,
|
||||
array $rawRow
|
||||
): int {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO payment_import_rows
|
||||
(batch_id, row_num, participant_id, raw_name, amount_cents, booked_at, status, raw_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$batchId,
|
||||
$rowNumber,
|
||||
$participantId,
|
||||
$rawName,
|
||||
$amountCents,
|
||||
$bookedAt,
|
||||
$status,
|
||||
json_encode($rawRow, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
|
||||
return (int)$pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{batch: array{id: int, original_filename: string, status: string, created_at: string}, rows: list<array>}|null
|
||||
*/
|
||||
function imports_fetch_batch(PDO $pdo, int $tenantId, int $batchId): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, original_filename, status, created_at
|
||||
FROM payment_import_batches
|
||||
WHERE id = ? AND tenant_id = ?'
|
||||
);
|
||||
$stmt->execute([$batchId, $tenantId]);
|
||||
$batch = $stmt->fetch();
|
||||
if ($batch === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT r.id, r.row_num, r.participant_id, r.raw_name, r.amount_cents, r.booked_at, r.status, p.display_name
|
||||
FROM payment_import_rows r
|
||||
LEFT JOIN participants p ON p.id = r.participant_id
|
||||
WHERE r.batch_id = ?
|
||||
ORDER BY r.row_num'
|
||||
);
|
||||
$stmt->execute([$batchId]);
|
||||
|
||||
return [
|
||||
'batch' => [
|
||||
'id' => (int)$batch['id'],
|
||||
'original_filename' => (string)$batch['original_filename'],
|
||||
'status' => (string)$batch['status'],
|
||||
'created_at' => (string)$batch['created_at'],
|
||||
],
|
||||
'rows' => $stmt->fetchAll(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id:int, original_filename:string, status:string, created_at:string, committed_at:?string, total_rows:int, matched_rows:int, unresolved_rows:int, imported_rows:int, ignored_rows:int}>
|
||||
*/
|
||||
function imports_fetch_recent_batches(PDO $pdo, int $tenantId, int $limit = 10): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT b.id, b.original_filename, b.status, b.created_at, b.committed_at,
|
||||
COUNT(r.id) AS total_rows,
|
||||
SUM(CASE WHEN r.status = 'matched' THEN 1 ELSE 0 END) AS matched_rows,
|
||||
SUM(CASE WHEN r.status IN ('unmatched', 'duplicate', 'invalid') THEN 1 ELSE 0 END) AS unresolved_rows,
|
||||
SUM(CASE WHEN r.status = 'imported' THEN 1 ELSE 0 END) AS imported_rows,
|
||||
SUM(CASE WHEN r.status = 'ignored' THEN 1 ELSE 0 END) AS ignored_rows
|
||||
FROM payment_import_batches b
|
||||
LEFT JOIN payment_import_rows r ON r.batch_id = b.id
|
||||
WHERE b.tenant_id = ?
|
||||
GROUP BY b.id, b.original_filename, b.status, b.created_at, b.committed_at
|
||||
ORDER BY b.created_at DESC, b.id DESC
|
||||
LIMIT " . max(1, min(50, $limit))
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
|
||||
return array_map(static function (array $row): array {
|
||||
return [
|
||||
'id' => (int)$row['id'],
|
||||
'original_filename' => (string)$row['original_filename'],
|
||||
'status' => (string)$row['status'],
|
||||
'created_at' => (string)$row['created_at'],
|
||||
'committed_at' => $row['committed_at'] !== null ? (string)$row['committed_at'] : null,
|
||||
'total_rows' => (int)$row['total_rows'],
|
||||
'matched_rows' => (int)$row['matched_rows'],
|
||||
'unresolved_rows' => (int)$row['unresolved_rows'],
|
||||
'imported_rows' => (int)$row['imported_rows'],
|
||||
'ignored_rows' => (int)$row['ignored_rows'],
|
||||
];
|
||||
}, $stmt->fetchAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:bool, error?:string}
|
||||
*/
|
||||
function imports_assign_row(PDO $pdo, int $tenantId, int $batchId, int $rowId, int $participantId): array
|
||||
{
|
||||
$batch = imports_fetch_batch($pdo, $tenantId, $batchId);
|
||||
if ($batch === null || $batch['batch']['status'] !== 'previewed') {
|
||||
return ['ok' => false, 'error' => 'Dieser Import kann nicht mehr bearbeitet werden.'];
|
||||
}
|
||||
|
||||
$summaries = ledger_fetch_participant_summaries($pdo, $tenantId, ['participant_ids' => [$participantId]]);
|
||||
$participant = $summaries[0] ?? null;
|
||||
if ($participant === null || empty($participant['active'])) {
|
||||
return ['ok' => false, 'error' => 'Das gewählte Mitglied wurde nicht gefunden.'];
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"UPDATE payment_import_rows r
|
||||
JOIN payment_import_batches b ON b.id = r.batch_id
|
||||
SET r.participant_id = ?, r.status = 'matched'
|
||||
WHERE r.id = ?
|
||||
AND r.batch_id = ?
|
||||
AND b.tenant_id = ?
|
||||
AND b.status = 'previewed'
|
||||
AND r.status IN ('unmatched', 'duplicate')"
|
||||
);
|
||||
$stmt->execute([$participantId, $rowId, $batchId, $tenantId]);
|
||||
|
||||
if ($stmt->rowCount() !== 1) {
|
||||
return ['ok' => false, 'error' => 'Diese Zeile kann nicht zugeordnet werden.'];
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:bool, error?:string}
|
||||
*/
|
||||
function imports_ignore_row(PDO $pdo, int $tenantId, int $batchId, int $rowId): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"UPDATE payment_import_rows r
|
||||
JOIN payment_import_batches b ON b.id = r.batch_id
|
||||
SET r.participant_id = NULL, r.status = 'ignored'
|
||||
WHERE r.id = ?
|
||||
AND r.batch_id = ?
|
||||
AND b.tenant_id = ?
|
||||
AND b.status = 'previewed'
|
||||
AND r.status IN ('unmatched', 'duplicate', 'invalid')"
|
||||
);
|
||||
$stmt->execute([$rowId, $batchId, $tenantId]);
|
||||
|
||||
if ($stmt->rowCount() !== 1) {
|
||||
return ['ok' => false, 'error' => 'Diese Zeile kann nicht ignoriert werden.'];
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits every 'matched' row of a previewed batch straight into the ledger.
|
||||
* Already-committed batches are rejected.
|
||||
*
|
||||
* @return array{imported: int}
|
||||
*/
|
||||
function imports_commit_batch(PDO $pdo, int $tenantId, int $batchId): array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT status FROM payment_import_batches WHERE id = ? AND tenant_id = ?');
|
||||
$stmt->execute([$batchId, $tenantId]);
|
||||
$status = $stmt->fetchColumn();
|
||||
if ($status === false) {
|
||||
throw new RuntimeException('Der Import wurde nicht gefunden.');
|
||||
}
|
||||
if ($status !== 'previewed') {
|
||||
throw new RuntimeException('Dieser Import wurde bereits verarbeitet.');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT r.id, r.participant_id, r.amount_cents, r.booked_at
|
||||
FROM payment_import_rows r
|
||||
JOIN participants p ON p.id = r.participant_id
|
||||
WHERE r.batch_id = ? AND r.status = 'matched'"
|
||||
);
|
||||
$stmt->execute([$batchId]);
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
$imported = 0;
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$updateRow = $pdo->prepare(
|
||||
"UPDATE payment_import_rows SET status = 'imported', ledger_entry_id = ? WHERE id = ?"
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$ledgerEntryId = ledger_record_payment(
|
||||
$pdo,
|
||||
$tenantId,
|
||||
(int)$row['participant_id'],
|
||||
(int)$row['amount_cents'],
|
||||
'csv_import'
|
||||
);
|
||||
|
||||
$updateRow->execute([$ledgerEntryId, $row['id']]);
|
||||
$imported++;
|
||||
}
|
||||
|
||||
$pdo->prepare("UPDATE payment_import_batches SET status = 'committed', committed_at = NOW() WHERE id = ?")
|
||||
->execute([$batchId]);
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return ['imported' => $imported];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst eine Mitglieder-CSV (Vorlage: Name, E-Mail, PayPal-Name, Aktiv). Erste
|
||||
* Zeile ist die Kopfzeile und wird uebersprungen. Trennzeichen wird anhand der
|
||||
* Kopfzeile automatisch erkannt (Komma oder Semikolon - Excel im deutschen
|
||||
* Gebietsschema exportiert Semikolon).
|
||||
*
|
||||
* @return list<array{row_number:int, name:string, email:string, paypal_name:string, active:bool}>
|
||||
*/
|
||||
function imports_parse_member_csv(string $filePath): array
|
||||
{
|
||||
$handle = fopen($filePath, 'r');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('Die CSV-Datei konnte nicht gelesen werden.');
|
||||
}
|
||||
|
||||
$firstLine = fgets($handle);
|
||||
if ($firstLine === false) {
|
||||
fclose($handle);
|
||||
return [];
|
||||
}
|
||||
// BOM entfernen, Trennzeichen erkennen.
|
||||
$firstLine = preg_replace('/^\xEF\xBB\xBF/', '', $firstLine);
|
||||
$delimiter = (substr_count($firstLine, ';') > substr_count($firstLine, ',')) ? ';' : ',';
|
||||
|
||||
$rows = [];
|
||||
$rowNumber = 0;
|
||||
while (($data = fgetcsv($handle, 2000, $delimiter)) !== false) {
|
||||
// Komplett leere Zeilen ueberspringen.
|
||||
if ($data === [null] || (count($data) === 1 && trim((string)$data[0]) === '')) {
|
||||
continue;
|
||||
}
|
||||
$rowNumber++;
|
||||
$aktivRaw = strtolower(trim((string)($data[3] ?? 'ja')));
|
||||
$active = !in_array($aktivRaw, ['nein', 'no', '0', 'false', 'inaktiv'], true);
|
||||
$rows[] = [
|
||||
'row_number' => $rowNumber,
|
||||
'name' => trim((string)($data[0] ?? '')),
|
||||
'email' => trim((string)($data[1] ?? '')),
|
||||
'paypal_name' => trim((string)($data[2] ?? '')),
|
||||
'active' => $active,
|
||||
];
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
+409
-10
@@ -41,6 +41,73 @@ function ledger_current_year(): int
|
||||
return (int)date('Y');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bemerkung fuer einen Journaleintrag saeubern: leere Angaben werden zu NULL,
|
||||
* die Laenge auf die Spaltenbreite (1000) begrenzt. Zeilenumbrueche werden zu
|
||||
* Leerzeichen, damit die Notiz in Tabellen und im PDF einzeilig bleibt.
|
||||
*/
|
||||
function ledger_normalize_note(?string $note): ?string
|
||||
{
|
||||
if ($note === null) {
|
||||
return null;
|
||||
}
|
||||
$note = preg_replace('/\s+/u', ' ', $note) ?? $note;
|
||||
$note = trim($note);
|
||||
if ($note === '') {
|
||||
return null;
|
||||
}
|
||||
if (strlen($note) > 1000) {
|
||||
// Byteweise kuerzen und danach eine ggf. angeschnittene Multibyte-
|
||||
// Sequenz am Ende wegnehmen (preg_match mit /u schlaegt bei
|
||||
// ungueltigem UTF-8 fehl). 1000 Bytes sind hoechstens 1000 Zeichen,
|
||||
// passen also immer in die Spalte.
|
||||
$note = substr($note, 0, 1000);
|
||||
while ($note !== '' && preg_match('//u', $note) !== 1) {
|
||||
$note = substr($note, 0, -1);
|
||||
}
|
||||
}
|
||||
|
||||
return $note;
|
||||
}
|
||||
|
||||
function ledger_default_tenant_slug(): string
|
||||
{
|
||||
$tenantSlug = app_env('M4_DEFAULT_TENANT_SLUG');
|
||||
if ($tenantSlug === null || trim($tenantSlug) === '') {
|
||||
$tenantSlug = app_env('M3_DEFAULT_TENANT_SLUG', 'default');
|
||||
}
|
||||
|
||||
return trim((string)$tenantSlug) !== '' ? trim((string)$tenantSlug) : 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: int, name: string, slug: string}|null
|
||||
*/
|
||||
function ledger_fetch_tenant_by_slug(PDO $pdo, string $slug): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT id, name, slug FROM tenants WHERE slug = ? LIMIT 1');
|
||||
$stmt->execute([trim($slug)]);
|
||||
$tenant = $stmt->fetch();
|
||||
|
||||
if ($tenant === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int)$tenant['id'],
|
||||
'name' => (string)$tenant['name'],
|
||||
'slug' => (string)$tenant['slug'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: int, name: string, slug: string}|null
|
||||
*/
|
||||
function ledger_fetch_default_tenant(PDO $pdo): ?array
|
||||
{
|
||||
return ledger_fetch_tenant_by_slug($pdo, ledger_default_tenant_slug());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}
|
||||
*/
|
||||
@@ -101,7 +168,7 @@ function ledger_fetch_tenant_totals(PDO $pdo, int $tenantId, ?int $year = null):
|
||||
* - participant_ids: list<int>
|
||||
* - email_norms: list<string>
|
||||
*
|
||||
* @return list<array{participant_id: int, tenant_id: int, legacy_mitarbeiter_id: ?int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}>
|
||||
* @return list<array{participant_id: int, tenant_id: int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}>
|
||||
*/
|
||||
function ledger_fetch_participant_summaries(PDO $pdo, int $tenantId, array $options = []): array
|
||||
{
|
||||
@@ -136,11 +203,21 @@ function ledger_fetch_participant_summaries(PDO $pdo, int $tenantId, array $opti
|
||||
array_push($params, ...$emailNorms);
|
||||
}
|
||||
|
||||
if (array_key_exists('user_ids', $options)) {
|
||||
$userIds = is_array($options['user_ids'])
|
||||
? ledger_normalize_ids($options['user_ids'])
|
||||
: [];
|
||||
if ($userIds === []) {
|
||||
return [];
|
||||
}
|
||||
$where[] = 'p.user_id IN (' . implode(',', array_fill(0, count($userIds), '?')) . ')';
|
||||
array_push($params, ...$userIds);
|
||||
}
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
p.id AS participant_id,
|
||||
p.tenant_id,
|
||||
p.legacy_mitarbeiter_id,
|
||||
p.display_name,
|
||||
p.email,
|
||||
p.email_norm,
|
||||
@@ -159,7 +236,7 @@ function ledger_fetch_participant_summaries(PDO $pdo, int $tenantId, array $opti
|
||||
AND le.participant_id = p.id
|
||||
AND le.voided_at IS NULL
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
GROUP BY p.id, p.tenant_id, p.legacy_mitarbeiter_id, p.display_name, p.email, p.email_norm, p.paypal_name, p.active
|
||||
GROUP BY p.id, p.tenant_id, p.display_name, p.email, p.email_norm, p.paypal_name, p.active
|
||||
ORDER BY p.display_name, p.id";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
@@ -170,7 +247,6 @@ function ledger_fetch_participant_summaries(PDO $pdo, int $tenantId, array $opti
|
||||
$summaries[] = [
|
||||
'participant_id' => (int)$row['participant_id'],
|
||||
'tenant_id' => (int)$row['tenant_id'],
|
||||
'legacy_mitarbeiter_id' => $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null,
|
||||
'display_name' => (string)$row['display_name'],
|
||||
'email' => $row['email'] !== null ? (string)$row['email'] : null,
|
||||
'email_norm' => $row['email_norm'] !== null ? (string)$row['email_norm'] : null,
|
||||
@@ -190,7 +266,7 @@ function ledger_fetch_participant_summaries(PDO $pdo, int $tenantId, array $opti
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{participant_id: int, tenant_id: int, legacy_mitarbeiter_id: ?int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}|null
|
||||
* @return array{participant_id: int, tenant_id: int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}|null
|
||||
*/
|
||||
function ledger_fetch_participant_summary(PDO $pdo, int $tenantId, int $participantId, ?int $year = null): ?array
|
||||
{
|
||||
@@ -202,6 +278,299 @@ function ledger_fetch_participant_summary(PDO $pdo, int $tenantId, int $particip
|
||||
return $summaries[0] ?? null;
|
||||
}
|
||||
|
||||
function ledger_is_default_tenant(PDO $pdo, int $tenantId): bool
|
||||
{
|
||||
$tenant = ledger_fetch_default_tenant($pdo);
|
||||
|
||||
return $tenant !== null && (int)$tenant['id'] === $tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{participant_id: int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, user_id: ?int, role: ?string, membership_status: ?string}>
|
||||
*/
|
||||
function ledger_fetch_participants_for_admin(PDO $pdo, int $tenantId): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT
|
||||
p.id AS participant_id,
|
||||
p.display_name,
|
||||
p.email,
|
||||
p.email_norm,
|
||||
p.paypal_name,
|
||||
p.active,
|
||||
p.user_id,
|
||||
tm.role,
|
||||
tm.status AS membership_status
|
||||
FROM participants p
|
||||
LEFT JOIN tenant_memberships tm
|
||||
ON tm.tenant_id = p.tenant_id
|
||||
AND tm.user_id = p.user_id
|
||||
WHERE p.tenant_id = ?
|
||||
ORDER BY p.display_name, p.id"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
|
||||
$rows = [];
|
||||
while ($row = $stmt->fetch()) {
|
||||
$rows[] = [
|
||||
'participant_id' => (int)$row['participant_id'],
|
||||
'display_name' => (string)$row['display_name'],
|
||||
'email' => $row['email'] !== null ? (string)$row['email'] : null,
|
||||
'email_norm' => $row['email_norm'] !== null ? (string)$row['email_norm'] : null,
|
||||
'paypal_name' => $row['paypal_name'] !== null ? (string)$row['paypal_name'] : null,
|
||||
'active' => (int)$row['active'] === 1,
|
||||
'user_id' => $row['user_id'] !== null ? (int)$row['user_id'] : null,
|
||||
'role' => $row['role'] !== null ? (string)$row['role'] : null,
|
||||
'membership_status' => $row['membership_status'] !== null ? (string)$row['membership_status'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a tenant-scoped participant.
|
||||
*
|
||||
* @throws Throwable on constraint violations (e.g. duplicate email)
|
||||
*/
|
||||
function ledger_create_participant(PDO $pdo, int $tenantId, string $displayName, string $email, ?string $paypalName, bool $active): int
|
||||
{
|
||||
$displayName = trim($displayName);
|
||||
$email = trim($email);
|
||||
$emailNorm = strtolower($email);
|
||||
$paypalName = $paypalName !== null ? trim($paypalName) : null;
|
||||
$paypalName = $paypalName !== '' ? $paypalName : null;
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO participants
|
||||
(tenant_id, display_name, email, email_norm, paypal_name, active)
|
||||
VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([$tenantId, $displayName, $email, $emailNorm, $paypalName, $active ? 1 : 0]);
|
||||
|
||||
return (int)$pdo->lastInsertId();
|
||||
}
|
||||
|
||||
function ledger_update_participant(PDO $pdo, int $tenantId, int $participantId, string $displayName, string $email, ?string $paypalName, bool $active): bool
|
||||
{
|
||||
$displayName = trim($displayName);
|
||||
$email = trim($email);
|
||||
$emailNorm = strtolower($email);
|
||||
$paypalName = $paypalName !== null ? trim($paypalName) : null;
|
||||
$paypalName = $paypalName !== '' ? $paypalName : null;
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE participants
|
||||
SET display_name = ?, email = ?, email_norm = ?, paypal_name = ?, active = ?
|
||||
WHERE id = ? AND tenant_id = ?'
|
||||
);
|
||||
$stmt->execute([$displayName, $email, $emailNorm, $paypalName, $active ? 1 : 0, $participantId, $tenantId]);
|
||||
|
||||
return $stmt->rowCount() >= 0;
|
||||
}
|
||||
|
||||
function ledger_set_participant_active(PDO $pdo, int $tenantId, int $participantId, bool $active): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('UPDATE participants SET active = ? WHERE id = ? AND tenant_id = ?');
|
||||
$stmt->execute([$active ? 1 : 0, $participantId, $tenantId]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymizes a participant (GDPR-style erasure) instead of deleting them:
|
||||
* name and email are replaced with a non-identifying placeholder and the
|
||||
* participant is deactivated, but their ledger history stays intact for
|
||||
* the tenant's bookkeeping.
|
||||
*/
|
||||
function ledger_anonymize_participant(PDO $pdo, int $tenantId, int $participantId): bool
|
||||
{
|
||||
$placeholderName = 'Gelöschter Teilnehmer #' . $participantId;
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE participants
|
||||
SET display_name = ?, email = NULL, email_norm = NULL, paypal_name = NULL, active = 0, user_id = NULL
|
||||
WHERE id = ? AND tenant_id = ?'
|
||||
);
|
||||
$stmt->execute([$placeholderName, $participantId, $tenantId]);
|
||||
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storniert einen Journaleintrag anhand seiner Ledger-ID - immer auf den
|
||||
* Mandanten eingegrenzt, damit ueber eine untergeschobene ID nicht in fremden
|
||||
* Mandanten storniert werden kann. Markiert den Eintrag als storniert statt
|
||||
* ihn zu loeschen, damit Korrekturen nachvollziehbar bleiben. Idempotent: ein
|
||||
* bereits stornierter oder fehlender Eintrag ergibt false.
|
||||
*
|
||||
* @param string|null $expectedType Absichern, dass z. B. ueber das
|
||||
* Einzahlungs-Formular kein Strich storniert
|
||||
* werden kann.
|
||||
*/
|
||||
function ledger_void_entry(PDO $pdo, int $tenantId, int $entryId, ?string $expectedType = null): bool
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT id, type
|
||||
FROM ledger_entries
|
||||
WHERE id = ? AND tenant_id = ? AND voided_at IS NULL
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$entryId, $tenantId]);
|
||||
$entry = $stmt->fetch();
|
||||
|
||||
if ($entry === false) {
|
||||
return false;
|
||||
}
|
||||
if ($expectedType !== null && (string)$entry['type'] !== $expectedType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('UPDATE ledger_entries SET voided_at = NOW() WHERE id = ? AND tenant_id = ? AND voided_at IS NULL');
|
||||
$stmt->execute([$entryId, $tenantId]);
|
||||
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storniert den juengsten, noch nicht stornierten Web-Selbsteintrag eines
|
||||
* Teilnehmers (Quelle 'self_entry'). Bewusst nur eigene Web-Eintraege - vom
|
||||
* Kassenwart per Sammelerfassung gesetzte Striche ('manual_bulk') bleiben
|
||||
* unangetastet.
|
||||
*
|
||||
* @return array{ok: bool, marks?: int, error?: string}
|
||||
*/
|
||||
function ledger_void_own_self_entry(PDO $pdo, int $tenantId, int $participantId): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT id, marks_count
|
||||
FROM ledger_entries
|
||||
WHERE tenant_id = ?
|
||||
AND participant_id = ?
|
||||
AND type = 'consumption'
|
||||
AND voided_at IS NULL
|
||||
AND source = 'self_entry'
|
||||
ORDER BY booked_at DESC, id DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$tenantId, $participantId]);
|
||||
$entry = $stmt->fetch();
|
||||
|
||||
if ($entry === false) {
|
||||
return ['ok' => false, 'error' => 'Es gibt keinen selbst eingetragenen Strich, der storniert werden könnte.'];
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$pdo->prepare("UPDATE ledger_entries SET voided_at = NOW() WHERE id = ? AND voided_at IS NULL")
|
||||
->execute([(int)$entry['id']]);
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
|
||||
return ['ok' => false, 'error' => 'Der Strich konnte nicht storniert werden.'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'marks' => (int)($entry['marks_count'] ?? 0)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a consumption entry straight into the ledger.
|
||||
*/
|
||||
function ledger_record_consumption(PDO $pdo, int $tenantId, int $participantId, int $marks, int $unitPriceCents, string $source, ?int $createdByUserId = null): int
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO ledger_entries
|
||||
(tenant_id, participant_id, type, amount_cents, marks_count, unit_price_cents, booked_at, source, created_by_user_id)
|
||||
SELECT ?, id, 'consumption', ?, ?, ?, NOW(), ?, ?
|
||||
FROM participants
|
||||
WHERE id = ? AND tenant_id = ?"
|
||||
);
|
||||
$stmt->execute([$tenantId, -($marks * $unitPriceCents), $marks, $unitPriceCents, $source, $createdByUserId, $participantId, $tenantId]);
|
||||
|
||||
if ($stmt->rowCount() !== 1) {
|
||||
throw new RuntimeException('Der Strich-Eintrag konnte nicht gespeichert werden.');
|
||||
}
|
||||
|
||||
return (int)$pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucht eine Einzahlung. $amountCents darf negativ sein (Abzug, Auszahlung,
|
||||
* Korrektur) - fuer Tippfehler ist aber ledger_void_entry der richtige Weg,
|
||||
* damit die Auswertung nicht durch Gegenbuchungen aufgeblaeht wird.
|
||||
*/
|
||||
function ledger_record_payment(PDO $pdo, int $tenantId, int $participantId, int $amountCents, string $source, ?int $createdByUserId = null, ?string $note = null): int
|
||||
{
|
||||
$note = ledger_normalize_note($note);
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO ledger_entries
|
||||
(tenant_id, participant_id, type, amount_cents, booked_at, source, created_by_user_id, note)
|
||||
SELECT ?, id, 'payment', ?, NOW(), ?, ?, ?
|
||||
FROM participants
|
||||
WHERE id = ? AND tenant_id = ?"
|
||||
);
|
||||
$stmt->execute([$tenantId, $amountCents, $source, $createdByUserId, $note, $participantId, $tenantId]);
|
||||
|
||||
if ($stmt->rowCount() !== 1) {
|
||||
throw new RuntimeException('Die Einzahlung konnte nicht gespeichert werden.');
|
||||
}
|
||||
|
||||
return (int)$pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bis zu dieser Mitgliederzahl passt die Strichliste auf ein einzelnes Blatt.
|
||||
* Erst darueber wird sie auf Vorder- und Rueckseite aufgeteilt - und nur dann
|
||||
* ergeben auch die Vorder-/Rueckseiten-Ansichten beim Erfassen einen Sinn.
|
||||
*/
|
||||
const LEDGER_SINGLE_PAGE_MAX_MEMBERS = 50;
|
||||
|
||||
/**
|
||||
* Braucht dieser Mandant eine zweiseitige Liste (Vorder- und Rueckseite)?
|
||||
*/
|
||||
function ledger_uses_two_sided_sheet(int $activeMemberCount): bool
|
||||
{
|
||||
return $activeMemberCount > LEDGER_SINGLE_PAGE_MAX_MEMBERS;
|
||||
}
|
||||
|
||||
/**
|
||||
* "100-Tage-Liste" Vorder-/Rueckseiten-Aufteilung: aktive Teilnehmer, deren
|
||||
* summierte Striche im nachlaufenden Fenster die Zehner-Schwelle erreichen
|
||||
* ($atLeastTen) oder darunter liegen.
|
||||
*
|
||||
* @return list<array{participant_id: int, tenant_id: int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}>
|
||||
*/
|
||||
function ledger_fetch_participants_by_window_marks(PDO $pdo, int $tenantId, int $windowDays, bool $atLeastTen): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT p.id
|
||||
FROM participants p
|
||||
LEFT JOIN ledger_entries le
|
||||
ON le.tenant_id = p.tenant_id
|
||||
AND le.participant_id = p.id
|
||||
AND le.type = 'consumption'
|
||||
AND le.voided_at IS NULL
|
||||
AND le.booked_at >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||||
WHERE p.tenant_id = ?
|
||||
AND p.active = 1
|
||||
GROUP BY p.id
|
||||
HAVING COALESCE(SUM(le.marks_count), 0) " . ($atLeastTen ? '>= 10' : '< 10')
|
||||
);
|
||||
$stmt->execute([$windowDays, $tenantId]);
|
||||
$ids = array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN));
|
||||
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ledger_fetch_participant_summaries($pdo, $tenantId, ['participant_ids' => $ids]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Options:
|
||||
* - participant_id: int
|
||||
@@ -209,8 +578,42 @@ function ledger_fetch_participant_summary(PDO $pdo, int $tenantId, int $particip
|
||||
* - include_voided: bool
|
||||
* - limit: int
|
||||
*
|
||||
* @return list<array{id: int, tenant_id: int, participant_id: int, display_name: string, type: string, amount_cents: int, marks_count: ?int, unit_price_cents: ?int, booked_at: string, source: string, note: ?string, legacy_table: ?string, legacy_id: ?int, voided_at: ?string}>
|
||||
* @return list<array{id: int, tenant_id: int, participant_id: int, display_name: string, type: string, amount_cents: int, marks_count: ?int, unit_price_cents: ?int, booked_at: string, source: string, note: ?string, voided_at: ?string}>
|
||||
*/
|
||||
/**
|
||||
* Verbrauch eines Teilnehmers pro Monat eines Jahres (nur nicht stornierte
|
||||
* Consumption-Buchungen). Fuer die Monatsuebersicht im Mitglieder-Dashboard.
|
||||
*
|
||||
* @return array<int, array{marks: int, cost_cents: int}> Schluessel 1..12
|
||||
*/
|
||||
function ledger_fetch_monthly_consumption(PDO $pdo, int $tenantId, int $participantId, int $year): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT MONTH(booked_at) AS m,
|
||||
COALESCE(SUM(marks_count), 0) AS marks,
|
||||
COALESCE(SUM(-amount_cents), 0) AS cost_cents
|
||||
FROM ledger_entries
|
||||
WHERE tenant_id = ?
|
||||
AND participant_id = ?
|
||||
AND type = 'consumption'
|
||||
AND voided_at IS NULL
|
||||
AND YEAR(booked_at) = ?
|
||||
GROUP BY MONTH(booked_at)"
|
||||
);
|
||||
$stmt->execute([$tenantId, $participantId, $year]);
|
||||
|
||||
$months = [];
|
||||
for ($m = 1; $m <= 12; $m++) {
|
||||
$months[$m] = ['marks' => 0, 'cost_cents' => 0];
|
||||
}
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$m = (int)$row['m'];
|
||||
$months[$m] = ['marks' => (int)$row['marks'], 'cost_cents' => (int)$row['cost_cents']];
|
||||
}
|
||||
|
||||
return $months;
|
||||
}
|
||||
|
||||
function ledger_fetch_recent_entries(PDO $pdo, int $tenantId, array $options = []): array
|
||||
{
|
||||
$limit = isset($options['limit']) ? (int)$options['limit'] : 100;
|
||||
@@ -245,8 +648,6 @@ function ledger_fetch_recent_entries(PDO $pdo, int $tenantId, array $options = [
|
||||
le.booked_at,
|
||||
le.source,
|
||||
le.note,
|
||||
le.legacy_table,
|
||||
le.legacy_id,
|
||||
le.voided_at
|
||||
FROM ledger_entries le
|
||||
JOIN participants p
|
||||
@@ -273,8 +674,6 @@ function ledger_fetch_recent_entries(PDO $pdo, int $tenantId, array $options = [
|
||||
'booked_at' => (string)$row['booked_at'],
|
||||
'source' => (string)$row['source'],
|
||||
'note' => $row['note'] !== null ? (string)$row['note'] : null,
|
||||
'legacy_table' => $row['legacy_table'] !== null ? (string)$row['legacy_table'] : null,
|
||||
'legacy_id' => $row['legacy_id'] !== null ? (int)$row['legacy_id'] : null,
|
||||
'voided_at' => $row['voided_at'] !== null ? (string)$row['voided_at'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/legal.php';
|
||||
require_once __DIR__ . '/saas-mail.php';
|
||||
require_once __DIR__ . '/rate-limit.php';
|
||||
require_once __DIR__ . '/billing.php';
|
||||
|
||||
/** @return list<string> */
|
||||
function app_validate_legal_request(array $input, string $requestType): array
|
||||
{
|
||||
$errors = [];
|
||||
$name = trim((string)($input['requester_name'] ?? ''));
|
||||
$email = trim((string)($input['requester_email'] ?? ''));
|
||||
$reference = trim((string)($input['contract_reference'] ?? ''));
|
||||
if (strlen($name) < 2 || strlen($name) > 255) {
|
||||
$errors[] = 'Bitte gib deinen vollständigen Namen an.';
|
||||
}
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[] = 'Bitte gib eine gültige E-Mail-Adresse für die Bestätigung an.';
|
||||
}
|
||||
if (!preg_match('/^[a-z0-9][a-z0-9-]{1,98}[a-z0-9]$/', strtolower($reference))) {
|
||||
$errors[] = 'Bitte gib das Kundenkürzel aus deinem Konto an.';
|
||||
}
|
||||
|
||||
if ($requestType === 'cancellation') {
|
||||
$kind = (string)($input['cancellation_kind'] ?? '');
|
||||
if (!in_array($kind, ['ordinary', 'extraordinary'], true)) {
|
||||
$errors[] = 'Bitte wähle die Art der Kündigung.';
|
||||
}
|
||||
if ($kind === 'extraordinary' && trim((string)($input['request_reason'] ?? '')) === '') {
|
||||
$errors[] = 'Bitte nenne bei einer außerordentlichen Kündigung den Kündigungsgrund.';
|
||||
}
|
||||
$requestedEnd = (string)($input['requested_end'] ?? '');
|
||||
if ($requestedEnd !== 'earliest') {
|
||||
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $requestedEnd);
|
||||
if ($date === false || $date->format('Y-m-d') !== $requestedEnd) {
|
||||
$errors[] = 'Bitte wähle „frühestmöglich“ oder ein gültiges Beendigungsdatum.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (strlen((string)($input['request_reason'] ?? '')) > 5000) {
|
||||
$errors[] = 'Der Grund ist zu lang (höchstens 5.000 Zeichen).';
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
function app_update_legal_request(PDO $pdo, string $referenceCode, string $status, array $metadata = []): void
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE legal_requests SET status = ?, metadata_json = ?, processed_at = NOW() WHERE reference_code = ?'
|
||||
);
|
||||
$stmt->execute([
|
||||
$status,
|
||||
$metadata !== [] ? json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
|
||||
$referenceCode,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordentliche Kündigungen eines eindeutig zugeordneten Stripe-Abos werden
|
||||
* sofort zum Periodenende vorgemerkt. Alle anderen Erklärungen bleiben als
|
||||
* fristwahrender Eingang in der DB und werden manuell bearbeitet.
|
||||
*
|
||||
* @return array{status: string, effective_end: string, processing_note: string}
|
||||
*/
|
||||
function app_process_cancellation_request(PDO $pdo, array $stored, array $input): array
|
||||
{
|
||||
$effectiveEnd = (string)$input['requested_end'] === 'earliest'
|
||||
? 'zum frühestmöglichen Zeitpunkt'
|
||||
: (string)$input['requested_end'];
|
||||
$manual = [
|
||||
'status' => 'received_manual',
|
||||
'effective_end' => $effectiveEnd,
|
||||
'processing_note' => 'Die Erklärung ist eingegangen und wird anhand der Vertragsdaten bearbeitet.',
|
||||
];
|
||||
|
||||
if ($stored['tenant_id'] === null
|
||||
|| (string)$input['cancellation_kind'] !== 'ordinary'
|
||||
|| (string)$input['requested_end'] !== 'earliest'
|
||||
) {
|
||||
app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual', [
|
||||
'cancellation_kind' => (string)$input['cancellation_kind'],
|
||||
]);
|
||||
return $manual;
|
||||
}
|
||||
|
||||
$billing = billing_fetch_or_init($pdo, (int)$stored['tenant_id']);
|
||||
if (empty($billing['stripe_subscription_id'])) {
|
||||
if ((string)$billing['plan_code'] === 'free') {
|
||||
$pdo->prepare('UPDATE tenants SET termination_requested_at = NOW(), contract_ends_at = NOW() WHERE id = ?')
|
||||
->execute([(int)$stored['tenant_id']]);
|
||||
$effectiveEnd = date('d.m.Y, H:i') . ' Uhr';
|
||||
app_update_legal_request($pdo, (string)$stored['reference_code'], 'contract_ended', [
|
||||
'cancellation_kind' => (string)$input['cancellation_kind'],
|
||||
'effective_end' => $effectiveEnd,
|
||||
]);
|
||||
return [
|
||||
'status' => 'contract_ended',
|
||||
'effective_end' => $effectiveEnd,
|
||||
'processing_note' => 'Der kostenlose Vertrag ist beendet. Die Anmeldung ist nicht mehr möglich; operative Mandantendaten werden nach 30 Tagen gelöscht.',
|
||||
];
|
||||
}
|
||||
app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual', [
|
||||
'cancellation_kind' => (string)$input['cancellation_kind'],
|
||||
]);
|
||||
return $manual;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = stripe_schedule_subscription_cancellation((string)$billing['stripe_subscription_id']);
|
||||
} catch (Throwable $e) {
|
||||
$result = ['ok' => false, 'current_period_end' => null, 'error' => $e->getMessage()];
|
||||
}
|
||||
if (!$result['ok']) {
|
||||
app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual', [
|
||||
'cancellation_kind' => (string)$input['cancellation_kind'],
|
||||
'stripe_error' => (string)$result['error'],
|
||||
]);
|
||||
return $manual;
|
||||
}
|
||||
|
||||
billing_update($pdo, (int)$stored['tenant_id'], [
|
||||
'subscription_status' => 'canceling',
|
||||
'current_period_end' => $result['current_period_end'],
|
||||
]);
|
||||
if ($result['current_period_end'] !== null) {
|
||||
$pdo->prepare('UPDATE tenants SET termination_requested_at = NOW(), contract_ends_at = ? WHERE id = ?')
|
||||
->execute([$result['current_period_end'], (int)$stored['tenant_id']]);
|
||||
}
|
||||
$effectiveEnd = $result['current_period_end'] !== null
|
||||
? date('d.m.Y, H:i', strtotime((string)$result['current_period_end'])) . ' Uhr'
|
||||
: 'zum Ende der laufenden Abrechnungsperiode';
|
||||
app_update_legal_request($pdo, (string)$stored['reference_code'], 'cancellation_scheduled', [
|
||||
'cancellation_kind' => (string)$input['cancellation_kind'],
|
||||
'effective_end' => $effectiveEnd,
|
||||
]);
|
||||
|
||||
return [
|
||||
'status' => 'cancellation_scheduled',
|
||||
'effective_end' => $effectiveEnd,
|
||||
'processing_note' => 'Das kostenpflichtige Abonnement wurde bei Stripe zum Ende der laufenden Abrechnungsperiode vorgemerkt.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, effective_end: string, processing_note: string}
|
||||
*/
|
||||
function app_process_withdrawal_request(PDO $pdo, array $stored): array
|
||||
{
|
||||
$manual = [
|
||||
'status' => 'received_manual',
|
||||
'effective_end' => 'mit Eingang des Widerrufs, sofern das Widerrufsrecht besteht',
|
||||
'processing_note' => 'Der Widerruf ist eingegangen und wird unverzüglich anhand der Vertragsdaten geprüft.',
|
||||
];
|
||||
if ($stored['tenant_id'] === null) {
|
||||
app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual');
|
||||
return $manual;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT customer_type, created_at FROM tenants WHERE id = ?');
|
||||
$stmt->execute([(int)$stored['tenant_id']]);
|
||||
$tenant = $stmt->fetch();
|
||||
if ($tenant === false) {
|
||||
app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual');
|
||||
return $manual;
|
||||
}
|
||||
|
||||
$billing = billing_fetch_or_init($pdo, (int)$stored['tenant_id']);
|
||||
if ((string)$billing['plan_code'] === 'free') {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT accepted_at, metadata_json FROM legal_acceptances
|
||||
WHERE tenant_id = ? AND document_type = 'terms' AND context = 'registration'
|
||||
ORDER BY accepted_at ASC LIMIT 1"
|
||||
);
|
||||
$stmt->execute([(int)$stored['tenant_id']]);
|
||||
$contractEvidence = $stmt->fetch();
|
||||
$contractStart = $contractEvidence !== false
|
||||
? strtotime((string)$contractEvidence['accepted_at'])
|
||||
: strtotime((string)$tenant['created_at']);
|
||||
} else {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT accepted_at, metadata_json FROM legal_acceptances
|
||||
WHERE tenant_id = ? AND document_type = 'withdrawal_information' AND context = 'paid_order'
|
||||
ORDER BY accepted_at DESC LIMIT 1"
|
||||
);
|
||||
$stmt->execute([(int)$stored['tenant_id']]);
|
||||
$contractEvidence = $stmt->fetch();
|
||||
$contractStart = $contractEvidence !== false ? strtotime((string)$contractEvidence['accepted_at']) : false;
|
||||
}
|
||||
$contractMetadata = $contractEvidence !== false
|
||||
? json_decode((string)($contractEvidence['metadata_json'] ?? ''), true)
|
||||
: null;
|
||||
$contractCustomerType = is_array($contractMetadata)
|
||||
? (string)($contractMetadata['customer_type'] ?? $tenant['customer_type'])
|
||||
: (string)$tenant['customer_type'];
|
||||
if ($contractCustomerType !== 'consumer') {
|
||||
app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual', [
|
||||
'review_reason' => 'contract_not_recorded_as_consumer',
|
||||
]);
|
||||
return $manual;
|
||||
}
|
||||
if ($contractStart === false || $contractStart < strtotime('-14 days')) {
|
||||
app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual', [
|
||||
'review_reason' => 'automatic_14_day_window_not_proven',
|
||||
]);
|
||||
return $manual;
|
||||
}
|
||||
|
||||
$stripeError = null;
|
||||
if (!empty($billing['stripe_subscription_id'])) {
|
||||
try {
|
||||
$cancel = stripe_cancel_subscription((string)$billing['stripe_subscription_id']);
|
||||
if (!$cancel['ok']) {
|
||||
$stripeError = (string)$cancel['error'];
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$stripeError = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$pdo->prepare('UPDATE tenants SET termination_requested_at = NOW(), contract_ends_at = NOW() WHERE id = ?')
|
||||
->execute([(int)$stored['tenant_id']]);
|
||||
if ($stripeError === null && !empty($billing['stripe_subscription_id'])) {
|
||||
billing_update($pdo, (int)$stored['tenant_id'], [
|
||||
'plan_code' => 'free',
|
||||
'subscription_status' => 'canceled',
|
||||
]);
|
||||
}
|
||||
|
||||
$status = $stripeError === null ? 'withdrawal_effective' : 'withdrawal_action_required';
|
||||
$effectiveEnd = date('d.m.Y, H:i') . ' Uhr';
|
||||
app_update_legal_request($pdo, (string)$stored['reference_code'], $status, [
|
||||
'effective_end' => $effectiveEnd,
|
||||
'stripe_error' => $stripeError,
|
||||
'refund_review_required' => (string)$billing['plan_code'] !== 'free',
|
||||
]);
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'effective_end' => $effectiveEnd,
|
||||
'processing_note' => $stripeError === null
|
||||
? 'Der Vertrag wurde beendet. Eine gegebenenfalls geschuldete Rückzahlung wird unverzüglich über das ursprüngliche Zahlungsmittel bearbeitet.'
|
||||
: 'Der Vertrag wurde lokal beendet. Die Beendigung beim Zahlungsdienst und eine gegebenenfalls geschuldete Rückzahlung müssen noch manuell abgeschlossen werden.',
|
||||
];
|
||||
}
|
||||
|
||||
function app_legal_request_confirmation_text(array $input, array $stored, array $processing): string
|
||||
{
|
||||
$isCancellation = (string)$input['request_type'] === 'cancellation';
|
||||
$type = $isCancellation ? 'Kündigung' : 'Widerruf';
|
||||
$lines = [
|
||||
"Eingangsbestätigung {$type} – Kaffeeliste",
|
||||
'',
|
||||
'Referenz: ' . $stored['reference_code'],
|
||||
'Eingang: ' . $stored['received_at'] . ' (Europe/Berlin)',
|
||||
'Name: ' . $input['requester_name'],
|
||||
'Bestätigungs-E-Mail: ' . $input['requester_email'],
|
||||
'Kundenkürzel / Vertragsreferenz: ' . $input['contract_reference'],
|
||||
];
|
||||
if ($isCancellation) {
|
||||
$lines[] = 'Art: ' . ((string)$input['cancellation_kind'] === 'extraordinary' ? 'außerordentliche Kündigung' : 'ordentliche Kündigung');
|
||||
$lines[] = 'Beendigung: ' . $processing['effective_end'];
|
||||
}
|
||||
if (trim((string)($input['request_reason'] ?? '')) !== '') {
|
||||
$lines[] = 'Erklärung / Grund: ' . trim((string)$input['request_reason']);
|
||||
}
|
||||
$lines[] = '';
|
||||
$lines[] = $processing['processing_note'];
|
||||
$lines[] = '';
|
||||
$lines[] = 'Clemens Creutzburg, CTB-IT';
|
||||
$lines[] = 'In den Sieben Stücken 9d, 30655 Hannover';
|
||||
$lines[] = app_legal_email();
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
function app_send_legal_request_confirmations(array $input, array $stored, array $processing): array
|
||||
{
|
||||
$text = app_legal_request_confirmation_text($input, $stored, $processing);
|
||||
$downloadUrl = saas_app_url('rechtserklaerung-bestaetigung.php?token=' . urlencode((string)$stored['access_token']) . '&download=1');
|
||||
$type = (string)$input['request_type'] === 'cancellation' ? 'Kündigung' : 'Widerruf';
|
||||
$customerResult = saas_send_mail(
|
||||
(string)$input['requester_email'],
|
||||
"Kaffeeliste: Eingang {$type} " . $stored['reference_code'],
|
||||
$text . "\n\nBestätigung als Textdatei speichern:\n" . $downloadUrl
|
||||
);
|
||||
$operatorResult = saas_send_mail(
|
||||
app_legal_email(),
|
||||
"Kaffeeliste: {$type} eingegangen " . $stored['reference_code'],
|
||||
$text . "\n\nInterner Status: " . $processing['status']
|
||||
);
|
||||
|
||||
return [
|
||||
'customer_ok' => (bool)($customerResult['ok'] ?? false),
|
||||
'operator_ok' => (bool)($operatorResult['ok'] ?? false),
|
||||
];
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
const APP_TERMS_VERSION = '2026-08-22';
|
||||
const APP_PRIVACY_VERSION = '2026-08-27-2';
|
||||
const APP_DPA_VERSION = '2026-08-22';
|
||||
const APP_WITHDRAWAL_VERSION = '2026-08-22';
|
||||
|
||||
function app_legal_email(): string
|
||||
{
|
||||
return (string)app_env('LEGAL_EMAIL', 'info@ctb-it.de');
|
||||
}
|
||||
|
||||
function app_legal_phone(): string
|
||||
{
|
||||
return trim((string)app_env('LEGAL_PHONE', ''));
|
||||
}
|
||||
|
||||
function app_legal_ticket_url(): string
|
||||
{
|
||||
return rtrim((string)app_env('LEGAL_TICKET_URL', 'https://ticketsystem.ctb-it.de'), '/');
|
||||
}
|
||||
|
||||
function app_public_legal_footer(): string
|
||||
{
|
||||
return '<footer class="public-footer-links">'
|
||||
. '<a href="impressum.php">Impressum</a>'
|
||||
. '<a href="agb.php">AGB</a>'
|
||||
. '<a href="datenschutz.php">Datenschutz</a>'
|
||||
. '<a href="avv.php">AVV</a>'
|
||||
. '<a href="widerruf.php">Widerrufsbelehrung</a>'
|
||||
. '<a class="legal-action-link" href="widerrufen.php">Vertrag widerrufen</a>'
|
||||
. '<a href="preise.php">Preise</a>'
|
||||
. '<a class="legal-action-link" href="kuendigen.php">Verträge hier kündigen</a>'
|
||||
. app_analytics_footer_settings_button_html()
|
||||
. '</footer>';
|
||||
}
|
||||
|
||||
function app_legal_document_version(string $documentType): string
|
||||
{
|
||||
return match ($documentType) {
|
||||
'terms' => APP_TERMS_VERSION,
|
||||
'privacy_notice' => APP_PRIVACY_VERSION,
|
||||
'dpa' => APP_DPA_VERSION,
|
||||
'withdrawal_information', 'early_performance' => APP_WITHDRAWAL_VERSION,
|
||||
default => throw new InvalidArgumentException('Unbekannter Rechtstext.'),
|
||||
};
|
||||
}
|
||||
|
||||
function app_legal_document_text(string $documentType): string
|
||||
{
|
||||
$filename = match ($documentType) {
|
||||
'terms' => 'agb-' . APP_TERMS_VERSION . '.txt',
|
||||
'privacy_notice' => 'datenschutz-' . APP_PRIVACY_VERSION . '.txt',
|
||||
'dpa' => 'avv-' . APP_DPA_VERSION . '.txt',
|
||||
'withdrawal_information', 'early_performance' => 'widerruf-' . APP_WITHDRAWAL_VERSION . '.txt',
|
||||
default => throw new InvalidArgumentException('Unbekannter Rechtstext.'),
|
||||
};
|
||||
$path = APP_ROOT . '/legal/' . $filename;
|
||||
$content = is_file($path) ? file_get_contents($path) : false;
|
||||
if ($content === false) {
|
||||
throw new RuntimeException('Rechtstext fehlt: ' . $filename);
|
||||
}
|
||||
|
||||
return trim($content);
|
||||
}
|
||||
|
||||
function app_render_legal_document(string $documentType): string
|
||||
{
|
||||
$blocks = preg_split('/\R\s*\R/', app_legal_document_text($documentType)) ?: [];
|
||||
$html = '';
|
||||
foreach ($blocks as $block) {
|
||||
$block = trim($block);
|
||||
if ($block === '') {
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($block, '## ')) {
|
||||
$html .= '<h2>' . htmlspecialchars(substr($block, 3), ENT_QUOTES, 'UTF-8') . '</h2>';
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($block, '### ')) {
|
||||
$html .= '<h3>' . htmlspecialchars(substr($block, 4), ENT_QUOTES, 'UTF-8') . '</h3>';
|
||||
continue;
|
||||
}
|
||||
$lines = preg_split('/\R/', $block) ?: [];
|
||||
if ($lines !== [] && count(array_filter($lines, static fn(string $line): bool => str_starts_with(trim($line), '- '))) === count($lines)) {
|
||||
$html .= '<ul>';
|
||||
foreach ($lines as $line) {
|
||||
$html .= '<li>' . htmlspecialchars(substr(trim($line), 2), ENT_QUOTES, 'UTF-8') . '</li>';
|
||||
}
|
||||
$html .= '</ul>';
|
||||
continue;
|
||||
}
|
||||
$escaped = htmlspecialchars($block, ENT_QUOTES, 'UTF-8');
|
||||
$escaped = preg_replace_callback(
|
||||
'~\[(.+?)\]\((https?://[^)]+|[a-z0-9-]+\.php)\)~i',
|
||||
static fn(array $match): string => '<a href="' . htmlspecialchars($match[2], ENT_QUOTES, 'UTF-8') . '">' . htmlspecialchars($match[1], ENT_QUOTES, 'UTF-8') . '</a>',
|
||||
$escaped
|
||||
) ?? $escaped;
|
||||
$html .= '<p>' . nl2br($escaped) . '</p>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
function app_record_legal_acceptance(
|
||||
PDO $pdo,
|
||||
?int $tenantId,
|
||||
?int $userId,
|
||||
string $documentType,
|
||||
string $context,
|
||||
array $metadata = []
|
||||
): void {
|
||||
$evidence = [
|
||||
'document_sha256' => hash('sha256', app_legal_document_text($documentType)),
|
||||
];
|
||||
if ($tenantId !== null) {
|
||||
$snapshotStmt = $pdo->prepare('SELECT slug, name FROM tenants WHERE id = ?');
|
||||
$snapshotStmt->execute([$tenantId]);
|
||||
$tenantSnapshot = $snapshotStmt->fetch();
|
||||
if ($tenantSnapshot !== false) {
|
||||
$evidence['tenant_slug'] = (string)$tenantSnapshot['slug'];
|
||||
$evidence['tenant_name'] = (string)$tenantSnapshot['name'];
|
||||
}
|
||||
}
|
||||
if ($userId !== null) {
|
||||
$snapshotStmt = $pdo->prepare('SELECT email_norm FROM users WHERE id = ?');
|
||||
$snapshotStmt->execute([$userId]);
|
||||
$userEmail = $snapshotStmt->fetchColumn();
|
||||
if ($userEmail !== false) {
|
||||
$evidence['user_email'] = (string)$userEmail;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO legal_acceptances
|
||||
(tenant_id, user_id, document_type, document_version, context, ip, user_agent, metadata_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$tenantId,
|
||||
$userId,
|
||||
$documentType,
|
||||
app_legal_document_version($documentType),
|
||||
$context,
|
||||
$_SERVER['REMOTE_ADDR'] ?? null,
|
||||
substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500),
|
||||
json_encode(
|
||||
$evidence + $metadata,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet einen Vertrag nur dann automatisch, wenn Kundenkürzel und die
|
||||
* E-Mail eines aktiven Inhabers zusammenpassen. Die Erklärung wird auch bei
|
||||
* fehlender Zuordnung gespeichert: Eine fehlerhafte Referenz darf nicht zum
|
||||
* Verlust einer fristgebundenen Kündigung oder eines Widerrufs führen.
|
||||
*
|
||||
* @return array{tenant_id: ?int, user_id: ?int, customer_type: ?string}
|
||||
*/
|
||||
function app_match_legal_contract(PDO $pdo, string $contractReference, string $email): array
|
||||
{
|
||||
$slug = strtolower(trim($contractReference));
|
||||
$emailNorm = strtolower(trim($email));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT t.id AS tenant_id, t.customer_type, u.id AS user_id
|
||||
FROM tenants t
|
||||
JOIN tenant_memberships tm ON tm.tenant_id = t.id AND tm.role = 'owner' AND tm.status = 'active'
|
||||
JOIN users u ON u.id = tm.user_id AND u.status = 'active'
|
||||
WHERE t.slug = ? AND u.email_norm = ?
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$slug, $emailNorm]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if ($row === false) {
|
||||
return ['tenant_id' => null, 'user_id' => null, 'customer_type' => null];
|
||||
}
|
||||
|
||||
return [
|
||||
'tenant_id' => (int)$row['tenant_id'],
|
||||
'user_id' => (int)$row['user_id'],
|
||||
'customer_type' => (string)$row['customer_type'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{reference_code: string, access_token: string, received_at: string, tenant_id: ?int, user_id: ?int}
|
||||
*/
|
||||
function app_store_legal_request(PDO $pdo, array $input): array
|
||||
{
|
||||
$match = app_match_legal_contract(
|
||||
$pdo,
|
||||
(string)$input['contract_reference'],
|
||||
(string)$input['requester_email']
|
||||
);
|
||||
$accessToken = bin2hex(random_bytes(32));
|
||||
$referenceCode = 'KL-' . strtoupper(bin2hex(random_bytes(6)));
|
||||
$receivedAt = date('Y-m-d H:i:s');
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO legal_requests
|
||||
(reference_code, access_token_hash, request_type, tenant_id, user_id,
|
||||
requester_name, requester_email, contract_reference, request_reason,
|
||||
requested_end, status, ip, user_agent, metadata_json, received_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$referenceCode,
|
||||
hash('sha256', $accessToken),
|
||||
(string)$input['request_type'],
|
||||
$match['tenant_id'],
|
||||
$match['user_id'],
|
||||
(string)$input['requester_name'],
|
||||
(string)$input['requester_email'],
|
||||
(string)$input['contract_reference'],
|
||||
($input['request_reason'] ?? '') !== '' ? (string)$input['request_reason'] : null,
|
||||
($input['requested_end'] ?? '') !== '' ? (string)$input['requested_end'] : null,
|
||||
'received',
|
||||
$_SERVER['REMOTE_ADDR'] ?? null,
|
||||
substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500),
|
||||
isset($input['metadata']) ? json_encode($input['metadata'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
|
||||
$receivedAt,
|
||||
]);
|
||||
|
||||
return [
|
||||
'reference_code' => $referenceCode,
|
||||
'access_token' => $accessToken,
|
||||
'received_at' => $receivedAt,
|
||||
'tenant_id' => $match['tenant_id'],
|
||||
'user_id' => $match['user_id'],
|
||||
];
|
||||
}
|
||||
|
||||
function app_fetch_legal_request_by_token(PDO $pdo, string $token): ?array
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT reference_code, request_type, requester_name, requester_email,
|
||||
contract_reference, request_reason, requested_end, status, metadata_json, received_at
|
||||
FROM legal_requests WHERE access_token_hash = ? LIMIT 1'
|
||||
);
|
||||
$stmt->execute([hash('sha256', $token)]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return $row !== false ? $row : null;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
/**
|
||||
* @return array{id: int, message: string, valid_from: string, valid_until: string}|null
|
||||
*/
|
||||
function notices_fetch_active(PDO $pdo, int $tenantId): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT id, message, valid_from, valid_until
|
||||
FROM notices
|
||||
WHERE tenant_id = ?
|
||||
AND deleted_at IS NULL
|
||||
AND valid_from <= NOW()
|
||||
AND valid_until >= NOW()
|
||||
ORDER BY valid_until ASC
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return $row !== false ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int, message: string, valid_from: string, valid_until: string}>
|
||||
*/
|
||||
function notices_fetch_all(PDO $pdo, int $tenantId): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT id, message, valid_from, valid_until
|
||||
FROM notices
|
||||
WHERE tenant_id = ?
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY valid_until DESC"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function notices_create(PDO $pdo, int $tenantId, string $message, string $validUntil, ?int $createdByUserId): bool
|
||||
{
|
||||
$message = trim($message);
|
||||
if ($message === '' || $validUntil === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO notices (tenant_id, message, valid_from, valid_until, created_by_user_id)
|
||||
VALUES (?, ?, NOW(), ?, ?)'
|
||||
);
|
||||
|
||||
return $stmt->execute([$tenantId, $message, $validUntil, $createdByUserId]);
|
||||
}
|
||||
|
||||
function notices_soft_delete(PDO $pdo, int $tenantId, int $noticeId): bool
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE notices
|
||||
SET deleted_at = NOW()
|
||||
WHERE id = ?
|
||||
AND tenant_id = ?
|
||||
AND deleted_at IS NULL'
|
||||
);
|
||||
$stmt->execute([$noticeId, $tenantId]);
|
||||
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/database.php';
|
||||
require_once __DIR__ . '/ledger.php';
|
||||
require_once __DIR__ . '/imports.php';
|
||||
require_once __DIR__ . '/audit.php';
|
||||
require_once __DIR__ . '/paypal-mail-parser.php';
|
||||
|
||||
/**
|
||||
* Basis-Eingangsadresse (Catch-all), an die Admins ihre PayPal-Mails
|
||||
* weiterleiten - z. B. "zahlungen@kaffeeliste.de". Nur ueber Server-/Env-
|
||||
* Einstellung durch den Betreiber setzbar (PAYPAL_INBOX_BASE).
|
||||
*/
|
||||
function paypal_inbox_base_address(): ?string
|
||||
{
|
||||
$base = app_env('PAYPAL_INBOX_BASE');
|
||||
|
||||
return ($base !== null && str_contains($base, '@')) ? $base : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die mandantenspezifische Plus-Adresse aus Basisadresse und Token,
|
||||
* z. B. "zahlungen+ab12cd@kaffeeliste.de". Null, wenn keine Basis konfiguriert.
|
||||
*/
|
||||
function paypal_inbox_address_for_token(string $token): ?string
|
||||
{
|
||||
$base = paypal_inbox_base_address();
|
||||
if ($base === null || $token === '') {
|
||||
return null;
|
||||
}
|
||||
[$local, $domain] = explode('@', $base, 2);
|
||||
|
||||
return $local . '+' . $token . '@' . $domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert (und erzeugt bei Bedarf) den eindeutigen Inbox-Token eines Mandanten.
|
||||
*/
|
||||
function paypal_inbox_ensure_token(PDO $pdo, int $tenantId): string
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT paypal_inbox_token FROM tenants WHERE id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
$token = (string) ($stmt->fetchColumn() ?: '');
|
||||
if ($token !== '') {
|
||||
return $token;
|
||||
}
|
||||
|
||||
for ($try = 0; $try < 5; $try++) {
|
||||
$candidate = bin2hex(random_bytes(6)); // 12 hex-Zeichen
|
||||
try {
|
||||
$pdo->prepare('UPDATE tenants SET paypal_inbox_token = ? WHERE id = ?')
|
||||
->execute([$candidate, $tenantId]);
|
||||
|
||||
return $candidate;
|
||||
} catch (Throwable $e) {
|
||||
// Kollision (unwahrscheinlich) - erneut versuchen.
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException('Es konnte kein eindeutiger Inbox-Token erzeugt werden.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt den Mandanten anhand des Plus-Adress-Tokens (aus der Empfaenger-
|
||||
* adresse der weitergeleiteten Mail). Null, wenn kein Mandant passt.
|
||||
*/
|
||||
function paypal_inbox_resolve_tenant(PDO $pdo, string $token): ?int
|
||||
{
|
||||
$token = trim($token);
|
||||
if ($token === '') {
|
||||
return null;
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT id FROM tenants WHERE paypal_inbox_token = ? AND status = 'active' LIMIT 1");
|
||||
$stmt->execute([$token]);
|
||||
$id = $stmt->fetchColumn();
|
||||
|
||||
return $id === false ? null : (int) $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zieht den Plus-Token aus einer Empfaengeradresse ("zahlungen+ab12cd@host").
|
||||
* Null, wenn kein Token enthalten ist.
|
||||
*/
|
||||
function paypal_inbox_extract_token(string $address): ?string
|
||||
{
|
||||
if (preg_match('/\+([A-Za-z0-9]{6,32})@/', $address, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucht eine PayPal-Netto-Zahlung als Einzahlung ins Ledger. Beim migrierten
|
||||
* Default-Mandanten wird zusaetzlich in die Legacy-Tabelle geschrieben und
|
||||
* gespiegelt (gleiches Muster wie Sammelerfassung/CSV-Import).
|
||||
*
|
||||
* @return int Ledger-Entry-ID
|
||||
*/
|
||||
function paypal_book_payment(PDO $pdo, int $tenantId, array $participant, int $netCents, ?int $actorUserId, ?string $note = null): int
|
||||
{
|
||||
return ledger_record_payment($pdo, $tenantId, (int) $participant['participant_id'], $netCents, 'paypal_import', $actorUserId, $note);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bemerkung fuer eine aus PayPal uebernommene Buchung: Zahler und Datum, damit
|
||||
* im Journal nachvollziehbar bleibt, woher die Gutschrift stammt.
|
||||
*/
|
||||
function paypal_booking_note(array $payment): string
|
||||
{
|
||||
$note = 'PayPal: ' . trim((string) ($payment['payer_name'] ?? ''));
|
||||
if (!empty($payment['paid_at'])) {
|
||||
$note .= ' vom ' . date('d.m.Y', strtotime((string) $payment['paid_at']));
|
||||
}
|
||||
$mitteilung = trim((string) ($payment['note'] ?? ''));
|
||||
if ($mitteilung !== '') {
|
||||
$note .= ' ("' . $mitteilung . '")';
|
||||
}
|
||||
|
||||
return $note;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht den passenden Teilnehmer zu einer PayPal-Zahlung: zuerst ueber die
|
||||
* Mailadresse des Zahlers, dann ueber den Zahlernamen
|
||||
* (paypal_name/display_name), zuletzt ueber die Mitteilung (Mitglied schreibt
|
||||
* dort teils seinen Namen). Liefert null, wenn nicht eindeutig.
|
||||
*
|
||||
* @return array{participant_id:int, display_name:string}|null
|
||||
*/
|
||||
function paypal_match_participant(PDO $pdo, int $tenantId, string $payerName, ?string $note, ?string $payerEmail = null): ?array
|
||||
{
|
||||
// Die Mailadresse zuerst: sie ist im Mandanten eindeutig (Unique Key auf
|
||||
// tenant_id + email_norm) und aendert sich nicht, wenn jemand bei PayPal
|
||||
// unter einem anderen Namen auftritt.
|
||||
$byEmail = paypal_find_participant_by_email($pdo, $tenantId, $payerEmail);
|
||||
if ($byEmail !== null) {
|
||||
return $byEmail;
|
||||
}
|
||||
|
||||
$byName = imports_find_participant($pdo, $tenantId, $payerName);
|
||||
if ($byName !== null) {
|
||||
return $byName;
|
||||
}
|
||||
if ($note !== null && trim($note) !== '') {
|
||||
return imports_find_participant($pdo, $tenantId, trim($note));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sieht der Absender nach PayPal aus? Erkennt Landesvarianten (paypal.at,
|
||||
* paypal.co.uk) und Versand-Subdomains (e.paypal.de); der Vergleich haengt
|
||||
* am Ende der Domain, "paypal.de.beispiel.com" ist also kein PayPal.
|
||||
*
|
||||
* Nur zur Anzeige in der Diagnose (check-imap-support.php): die
|
||||
* Verarbeitung prueft den Absender bewusst nicht, weil Zahlungsmails auch
|
||||
* ueber manuelle Weiterleitungen ankommen. Massgeblich ist die
|
||||
* Empfangsadresse.
|
||||
*/
|
||||
function paypal_from_is_paypal(string $fromHeader): bool
|
||||
{
|
||||
if (!preg_match_all('/[\w.+-]+@([\w-]+(?:\.[\w-]+)+)/u', strtolower($fromHeader), $treffer)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($treffer[1] as $domain) {
|
||||
if (preg_match('/(^|\.)paypal\.[a-z]{2,}(\.[a-z]{2,})?$/', $domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gehoeren zwei Adressen zum selben Postfach? Plus-Adressierung wird dabei
|
||||
* ignoriert, "zahlungen+ab12cd@host" ist also dasselbe Postfach wie
|
||||
* "zahlungen@host". $kandidat darf mehrere Adressen enthalten (die
|
||||
* Empfaengerzeilen einer weitergeleiteten Mail).
|
||||
*/
|
||||
function paypal_same_mailbox(string $adresse, string $kandidat): bool
|
||||
{
|
||||
$adresse = strtolower(trim($adresse));
|
||||
if ($adresse === '' || !str_contains($adresse, '@')) {
|
||||
return false;
|
||||
}
|
||||
[$lokal, $domain] = explode('@', $adresse, 2);
|
||||
$lokal = explode('+', $lokal)[0];
|
||||
|
||||
if (!preg_match_all('/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/u', strtolower($kandidat), $treffer)) {
|
||||
return false;
|
||||
}
|
||||
foreach ($treffer[0] as $andere) {
|
||||
[$andereLokal, $andereDomain] = explode('@', $andere, 2);
|
||||
if (explode('+', $andereLokal)[0] === $lokal && $andereDomain === $domain) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mitglied ueber seine Mailadresse finden. Null, wenn keine Adresse vorliegt
|
||||
* oder kein Mitglied dazu passt.
|
||||
*/
|
||||
function paypal_find_participant_by_email(PDO $pdo, int $tenantId, ?string $email): ?array
|
||||
{
|
||||
$email = strtolower(trim((string) $email));
|
||||
if ($email === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, display_name
|
||||
FROM participants
|
||||
WHERE tenant_id = ? AND email_norm = ?
|
||||
ORDER BY id
|
||||
LIMIT 2'
|
||||
);
|
||||
$stmt->execute([$tenantId, $email]);
|
||||
$rows = $stmt->fetchAll();
|
||||
if (count($rows) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'participant_id' => (int) $rows[0]['id'],
|
||||
'display_name' => (string) $rows[0]['display_name'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verarbeitet eine geparste PayPal-Zahlung fuer einen Mandanten:
|
||||
* - Dedup ueber den Transaktionscode (bereits verarbeitet -> uebersprungen).
|
||||
* - Eindeutiger Match -> automatisch als Netto-Einzahlung gebucht.
|
||||
* - Kein eindeutiger Match -> in der Warteschlange ('unmatched') abgelegt.
|
||||
* - PayPal fuer den Mandanten abgeschaltet -> geparkt ('parked'): gespeichert,
|
||||
* aber ohne automatische Buchung.
|
||||
*
|
||||
* @param array $parsed Ergebnis von paypal_parse_notification()
|
||||
* @return array{status:string, payment_id?:int, participant?:string}
|
||||
*/
|
||||
function paypal_reconcile(PDO $pdo, int $tenantId, array $parsed, ?int $actorUserId = null): array
|
||||
{
|
||||
$code = (string) ($parsed['transaction_code'] ?? '');
|
||||
if ($code === '') {
|
||||
return ['status' => 'no_code'];
|
||||
}
|
||||
$netCents = (int) ($parsed['net_cents'] ?? 0);
|
||||
if ($netCents <= 0) {
|
||||
return ['status' => 'no_amount'];
|
||||
}
|
||||
|
||||
// Dedup: Transaktionscode ist global eindeutig. INSERT IGNORE als Schranke.
|
||||
$insert = $pdo->prepare(
|
||||
'INSERT IGNORE INTO paypal_payments
|
||||
(tenant_id, transaction_code, payer_name, payer_email, note, gross_cents, fee_cents, net_cents, paid_at, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$insert->execute([
|
||||
$tenantId,
|
||||
$code,
|
||||
(string) ($parsed['payer_name'] ?? ''),
|
||||
(string) ($parsed['payer_email'] ?? ''),
|
||||
$parsed['note'] ?? null,
|
||||
(int) ($parsed['gross_cents'] ?? 0),
|
||||
$parsed['fee_cents'] ?? null,
|
||||
$netCents,
|
||||
$parsed['date'] ?? null,
|
||||
'unmatched',
|
||||
]);
|
||||
if ($insert->rowCount() === 0) {
|
||||
return ['status' => 'duplicate'];
|
||||
}
|
||||
$paymentId = (int) $pdo->lastInsertId();
|
||||
|
||||
// Ist PayPal fuer diesen Mandanten abgeschaltet - vom Betreiber oder vom
|
||||
// Mandanten selbst -, wird die Zahlung nur geparkt: gespeichert, aber
|
||||
// nicht automatisch gutgeschrieben. Wegwerfen waere schlechter (eine
|
||||
// echte Zahlung ginge unbemerkt verloren), automatisch buchen ebenfalls
|
||||
// (es soll ja gerade nichts ueber PayPal laufen). Die Zuordnungsseite
|
||||
// bleibt fuer offene Zahlungen erreichbar, dort entscheidet der Mandant.
|
||||
require_once __DIR__ . '/features.php';
|
||||
if (!app_feature_available($pdo, $tenantId, 'paypal_inbox')) {
|
||||
return ['status' => 'parked', 'payment_id' => $paymentId];
|
||||
}
|
||||
|
||||
// Eindeutiger Match -> automatisch buchen.
|
||||
$participant = paypal_match_participant(
|
||||
$pdo,
|
||||
$tenantId,
|
||||
(string) ($parsed['payer_name'] ?? ''),
|
||||
$parsed['note'] ?? null,
|
||||
$parsed['payer_email'] ?? null
|
||||
);
|
||||
if ($participant === null) {
|
||||
return ['status' => 'unmatched', 'payment_id' => $paymentId];
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
$ledgerId = paypal_book_payment($pdo, $tenantId, $participant, $netCents, $actorUserId, paypal_booking_note([
|
||||
'payer_name' => $parsed['payer_name'] ?? '',
|
||||
'paid_at' => $parsed['date'] ?? null,
|
||||
'note' => $parsed['note'] ?? null,
|
||||
]));
|
||||
$pdo->prepare("UPDATE paypal_payments SET participant_id = ?, ledger_entry_id = ?, status = 'booked' WHERE id = ?")
|
||||
->execute([(int) $participant['participant_id'], $ledgerId, $paymentId]);
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
// Buchung fehlgeschlagen -> in der Warteschlange belassen.
|
||||
return ['status' => 'unmatched', 'payment_id' => $paymentId];
|
||||
}
|
||||
|
||||
app_audit_log($pdo, $tenantId, $actorUserId, 'paypal_import.auto_booked', 'paypal_payment', $paymentId, [
|
||||
'transaction_code' => $code,
|
||||
'net_cents' => $netCents,
|
||||
'participant_id' => (int) $participant['participant_id'],
|
||||
]);
|
||||
|
||||
return ['status' => 'booked', 'payment_id' => $paymentId, 'participant' => (string) $participant['display_name']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorschau ohne jeden Schreibzugriff: was wuerde mit dieser Zahlung
|
||||
* passieren? Gedacht fuer den Probelauf des Abrufskripts (--dry-run). Der
|
||||
* Probelauf soll wirklich nichts anfassen - eine Vorschau, die im
|
||||
* Hintergrund bucht, waere schlimmer als gar keine.
|
||||
*
|
||||
* @param array $parsed Ergebnis von paypal_parse_notification()
|
||||
* @return array{status:string, participant?:string, net_cents?:int}
|
||||
*/
|
||||
function paypal_preview(PDO $pdo, int $tenantId, array $parsed): array
|
||||
{
|
||||
$code = (string) ($parsed['transaction_code'] ?? '');
|
||||
if ($code === '') {
|
||||
return ['status' => 'no_code'];
|
||||
}
|
||||
$netCents = (int) ($parsed['net_cents'] ?? 0);
|
||||
if ($netCents <= 0) {
|
||||
return ['status' => 'no_amount'];
|
||||
}
|
||||
|
||||
// Dedup wie beim echten Lauf: der Transaktionscode ist global eindeutig.
|
||||
$stmt = $pdo->prepare('SELECT id FROM paypal_payments WHERE transaction_code = ? LIMIT 1');
|
||||
$stmt->execute([$code]);
|
||||
if ($stmt->fetchColumn() !== false) {
|
||||
return ['status' => 'duplicate'];
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/features.php';
|
||||
if (!app_feature_available($pdo, $tenantId, 'paypal_inbox')) {
|
||||
return ['status' => 'would_park', 'net_cents' => $netCents];
|
||||
}
|
||||
|
||||
$participant = paypal_match_participant(
|
||||
$pdo,
|
||||
$tenantId,
|
||||
(string) ($parsed['payer_name'] ?? ''),
|
||||
$parsed['note'] ?? null,
|
||||
$parsed['payer_email'] ?? null
|
||||
);
|
||||
if ($participant === null) {
|
||||
return ['status' => 'would_queue', 'net_cents' => $netCents];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'would_book',
|
||||
'participant' => (string) $participant['display_name'],
|
||||
'net_cents' => $netCents,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verarbeitet eine rohe PayPal-Mail vollstaendig: Mandant per Plus-Token der
|
||||
* Empfangsadresse, Parsing, Abgleich. Fuer den IMAP-Cron und Tests.
|
||||
*
|
||||
* Der Absender wird nicht ausgewertet - siehe Kommentar im Rumpf.
|
||||
*
|
||||
* @param string $recipient Empfaengeradresse mit Plus-Token
|
||||
* @param string $rawBody (dekodierter) Mail-Body
|
||||
* @return array{status:string, tenant_id?:int}
|
||||
*/
|
||||
function paypal_process_raw(PDO $pdo, string $recipient, string $rawBody, bool $previewOnly = false): array
|
||||
{
|
||||
// Der Absender wird bewusst nicht geprueft: Zahlungsmails erreichen die
|
||||
// Kaffeeliste auch ueber manuelle Weiterleitungen, und dann steht dort
|
||||
// die Adresse des Weiterleitenden statt PayPal. Massgeblich ist allein
|
||||
// die Empfangsadresse - ihr Token bestimmt den Mandanten und ist damit
|
||||
// zugleich das Geheimnis, das den Zugang schuetzt.
|
||||
$token = paypal_inbox_extract_token($recipient);
|
||||
if ($token === null) {
|
||||
return ['status' => 'no_token'];
|
||||
}
|
||||
$tenantId = paypal_inbox_resolve_tenant($pdo, $token);
|
||||
if ($tenantId === null) {
|
||||
return ['status' => 'unknown_tenant'];
|
||||
}
|
||||
$parsed = paypal_parse_notification($rawBody);
|
||||
if ($parsed === null) {
|
||||
return ['status' => 'not_a_payment'];
|
||||
}
|
||||
|
||||
// Die eigene Eingangsadresse steht in jeder weitergeleiteten Mail und darf
|
||||
// niemals als Adresse des Zahlers durchgehen - sonst wuerde eine Zahlung
|
||||
// dem Mitglied gutgeschrieben, dem diese Adresse gehoert. Die Pruefung
|
||||
// haengt bewusst an der tatsaechlichen Empfaengeradresse und nicht nur an
|
||||
// der Konfiguration.
|
||||
if (paypal_same_mailbox((string) ($parsed['payer_email'] ?? ''), $recipient)) {
|
||||
$parsed['payer_email'] = null;
|
||||
}
|
||||
|
||||
$result = $previewOnly
|
||||
? paypal_preview($pdo, $tenantId, $parsed)
|
||||
: paypal_reconcile($pdo, $tenantId, $parsed);
|
||||
$result['tenant_id'] = $tenantId;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offene (noch nicht zugeordnete) PayPal-Zahlungen eines Mandanten.
|
||||
*
|
||||
* @return list<array>
|
||||
*/
|
||||
function paypal_fetch_unmatched(PDO $pdo, int $tenantId): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT id, transaction_code, payer_name, payer_email, note, net_cents, paid_at, created_at
|
||||
FROM paypal_payments
|
||||
WHERE tenant_id = ? AND status = 'unmatched'
|
||||
ORDER BY created_at DESC, id DESC"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordnet eine Zahlung aus der Warteschlange manuell einem Teilnehmer zu und
|
||||
* bucht den Netto-Betrag als Einzahlung.
|
||||
*
|
||||
* @return array{ok:bool, error?:string}
|
||||
*/
|
||||
/**
|
||||
* Soll der Zahlername als PayPal-Name beim Mitglied hinterlegt werden?
|
||||
* Liefert den zu speichernden Namen oder null, wenn nichts zu lernen ist.
|
||||
*
|
||||
* Zurueckhaltend absichtlich: ein bereits gepflegter PayPal-Name wird nie
|
||||
* ueberschrieben, ein Name gleich dem Anzeigenamen bringt nichts (der wird
|
||||
* ohnehin geprueft), und wenn der Name im Mandanten nicht eindeutig waere,
|
||||
* wuerde das Merken die automatische Zuordnung sogar blockieren - dann
|
||||
* lieber weiter von Hand zuordnen.
|
||||
*/
|
||||
function paypal_remember_payer_name(PDO $pdo, int $tenantId, int $participantId, string $payerName): ?string
|
||||
{
|
||||
$payerName = trim($payerName);
|
||||
if ($payerName === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT display_name, paypal_name FROM participants WHERE id = ? AND tenant_id = ?');
|
||||
$stmt->execute([$participantId, $tenantId]);
|
||||
$participant = $stmt->fetch();
|
||||
if ($participant === false) {
|
||||
return null;
|
||||
}
|
||||
if (trim((string) ($participant['paypal_name'] ?? '')) !== '') {
|
||||
return null;
|
||||
}
|
||||
if (strcasecmp(trim((string) $participant['display_name']), $payerName) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Wuerde der Name auf ein weiteres Mitglied passen, bliebe die Zuordnung
|
||||
// mehrdeutig (imports_find_participant verlangt genau einen Treffer).
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM participants
|
||||
WHERE tenant_id = ?
|
||||
AND id <> ?
|
||||
AND (LOWER(paypal_name) = LOWER(?) OR LOWER(display_name) = LOWER(?))'
|
||||
);
|
||||
$stmt->execute([$tenantId, $participantId, $payerName, $payerName]);
|
||||
if ((int) $stmt->fetchColumn() > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $payerName;
|
||||
}
|
||||
|
||||
function paypal_assign_payment(PDO $pdo, int $tenantId, int $paymentId, int $participantId, ?int $actorUserId): array
|
||||
{
|
||||
$stmt = $pdo->prepare("SELECT id, net_cents, status, payer_name, note, paid_at FROM paypal_payments WHERE id = ? AND tenant_id = ?");
|
||||
$stmt->execute([$paymentId, $tenantId]);
|
||||
$payment = $stmt->fetch();
|
||||
if ($payment === false || (string) $payment['status'] !== 'unmatched') {
|
||||
return ['ok' => false, 'error' => 'Diese Zahlung ist nicht (mehr) offen.'];
|
||||
}
|
||||
|
||||
$summaries = ledger_fetch_participant_summaries($pdo, $tenantId, ['participant_ids' => [$participantId]]);
|
||||
$participant = $summaries[0] ?? null;
|
||||
if ($participant === null) {
|
||||
return ['ok' => false, 'error' => 'Das gewählte Mitglied wurde nicht gefunden.'];
|
||||
}
|
||||
|
||||
// Aus der Handarbeit lernen: heisst jemand bei PayPal anders als in der
|
||||
// Kaffeeliste, greift die automatische Zuordnung nie - denn sie verlangt
|
||||
// exakte Uebereinstimmung. Nach der ersten manuellen Zuordnung wird der
|
||||
// Zahlername deshalb beim Mitglied hinterlegt, damit die naechste Zahlung
|
||||
// von allein ankommt.
|
||||
$gemerkterName = paypal_remember_payer_name($pdo, $tenantId, $participantId, (string) $payment['payer_name']);
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
$ledgerId = paypal_book_payment($pdo, $tenantId, $participant, (int) $payment['net_cents'], $actorUserId, paypal_booking_note($payment));
|
||||
$pdo->prepare("UPDATE paypal_payments SET participant_id = ?, ledger_entry_id = ?, status = 'booked' WHERE id = ?")
|
||||
->execute([$participantId, $ledgerId, $paymentId]);
|
||||
if ($gemerkterName !== null) {
|
||||
$pdo->prepare('UPDATE participants SET paypal_name = ? WHERE id = ? AND tenant_id = ?')
|
||||
->execute([$gemerkterName, $participantId, $tenantId]);
|
||||
}
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
|
||||
return ['ok' => false, 'error' => 'Die Zahlung konnte nicht gebucht werden.'];
|
||||
}
|
||||
|
||||
app_audit_log($pdo, $tenantId, $actorUserId, 'paypal_import.manual_assigned', 'paypal_payment', $paymentId, [
|
||||
'participant_id' => $participantId,
|
||||
'net_cents' => (int) $payment['net_cents'],
|
||||
'learned_paypal_name' => $gemerkterName,
|
||||
]);
|
||||
|
||||
return ['ok' => true, 'learned_paypal_name' => $gemerkterName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwirft eine offene Zahlung aus der Warteschlange (z. B. Fehleingang, keine
|
||||
* Kaffeelisten-Zahlung).
|
||||
*/
|
||||
function paypal_ignore_payment(PDO $pdo, int $tenantId, int $paymentId, ?int $actorUserId): array
|
||||
{
|
||||
$stmt = $pdo->prepare("UPDATE paypal_payments SET status = 'ignored' WHERE id = ? AND tenant_id = ? AND status = 'unmatched'");
|
||||
$stmt->execute([$paymentId, $tenantId]);
|
||||
if ($stmt->rowCount() === 1) {
|
||||
app_audit_log($pdo, $tenantId, $actorUserId, 'paypal_import.ignored', 'paypal_payment', $paymentId);
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
return ['ok' => false, 'error' => 'Diese Zahlung ist nicht (mehr) offen.'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Anzahl der offenen Zahlungen in der Warteschlange. Bewusst als eigene
|
||||
* Zaehl-Abfrage neben paypal_fetch_unmatched(): die Navigation braucht nur
|
||||
* die Information "gibt es noch etwas zu tun", nicht die Datensaetze.
|
||||
*/
|
||||
function paypal_count_unmatched(PDO $pdo, int $tenantId): int
|
||||
{
|
||||
static $cache = [];
|
||||
|
||||
if (array_key_exists($tenantId, $cache)) {
|
||||
return $cache[$tenantId];
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT COUNT(*) FROM paypal_payments WHERE tenant_id = ? AND status = 'unmatched'"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$cache[$tenantId] = (int)$stmt->fetchColumn();
|
||||
} catch (Throwable $e) {
|
||||
// Fehlt die Tabelle, gibt es auch keine Warteschlange - die
|
||||
// Navigation darf daran nicht scheitern.
|
||||
$cache[$tenantId] = 0;
|
||||
}
|
||||
|
||||
return $cache[$tenantId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Darf die Zuordnungsseite geoeffnet werden? Die Funktion kennt drei Faelle:
|
||||
* der Betreiber hat gesperrt (nein), der Mandant bietet PayPal an (ja) - und
|
||||
* dazwischen der Fall, dass der Mandant PayPal gerade abgeschaltet hat,
|
||||
* aber noch Zahlungen unzugeordnet in der Warteschlange liegen. Die waeren
|
||||
* sonst weder ueber das Menue noch ueber die URL erreichbar; die Seite bleibt
|
||||
* deshalb offen, bis die Warteschlange leer ist.
|
||||
*/
|
||||
function paypal_inbox_accessible(PDO $pdo, int $tenantId): bool
|
||||
{
|
||||
require_once __DIR__ . '/features.php';
|
||||
|
||||
if (!app_feature_enabled($pdo, $tenantId, 'paypal_inbox')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (app_feature_available($pdo, $tenantId, 'paypal_inbox')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return paypal_count_unmatched($pdo, $tenantId) > 0;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Parser fuer PayPal-Zahlungseingangs-Mails ("Du hast eine Zahlung erhalten").
|
||||
*
|
||||
* Bewusst tolerant gegenueber dem konkreten Waehrungssymbol (in weitergeleiteten
|
||||
* Mails oft falsch kodiert) und der HTML-Struktur: der Text wird zunaechst von
|
||||
* Tags befreit, Entities/NBSP normalisiert, dann ueber robuste Marker
|
||||
* ("… hat dir … EUR gesendet", "Transaktionscode", "Erhaltener Betrag",
|
||||
* "Gebuehr", "Summe", "Transaktionsdatum") ausgewertet. Der Transaktionscode
|
||||
* wird primaer aus der Detail-URL /activities/details/<CODE> gezogen, weil der
|
||||
* dort am zuverlaessigsten steht.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wandelt den (ggf. UTF-16- oder Windows-1252-kodierten) Mail-Body in
|
||||
* normalisierten UTF-8-Text um: Skript/Style raus, Tags durch Leerzeichen
|
||||
* ersetzt, Entities dekodiert, NBSP und Mehrfach-Whitespace zusammengefasst.
|
||||
*/
|
||||
function paypal_mail_to_text(string $body): string
|
||||
{
|
||||
// UTF-16-BOM erkennen und nach UTF-8 wandeln (weitergeleitete .htm-Exporte).
|
||||
if (str_starts_with($body, "\xFF\xFE") || str_starts_with($body, "\xFE\xFF")) {
|
||||
$converted = @iconv('UTF-16', 'UTF-8//IGNORE', $body);
|
||||
if ($converted !== false) {
|
||||
$body = $converted;
|
||||
}
|
||||
} elseif (preg_match('//u', $body) !== 1) {
|
||||
// Outlook/Hotmail kennzeichnet weitergeleitete Nachrichten teils als
|
||||
// UTF-8, liefert den Body aber tatsaechlich in Windows-1252. Dann
|
||||
// schlagen alle folgenden Unicode-Regulaerausdruecke still fehl. Das
|
||||
// Eurozeichen (0x80) ist zugleich ein typisches Erkennungsmerkmal fuer
|
||||
// Windows-1252 statt ISO-8859-1.
|
||||
$converted = @iconv('Windows-1252', 'UTF-8//IGNORE', $body);
|
||||
if ($converted !== false) {
|
||||
$body = $converted;
|
||||
}
|
||||
}
|
||||
|
||||
$body = preg_replace('#<(script|style)[^>]*>.*?</\1>#is', ' ', $body) ?? $body;
|
||||
$text = preg_replace('#<[^>]+>#', ' ', $body) ?? $body;
|
||||
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$text = str_replace(["\xC2\xA0", "\xEF\xBB\xBF"], ' ', $text); // NBSP, BOM
|
||||
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
|
||||
|
||||
return trim($text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deutscher Betrag ("1.234,56") -> Cent. Null bei ungueltigem Format.
|
||||
*/
|
||||
function paypal_amount_to_cents(string $raw): ?int
|
||||
{
|
||||
$raw = trim($raw);
|
||||
$raw = str_replace('.', '', $raw); // Tausenderpunkte
|
||||
$raw = str_replace(',', '.', $raw);
|
||||
if (!preg_match('/^\d+(\.\d{1,2})?$/', $raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) round(((float) $raw) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deutsches Datum ("7. Juli 2026") -> "Y-m-d". Null, wenn nicht erkennbar.
|
||||
*/
|
||||
function paypal_german_date_to_iso(string $raw): ?string
|
||||
{
|
||||
$months = [
|
||||
'januar' => 1, 'februar' => 2, 'märz' => 3, 'maerz' => 3, 'april' => 4,
|
||||
'mai' => 5, 'juni' => 6, 'juli' => 7, 'august' => 8, 'september' => 9,
|
||||
'oktober' => 10, 'november' => 11, 'dezember' => 12,
|
||||
];
|
||||
if (!preg_match('/(\d{1,2})\.\s*([\p{L}]+)\s+(\d{4})/u', $raw, $m)) {
|
||||
return null;
|
||||
}
|
||||
$day = (int) $m[1];
|
||||
$monthName = mb_strtolower_compat($m[2]);
|
||||
$year = (int) $m[3];
|
||||
if (!isset($months[$monthName])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf('%04d-%02d-%02d', $year, $months[$monthName], $day);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kleinschreibung ohne mbstring-Abhaengigkeit (nur fuer deutsche Monatsnamen
|
||||
* inkl. Umlaut noetig).
|
||||
*/
|
||||
function mb_strtolower_compat(string $s): string
|
||||
{
|
||||
if (function_exists('mb_strtolower')) {
|
||||
return mb_strtolower($s, 'UTF-8');
|
||||
}
|
||||
$s = strtr($s, ['Ä' => 'ä', 'Ö' => 'ö', 'Ü' => 'ü']);
|
||||
|
||||
return strtolower($s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Betrag in Cent aus dem Text nach einem Label ("Erhaltener Betrag" etc.).
|
||||
* Waehrungssymbol zwischen Zahl und "EUR" wird ignoriert.
|
||||
*/
|
||||
function paypal_extract_labeled_amount(string $text, string $labelRegex): ?int
|
||||
{
|
||||
if (preg_match('/' . $labelRegex . '\s*([\d.]+,\d{2})\s*\S*\s*EUR/u', $text, $m)) {
|
||||
return paypal_amount_to_cents($m[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mailadresse des Zahlers aus der Benachrichtigung.
|
||||
*
|
||||
* Bewusst eng gefasst: In einer weitergeleiteten Mail stehen mehrere
|
||||
* Adressen - die von PayPal selbst, die Eingangsadresse der Kaffeeliste und
|
||||
* je nach Weiterleitung auch die des urspruenglichen Empfaengers. Wuerde
|
||||
* einfach die erste beste genommen, koennte eine Zahlung beim Kassenwart
|
||||
* statt beim Zahler landen. Genommen wird deshalb nur eine Adresse, die
|
||||
* nahe am Namen des Zahlers steht (PayPal schreibt sie dort hin), und auch
|
||||
* die erst nach Abzug aller bekannten Fremdadressen.
|
||||
*
|
||||
* @param string $text Bereits per paypal_mail_to_text() normalisierter Text
|
||||
* @param string $payer Erkannter Name des Zahlers
|
||||
*/
|
||||
function paypal_extract_payer_email(string $text, string $payer): ?string
|
||||
{
|
||||
if (!preg_match_all('/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/u', $text, $treffer, PREG_OFFSET_CAPTURE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Eingangsadresse der Kaffeeliste (zahlungen+<token>@…) und alles von
|
||||
// PayPal selbst scheiden aus.
|
||||
// Der Parser wird auch aus Testskripten ohne geladene Umgebung benutzt.
|
||||
$inboxBase = function_exists('app_env') ? (string) (app_env('PAYPAL_INBOX_BASE') ?? '') : '';
|
||||
$inboxLokalteil = $inboxBase !== '' ? strtolower(explode('@', $inboxBase)[0]) : '';
|
||||
|
||||
$namePosition = stripos($text, $payer);
|
||||
|
||||
$kandidaten = [];
|
||||
foreach ($treffer[0] as [$adresse, $position]) {
|
||||
$adresse = strtolower(rtrim($adresse, '.'));
|
||||
$domain = substr($adresse, strpos($adresse, '@') + 1);
|
||||
$lokalteil = substr($adresse, 0, strpos($adresse, '@'));
|
||||
|
||||
if (preg_match('/(^|\.)paypal\.[a-z.]+$/', $domain)) {
|
||||
continue;
|
||||
}
|
||||
if ($inboxLokalteil !== '' && str_starts_with($lokalteil, $inboxLokalteil)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$abstand = $namePosition === false ? PHP_INT_MAX : abs($position - $namePosition);
|
||||
$kandidaten[$adresse] = min($kandidaten[$adresse] ?? PHP_INT_MAX, $abstand);
|
||||
}
|
||||
|
||||
if ($kandidaten === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
asort($kandidaten);
|
||||
$beste = array_key_first($kandidaten);
|
||||
|
||||
// Steht keine Adresse in der Naehe des Zahlernamens, ist die Zuordnung zu
|
||||
// unsicher - dann lieber keine liefern als die falsche.
|
||||
return $kandidaten[$beste] <= 400 ? $beste : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst eine PayPal-Zahlungseingangs-Mail.
|
||||
*
|
||||
* @return array{
|
||||
* payer_name: string,
|
||||
* payer_email: ?string,
|
||||
* transaction_code: ?string,
|
||||
* gross_cents: int,
|
||||
* fee_cents: ?int,
|
||||
* net_cents: int,
|
||||
* date: ?string,
|
||||
* note: ?string
|
||||
* }|null Null, wenn es keine erkennbare Zahlungseingangs-Mail ist.
|
||||
*/
|
||||
function paypal_parse_notification(string $body): ?array
|
||||
{
|
||||
$text = paypal_mail_to_text($body);
|
||||
|
||||
// Kernmarker: "<Name> hat dir <Betrag> … EUR gesendet". Fehlt er, ist es
|
||||
// keine Zahlungseingangs-Mail (z. B. Werbung, Sicherheitsmail).
|
||||
if (!preg_match('/([\p{L}\p{M}.\'\- ]{2,60}?)\s+hat dir\s+([\d.]+,\d{2})\s*\S*\s*EUR\s+gesendet/u', $text, $m)) {
|
||||
return null;
|
||||
}
|
||||
$payer = trim($m[1]);
|
||||
$grossCents = paypal_amount_to_cents($m[2]) ?? 0;
|
||||
|
||||
// Transaktionscode primaer aus der Detail-URL (steht dort am zuverlaessigsten),
|
||||
// sonst nach dem Label.
|
||||
$code = null;
|
||||
if (preg_match('#/activities/details/([A-Z0-9]{12,25})#', $body, $mc)) {
|
||||
$code = $mc[1];
|
||||
} elseif (preg_match('/Transaktionscode\s+([A-Z0-9]{12,25})/u', $text, $mc)) {
|
||||
$code = $mc[1];
|
||||
}
|
||||
|
||||
$receivedCents = paypal_extract_labeled_amount($text, 'Erhaltener Betrag');
|
||||
$feeCents = paypal_extract_labeled_amount($text, 'Geb(?:ü|ue)hr');
|
||||
$summeCents = paypal_extract_labeled_amount($text, 'Summe');
|
||||
|
||||
// Netto = tatsaechlich angekommener Betrag: bei Waren & Dienstleistungen die
|
||||
// "Summe" (nach Abzug der Gebuehr), bei Freunde & Familie der erhaltene
|
||||
// Betrag (keine Gebuehr, keine Summe-Zeile), im Zweifel der Sende-Betrag.
|
||||
$netCents = $summeCents ?? $receivedCents ?? $grossCents;
|
||||
|
||||
$date = null;
|
||||
if (preg_match('/Transaktionsdatum\s+(\d{1,2}\.\s*[\p{L}]+\s+\d{4})/u', $text, $md)) {
|
||||
$date = paypal_german_date_to_iso($md[1]);
|
||||
}
|
||||
|
||||
// Mitteilung des Zahlers ("Mitteilung von <Name> <Text>") bis zum naechsten
|
||||
// bekannten Abschnitt. Der Zahlername dient als Anker, da er Leerzeichen
|
||||
// enthalten kann.
|
||||
$note = null;
|
||||
$noteRegex = '/Mitteilung von\s+' . preg_quote($payer, '/')
|
||||
. '\s+(.+?)\s*(?:Geb(?:ü|ue)hr|Summe|Erhaltener Betrag|Transaktions|Lieferadresse|Du siehst|Bist du|Copyright|$)/u';
|
||||
if (preg_match($noteRegex, $text, $mn)) {
|
||||
$note = trim($mn[1]);
|
||||
}
|
||||
|
||||
return [
|
||||
'payer_name' => $payer,
|
||||
'payer_email' => paypal_extract_payer_email($text, $payer),
|
||||
'transaction_code' => $code,
|
||||
'gross_cents' => $grossCents,
|
||||
'fee_cents' => $feeCents,
|
||||
'net_cents' => $netCents,
|
||||
'date' => $date,
|
||||
'note' => $note,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
/**
|
||||
* Wasserzeichen des kostenlosen Tarifs: eine kleine Marke unten rechts, die
|
||||
* auf die Kaffeeliste verweist. Sie erscheint nur im Tarif "free" - wer
|
||||
* zahlt, bekommt seine Oberflaeche ohne fremdes Logo.
|
||||
*
|
||||
* Bewusst getrennt von app/branding.php: dort geht es um das Logo *des
|
||||
* Mandanten*, hier um das Logo *des Anbieters*. Beide koennen gleichzeitig
|
||||
* sichtbar sein (Kopfzeile oben, Wasserzeichen unten rechts).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tarif eines Mandanten, ohne Seiteneffekt. Absichtlich nicht
|
||||
* billing_fetch_or_init(): das Wasserzeichen wird auf jeder Seite geprueft
|
||||
* und darf dabei keine Zeile anlegen. Fehlt der Datensatz, gilt - wie beim
|
||||
* Anlegen - "free".
|
||||
*/
|
||||
function plan_watermark_plan_code(PDO $pdo, int $tenantId): string
|
||||
{
|
||||
static $cache = [];
|
||||
|
||||
if (array_key_exists($tenantId, $cache)) {
|
||||
return $cache[$tenantId];
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare('SELECT plan_code FROM tenant_billing WHERE tenant_id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
$planCode = $stmt->fetchColumn();
|
||||
$cache[$tenantId] = $planCode === false ? 'free' : (string)$planCode;
|
||||
} catch (Throwable $e) {
|
||||
// Laesst sich der Tarif nicht lesen, bleibt die Oberflaeche sauber -
|
||||
// ein Wasserzeichen bei einem zahlenden Kunden waere der schlimmere
|
||||
// Fehler als ein fehlendes bei einem kostenlosen.
|
||||
$cache[$tenantId] = 'unknown';
|
||||
}
|
||||
|
||||
return $cache[$tenantId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt dieser Mandant das Wasserzeichen? Im kostenlosen Tarif immer - es ist
|
||||
* der Gegenwert fuer die kostenlose Nutzung und deshalb nicht abschaltbar.
|
||||
* Alle anderen entscheiden ueber die Mandant-Einstellung
|
||||
* `watermark_enabled` (Standard: an).
|
||||
*/
|
||||
function plan_watermark_visible(PDO $pdo, int $tenantId): bool
|
||||
{
|
||||
if ($tenantId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$planCode = plan_watermark_plan_code($pdo, $tenantId);
|
||||
if ($planCode === 'free') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($planCode === 'unknown') {
|
||||
return false;
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/features.php';
|
||||
|
||||
return app_tenant_switch_enabled($pdo, $tenantId, 'watermark_enabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wortmarke der Kaffeeliste als Inline-SVG. Als Inline-Grafik statt als
|
||||
* Bilddatei, damit das Wasserzeichen ohne zusaetzlichen Request auskommt,
|
||||
* auf jedem Bildschirm scharf bleibt und die Farbe der Umgebung uebernimmt.
|
||||
*/
|
||||
function plan_watermark_logo_svg(): string
|
||||
{
|
||||
return '<svg class="plan-watermark-logo" viewBox="0 0 24 24" width="18" height="18" '
|
||||
. 'aria-hidden="true" focusable="false" fill="none" stroke="currentColor" '
|
||||
. 'stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">'
|
||||
. '<path d="M4 9h13v6a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V9Z"/>'
|
||||
. '<path d="M17 10.5h1.5a2.5 2.5 0 0 1 0 5H17"/>'
|
||||
. '<path d="M8 5.5c0-1 1-1.4 1-2.5M12 5.5c0-1 1-1.4 1-2.5"/>'
|
||||
. '</svg>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Beschriftung des Wasserzeichens: die Marketing-Domain ohne Schema
|
||||
* ("kaffeeliste.de"). Laesst sie sich nicht ermitteln, bleibt der Name der
|
||||
* App uebrig. Wird von der Web-Ansicht und vom PDF-Ausdruck gemeinsam
|
||||
* genutzt, damit beide dieselbe Adresse nennen.
|
||||
*/
|
||||
function plan_watermark_label(): string
|
||||
{
|
||||
$url = app_marketing_url();
|
||||
|
||||
return $url !== '' ? (string)preg_replace('#^https?://#i', '', $url) : 'Kaffeeliste';
|
||||
}
|
||||
|
||||
/**
|
||||
* Markup des Wasserzeichens. Ohne erreichbare Marketing-Domain (CLI,
|
||||
* ungewoehnliche Hosts) bleibt es bei Logo und Namen ohne Link - ein
|
||||
* Verweis ins Leere waere schlechter als keiner.
|
||||
*/
|
||||
function plan_watermark_html(): string
|
||||
{
|
||||
$url = app_marketing_url();
|
||||
$label = plan_watermark_label();
|
||||
$inner = plan_watermark_logo_svg()
|
||||
. '<span class="plan-watermark-text">' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</span>';
|
||||
|
||||
if ($url === '') {
|
||||
return '<div class="plan-watermark" title="Kaffeeliste – die digitale Kaffeekasse">' . $inner . '</div>';
|
||||
}
|
||||
|
||||
return '<a class="plan-watermark" href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '"'
|
||||
. ' target="_blank" rel="noopener noreferrer"'
|
||||
. ' title="Kaffeeliste – die digitale Kaffeekasse">' . $inner . '</a>';
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/saas-auth.php';
|
||||
require_once __DIR__ . '/audit.php';
|
||||
require_once __DIR__ . '/totp.php';
|
||||
|
||||
/**
|
||||
* Platform-admin access is deliberately a separate concept from
|
||||
* tenant_memberships/saas_user_has_role(): it must never be wired into the
|
||||
* regular tenant-scoped pages, or the tenant isolation those pages and
|
||||
* scripts/check-m8-tenant-isolation.php rely on would silently break. It
|
||||
* only gates the backoffice-*.php pages.
|
||||
*/
|
||||
function app_is_platform_admin(PDO $pdo, ?int $userId): bool
|
||||
{
|
||||
if ($userId === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT 1 FROM platform_admins WHERE user_id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires a real SaaS login (any tenant role, or none) plus a
|
||||
* platform_admins entry. Exits with 403 otherwise.
|
||||
*
|
||||
* @return array{user_id: int, email: string, display_name: string}
|
||||
*/
|
||||
function app_require_platform_admin(PDO $pdo): array
|
||||
{
|
||||
$user = saas_current_user($pdo);
|
||||
if ($user === null) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!app_is_platform_admin($pdo, (int)$user['user_id'])) {
|
||||
http_response_code(403);
|
||||
exit('Kein Zugriff.');
|
||||
}
|
||||
|
||||
// Das Back-Office sieht alle Mandanten - fuer diese Konten laesst sich ein
|
||||
// zweiter Faktor erzwingen. Bewusst als Weiterleitung auf die Einrichtung
|
||||
// statt als harte Sperre: sonst koennte der Schalter den einzigen
|
||||
// Platform-Admin dauerhaft aussperren.
|
||||
if (app_totp_required_for_platform_admins() && !app_totp_is_active($pdo, (int)$user['user_id'])) {
|
||||
header('Location: zwei-faktor-einrichten.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int, slug: string, name: string, status: string, created_at: string, participant_count: int, active_participant_count: int, balance_cents: int}>
|
||||
*/
|
||||
function app_backoffice_fetch_tenants(PDO $pdo): array
|
||||
{
|
||||
$stmt = $pdo->query(
|
||||
"SELECT
|
||||
t.id, t.slug, t.name, t.status, t.created_at,
|
||||
COALESCE(tb.plan_code, 'free') AS plan_code,
|
||||
COUNT(p.id) AS participant_count,
|
||||
COALESCE(SUM(p.active), 0) AS active_participant_count,
|
||||
COALESCE((
|
||||
SELECT SUM(le.amount_cents)
|
||||
FROM ledger_entries le
|
||||
WHERE le.tenant_id = t.id AND le.voided_at IS NULL
|
||||
), 0) AS balance_cents
|
||||
FROM tenants t
|
||||
LEFT JOIN participants p ON p.tenant_id = t.id
|
||||
LEFT JOIN tenant_billing tb ON tb.tenant_id = t.id
|
||||
GROUP BY t.id, t.slug, t.name, t.status, t.created_at, tb.plan_code
|
||||
ORDER BY t.created_at DESC"
|
||||
);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{tenant: array, settings: ?array, members: list<array>, recent_entries: list<array>, recent_audit: list<array>}|null
|
||||
*/
|
||||
function app_backoffice_fetch_tenant_detail(PDO $pdo, int $tenantId): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT id, slug, name, status, timezone, locale, currency_code, created_at FROM tenants WHERE id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
$tenant = $stmt->fetch();
|
||||
if ($tenant === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM tenant_settings WHERE tenant_id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
$settings = $stmt->fetch() ?: null;
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT u.id, u.email, u.display_name, u.status, tm.role, tm.status AS membership_status
|
||||
FROM tenant_memberships tm
|
||||
JOIN users u ON u.id = tm.user_id
|
||||
WHERE tm.tenant_id = ?
|
||||
ORDER BY tm.role, u.display_name'
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$members = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT le.id, le.type, le.amount_cents, le.booked_at, le.source, p.display_name
|
||||
FROM ledger_entries le
|
||||
JOIN participants p ON p.id = le.participant_id
|
||||
WHERE le.tenant_id = ? AND le.voided_at IS NULL
|
||||
ORDER BY le.booked_at DESC, le.id DESC
|
||||
LIMIT 20'
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$recentEntries = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT a.id, a.action, a.subject_type, a.subject_id, a.created_at, u.display_name AS actor_name
|
||||
FROM audit_log a
|
||||
LEFT JOIN users u ON u.id = a.actor_user_id
|
||||
WHERE a.tenant_id = ?
|
||||
ORDER BY a.created_at DESC, a.id DESC
|
||||
LIMIT 20'
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$recentAudit = $stmt->fetchAll();
|
||||
|
||||
return [
|
||||
'tenant' => $tenant,
|
||||
'settings' => $settings,
|
||||
'members' => $members,
|
||||
'recent_entries' => $recentEntries,
|
||||
'recent_audit' => $recentAudit,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
/**
|
||||
* Vollständiger Katalog aller Tabellen, die zur Kaffeeliste gehören. Die
|
||||
* zentrale SQL-Umschreibung beschränkt sich bewusst auf diese feste Liste;
|
||||
* ein vom Request gelieferter Bezeichner kann dadurch nie in SQL gelangen.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
function app_database_table_names(): array
|
||||
{
|
||||
return [
|
||||
'schema_migrations',
|
||||
'CoffeeSurveyVotedEmails',
|
||||
'CoffeeSurveyResponses',
|
||||
'dev_baseline_metadata',
|
||||
'kl_Kaffeeverbrauch',
|
||||
'kl_Einzahlungen',
|
||||
'kl_Mitarbeiter',
|
||||
'kl_hinweise',
|
||||
'kl_config',
|
||||
'payment_import_batches',
|
||||
'payment_import_rows',
|
||||
'stripe_webhook_events',
|
||||
'totp_recovery_codes',
|
||||
'public_page_views',
|
||||
'rate_limit_attempts',
|
||||
'tenant_memberships',
|
||||
'legal_acceptances',
|
||||
'outbound_emails',
|
||||
'user_auth_tokens',
|
||||
'tenant_settings',
|
||||
'platform_admins',
|
||||
'tenant_features',
|
||||
'paypal_payments',
|
||||
'ledger_entries',
|
||||
'legal_requests',
|
||||
'tenant_domains',
|
||||
'tenant_billing',
|
||||
'faq_entries',
|
||||
'participants',
|
||||
'audit_log',
|
||||
'notices',
|
||||
'tenants',
|
||||
'users',
|
||||
];
|
||||
}
|
||||
|
||||
function app_validate_db_table_prefix(string $prefix): string
|
||||
{
|
||||
$prefix = trim($prefix);
|
||||
if ($prefix === '') {
|
||||
return '';
|
||||
}
|
||||
if (strlen($prefix) > 16 || preg_match('/^[a-z][a-z0-9_]*_$/', $prefix) !== 1) {
|
||||
throw new RuntimeException(
|
||||
'DB_TABLE_PREFIX muss leer sein oder aus höchstens 16 Kleinbuchstaben, Ziffern und Unterstrichen bestehen und mit Unterstrich enden.'
|
||||
);
|
||||
}
|
||||
|
||||
return $prefix;
|
||||
}
|
||||
|
||||
function app_db_table_prefix(): string
|
||||
{
|
||||
$prefix = app_validate_db_table_prefix((string)app_env('DB_TABLE_PREFIX', ''));
|
||||
if ($prefix === '' && !app_is_dev()) {
|
||||
throw new RuntimeException('DB_TABLE_PREFIX darf außerhalb der Entwicklungsumgebung nicht leer sein.');
|
||||
}
|
||||
|
||||
return $prefix;
|
||||
}
|
||||
|
||||
function app_db_table_name(string $baseName, ?string $prefix = null): string
|
||||
{
|
||||
if (!in_array($baseName, app_database_table_names(), true)) {
|
||||
throw new InvalidArgumentException('Unbekannte Anwendungstabelle: ' . $baseName);
|
||||
}
|
||||
|
||||
return app_validate_db_table_prefix($prefix ?? app_db_table_prefix()) . $baseName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt ausschließlich bekannte Tabellen- und FK-Namen um. Das gilt auch
|
||||
* innerhalb der dynamischen DDL-Strings in den Migrationen 0003/0008 und für
|
||||
* deren INFORMATION_SCHEMA-Vergleiche.
|
||||
*/
|
||||
function app_prefix_database_sql(string $sql, ?string $prefix = null): string
|
||||
{
|
||||
$prefix = app_validate_db_table_prefix($prefix ?? app_db_table_prefix());
|
||||
if ($prefix === '') {
|
||||
return $sql;
|
||||
}
|
||||
|
||||
static $tablePattern = null;
|
||||
if ($tablePattern === null) {
|
||||
$tables = app_database_table_names();
|
||||
usort($tables, static fn(string $a, string $b): int => strlen($b) <=> strlen($a));
|
||||
$tablePattern = '/(?<![A-Za-z0-9_])('
|
||||
. implode('|', array_map(static fn(string $table): string => preg_quote($table, '/'), $tables))
|
||||
. ')(?![A-Za-z0-9_])/i';
|
||||
}
|
||||
$sql = preg_replace_callback(
|
||||
$tablePattern,
|
||||
static fn(array $match): string => $prefix . $match[0],
|
||||
$sql
|
||||
) ?? $sql;
|
||||
|
||||
// MySQL verlangt innerhalb einer Datenbank eindeutige Namen für
|
||||
// Fremdschlüssel-Constraints. Die Migrationen verwenden durchgängig
|
||||
// sprechende fk_*-Namen; sie brauchen daher ebenfalls den Umgebungs-Prefix.
|
||||
$sql = preg_replace_callback(
|
||||
'/(?<![A-Za-z0-9_])(fk_[A-Za-z0-9_]+)(?![A-Za-z0-9_])/i',
|
||||
static fn(array $match): string => $prefix . $match[1],
|
||||
$sql
|
||||
) ?? $sql;
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
final class AppPrefixedPDO extends PDO
|
||||
{
|
||||
private string $tablePrefix = '';
|
||||
|
||||
public function setTablePrefix(string $prefix): void
|
||||
{
|
||||
$this->tablePrefix = app_validate_db_table_prefix($prefix);
|
||||
}
|
||||
|
||||
public function tablePrefix(): string
|
||||
{
|
||||
return $this->tablePrefix;
|
||||
}
|
||||
|
||||
public function prepare(string $query, array $options = []): PDOStatement|false
|
||||
{
|
||||
return parent::prepare(app_prefix_database_sql($query, $this->tablePrefix), $options);
|
||||
}
|
||||
|
||||
public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): PDOStatement|false
|
||||
{
|
||||
$query = app_prefix_database_sql($query, $this->tablePrefix);
|
||||
if ($fetchMode === null) {
|
||||
return parent::query($query);
|
||||
}
|
||||
|
||||
return parent::query($query, $fetchMode, ...$fetchModeArgs);
|
||||
}
|
||||
|
||||
public function exec(string $statement): int|false
|
||||
{
|
||||
return parent::exec(app_prefix_database_sql($statement, $this->tablePrefix));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/database.php';
|
||||
|
||||
/**
|
||||
* Fester Katalog der gezaehlten oeffentlichen Seiten. Ein Request-Wert darf
|
||||
* nie zum Seitenschluessel werden: So koennen insbesondere Query-Parameter,
|
||||
* Token oder frei eingegebene Inhalte nicht in der Statistik landen.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function app_public_page_view_labels(): array
|
||||
{
|
||||
return [
|
||||
'landing' => 'Startseite',
|
||||
'prices' => 'Preise',
|
||||
'register' => 'Registrierung',
|
||||
'login' => 'Login',
|
||||
'privacy' => 'Datenschutz',
|
||||
'imprint' => 'Impressum',
|
||||
'terms' => 'AGB',
|
||||
'dpa' => 'AVV',
|
||||
'withdrawal_information' => 'Widerrufsbelehrung',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Erhoeht ausschliesslich einen aggregierten Tageszaehler. Es werden keine
|
||||
* Request-Metadaten gelesen oder gespeichert. Schreibfehler duerfen eine
|
||||
* oeffentliche Seite nicht unbenutzbar machen, etwa waehrend eines Deploys,
|
||||
* bei dem die Migration wenige Sekunden nach dem Code eingespielt wird.
|
||||
*/
|
||||
function app_record_public_page_view(string $pageKey, ?PDO $pdo = null): void
|
||||
{
|
||||
// Ein explizit injiziertes PDO ist nur fuer den CLI-Regressionstest
|
||||
// vorgesehen. Normale CLI-Aufrufe sollen keine Seitenansicht erzeugen.
|
||||
if (($pdo === null && PHP_SAPI === 'cli') || ($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
|
||||
return;
|
||||
}
|
||||
if (!array_key_exists($pageKey, app_public_page_view_labels())) {
|
||||
throw new InvalidArgumentException('Unbekannter öffentlicher Seitenschlüssel.');
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = ($pdo ?? app_db_pdo())->prepare(
|
||||
'INSERT INTO public_page_views (view_date, page_key, view_count)
|
||||
VALUES (CURRENT_DATE, ?, 1)
|
||||
ON DUPLICATE KEY UPDATE view_count = view_count + 1'
|
||||
);
|
||||
$stmt->execute([$pageKey]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('Anonymer Seitenzähler konnte nicht aktualisiert werden: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* totals: array{today: int, last_30_days: int, all_time: int},
|
||||
* pages: list<array{page_key: string, label: string, today: int, last_30_days: int, all_time: int}>
|
||||
* }
|
||||
*/
|
||||
function app_public_page_view_summary(PDO $pdo): array
|
||||
{
|
||||
$totalsRow = $pdo->query(
|
||||
'SELECT
|
||||
COALESCE(SUM(CASE WHEN view_date = CURRENT_DATE THEN view_count ELSE 0 END), 0) AS today,
|
||||
COALESCE(SUM(CASE WHEN view_date >= CURRENT_DATE - INTERVAL 29 DAY THEN view_count ELSE 0 END), 0) AS last_30_days,
|
||||
COALESCE(SUM(view_count), 0) AS all_time
|
||||
FROM public_page_views'
|
||||
)->fetch() ?: [];
|
||||
|
||||
$pageRows = $pdo->query(
|
||||
'SELECT
|
||||
page_key,
|
||||
COALESCE(SUM(CASE WHEN view_date = CURRENT_DATE THEN view_count ELSE 0 END), 0) AS today,
|
||||
COALESCE(SUM(CASE WHEN view_date >= CURRENT_DATE - INTERVAL 29 DAY THEN view_count ELSE 0 END), 0) AS last_30_days,
|
||||
COALESCE(SUM(view_count), 0) AS all_time
|
||||
FROM public_page_views
|
||||
GROUP BY page_key
|
||||
ORDER BY all_time DESC, page_key ASC'
|
||||
)->fetchAll();
|
||||
|
||||
$labels = app_public_page_view_labels();
|
||||
$pages = [];
|
||||
foreach ($pageRows as $row) {
|
||||
$pageKey = (string)$row['page_key'];
|
||||
$pages[] = [
|
||||
'page_key' => $pageKey,
|
||||
'label' => $labels[$pageKey] ?? $pageKey,
|
||||
'today' => (int)$row['today'],
|
||||
'last_30_days' => (int)$row['last_30_days'],
|
||||
'all_time' => (int)$row['all_time'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'totals' => [
|
||||
'today' => (int)($totalsRow['today'] ?? 0),
|
||||
'last_30_days' => (int)($totalsRow['last_30_days'] ?? 0),
|
||||
'all_time' => (int)($totalsRow['all_time'] ?? 0),
|
||||
],
|
||||
'pages' => $pages,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
/**
|
||||
* Betreiber-Benachrichtigungen ueber Pushover. Bewusst als reine
|
||||
* "fire and forget"-Meldung ausgelegt: schlaegt der Versand fehl, darf das
|
||||
* den ausloesenden Vorgang (z. B. eine Registrierung) niemals abbrechen -
|
||||
* der Kunde haette sonst kein Konto, nur weil der Push nicht rausging.
|
||||
*
|
||||
* Konfiguration ueber PUSHOVER_TOKEN (App-Token) und PUSHOVER_USER
|
||||
* (User- oder Group-Key) in env.local.php. Fehlt eines davon, ist die
|
||||
* Funktion still deaktiviert - lokal und auf Staging passiert dann nichts.
|
||||
*/
|
||||
function pushover_is_configured(): bool
|
||||
{
|
||||
return (string)app_env('PUSHOVER_TOKEN', '') !== ''
|
||||
&& (string)app_env('PUSHOVER_USER', '') !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, error: ?string}
|
||||
*/
|
||||
function pushover_notify(string $title, string $message, ?string $url = null, ?string $urlTitle = null): array
|
||||
{
|
||||
if (!pushover_is_configured()) {
|
||||
return ['ok' => false, 'error' => 'Pushover ist nicht konfiguriert.'];
|
||||
}
|
||||
|
||||
$params = [
|
||||
'token' => (string)app_env('PUSHOVER_TOKEN', ''),
|
||||
'user' => (string)app_env('PUSHOVER_USER', ''),
|
||||
// Pushover kappt laengere Werte serverseitig; wir kuerzen selbst,
|
||||
// damit die Meldung nicht mitten im Wort abbricht.
|
||||
'title' => mb_substr($title, 0, 250),
|
||||
'message' => mb_substr($message, 0, 1024),
|
||||
];
|
||||
if ($url !== null && $url !== '') {
|
||||
$params['url'] = $url;
|
||||
$params['url_title'] = $urlTitle ?? $url;
|
||||
}
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => 'Content-Type: application/x-www-form-urlencoded',
|
||||
'content' => http_build_query($params),
|
||||
'timeout' => 10,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$responseBody = @file_get_contents('https://api.pushover.net/1/messages.json', false, $context);
|
||||
$status = 0;
|
||||
foreach ($http_response_header ?? [] as $header) {
|
||||
if (preg_match('~^HTTP/\S+\s+(\d{3})~', $header, $m) === 1) {
|
||||
$status = (int)$m[1];
|
||||
}
|
||||
}
|
||||
|
||||
if ($responseBody === false) {
|
||||
error_log('Pushover: keine Antwort von api.pushover.net');
|
||||
return ['ok' => false, 'error' => 'Keine Antwort von Pushover.'];
|
||||
}
|
||||
|
||||
$data = json_decode($responseBody, true);
|
||||
if (!is_array($data) || $status >= 400 || (int)($data['status'] ?? 0) !== 1) {
|
||||
$errors = is_array($data['errors'] ?? null) ? implode('; ', $data['errors']) : 'HTTP ' . $status;
|
||||
error_log('Pushover fehlgeschlagen: ' . $errors);
|
||||
return ['ok' => false, 'error' => $errors];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldung ueber eine abgeschlossene Selbstregistrierung. Wird nur aus
|
||||
* register.php aufgerufen, nicht aus saas_register_tenant_owner() selbst -
|
||||
* sonst wuerden die check-*-Skripte in scripts/ bei jedem Testlauf Pushes
|
||||
* ausloesen.
|
||||
*/
|
||||
function pushover_notify_new_tenant(string $tenantName, string $tenantSlug, string $email, string $customerType): array
|
||||
{
|
||||
$type = $customerType === 'b2c' ? 'Privatkunde' : 'Geschäftskunde';
|
||||
$baseUrl = rtrim((string)app_env('APP_BASE_URL', ''), '/');
|
||||
|
||||
return pushover_notify(
|
||||
'Neue Registrierung: ' . $tenantName,
|
||||
sprintf("%s (%s)\nKürzel: %s\nE-Mail: %s", $tenantName, $type, $tenantSlug, $email),
|
||||
$baseUrl !== '' ? $baseUrl . '/backoffice.php' : null,
|
||||
'Back-Office öffnen'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
function app_client_ip(): string
|
||||
{
|
||||
return (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||||
}
|
||||
|
||||
/**
|
||||
* Records an attempt in the given bucket and reports whether the caller is
|
||||
* still within the allowed rate. Call before doing the sensitive work (auth
|
||||
* check, mail dispatch, account creation); if this returns false, show a
|
||||
* generic "too many attempts" message without processing the request, so a
|
||||
* bucket also can't be used to enumerate valid accounts by timing.
|
||||
*/
|
||||
function app_rate_limit_check(PDO $pdo, string $bucket, int $maxAttempts, int $windowSeconds): bool
|
||||
{
|
||||
$pdo->prepare('INSERT INTO rate_limit_attempts (bucket) VALUES (?)')->execute([$bucket]);
|
||||
|
||||
// Globaler Maximalhorizont: unabhängig davon, ob derselbe Bucket je wieder
|
||||
// benutzt wird, bleibt keine IP-basierte Rate-Limit-Zeile länger als 24h.
|
||||
$pdo->exec('DELETE FROM rate_limit_attempts WHERE created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)');
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM rate_limit_attempts WHERE bucket = ? AND created_at >= DATE_SUB(NOW(), INTERVAL ? SECOND)'
|
||||
);
|
||||
$stmt->execute([$bucket, $windowSeconds]);
|
||||
|
||||
return (int)$stmt->fetchColumn() <= $maxAttempts;
|
||||
}
|
||||
+420
-14
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/database.php';
|
||||
require_once __DIR__ . '/faq.php';
|
||||
require_once __DIR__ . '/billing.php';
|
||||
require_once __DIR__ . '/legal.php';
|
||||
|
||||
function saas_email_norm(string $email): string
|
||||
{
|
||||
@@ -53,7 +56,7 @@ function saas_normalize_host(?string $host): string
|
||||
|
||||
function saas_primary_app_host(): string
|
||||
{
|
||||
return saas_normalize_host(app_env('APP_PRIMARY_HOST', ''));
|
||||
return saas_normalize_host(app_env('APP_PRIMARY_HOST', app_primary_host() ?? ''));
|
||||
}
|
||||
|
||||
function saas_is_primary_app_host(?string $host = null): bool
|
||||
@@ -90,6 +93,7 @@ function saas_resolve_tenant_domain(PDO $pdo, ?string $host = null): ?array
|
||||
WHERE td.domain_norm = ?
|
||||
AND td.status = ?
|
||||
AND t.status = ?
|
||||
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$host, 'active', 'active']);
|
||||
@@ -164,6 +168,8 @@ function saas_current_user(?PDO $pdo = null): ?array
|
||||
t.id AS tenant_id,
|
||||
t.slug AS tenant_slug,
|
||||
t.name AS tenant_name,
|
||||
t.customer_type,
|
||||
t.contract_ends_at,
|
||||
t.status AS tenant_status,
|
||||
tm.role,
|
||||
tm.status AS membership_status
|
||||
@@ -175,6 +181,7 @@ function saas_current_user(?PDO $pdo = null): ?array
|
||||
AND u.status = ?
|
||||
AND tm.status = ?
|
||||
AND t.status = ?
|
||||
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$userId, $tenantId, 'active', 'active', 'active']);
|
||||
@@ -262,6 +269,7 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
|
||||
t.id AS tenant_id,
|
||||
t.slug,
|
||||
t.name,
|
||||
t.customer_type,
|
||||
t.status,
|
||||
t.timezone,
|
||||
t.locale,
|
||||
@@ -271,7 +279,23 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
|
||||
ts.paypal_enabled,
|
||||
ts.paypal_url_template,
|
||||
ts.sheet_window_days,
|
||||
ts.negative_warning_cents
|
||||
ts.negative_warning_cents,
|
||||
ts.pdf_show_empty_rows,
|
||||
ts.pdf_split_mode,
|
||||
ts.pdf_row_height_px,
|
||||
ts.pdf_watermark_text,
|
||||
ts.pdf_footer_text,
|
||||
ts.payment_reminder_enabled,
|
||||
ts.payment_reminder_interval_days,
|
||||
ts.pdf_watermark_logo,
|
||||
ts.cash_enabled,
|
||||
ts.cash_contact,
|
||||
ts.bank_transfer_enabled,
|
||||
ts.bank_account_holder,
|
||||
ts.bank_iban,
|
||||
ts.brand_logo,
|
||||
ts.brand_color,
|
||||
ts.watermark_enabled
|
||||
FROM tenants t
|
||||
LEFT JOIN tenant_settings ts ON ts.tenant_id = t.id
|
||||
WHERE t.id = ?
|
||||
@@ -294,6 +318,7 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
|
||||
'tenant_id' => (int)$settings['tenant_id'],
|
||||
'slug' => (string)$settings['slug'],
|
||||
'name' => (string)$settings['name'],
|
||||
'customer_type' => (string)$settings['customer_type'],
|
||||
'status' => (string)$settings['status'],
|
||||
'timezone' => (string)$settings['timezone'],
|
||||
'locale' => (string)$settings['locale'],
|
||||
@@ -304,12 +329,29 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
|
||||
'paypal_url_template' => (string)$settings['paypal_url_template'],
|
||||
'sheet_window_days' => (int)$settings['sheet_window_days'],
|
||||
'negative_warning_cents' => (int)$settings['negative_warning_cents'],
|
||||
'pdf_show_empty_rows' => (int)$settings['pdf_show_empty_rows'],
|
||||
'pdf_split_mode' => (string)$settings['pdf_split_mode'],
|
||||
'pdf_row_height_px' => (int)$settings['pdf_row_height_px'],
|
||||
'pdf_watermark_text' => (string)$settings['pdf_watermark_text'],
|
||||
'pdf_footer_text' => (string)$settings['pdf_footer_text'],
|
||||
'payment_reminder_enabled' => (int)$settings['payment_reminder_enabled'],
|
||||
'payment_reminder_interval_days' => (int)$settings['payment_reminder_interval_days'],
|
||||
'pdf_watermark_logo' => (string)$settings['pdf_watermark_logo'],
|
||||
'cash_enabled' => (int)$settings['cash_enabled'],
|
||||
'cash_contact' => (string)$settings['cash_contact'],
|
||||
'bank_transfer_enabled' => (int)$settings['bank_transfer_enabled'],
|
||||
'bank_account_holder' => (string)$settings['bank_account_holder'],
|
||||
'bank_iban' => (string)$settings['bank_iban'],
|
||||
'brand_logo' => (string)$settings['brand_logo'],
|
||||
'brand_color' => (string)$settings['brand_color'],
|
||||
'watermark_enabled' => (int)$settings['watermark_enabled'],
|
||||
];
|
||||
}
|
||||
|
||||
function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): array
|
||||
{
|
||||
$tenantName = trim((string)($input['tenant_name'] ?? ''));
|
||||
$customerType = (string)($input['customer_type'] ?? '');
|
||||
$timezone = trim((string)($input['timezone'] ?? 'Europe/Berlin'));
|
||||
$locale = trim((string)($input['locale'] ?? 'de-DE'));
|
||||
$currencyCode = strtoupper(trim((string)($input['currency_code'] ?? 'EUR')));
|
||||
@@ -317,11 +359,28 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
$sheetWindowDays = filter_var($input['sheet_window_days'] ?? null, FILTER_VALIDATE_INT);
|
||||
$negativeWarningCents = saas_parse_money_cents($input['negative_warning'] ?? '');
|
||||
$paypalUrlTemplate = trim((string)($input['paypal_url_template'] ?? ''));
|
||||
$cashContact = trim((string)($input['cash_contact'] ?? ''));
|
||||
$bankAccountHolder = trim((string)($input['bank_account_holder'] ?? ''));
|
||||
$bankIban = strtoupper(preg_replace('/\s+/', '', (string)($input['bank_iban'] ?? '')));
|
||||
$pdfSplitMode = trim((string)($input['pdf_split_mode'] ?? 'drinking_behavior'));
|
||||
$pdfRowHeightPx = filter_var($input['pdf_row_height_px'] ?? null, FILTER_VALIDATE_INT);
|
||||
$pdfWatermarkText = trim((string)($input['pdf_watermark_text'] ?? ''));
|
||||
$pdfFooterText = trim((string)($input['pdf_footer_text'] ?? ''));
|
||||
$paymentReminderIntervalDays = filter_var($input['payment_reminder_interval_days'] ?? null, FILTER_VALIDATE_INT);
|
||||
$brandColor = strtolower(trim((string)($input['brand_color'] ?? '')));
|
||||
|
||||
$errors = [];
|
||||
// Leer = Standarddesign. Sonst ein vollstaendiger Hex-Wert, der ohne
|
||||
// weitere Aufbereitung in das erzeugte Stylesheet geschrieben werden kann.
|
||||
if ($brandColor !== '' && !preg_match('/^#[0-9a-f]{6}$/', $brandColor)) {
|
||||
$errors[] = 'Die Akzentfarbe muss ein Hex-Wert wie #38761d sein.';
|
||||
}
|
||||
if (strlen($tenantName) < 3 || strlen($tenantName) > 255) {
|
||||
$errors[] = 'Der Kundenname muss zwischen 3 und 255 Zeichen lang sein.';
|
||||
}
|
||||
if (!in_array($customerType, ['business', 'consumer'], true)) {
|
||||
$errors[] = 'Bitte gib an, ob der Vertrag geschäftlich oder als Verbraucher geführt wird.';
|
||||
}
|
||||
if (!in_array($timezone, DateTimeZone::listIdentifiers(), true)) {
|
||||
$errors[] = 'Die Zeitzone ist ungültig.';
|
||||
}
|
||||
@@ -343,6 +402,30 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
if (strlen($paypalUrlTemplate) > 1000) {
|
||||
$errors[] = 'Der PayPal-Link ist zu lang.';
|
||||
}
|
||||
if (!in_array($pdfSplitMode, ['drinking_behavior', 'alphabetical'], true)) {
|
||||
$errors[] = 'Die Sortierung für den Ausdruck ist ungültig.';
|
||||
}
|
||||
if (strlen($pdfWatermarkText) > 500) {
|
||||
$errors[] = 'Der Wasserzeichen-Text im Ausdruck ist zu lang (max. 500 Zeichen).';
|
||||
}
|
||||
if (strlen($pdfFooterText) > 500) {
|
||||
$errors[] = 'Der Fußzeilen-Hinweis im Ausdruck ist zu lang (max. 500 Zeichen).';
|
||||
}
|
||||
if ($pdfRowHeightPx === false || $pdfRowHeightPx < 10 || $pdfRowHeightPx > 60) {
|
||||
$errors[] = 'Die Zeilenhöhe im Ausdruck muss zwischen 10 und 60 Pixel liegen.';
|
||||
}
|
||||
if ($paymentReminderIntervalDays === false || $paymentReminderIntervalDays < 1 || $paymentReminderIntervalDays > 90) {
|
||||
$errors[] = 'Das Erinnerungs-Intervall muss zwischen 1 und 90 Tagen liegen.';
|
||||
}
|
||||
if (strlen($cashContact) > 255) {
|
||||
$errors[] = 'Der Bar-Ansprechpartner ist zu lang (max. 255 Zeichen).';
|
||||
}
|
||||
if (strlen($bankAccountHolder) > 255) {
|
||||
$errors[] = 'Der Kontoinhaber ist zu lang (max. 255 Zeichen).';
|
||||
}
|
||||
if ($bankIban !== '' && !preg_match('/^[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}$/', $bankIban)) {
|
||||
$errors[] = 'Die IBAN hat kein gültiges Format.';
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
return ['ok' => false, 'errors' => $errors];
|
||||
@@ -350,29 +433,62 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
|
||||
$selfEntryEnabled = !empty($input['self_entry_enabled']) ? 1 : 0;
|
||||
$paypalEnabled = !empty($input['paypal_enabled']) ? 1 : 0;
|
||||
$cashEnabled = !empty($input['cash_enabled']) ? 1 : 0;
|
||||
$bankTransferEnabled = !empty($input['bank_transfer_enabled']) ? 1 : 0;
|
||||
$pdfShowEmptyRows = !empty($input['pdf_show_empty_rows']) ? 1 : 0;
|
||||
$paymentReminderEnabled = !empty($input['payment_reminder_enabled']) ? 1 : 0;
|
||||
|
||||
// Der Hinweis auf die Kaffeeliste laesst sich abschalten - ausser im
|
||||
// kostenlosen Tarif. Die Pruefung steht hier und nicht nur im Formular,
|
||||
// damit auch ein nachgebauter POST das Kaestchen nicht leeren kann. Vor
|
||||
// der Transaktion, weil billing_fetch_or_init() eine fehlende Zeile
|
||||
// anlegt.
|
||||
$watermarkEnabled = !empty($input['watermark_enabled']) ? 1 : 0;
|
||||
if (billing_fetch_or_init($pdo, $tenantId)['plan_code'] === 'free') {
|
||||
$watermarkEnabled = 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE tenants
|
||||
SET name = ?, timezone = ?, locale = ?, currency_code = ?
|
||||
SET name = ?, customer_type = ?, timezone = ?, locale = ?, currency_code = ?
|
||||
WHERE id = ?'
|
||||
);
|
||||
$stmt->execute([$tenantName, $timezone, $locale, $currencyCode, $tenantId]);
|
||||
$stmt->execute([$tenantName, $customerType, $timezone, $locale, $currencyCode, $tenantId]);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO tenant_settings
|
||||
(tenant_id, mark_price_cents, self_entry_enabled, paypal_enabled,
|
||||
paypal_url_template, sheet_window_days, negative_warning_cents)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
paypal_url_template, sheet_window_days, negative_warning_cents,
|
||||
pdf_show_empty_rows, pdf_split_mode, pdf_row_height_px,
|
||||
pdf_watermark_text, pdf_footer_text,
|
||||
payment_reminder_enabled, payment_reminder_interval_days,
|
||||
cash_enabled, cash_contact, bank_transfer_enabled,
|
||||
bank_account_holder, bank_iban, brand_color, watermark_enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
mark_price_cents = VALUES(mark_price_cents),
|
||||
self_entry_enabled = VALUES(self_entry_enabled),
|
||||
paypal_enabled = VALUES(paypal_enabled),
|
||||
paypal_url_template = VALUES(paypal_url_template),
|
||||
sheet_window_days = VALUES(sheet_window_days),
|
||||
negative_warning_cents = VALUES(negative_warning_cents)'
|
||||
negative_warning_cents = VALUES(negative_warning_cents),
|
||||
pdf_show_empty_rows = VALUES(pdf_show_empty_rows),
|
||||
pdf_split_mode = VALUES(pdf_split_mode),
|
||||
pdf_row_height_px = VALUES(pdf_row_height_px),
|
||||
pdf_watermark_text = VALUES(pdf_watermark_text),
|
||||
pdf_footer_text = VALUES(pdf_footer_text),
|
||||
payment_reminder_enabled = VALUES(payment_reminder_enabled),
|
||||
payment_reminder_interval_days = VALUES(payment_reminder_interval_days),
|
||||
cash_enabled = VALUES(cash_enabled),
|
||||
cash_contact = VALUES(cash_contact),
|
||||
bank_transfer_enabled = VALUES(bank_transfer_enabled),
|
||||
bank_account_holder = VALUES(bank_account_holder),
|
||||
bank_iban = VALUES(bank_iban),
|
||||
brand_color = VALUES(brand_color),
|
||||
watermark_enabled = VALUES(watermark_enabled)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$tenantId,
|
||||
@@ -382,6 +498,20 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
$paypalUrlTemplate,
|
||||
(int)$sheetWindowDays,
|
||||
-abs($negativeWarningCents),
|
||||
$pdfShowEmptyRows,
|
||||
$pdfSplitMode,
|
||||
(int)$pdfRowHeightPx,
|
||||
$pdfWatermarkText,
|
||||
$pdfFooterText,
|
||||
$paymentReminderEnabled,
|
||||
(int)$paymentReminderIntervalDays,
|
||||
$cashEnabled,
|
||||
$cashContact,
|
||||
$bankTransferEnabled,
|
||||
$bankAccountHolder,
|
||||
$bankIban,
|
||||
$brandColor,
|
||||
$watermarkEnabled,
|
||||
]);
|
||||
|
||||
$pdo->commit();
|
||||
@@ -435,7 +565,8 @@ function saas_find_auth_identity(PDO $pdo, string $email, string $tenantSlug = '
|
||||
WHERE u.email_norm = ?
|
||||
AND u.status = ?
|
||||
AND tm.status = ?
|
||||
AND t.status = ?' . $tenantFilter . '
|
||||
AND t.status = ?
|
||||
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())' . $tenantFilter . '
|
||||
ORDER BY
|
||||
CASE tm.role
|
||||
WHEN \'owner\' THEN 1
|
||||
@@ -474,6 +605,7 @@ function saas_list_user_memberships(PDO $pdo, int $userId): array
|
||||
AND u.status = ?
|
||||
AND tm.status = ?
|
||||
AND t.status = ?
|
||||
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())
|
||||
ORDER BY
|
||||
CASE tm.role
|
||||
WHEN \'owner\' THEN 1
|
||||
@@ -511,6 +643,7 @@ function saas_identity_for_user_tenant(PDO $pdo, int $userId, int $tenantId): ?a
|
||||
AND u.status = ?
|
||||
AND tm.status = ?
|
||||
AND t.status = ?
|
||||
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$userId, $tenantId, 'active', 'active', 'active']);
|
||||
@@ -667,8 +800,17 @@ function saas_reset_password_with_token(PDO $pdo, string $token, string $passwor
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
$pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
|
||||
->execute([password_hash($password, PASSWORD_DEFAULT), (int)$tokenRow['user_id']]);
|
||||
// Wer den per Mail zugestellten Link oeffnen konnte, hat den Zugriff
|
||||
// auf das Postfach nachgewiesen - fachlich dasselbe wie eine
|
||||
// Verifikation. Das betrifft vor allem eingeladene Mitglieder: die
|
||||
// Zugangsvergabe laeuft ueber genau diesen Reset-Link, ohne dass je
|
||||
// eine separate Verifikationsmail verschickt wird.
|
||||
$pdo->prepare(
|
||||
'UPDATE users
|
||||
SET password_hash = ?,
|
||||
email_verified_at = COALESCE(email_verified_at, NOW())
|
||||
WHERE id = ?'
|
||||
)->execute([password_hash($password, PASSWORD_DEFAULT), (int)$tokenRow['user_id']]);
|
||||
$pdo->prepare('UPDATE user_auth_tokens SET consumed_at = NOW() WHERE id = ?')
|
||||
->execute([(int)$tokenRow['id']]);
|
||||
$pdo->commit();
|
||||
@@ -686,6 +828,121 @@ function saas_reset_password_with_token(PDO $pdo, string $token, string $passwor
|
||||
return ['ok' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwortwechsel fuer den bereits angemeldeten Nutzer. Bewusst getrennt vom
|
||||
* Token-Reset: hier weist das aktuelle Passwort die Identitaet nach, nicht
|
||||
* ein Mail-Link. Setzt anders als der Reset keine E-Mail-Verifikation, weil
|
||||
* dabei nichts ueber das Postfach nachgewiesen wird.
|
||||
*/
|
||||
function saas_change_password(
|
||||
PDO $pdo,
|
||||
int $userId,
|
||||
string $currentPassword,
|
||||
string $password,
|
||||
string $passwordConfirm
|
||||
): array {
|
||||
$errors = [];
|
||||
if (strlen($password) < 8) {
|
||||
$errors[] = 'Das neue Passwort muss mindestens 8 Zeichen lang sein.';
|
||||
}
|
||||
if ($password !== $passwordConfirm) {
|
||||
$errors[] = 'Die Passwort-Wiederholung stimmt nicht.';
|
||||
}
|
||||
if ($currentPassword !== '' && $password === $currentPassword) {
|
||||
$errors[] = 'Das neue Passwort muss sich vom bisherigen unterscheiden.';
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
return ['ok' => false, 'errors' => $errors];
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT password_hash, status FROM users WHERE id = ? LIMIT 1');
|
||||
$stmt->execute([$userId]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if ($user === false || (string)$user['status'] !== 'active') {
|
||||
return ['ok' => false, 'errors' => ['Dieses Benutzerkonto ist nicht aktiv.']];
|
||||
}
|
||||
|
||||
$hash = (string)($user['password_hash'] ?? '');
|
||||
if ($hash === '' || !password_verify($currentPassword, $hash)) {
|
||||
return ['ok' => false, 'errors' => ['Das aktuelle Passwort ist nicht korrekt.']];
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
|
||||
->execute([password_hash($password, PASSWORD_DEFAULT), $userId]);
|
||||
} catch (Throwable $e) {
|
||||
return ['ok' => false, 'errors' => ['Das Passwort konnte nicht gespeichert werden.']];
|
||||
}
|
||||
|
||||
// Die eigene Session bleibt bestehen, bekommt aber eine neue ID, damit ein
|
||||
// eventuell mitgelesener Session-Bezeichner nach dem Wechsel wertlos ist.
|
||||
if (PHP_SAPI !== 'cli' && session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
|
||||
return ['ok' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestaetigt das Passwort eines bereits angemeldeten Kontos, ohne es zu
|
||||
* aendern. Gedacht fuer Aktionen, die eine bestehende Sitzung allein nicht
|
||||
* rechtfertigen soll - etwa das Abschalten des zweiten Faktors.
|
||||
*/
|
||||
function saas_password_matches(PDO $pdo, int $userId, string $password): bool
|
||||
{
|
||||
if ($password === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT password_hash, status FROM users WHERE id = ? LIMIT 1');
|
||||
$stmt->execute([$userId]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if ($user === false || (string)$user['status'] !== 'active') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hash = (string)($user['password_hash'] ?? '');
|
||||
|
||||
return $hash !== '' && password_verify($password, $hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hat der Nutzer seine E-Mail-Adresse nachgewiesen? Wird fuer Aktionen
|
||||
* geprueft, die Mails an Dritte ausloesen oder fremde Konten anlegen -
|
||||
* siehe saas_require_verified_email().
|
||||
*/
|
||||
function saas_email_verified(?array $user = null): bool
|
||||
{
|
||||
$user = $user ?? saas_current_user();
|
||||
|
||||
return $user !== null && ($user['email_verified_at'] ?? null) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sperrt Aktionen, die aus dem Konto heraus nach aussen wirken (Mitglieder
|
||||
* einladen, Massenmail, Jahresabschluss-Mails), solange die eigene Adresse
|
||||
* nicht bestaetigt ist. Bewusst nicht auf den gesamten Login angewendet: die
|
||||
* eigene Kaffeeliste darf man auch unbestaetigt fuehren, nur nicht im Namen
|
||||
* einer moeglicherweise fremden Adresse Mails an Dritte ausloesen.
|
||||
*/
|
||||
function saas_require_verified_email(?array $user = null): array
|
||||
{
|
||||
$user = $user ?? saas_require_login();
|
||||
|
||||
if (saas_email_verified($user)) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
http_response_code(403);
|
||||
die(
|
||||
'Für diese Aktion muss die eigene E-Mail-Adresse bestätigt sein. '
|
||||
. 'Den Bestätigungslink kannst du unter "Kundenkonto" erneut anfordern.'
|
||||
);
|
||||
}
|
||||
|
||||
function saas_request_email_verification(PDO $pdo, int $userId, ?int $tenantId = null): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
@@ -802,7 +1059,8 @@ function saas_authenticate(PDO $pdo, string $email, string $password, string $te
|
||||
JOIN tenants t ON t.id = tm.tenant_id
|
||||
WHERE tm.user_id = ?
|
||||
AND tm.status = ?
|
||||
AND t.status = ?' . $tenantFilter . '
|
||||
AND t.status = ?
|
||||
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())' . $tenantFilter . '
|
||||
ORDER BY
|
||||
CASE tm.role
|
||||
WHEN \'owner\' THEN 1
|
||||
@@ -848,6 +1106,132 @@ function saas_authenticate(PDO $pdo, string $email, string $password, string $te
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Roles an admin can hand out through the member management UI. "owner" is
|
||||
* deliberately excluded here; it is only ever set during registration.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
function saas_grantable_roles(): array
|
||||
{
|
||||
return ['member', 'treasurer', 'admin'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants (or updates) tenant access for an existing participant: creates the
|
||||
* login account if needed, links it to the participant, and (re)activates
|
||||
* the tenant membership with the given role. The participant's name/email
|
||||
* stay the notification identity regardless of login state.
|
||||
*
|
||||
* @return array{ok: bool, errors: list<string>, token?: string, user_email?: string}
|
||||
*/
|
||||
function saas_grant_participant_access(PDO $pdo, int $tenantId, int $participantId, string $role): array
|
||||
{
|
||||
if (!in_array($role, saas_grantable_roles(), true)) {
|
||||
return ['ok' => false, 'errors' => ['Ungültige Rolle.']];
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, display_name, email, email_norm
|
||||
FROM participants
|
||||
WHERE id = ? AND tenant_id = ?'
|
||||
);
|
||||
$stmt->execute([$participantId, $tenantId]);
|
||||
$participant = $stmt->fetch();
|
||||
if ($participant === false) {
|
||||
return ['ok' => false, 'errors' => ['Teilnehmer wurde nicht gefunden.']];
|
||||
}
|
||||
|
||||
$emailNorm = (string)($participant['email_norm'] ?? '');
|
||||
if ($emailNorm === '' || !filter_var($participant['email'], FILTER_VALIDATE_EMAIL)) {
|
||||
return ['ok' => false, 'errors' => ['Für den Zugang wird eine gültige E-Mail-Adresse benötigt.']];
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id, status FROM users WHERE email_norm = ? LIMIT 1');
|
||||
$stmt->execute([$emailNorm]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if ($user === false) {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO users (email, email_norm, display_name, status) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([$participant['email'], $emailNorm, $participant['display_name'], 'active']);
|
||||
$userId = (int)$pdo->lastInsertId();
|
||||
} elseif ((string)$user['status'] !== 'active') {
|
||||
$pdo->rollBack();
|
||||
|
||||
return ['ok' => false, 'errors' => ['Dieses Benutzerkonto ist nicht aktiv.']];
|
||||
} else {
|
||||
$userId = (int)$user['id'];
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status, invited_at, joined_at)
|
||||
VALUES (?, ?, ?, ?, NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
role = VALUES(role),
|
||||
status = VALUES(status),
|
||||
invited_at = COALESCE(tenant_memberships.invited_at, VALUES(invited_at)),
|
||||
joined_at = COALESCE(tenant_memberships.joined_at, VALUES(joined_at))'
|
||||
);
|
||||
$stmt->execute([$tenantId, $userId, $role, 'active']);
|
||||
|
||||
$pdo->prepare('UPDATE participants SET user_id = ? WHERE id = ? AND tenant_id = ?')
|
||||
->execute([$userId, $participantId, $tenantId]);
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
|
||||
return ['ok' => false, 'errors' => ['Der Zugang konnte nicht angelegt werden.']];
|
||||
}
|
||||
|
||||
$token = saas_create_auth_token($pdo, $userId, 'password_reset', $tenantId, 1440);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'errors' => [],
|
||||
'token' => $token['token'],
|
||||
'user_email' => (string)$participant['email'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes tenant access without touching the participant's name/email or
|
||||
* deleting the membership row, so the grant can be reinstated later and the
|
||||
* history stays auditable. Owners cannot be revoked here.
|
||||
*
|
||||
* @return array{ok: bool, errors: list<string>}
|
||||
*/
|
||||
function saas_revoke_participant_access(PDO $pdo, int $tenantId, int $participantId): array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT user_id FROM participants WHERE id = ? AND tenant_id = ?');
|
||||
$stmt->execute([$participantId, $tenantId]);
|
||||
$participant = $stmt->fetch();
|
||||
if ($participant === false || $participant['user_id'] === null) {
|
||||
return ['ok' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
$userId = (int)$participant['user_id'];
|
||||
|
||||
$stmt = $pdo->prepare('SELECT role FROM tenant_memberships WHERE tenant_id = ? AND user_id = ?');
|
||||
$stmt->execute([$tenantId, $userId]);
|
||||
$membership = $stmt->fetch();
|
||||
if ($membership !== false && (string)$membership['role'] === 'owner') {
|
||||
return ['ok' => false, 'errors' => ['Dem Inhaber kann der Zugang nicht entzogen werden.']];
|
||||
}
|
||||
|
||||
$pdo->prepare("UPDATE tenant_memberships SET status = 'revoked' WHERE tenant_id = ? AND user_id = ?")
|
||||
->execute([$tenantId, $userId]);
|
||||
|
||||
return ['ok' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
function saas_register_tenant_owner(PDO $pdo, array $input): array
|
||||
{
|
||||
$tenantName = trim((string)($input['tenant_name'] ?? ''));
|
||||
@@ -861,6 +1245,7 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
|
||||
$emailNorm = saas_email_norm($email);
|
||||
$password = (string)($input['password'] ?? '');
|
||||
$passwordConfirm = (string)($input['password_confirm'] ?? '');
|
||||
$customerType = (string)($input['customer_type'] ?? '');
|
||||
|
||||
$errors = [];
|
||||
if (strlen($tenantName) < 3 || strlen($tenantName) > 255) {
|
||||
@@ -881,6 +1266,18 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
|
||||
if ($password !== $passwordConfirm) {
|
||||
$errors[] = 'Die Passwort-Wiederholung stimmt nicht.';
|
||||
}
|
||||
if (!in_array($customerType, ['business', 'consumer'], true)) {
|
||||
$errors[] = 'Bitte gib an, ob du als Unternehmen oder als Verbraucher handelst.';
|
||||
}
|
||||
if (empty($input['accept_terms'])) {
|
||||
$errors[] = 'Bitte akzeptiere die AGB.';
|
||||
}
|
||||
if (empty($input['acknowledge_privacy'])) {
|
||||
$errors[] = 'Bitte bestätige, dass du die Datenschutzerklärung zur Kenntnis genommen hast.';
|
||||
}
|
||||
if (empty($input['accept_dpa'])) {
|
||||
$errors[] = 'Bitte vereinbare den AVV für den Fall, dass du Daten anderer Personen verwaltest.';
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
return ['ok' => false, 'errors' => $errors];
|
||||
@@ -920,10 +1317,10 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO tenants (slug, name, status, timezone, locale, currency_code)
|
||||
VALUES (?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO tenants (slug, name, customer_type, status, timezone, locale, currency_code)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([$tenantSlug, $tenantName, 'active', 'Europe/Berlin', 'de-DE', 'EUR']);
|
||||
$stmt->execute([$tenantSlug, $tenantName, $customerType, 'active', 'Europe/Berlin', 'de-DE', 'EUR']);
|
||||
$tenantId = (int)$pdo->lastInsertId();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
@@ -933,6 +1330,9 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
|
||||
);
|
||||
$stmt->execute([$tenantId, 20, 1, 0, '']);
|
||||
|
||||
faq_seed_default_entries($pdo, $tenantId);
|
||||
billing_fetch_or_init($pdo, $tenantId);
|
||||
|
||||
if ($existingUser === false) {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO users (email, email_norm, display_name, password_hash, status)
|
||||
@@ -963,6 +1363,11 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
|
||||
);
|
||||
$stmt->execute([$tenantId, $userId, $displayName, $email, $emailNorm, 1]);
|
||||
|
||||
$acceptanceMetadata = ['customer_type' => $customerType];
|
||||
app_record_legal_acceptance($pdo, $tenantId, $userId, 'terms', 'registration', $acceptanceMetadata);
|
||||
app_record_legal_acceptance($pdo, $tenantId, $userId, 'privacy_notice', 'registration', $acceptanceMetadata);
|
||||
app_record_legal_acceptance($pdo, $tenantId, $userId, 'dpa', 'registration', $acceptanceMetadata);
|
||||
|
||||
$verificationToken = saas_create_auth_token($pdo, $userId, 'email_verification', $tenantId, 1440);
|
||||
|
||||
$pdo->commit();
|
||||
@@ -987,6 +1392,7 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
|
||||
'tenant_id' => $tenantId,
|
||||
'tenant_slug' => $tenantSlug,
|
||||
'tenant_name' => $tenantName,
|
||||
'customer_type' => $customerType,
|
||||
'role' => 'owner',
|
||||
],
|
||||
'email_verification_token' => $verificationToken['token'],
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/legal.php';
|
||||
|
||||
function saas_mail_transport(): string
|
||||
{
|
||||
@@ -131,6 +132,124 @@ function saas_send_password_reset_mail(string $to, string $token): array
|
||||
return saas_send_mail($to, 'Kaffeeliste Passwort zurücksetzen', $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the tenant-generic balance notification body. Replaces the old
|
||||
* mailversenden.php text, which hardcoded one customer's SMTP host,
|
||||
* PayPal link and FAQ URL and therefore only ever worked for that one
|
||||
* tenant.
|
||||
*/
|
||||
function saas_render_balance_mail_body(string $displayName, int $balanceCents, array $tenantSettings, string $dashboardUrl): string
|
||||
{
|
||||
$amount = saas_format_money_cents(abs($balanceCents)) . ' €';
|
||||
$lines = ["Hallo {$displayName},", ''];
|
||||
|
||||
if ($balanceCents < 0) {
|
||||
$lines[] = "dein aktueller Kaffeelisten-Stand ist im Minus: {$amount}.";
|
||||
$lines[] = "Bitte gleiche den Betrag bei Gelegenheit aus.";
|
||||
if ((int)($tenantSettings['paypal_enabled'] ?? 0) === 1 && trim((string)($tenantSettings['paypal_url_template'] ?? '')) !== '') {
|
||||
$paypalLink = trim((string)$tenantSettings['paypal_url_template']) . number_format(abs($balanceCents) / 100, 2, '.', '');
|
||||
$lines[] = '';
|
||||
$lines[] = "Direkt bezahlen: {$paypalLink}";
|
||||
}
|
||||
} else {
|
||||
$lines[] = "dein aktuelles Guthaben in der Kaffeeliste beträgt {$amount}.";
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
$lines[] = "Deinen aktuellen Stand findest du hier: {$dashboardUrl}";
|
||||
$lines[] = '';
|
||||
$lines[] = 'Deine Kaffeeliste';
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
function saas_log_outbound_email(
|
||||
PDO $pdo,
|
||||
int $tenantId,
|
||||
?int $participantId,
|
||||
string $template,
|
||||
string $subject,
|
||||
string $status,
|
||||
?string $error,
|
||||
?int $createdByUserId
|
||||
): int {
|
||||
$pdo->exec('DELETE FROM outbound_emails WHERE created_at < DATE_SUB(NOW(), INTERVAL 180 DAY)');
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO outbound_emails
|
||||
(tenant_id, participant_id, template, subject, status, sent_at, error, created_by_user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$tenantId,
|
||||
$participantId,
|
||||
$template,
|
||||
$subject,
|
||||
$status,
|
||||
in_array($status, ['sent', 'failed'], true) ? date('Y-m-d H:i:s') : null,
|
||||
$error,
|
||||
$createdByUserId,
|
||||
]);
|
||||
|
||||
return (int)$pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int, participant_id: ?int, display_name: ?string, template: string, subject: string, status: string, sent_at: ?string, error: ?string, created_at: string}>
|
||||
*/
|
||||
function saas_fetch_outbound_email_log(PDO $pdo, int $tenantId, int $limit = 50): array
|
||||
{
|
||||
$limit = max(1, min($limit, 200));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT o.id, o.participant_id, p.display_name, o.template, o.subject, o.status, o.sent_at, o.error, o.created_at
|
||||
FROM outbound_emails o
|
||||
LEFT JOIN participants p ON p.id = o.participant_id
|
||||
WHERE o.tenant_id = ?
|
||||
ORDER BY o.created_at DESC, o.id DESC
|
||||
LIMIT {$limit}"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function saas_render_year_end_bonus_mail_body(string $displayName, int $yearMarks, int $bonusCents, string $dashboardUrl): string
|
||||
{
|
||||
$bonus = saas_format_money_cents($bonusCents) . ' €';
|
||||
$lines = [
|
||||
"Hallo {$displayName},",
|
||||
'',
|
||||
"vielen Dank für deine Nutzung der Kaffeeliste in diesem Jahr!",
|
||||
"Du hast dieses Jahr {$yearMarks} Striche gemacht.",
|
||||
"Dafür wurden dir {$bonus} auf deinem Kaffeelisten-Konto gutgeschrieben.",
|
||||
'',
|
||||
"Deinen aktuellen Stand findest du hier: {$dashboardUrl}",
|
||||
'',
|
||||
'Deine Kaffeeliste',
|
||||
];
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
function saas_send_invite_mail(string $to, string $tenantName, string $role, string $token): array
|
||||
{
|
||||
$link = saas_app_url('passwort-zuruecksetzen.php?token=' . urlencode($token));
|
||||
$body = implode("\n", [
|
||||
'Hallo,',
|
||||
'',
|
||||
"du wurdest für \"{$tenantName}\" in der Kaffeeliste als {$role} eingeladen.",
|
||||
'Öffne diesen Link, um ein Passwort zu setzen und dich anzumelden:',
|
||||
'',
|
||||
$link,
|
||||
'',
|
||||
'Der Link ist zeitlich begrenzt und kann nur einmal verwendet werden.',
|
||||
'Falls du diese Einladung nicht erwartet hast, kannst du diese Nachricht ignorieren.',
|
||||
'',
|
||||
'Deine Kaffeeliste',
|
||||
]);
|
||||
|
||||
return saas_send_mail($to, 'Einladung zur Kaffeeliste', $body);
|
||||
}
|
||||
|
||||
function saas_send_email_verification_mail(string $to, string $token): array
|
||||
{
|
||||
$link = saas_app_url('email-verifizieren.php?token=' . urlencode($token));
|
||||
@@ -149,3 +268,124 @@ function saas_send_email_verification_mail(string $to, string $token): array
|
||||
|
||||
return saas_send_mail($to, 'Kaffeeliste E-Mail bestätigen', $body);
|
||||
}
|
||||
|
||||
function saas_send_registration_contract_mail(string $to, string $tenantName, string $tenantSlug, string $customerType): array
|
||||
{
|
||||
$body = implode("\n", [
|
||||
'Vertragsbestätigung Kaffeeliste',
|
||||
'',
|
||||
'Kunde: ' . $tenantName,
|
||||
'Kundenkürzel: ' . $tenantSlug,
|
||||
'Kundentyp: ' . ($customerType === 'consumer' ? 'Verbraucher' : 'Unternehmer'),
|
||||
'Tarif: Kostenlos, 0,00 € pro Monat, unbefristet, jederzeit kündbar',
|
||||
'Vertragsschluss: ' . date('d.m.Y H:i') . ' Uhr (Europe/Berlin)',
|
||||
'',
|
||||
'Nachfolgend erhältst du die bei Vertragsschluss geltenden Texte auf einem dauerhaften Datenträger.',
|
||||
'',
|
||||
'================ AGB ================',
|
||||
app_legal_document_text('terms'),
|
||||
'',
|
||||
'================ AVV ================',
|
||||
app_legal_document_text('dpa'),
|
||||
'',
|
||||
'========== WIDERRUFSBELEHRUNG ==========',
|
||||
app_legal_document_text('withdrawal_information'),
|
||||
]);
|
||||
|
||||
return saas_send_mail($to, 'Kaffeeliste Vertragsbestätigung – kostenloser Tarif', $body);
|
||||
}
|
||||
|
||||
function saas_send_paid_contract_confirmation(PDO $pdo, int $tenantId, string $planCode): array
|
||||
{
|
||||
require_once __DIR__ . '/billing.php';
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT t.name, t.slug, t.customer_type, u.email
|
||||
FROM tenants t
|
||||
JOIN tenant_memberships tm ON tm.tenant_id = t.id AND tm.role = 'owner' AND tm.status = 'active'
|
||||
JOIN users u ON u.id = tm.user_id AND u.status = 'active'
|
||||
WHERE t.id = ? ORDER BY tm.id LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$contract = $stmt->fetch();
|
||||
$plan = billing_plans()[$planCode] ?? null;
|
||||
if ($contract === false || $plan === null) {
|
||||
return ['ok' => false, 'transport' => saas_mail_transport(), 'error' => 'contract confirmation data missing'];
|
||||
}
|
||||
|
||||
$body = implode("\n", [
|
||||
'Vertragsbestätigung Kaffeeliste – kostenpflichtiger Tarif',
|
||||
'',
|
||||
'Kunde: ' . $contract['name'],
|
||||
'Kundenkürzel: ' . $contract['slug'],
|
||||
'Kundentyp: ' . ($contract['customer_type'] === 'consumer' ? 'Verbraucher' : 'Unternehmer'),
|
||||
'Tarif: ' . $plan['label'],
|
||||
'Gesamtpreis: ' . number_format(((int)$plan['price_cents']) / 100, 2, ',', '') . ' € pro Monat',
|
||||
'Laufzeit: unbefristet; monatliche Abrechnung; keine längere Mindestlaufzeit',
|
||||
'Kündigung: jederzeit zum Ende der laufenden Abrechnungsperiode',
|
||||
'Bestätigung: ' . date('d.m.Y H:i') . ' Uhr (Europe/Berlin)',
|
||||
'',
|
||||
'================ AGB ================',
|
||||
app_legal_document_text('terms'),
|
||||
'',
|
||||
'========== WIDERRUFSBELEHRUNG ==========',
|
||||
app_legal_document_text('withdrawal_information'),
|
||||
]);
|
||||
|
||||
return saas_send_mail((string)$contract['email'], 'Kaffeeliste Vertragsbestätigung – ' . $plan['label'], $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Textkoerper der automatischen Zahlungserinnerung an ein Mitglied mit
|
||||
* offenem Betrag oberhalb der Warnschwelle des Mandanten.
|
||||
*/
|
||||
function saas_render_payment_reminder_mail_body(string $displayName, int $debtCents, array $tenantSettings, string $dashboardUrl): string
|
||||
{
|
||||
$amount = saas_format_money_cents($debtCents) . ' €';
|
||||
$lines = [
|
||||
"Hallo {$displayName},",
|
||||
'',
|
||||
"auf deinem Kaffeelisten-Konto ist aktuell ein offener Betrag von {$amount} aufgelaufen.",
|
||||
"Bitte gleiche ihn bei Gelegenheit aus.",
|
||||
];
|
||||
|
||||
if ((int)($tenantSettings['paypal_enabled'] ?? 0) === 1 && trim((string)($tenantSettings['paypal_url_template'] ?? '')) !== '') {
|
||||
$paypalLink = trim((string)$tenantSettings['paypal_url_template']) . number_format($debtCents / 100, 2, '.', '');
|
||||
$lines[] = '';
|
||||
$lines[] = "Direkt bezahlen: {$paypalLink}";
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
$lines[] = "Deinen aktuellen Stand findest du hier: {$dashboardUrl}";
|
||||
$lines[] = '';
|
||||
$lines[] = 'Deine Kaffeeliste';
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert das Datum der letzten Zahlungserinnerung je Teilnehmer eines
|
||||
* Mandanten (nur tatsaechlich versendete). Fuer die Intervall-Pruefung im
|
||||
* Erinnerungs-Cron, damit niemand haeufiger als eingestellt erinnert wird.
|
||||
*
|
||||
* @return array<int, string> participant_id => letztes sent_at ('Y-m-d H:i:s')
|
||||
*/
|
||||
function saas_fetch_last_payment_reminders(PDO $pdo, int $tenantId): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT participant_id, MAX(sent_at) AS last_sent
|
||||
FROM outbound_emails
|
||||
WHERE tenant_id = ?
|
||||
AND template = 'payment_reminder'
|
||||
AND status = 'sent'
|
||||
AND participant_id IS NOT NULL
|
||||
GROUP BY participant_id"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
|
||||
$map = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$map[(int)$row['participant_id']] = (string)$row['last_sent'];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/rate-limit.php';
|
||||
|
||||
/**
|
||||
* Schutz oeffentlicher Formulare gegen automatisierte Massenanlage.
|
||||
*
|
||||
* Hintergrund: Auf der Testumgebung liefen ab Juli 2026 taeglich 10-20
|
||||
* Registrierungen eines Bots, der zufaellige Namen mit *fremden*, echten
|
||||
* E-Mail-Adressen kombinierte. Der Server verschickte daraufhin
|
||||
* Vertrags- und Verifikationsmail an Leute, die nie etwas bestellt hatten -
|
||||
* Mail-Bombing ueber die eigene Domain, auf Kosten der Zustellbarkeit.
|
||||
*
|
||||
* Das IP-Rate-Limit half dagegen nicht: die Anfragen kamen ueber wechselnde
|
||||
* Rechenzentrums-IPs mit je einem Versuch. Deshalb hier bewusst Merkmale,
|
||||
* die nicht an der Herkunft haengen - und ohne Drittanbieter, der sonst in
|
||||
* den Registrierungspfad und die Datenschutzerklaerung muesste.
|
||||
*/
|
||||
|
||||
// Schneller als das kann niemand acht Felder und drei Checkboxen ausfuellen.
|
||||
const APP_SPAM_GUARD_MIN_SECONDS = 4;
|
||||
|
||||
// Notbremse ueber alle Registrierungen hinweg, unabhaengig von der IP.
|
||||
const APP_SPAM_GUARD_GLOBAL_MAX = 20;
|
||||
const APP_SPAM_GUARD_GLOBAL_WINDOW = 3600;
|
||||
|
||||
/**
|
||||
* Merkt sich, wann das Formular ausgeliefert wurde. Beim Rendern aufrufen,
|
||||
* nicht beim Verarbeiten.
|
||||
*/
|
||||
function app_spam_guard_mark_form_rendered(string $formKey): void
|
||||
{
|
||||
app_start_session();
|
||||
$_SESSION['spam_guard_rendered'][$formKey] = time();
|
||||
}
|
||||
|
||||
/**
|
||||
* Kam die Antwort unmenschlich schnell? Fehlt der Zeitstempel ganz, wurde
|
||||
* das Formular in dieser Session nie ausgeliefert - das zaehlt ebenfalls als
|
||||
* Treffer. (Ein echter Nutzer kommt hier nicht an: ohne funktionierende
|
||||
* Session waere schon die CSRF-Pruefung fehlgeschlagen.)
|
||||
*/
|
||||
function app_spam_guard_submitted_too_fast(string $formKey, int $minSeconds = APP_SPAM_GUARD_MIN_SECONDS): bool
|
||||
{
|
||||
app_start_session();
|
||||
$renderedAt = $_SESSION['spam_guard_rendered'][$formKey] ?? null;
|
||||
if (!is_int($renderedAt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (time() - $renderedAt) < $minSeconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Honigtopf: fuer Menschen unsichtbar, fuer Formular-Bots ein Pflichtfeld.
|
||||
*
|
||||
* Die Positionierung steht bewusst inline und nicht in public.css - laedt das
|
||||
* Stylesheet einmal nicht, waere das Feld sonst sichtbar und ein echter
|
||||
* Nutzer koennte es ausfuellen. autocomplete="off" und der neutrale Name
|
||||
* halten Passwortmanager davon ab, es selbsttaetig zu befuellen;
|
||||
* tabindex="-1" und aria-hidden nehmen es aus Tastaturbedienung und
|
||||
* Screenreader heraus.
|
||||
*/
|
||||
function app_spam_guard_honeypot_field(): string
|
||||
{
|
||||
return '<div style="position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden" aria-hidden="true">'
|
||||
. '<label for="contact_reference">Dieses Feld bitte leer lassen</label>'
|
||||
. '<input type="text" name="contact_reference" id="contact_reference" value="" autocomplete="off" tabindex="-1">'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
function app_spam_guard_honeypot_tripped(): bool
|
||||
{
|
||||
return trim((string)($_POST['contact_reference'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt die Domain der Adresse ueberhaupt Mail an? Ohne MX-Eintrag gilt nach
|
||||
* RFC 5321 der A/AAAA-Record als Ziel, deshalb beide als Rueckfallebene.
|
||||
*
|
||||
* Bewusst fail-open: steht checkdnsrr() nicht zur Verfuegung, wird die
|
||||
* Adresse durchgelassen. Eine kaputte DNS-Aufloesung darf keine echten
|
||||
* Registrierungen blockieren.
|
||||
*/
|
||||
function app_spam_guard_email_domain_resolves(string $email): bool
|
||||
{
|
||||
if (!function_exists('checkdnsrr')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$at = strrpos($email, '@');
|
||||
if ($at === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$domain = rtrim(substr($email, $at + 1), '.');
|
||||
if ($domain === '' || !str_contains($domain, '.')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return @checkdnsrr($domain, 'MX')
|
||||
|| @checkdnsrr($domain, 'A')
|
||||
|| @checkdnsrr($domain, 'AAAA');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Treffer wird protokolliert, aber dem Absender gegenueber nicht
|
||||
* benannt: er bekommt dieselbe generische Meldung wie bei einem
|
||||
* ausgereizten Rate-Limit. So verraet die Antwort nicht, welche Huerde
|
||||
* angeschlagen hat, und ein Bot kann sich nicht darauf einstellen.
|
||||
*/
|
||||
function app_spam_guard_log(string $formKey, string $reason): void
|
||||
{
|
||||
error_log(sprintf('Spam-Guard: %s abgewiesen (%s), IP %s', $formKey, $reason, app_client_ip()));
|
||||
}
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
/**
|
||||
* Minimal Stripe REST API client using PHP streams (no curl extension
|
||||
* available in this environment, and streams are more portable anyway -
|
||||
* no dependency on a vendored SDK, no composer.json needed for this).
|
||||
*/
|
||||
|
||||
function stripe_secret_key(): string
|
||||
{
|
||||
$key = app_env('STRIPE_SECRET_KEY');
|
||||
if ($key === null || trim($key) === '') {
|
||||
throw new RuntimeException('STRIPE_SECRET_KEY ist nicht gesetzt.');
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
function stripe_webhook_secret(): string
|
||||
{
|
||||
$secret = app_env('STRIPE_WEBHOOK_SECRET');
|
||||
if ($secret === null || trim($secret) === '') {
|
||||
throw new RuntimeException('STRIPE_WEBHOOK_SECRET ist nicht gesetzt.');
|
||||
}
|
||||
|
||||
return $secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens nested params into Stripe's bracket-notation form fields, e.g.
|
||||
* ['line_items' => [['price' => 'x']]] -> line_items[0][price]=x
|
||||
*
|
||||
* @param array<int|string, mixed> $params
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function stripe_flatten_params(array $params, string $prefix = ''): array
|
||||
{
|
||||
$flat = [];
|
||||
foreach ($params as $key => $value) {
|
||||
$paramKey = $prefix === '' ? (string)$key : "{$prefix}[{$key}]";
|
||||
if (is_array($value)) {
|
||||
$flat += stripe_flatten_params($value, $paramKey);
|
||||
} elseif ($value !== null) {
|
||||
$flat[$paramKey] = (string)$value;
|
||||
}
|
||||
}
|
||||
|
||||
return $flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
* @return array{ok: bool, status: int, data: array, error: ?string}
|
||||
*/
|
||||
function stripe_request(string $method, string $path, array $params = []): array
|
||||
{
|
||||
$url = 'https://api.stripe.com/v1/' . ltrim($path, '/');
|
||||
$body = null;
|
||||
|
||||
if ($method === 'GET') {
|
||||
if ($params !== []) {
|
||||
$url .= '?' . http_build_query(stripe_flatten_params($params));
|
||||
}
|
||||
} else {
|
||||
$body = http_build_query(stripe_flatten_params($params));
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'Authorization: Bearer ' . stripe_secret_key(),
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
];
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => $method,
|
||||
'header' => implode("\r\n", $headers),
|
||||
'content' => $body,
|
||||
'timeout' => 20,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$responseBody = @file_get_contents($url, false, $context);
|
||||
$status = 0;
|
||||
foreach ($http_response_header ?? [] as $header) {
|
||||
if (preg_match('~^HTTP/\S+\s+(\d{3})~', $header, $m) === 1) {
|
||||
$status = (int)$m[1];
|
||||
}
|
||||
}
|
||||
|
||||
if ($responseBody === false) {
|
||||
return ['ok' => false, 'status' => 0, 'data' => [], 'error' => 'Stripe-Anfrage fehlgeschlagen (keine Antwort).'];
|
||||
}
|
||||
|
||||
$data = json_decode($responseBody, true);
|
||||
if (!is_array($data)) {
|
||||
return ['ok' => false, 'status' => $status, 'data' => [], 'error' => 'Ungültige Antwort von Stripe.'];
|
||||
}
|
||||
|
||||
if ($status >= 400) {
|
||||
return ['ok' => false, 'status' => $status, 'data' => $data, 'error' => $data['error']['message'] ?? 'Stripe-Fehler.'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'status' => $status, 'data' => $data, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an existing recurring Price by lookup_key, or creates the Product
|
||||
* and Price if none exists yet. Idempotent: safe to call on every request
|
||||
* that needs the price id (result should be cached by the caller).
|
||||
*/
|
||||
function stripe_find_or_create_price(string $lookupKey, string $productName, int $priceCents): ?string
|
||||
{
|
||||
$existing = stripe_request('GET', 'prices', ['lookup_keys' => [$lookupKey], 'active' => 'true']);
|
||||
if ($existing['ok'] && !empty($existing['data']['data'][0]['id'])) {
|
||||
return (string)$existing['data']['data'][0]['id'];
|
||||
}
|
||||
|
||||
$product = stripe_request('POST', 'products', ['name' => $productName]);
|
||||
if (!$product['ok']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$price = stripe_request('POST', 'prices', [
|
||||
'product' => $product['data']['id'],
|
||||
'unit_amount' => $priceCents,
|
||||
'currency' => 'eur',
|
||||
'recurring' => ['interval' => 'month'],
|
||||
'lookup_key' => $lookupKey,
|
||||
]);
|
||||
|
||||
return $price['ok'] ? (string)$price['data']['id'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, url: ?string, error: ?string}
|
||||
*/
|
||||
function stripe_create_checkout_session(string $priceId, string $customerEmail, ?string $existingCustomerId, string $successUrl, string $cancelUrl, array $metadata = []): array
|
||||
{
|
||||
$params = [
|
||||
'mode' => 'subscription',
|
||||
'line_items' => [['price' => $priceId, 'quantity' => 1]],
|
||||
'success_url' => $successUrl,
|
||||
'cancel_url' => $cancelUrl,
|
||||
'metadata' => $metadata,
|
||||
// Auch auf die Subscription selbst spiegeln: subscription.updated/
|
||||
// .deleted-Events liefern kein Checkout-Session-Objekt, aber die
|
||||
// Metadaten der Subscription, darueber loesen wir tenant_id auf.
|
||||
'subscription_data' => ['metadata' => $metadata],
|
||||
'locale' => 'de',
|
||||
'custom_text' => [
|
||||
'submit' => [
|
||||
'message' => 'Monatlich kündbar zum Ende der laufenden Abrechnungsperiode. Es gelten die vorab bestätigten AGB und die Widerrufsbelehrung.',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
if ($existingCustomerId !== null) {
|
||||
$params['customer'] = $existingCustomerId;
|
||||
} else {
|
||||
$params['customer_email'] = $customerEmail;
|
||||
}
|
||||
|
||||
$result = stripe_request('POST', 'checkout/sessions', $params);
|
||||
if (!$result['ok']) {
|
||||
return ['ok' => false, 'url' => null, 'error' => $result['error']];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'url' => (string)$result['data']['url'], 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, url: ?string, error: ?string}
|
||||
*/
|
||||
function stripe_create_billing_portal_session(string $customerId, string $returnUrl): array
|
||||
{
|
||||
$result = stripe_request('POST', 'billing_portal/sessions', [
|
||||
'customer' => $customerId,
|
||||
'return_url' => $returnUrl,
|
||||
'locale' => 'de',
|
||||
// Als gezielter Stripe-Flow sind Navigation und andere Portalaktionen
|
||||
// ausgeblendet. Tarifwechsel und Kündigung bleiben damit in unseren
|
||||
// rechtlich dokumentierten Bestell- bzw. Kündigungsabläufen.
|
||||
'flow_data' => [
|
||||
'type' => 'payment_method_update',
|
||||
'after_completion' => [
|
||||
'type' => 'redirect',
|
||||
'redirect' => ['return_url' => $returnUrl],
|
||||
],
|
||||
],
|
||||
]);
|
||||
if (!$result['ok']) {
|
||||
return ['ok' => false, 'url' => null, 'error' => $result['error']];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'url' => (string)$result['data']['url'], 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a Stripe webhook signature per Stripe's documented scheme:
|
||||
* the Stripe-Signature header contains "t=<timestamp>,v1=<hmac>[,v0=...]";
|
||||
* the expected signature is HMAC-SHA256("{t}.{payload}", secret). A
|
||||
* $toleranceSeconds window guards against replay of old, captured payloads.
|
||||
*/
|
||||
function stripe_verify_webhook_signature(string $payload, string $signatureHeader, string $secret, int $toleranceSeconds = 300): bool
|
||||
{
|
||||
$parts = [];
|
||||
foreach (explode(',', $signatureHeader) as $piece) {
|
||||
[$k, $v] = array_pad(explode('=', trim($piece), 2), 2, null);
|
||||
if ($k !== null && $v !== null) {
|
||||
$parts[$k][] = $v;
|
||||
}
|
||||
}
|
||||
|
||||
$timestamp = isset($parts['t'][0]) ? (int)$parts['t'][0] : 0;
|
||||
$signatures = $parts['v1'] ?? [];
|
||||
if ($timestamp <= 0 || $signatures === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (abs(time() - $timestamp) > $toleranceSeconds) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
|
||||
|
||||
foreach ($signatures as $signature) {
|
||||
if (hash_equals($expected, $signature)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a subscription including its first item's price, so the caller
|
||||
* can see the currently billed price and swap it out in place.
|
||||
*
|
||||
* @return array{ok: bool, id: ?string, item_id: ?string, price_lookup_key: ?string, status: ?string, cancel_at_period_end: bool, error: ?string}
|
||||
*/
|
||||
function stripe_get_subscription(string $subscriptionId): array
|
||||
{
|
||||
$result = stripe_request('GET', "subscriptions/{$subscriptionId}");
|
||||
if (!$result['ok']) {
|
||||
return ['ok' => false, 'id' => null, 'item_id' => null, 'price_lookup_key' => null, 'status' => null, 'cancel_at_period_end' => false, 'error' => $result['error']];
|
||||
}
|
||||
|
||||
$item = $result['data']['items']['data'][0] ?? null;
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'id' => (string)($result['data']['id'] ?? ''),
|
||||
'item_id' => $item !== null ? (string)($item['id'] ?? '') : null,
|
||||
'price_lookup_key' => $item !== null ? (string)($item['price']['lookup_key'] ?? '') : null,
|
||||
'status' => (string)($result['data']['status'] ?? ''),
|
||||
'cancel_at_period_end' => (bool)($result['data']['cancel_at_period_end'] ?? false),
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches an existing active subscription to a different Price in place.
|
||||
* The billing cycle stays unchanged and no unclear mid-cycle debit/credit is
|
||||
* created; the new full monthly amount applies at the next renewal.
|
||||
* Also clears any pending cancel_at_period_end, since choosing a plan is
|
||||
* an explicit signal the tenant wants to keep the subscription running.
|
||||
*
|
||||
* @return array{ok: bool, error: ?string}
|
||||
*/
|
||||
function stripe_update_subscription_price(string $subscriptionId, string $subscriptionItemId, string $newPriceId): array
|
||||
{
|
||||
$result = stripe_request('POST', "subscriptions/{$subscriptionId}", [
|
||||
'items' => [['id' => $subscriptionItemId, 'price' => $newPriceId]],
|
||||
'proration_behavior' => 'none',
|
||||
'cancel_at_period_end' => 'false',
|
||||
]);
|
||||
|
||||
if (!$result['ok']) {
|
||||
return ['ok' => false, 'error' => $result['error']];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a subscription immediately. This is reserved for exceptional
|
||||
* workflows such as a confirmed withdrawal; ordinary cancellations use
|
||||
* stripe_schedule_subscription_cancellation() so paid access remains until
|
||||
* the end of the already paid billing period.
|
||||
*
|
||||
* @return array{ok: bool, error: ?string}
|
||||
*/
|
||||
function stripe_cancel_subscription(string $subscriptionId): array
|
||||
{
|
||||
$result = stripe_request('DELETE', "subscriptions/{$subscriptionId}");
|
||||
if (!$result['ok']) {
|
||||
return ['ok' => false, 'error' => $result['error']];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, current_period_end: ?string, error: ?string}
|
||||
*/
|
||||
function stripe_schedule_subscription_cancellation(string $subscriptionId): array
|
||||
{
|
||||
$result = stripe_request('POST', "subscriptions/{$subscriptionId}", [
|
||||
'cancel_at_period_end' => 'true',
|
||||
]);
|
||||
if (!$result['ok']) {
|
||||
return ['ok' => false, 'current_period_end' => null, 'error' => $result['error']];
|
||||
}
|
||||
|
||||
$periodEnd = isset($result['data']['current_period_end'])
|
||||
? date('Y-m-d H:i:s', (int)$result['data']['current_period_end'])
|
||||
: null;
|
||||
|
||||
return ['ok' => true, 'current_period_end' => $periodEnd, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: string, type: string, data: array}|null
|
||||
*/
|
||||
function stripe_parse_event(string $payload): ?array
|
||||
{
|
||||
$decoded = json_decode($payload, true);
|
||||
if (!is_array($decoded) || !isset($decoded['type'], $decoded['data']['object'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (string)($decoded['id'] ?? ''),
|
||||
'type' => (string)$decoded['type'],
|
||||
'data' => $decoded['data']['object'],
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
/**
|
||||
* Geschuetztes Verzeichnis fuer Mandanten-Logos (per .htaccess von aussen
|
||||
* gesperrt, siehe var/-Regel). Wird beim ersten Upload angelegt.
|
||||
*/
|
||||
function tenant_logo_dir(): string
|
||||
{
|
||||
return APP_ROOT . '/var/tenant_logos';
|
||||
}
|
||||
|
||||
/**
|
||||
* Vollstaendiger Pfad zu einer gespeicherten Logo-Datei oder null, wenn kein
|
||||
* Logo hinterlegt ist bzw. die Datei fehlt.
|
||||
*/
|
||||
function tenant_logo_path(string $filename): ?string
|
||||
{
|
||||
$filename = basename(trim($filename));
|
||||
if ($filename === '') {
|
||||
return null;
|
||||
}
|
||||
$path = tenant_logo_dir() . '/' . $filename;
|
||||
|
||||
return is_file($path) ? $path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dateiname-Praefix je Verwendungszweck. Das Wasserzeichen behaelt bewusst
|
||||
* seinen historischen Praefix "logo_", damit bereits hochgeladene Dateien
|
||||
* weiter gefunden werden.
|
||||
*/
|
||||
function tenant_logo_prefix(string $kind): string
|
||||
{
|
||||
return $kind === 'brand' ? 'brand_' : 'logo_';
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt ein hochgeladenes Logo (PNG oder JPEG) entgegen, prueft und speichert
|
||||
* es unter einem festen Namen pro Mandant und Verwendungszweck.
|
||||
*
|
||||
* @param array $file Eintrag aus $_FILES
|
||||
* @param string $kind 'watermark' (Ausdruck) oder 'brand' (App-Oberflaeche)
|
||||
* @return array{ok: bool, filename?: string, error?: string}
|
||||
*/
|
||||
function tenant_logo_store(int $tenantId, array $file, string $kind = 'watermark'): array
|
||||
{
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
return ['ok' => false, 'error' => 'Das Logo konnte nicht hochgeladen werden.'];
|
||||
}
|
||||
if (($file['size'] ?? 0) <= 0 || $file['size'] > 2 * 1024 * 1024) {
|
||||
return ['ok' => false, 'error' => 'Das Logo ist leer oder größer als 2 MB.'];
|
||||
}
|
||||
$tmp = (string)($file['tmp_name'] ?? '');
|
||||
if (!is_uploaded_file($tmp)) {
|
||||
return ['ok' => false, 'error' => 'Die hochgeladene Datei konnte nicht verifiziert werden.'];
|
||||
}
|
||||
|
||||
$info = @getimagesize($tmp);
|
||||
if ($info === false || !in_array($info[2], [IMAGETYPE_PNG, IMAGETYPE_JPEG], true)) {
|
||||
return ['ok' => false, 'error' => 'Es sind nur PNG- oder JPEG-Bilder erlaubt.'];
|
||||
}
|
||||
$ext = $info[2] === IMAGETYPE_PNG ? 'png' : 'jpg';
|
||||
|
||||
$dir = tenant_logo_dir();
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0700, true);
|
||||
}
|
||||
if (!is_dir($dir) || !is_writable($dir)) {
|
||||
return ['ok' => false, 'error' => 'Das Logo-Verzeichnis ist nicht beschreibbar.'];
|
||||
}
|
||||
|
||||
// Alte Varianten (andere Endung) desselben Mandanten entfernen.
|
||||
$prefix = tenant_logo_prefix($kind);
|
||||
foreach (['png', 'jpg'] as $altExt) {
|
||||
$old = $dir . '/' . $prefix . $tenantId . '.' . $altExt;
|
||||
if (is_file($old)) {
|
||||
@unlink($old);
|
||||
}
|
||||
}
|
||||
|
||||
$filename = $prefix . $tenantId . '.' . $ext;
|
||||
if (!move_uploaded_file($tmp, $dir . '/' . $filename)) {
|
||||
return ['ok' => false, 'error' => 'Das Logo konnte nicht gespeichert werden.'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'filename' => $filename];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loescht eine hinterlegte Logo-Datei (falls vorhanden).
|
||||
*/
|
||||
function tenant_logo_delete(string $filename): void
|
||||
{
|
||||
$path = tenant_logo_path($filename);
|
||||
if ($path !== null) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
+457
@@ -0,0 +1,457 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/saas-auth.php';
|
||||
|
||||
/**
|
||||
* Zweiter Faktor per TOTP (RFC 6238) - dieselben sechsstelligen Codes, die
|
||||
* Google Authenticator, Aegis, 1Password und Co. erzeugen.
|
||||
*
|
||||
* Bewusst selbst implementiert statt per Bibliothek: der Algorithmus ist ein
|
||||
* HMAC plus eine Truncation, PHP bringt beides mit, und ein zweiter Faktor
|
||||
* ist genau die Stelle, an der man keine ungeprueften Abhaengigkeiten haben
|
||||
* will. Der QR-Code entsteht aus dem ohnehin vorhandenen TCPDF, damit kein
|
||||
* externer Dienst den Klartext des Geheimnisses zu sehen bekommt.
|
||||
*/
|
||||
|
||||
const APP_TOTP_DIGITS = 6;
|
||||
const APP_TOTP_PERIOD = 30;
|
||||
|
||||
// Ein Schritt Toleranz in jede Richtung faengt Uhrenabweichungen zwischen
|
||||
// Telefon und Server ab, ohne das Zeitfenster nennenswert aufzuweiten.
|
||||
const APP_TOTP_WINDOW = 1;
|
||||
|
||||
const APP_TOTP_RECOVERY_CODE_COUNT = 10;
|
||||
|
||||
/** Nach dieser Zeit ist eine angefangene Anmeldung verfallen. */
|
||||
const APP_TOTP_CHALLENGE_TTL = 900;
|
||||
|
||||
function app_totp_base32_encode(string $binary): string
|
||||
{
|
||||
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
$out = '';
|
||||
$buffer = 0;
|
||||
$bitsLeft = 0;
|
||||
|
||||
for ($i = 0, $len = strlen($binary); $i < $len; $i++) {
|
||||
$buffer = ($buffer << 8) | ord($binary[$i]);
|
||||
$bitsLeft += 8;
|
||||
while ($bitsLeft >= 5) {
|
||||
$bitsLeft -= 5;
|
||||
$out .= $alphabet[($buffer >> $bitsLeft) & 31];
|
||||
}
|
||||
}
|
||||
if ($bitsLeft > 0) {
|
||||
$out .= $alphabet[($buffer << (5 - $bitsLeft)) & 31];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
function app_totp_base32_decode(string $base32): string
|
||||
{
|
||||
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
// Leerzeichen und Bindestriche kommen vor, wenn jemand das Geheimnis von
|
||||
// Hand aus der Anzeige abtippt; Padding ist optional.
|
||||
$base32 = strtoupper(preg_replace('/[\s-]+/', '', $base32) ?? '');
|
||||
$base32 = rtrim($base32, '=');
|
||||
|
||||
$buffer = 0;
|
||||
$bitsLeft = 0;
|
||||
$out = '';
|
||||
|
||||
for ($i = 0, $len = strlen($base32); $i < $len; $i++) {
|
||||
$index = strpos($alphabet, $base32[$i]);
|
||||
if ($index === false) {
|
||||
return '';
|
||||
}
|
||||
$buffer = ($buffer << 5) | $index;
|
||||
$bitsLeft += 5;
|
||||
if ($bitsLeft >= 8) {
|
||||
$bitsLeft -= 8;
|
||||
$out .= chr(($buffer >> $bitsLeft) & 255);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** 20 Zufallsbytes = 160 Bit, die von RFC 4226 empfohlene Schluessellaenge. */
|
||||
function app_totp_generate_secret(): string
|
||||
{
|
||||
return app_totp_base32_encode(random_bytes(20));
|
||||
}
|
||||
|
||||
function app_totp_code(string $secret, int $timeStep): string
|
||||
{
|
||||
$key = app_totp_base32_decode($secret);
|
||||
if ($key === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$counter = pack('J', $timeStep);
|
||||
$hash = hash_hmac('sha1', $counter, $key, true);
|
||||
|
||||
// Dynamic Truncation nach RFC 4226 Abschnitt 5.3.
|
||||
$offset = ord($hash[19]) & 0x0f;
|
||||
$value = ((ord($hash[$offset]) & 0x7f) << 24)
|
||||
| ((ord($hash[$offset + 1]) & 0xff) << 16)
|
||||
| ((ord($hash[$offset + 2]) & 0xff) << 8)
|
||||
| (ord($hash[$offset + 3]) & 0xff);
|
||||
|
||||
return str_pad((string)($value % (10 ** APP_TOTP_DIGITS)), APP_TOTP_DIGITS, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft den Code gegen das Toleranzfenster und liefert den getroffenen
|
||||
* Zeitschritt zurueck - den braucht der Aufrufer, um einen einmal benutzten
|
||||
* Code nicht ein zweites Mal zuzulassen.
|
||||
*/
|
||||
function app_totp_verify_code(string $secret, string $code, ?int $now = null): ?int
|
||||
{
|
||||
$code = preg_replace('/\s+/', '', $code) ?? '';
|
||||
if (preg_match('/^\d{' . APP_TOTP_DIGITS . '}$/', $code) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$currentStep = intdiv($now ?? time(), APP_TOTP_PERIOD);
|
||||
for ($offset = -APP_TOTP_WINDOW; $offset <= APP_TOTP_WINDOW; $offset++) {
|
||||
$step = $currentStep + $offset;
|
||||
$expected = app_totp_code($secret, $step);
|
||||
if ($expected !== '' && hash_equals($expected, $code)) {
|
||||
return $step;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* otpauth-URI nach der Key-Uri-Format-Spezifikation. Der Label-Teil traegt
|
||||
* Aussteller und Konto, damit in der App nicht zehn namenlose Eintraege
|
||||
* stehen.
|
||||
*/
|
||||
function app_totp_provisioning_uri(string $secret, string $accountName): string
|
||||
{
|
||||
$issuer = 'Kaffeeliste';
|
||||
|
||||
return 'otpauth://totp/'
|
||||
. rawurlencode($issuer) . ':' . rawurlencode($accountName)
|
||||
. '?' . http_build_query([
|
||||
'secret' => $secret,
|
||||
'issuer' => $issuer,
|
||||
'algorithm' => 'SHA1',
|
||||
'digits' => APP_TOTP_DIGITS,
|
||||
'period' => APP_TOTP_PERIOD,
|
||||
], '', '&', PHP_QUERY_RFC3986);
|
||||
}
|
||||
|
||||
/**
|
||||
* QR-Code als eingebettetes SVG. TCPDF liefert ein vollstaendiges Dokument
|
||||
* samt XML-Deklaration; fuer die Einbettung in eine HTML-Seite bleibt nur
|
||||
* das svg-Element uebrig.
|
||||
*/
|
||||
function app_totp_qr_svg(string $uri): string
|
||||
{
|
||||
require_once __DIR__ . '/../TCPDF/tcpdf_barcodes_2d.php';
|
||||
|
||||
$barcode = new TCPDF2DBarcode($uri, 'QRCODE,M');
|
||||
$svg = $barcode->getBarcodeSVGcode(4, 4, 'black');
|
||||
$start = strpos($svg, '<svg');
|
||||
|
||||
return $start === false ? '' : substr($svg, $start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wiederherstellungscodes fuer den Fall, dass das Telefon weg ist. Format
|
||||
* "abcd-efgh" aus einem Alphabet ohne verwechselbare Zeichen (kein 0/O,
|
||||
* kein 1/l), weil die Codes von Hand abgeschrieben und wieder eingetippt
|
||||
* werden.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
function app_totp_generate_recovery_codes(int $count = APP_TOTP_RECOVERY_CODE_COUNT): array
|
||||
{
|
||||
$alphabet = 'abcdefghjkmnpqrstuvwxyz23456789';
|
||||
$max = strlen($alphabet) - 1;
|
||||
$codes = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$code = '';
|
||||
for ($c = 0; $c < 8; $c++) {
|
||||
if ($c === 4) {
|
||||
$code .= '-';
|
||||
}
|
||||
$code .= $alphabet[random_int(0, $max)];
|
||||
}
|
||||
$codes[] = $code;
|
||||
}
|
||||
|
||||
return $codes;
|
||||
}
|
||||
|
||||
function app_totp_normalize_recovery_code(string $code): string
|
||||
{
|
||||
return strtolower(preg_replace('/[^A-Za-z0-9]/', '', $code) ?? '');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persistenz
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Ist der zweite Faktor fuer dieses Konto scharf? Ein angefangenes, aber nie
|
||||
* bestaetigtes Geheimnis zaehlt nicht - sonst sperrte sich jemand aus, der
|
||||
* die Einrichtung auf halbem Weg abbricht.
|
||||
*/
|
||||
function app_totp_is_active(PDO $pdo, int $userId): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT totp_confirmed_at FROM users WHERE id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$confirmedAt = $stmt->fetchColumn();
|
||||
|
||||
return $confirmedAt !== false && $confirmedAt !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{secret: ?string, confirmed_at: ?string, last_step: ?int}
|
||||
*/
|
||||
function app_totp_state(PDO $pdo, int $userId): array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT totp_secret, totp_confirmed_at, totp_last_step FROM users WHERE id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if ($row === false) {
|
||||
return ['secret' => null, 'confirmed_at' => null, 'last_step' => null];
|
||||
}
|
||||
|
||||
return [
|
||||
'secret' => $row['totp_secret'] !== null ? (string)$row['totp_secret'] : null,
|
||||
'confirmed_at' => $row['totp_confirmed_at'] !== null ? (string)$row['totp_confirmed_at'] : null,
|
||||
'last_step' => $row['totp_last_step'] !== null ? (int)$row['totp_last_step'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt ein frisches, noch unbestaetigtes Geheimnis an. Ein bereits scharfer
|
||||
* zweiter Faktor wird dabei nicht angetastet - abschalten laeuft
|
||||
* ausschliesslich ueber app_totp_disable().
|
||||
*/
|
||||
function app_totp_begin_setup(PDO $pdo, int $userId): ?string
|
||||
{
|
||||
if (app_totp_is_active($pdo, $userId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$secret = app_totp_generate_secret();
|
||||
$pdo->prepare('UPDATE users SET totp_secret = ?, totp_confirmed_at = NULL, totp_last_step = NULL WHERE id = ?')
|
||||
->execute([$secret, $userId]);
|
||||
|
||||
return $secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schaltet den zweiten Faktor scharf, nachdem ein gueltiger Code aus der App
|
||||
* kam, und gibt die Wiederherstellungscodes im Klartext zurueck - das ist
|
||||
* der einzige Moment, in dem sie sichtbar sind. Gespeichert werden nur ihre
|
||||
* Hashes.
|
||||
*
|
||||
* @return list<string>|null Klartext-Codes, oder null bei falschem Code.
|
||||
*/
|
||||
function app_totp_confirm_setup(PDO $pdo, int $userId, string $code): ?array
|
||||
{
|
||||
$state = app_totp_state($pdo, $userId);
|
||||
if ($state['secret'] === null || $state['confirmed_at'] !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$step = app_totp_verify_code($state['secret'], $code);
|
||||
if ($step === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$codes = app_totp_generate_recovery_codes();
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$pdo->prepare('UPDATE users SET totp_confirmed_at = NOW(), totp_last_step = ? WHERE id = ?')
|
||||
->execute([$step, $userId]);
|
||||
$pdo->prepare('DELETE FROM totp_recovery_codes WHERE user_id = ?')->execute([$userId]);
|
||||
|
||||
$insert = $pdo->prepare('INSERT INTO totp_recovery_codes (user_id, code_hash) VALUES (?, ?)');
|
||||
foreach ($codes as $plain) {
|
||||
$insert->execute([$userId, password_hash(app_totp_normalize_recovery_code($plain), PASSWORD_DEFAULT)]);
|
||||
}
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft einen Code aus der App. Ein bereits verwendeter Zeitschritt wird
|
||||
* abgelehnt: sonst liesse sich ein einmal abgefangener Code innerhalb seines
|
||||
* Gueltigkeitsfensters ein zweites Mal einloesen.
|
||||
*/
|
||||
function app_totp_verify_for_user(PDO $pdo, int $userId, string $code): bool
|
||||
{
|
||||
$state = app_totp_state($pdo, $userId);
|
||||
if ($state['secret'] === null || $state['confirmed_at'] === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$step = app_totp_verify_code($state['secret'], $code);
|
||||
if ($step === null) {
|
||||
return false;
|
||||
}
|
||||
if ($state['last_step'] !== null && $step <= $state['last_step']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$pdo->prepare('UPDATE users SET totp_last_step = ? WHERE id = ?')->execute([$step, $userId]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loest einen Wiederherstellungscode ein. Jeder Code gilt genau einmal.
|
||||
*/
|
||||
function app_totp_consume_recovery_code(PDO $pdo, int $userId, string $code): bool
|
||||
{
|
||||
$normalized = app_totp_normalize_recovery_code($code);
|
||||
if ($normalized === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id, code_hash FROM totp_recovery_codes WHERE user_id = ? AND used_at IS NULL');
|
||||
$stmt->execute([$userId]);
|
||||
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
if (password_verify($normalized, (string)$row['code_hash'])) {
|
||||
$pdo->prepare('UPDATE totp_recovery_codes SET used_at = NOW() WHERE id = ? AND used_at IS NULL')
|
||||
->execute([(int)$row['id']]);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function app_totp_unused_recovery_code_count(PDO $pdo, int $userId): int
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM totp_recovery_codes WHERE user_id = ? AND used_at IS NULL');
|
||||
$stmt->execute([$userId]);
|
||||
|
||||
return (int)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
function app_totp_disable(PDO $pdo, int $userId): void
|
||||
{
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$pdo->prepare('UPDATE users SET totp_secret = NULL, totp_confirmed_at = NULL, totp_last_step = NULL WHERE id = ?')
|
||||
->execute([$userId]);
|
||||
$pdo->prepare('DELETE FROM totp_recovery_codes WHERE user_id = ?')->execute([$userId]);
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Angefangene Anmeldung (Passwort stimmt, zweiter Faktor fehlt noch)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Zwischenzustand nach richtigem Passwort. Bewusst nach demselben Muster wie
|
||||
* saas_start_pending_tenant_selection(): Session-ID erneuern und alle
|
||||
* Login-Schluessel loeschen, damit dieser Zustand fuer sich genommen
|
||||
* keinerlei Zugriff gewaehrt.
|
||||
*/
|
||||
function app_totp_start_challenge(int $userId): void
|
||||
{
|
||||
app_start_session();
|
||||
session_regenerate_id(true);
|
||||
|
||||
$_SESSION['totp_pending_user_id'] = $userId;
|
||||
$_SESSION['totp_pending_started_at'] = time();
|
||||
unset(
|
||||
$_SESSION['saas_user_id'],
|
||||
$_SESSION['saas_tenant_id'],
|
||||
$_SESSION['saas_role'],
|
||||
$_SESSION['saas_pending_user_id'],
|
||||
$_SESSION['saas_pending_started_at']
|
||||
);
|
||||
}
|
||||
|
||||
function app_totp_challenge_user_id(): ?int
|
||||
{
|
||||
app_start_session();
|
||||
|
||||
$userId = isset($_SESSION['totp_pending_user_id']) ? (int)$_SESSION['totp_pending_user_id'] : 0;
|
||||
$startedAt = isset($_SESSION['totp_pending_started_at']) ? (int)$_SESSION['totp_pending_started_at'] : 0;
|
||||
if ($userId <= 0 || $startedAt <= 0 || $startedAt < time() - APP_TOTP_CHALLENGE_TTL) {
|
||||
app_totp_clear_challenge();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
function app_totp_clear_challenge(): void
|
||||
{
|
||||
app_start_session();
|
||||
unset($_SESSION['totp_pending_user_id'], $_SESSION['totp_pending_started_at']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Muss dieses Konto zwingend einen zweiten Faktor haben? Standardmaessig
|
||||
* nein - der Schalter existiert, damit der Betreiber ihn erst umlegt,
|
||||
* nachdem er seine eigene Einrichtung erfolgreich getestet hat. Andernfalls
|
||||
* koennte ein Fehler im Einrichtungsweg den einzigen Platform-Admin vom
|
||||
* Back-Office aussperren.
|
||||
*/
|
||||
function app_totp_required_for_platform_admins(): bool
|
||||
{
|
||||
return app_env('APP_REQUIRE_2FA_FOR_ADMINS', '0') === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Schliesst eine Anmeldung ab, deren zweiter Faktor bestaetigt ist. Die
|
||||
* Mandantenlage wird dabei frisch aus der Datenbank gelesen statt aus dem
|
||||
* Zwischenzustand uebernommen - zwischen Passwort und Code koennen Minuten
|
||||
* liegen, in denen sich Mitgliedschaften geaendert haben.
|
||||
*/
|
||||
function app_totp_finish_login(PDO $pdo, int $userId): void
|
||||
{
|
||||
app_totp_clear_challenge();
|
||||
$memberships = saas_list_user_memberships($pdo, $userId);
|
||||
|
||||
if (count($memberships) > 1) {
|
||||
saas_start_pending_tenant_selection($userId);
|
||||
header('Location: mandant-auswahl.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$identity = $memberships !== []
|
||||
? saas_identity_for_user_tenant($pdo, $userId, (int)$memberships[0]['tenant_id'])
|
||||
: null;
|
||||
|
||||
if ($identity === null) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
saas_session_login($identity);
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* Anpassungen der Kaffeeliste gegenueber dem HTML5-UP-Template (main.css).
|
||||
* Bewusst eine eigene Datei, damit main.css unveraendert bleibt und bei einem
|
||||
* Template-Update nicht mit eigenen Aenderungen kollidiert. Wird in header.php
|
||||
* NACH main.css geladen.
|
||||
*/
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Formular-Abstaende
|
||||
*
|
||||
* Im Template hat das Label 1em Abstand nach unten, das Eingabefeld aber gar
|
||||
* keinen. In einem Formular mit mehreren Feldern klebte das naechste Label
|
||||
* dadurch direkt am Feld darueber und wirkte, als gehoere es zu diesem statt
|
||||
* zum eigenen Feld. Jetzt gilt: Label eng am eigenen Feld, deutlicher Abstand
|
||||
* zur naechsten Gruppe.
|
||||
* ------------------------------------------------------------------------ */
|
||||
label {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="email"],
|
||||
input[type="tel"],
|
||||
input[type="search"],
|
||||
input[type="url"],
|
||||
input[type="number"],
|
||||
input[type="date"],
|
||||
input[type="datetime-local"],
|
||||
input[type="file"],
|
||||
select,
|
||||
textarea {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
/* Achtung: hier bewusst KEIN ".row > * > :last-child { margin-bottom: 0 }".
|
||||
In einer Grid-Spalte ist das Eingabefeld das letzte Kind - genau dessen
|
||||
unterer Abstand erzeugt die Trennung zur naechsten Formularzeile. */
|
||||
|
||||
/* In Tabellen und nebeneinanderliegenden Bedienelementen wuerde der
|
||||
zusaetzliche Abstand die Zeilen auseinanderziehen. */
|
||||
td label,
|
||||
td input,
|
||||
td select,
|
||||
td textarea,
|
||||
.form-inline label,
|
||||
.form-inline input,
|
||||
.form-inline select {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Zahlen-, Datums- und Dateifelder
|
||||
*
|
||||
* main.css stylt diese Typen nicht, sie erschienen deshalb als kleine native
|
||||
* Kaestchen zwischen den sonst einheitlich gestalteten Feldern. Hier wird das
|
||||
* Aussehen der Textfelder uebernommen.
|
||||
* ------------------------------------------------------------------------ */
|
||||
input[type="number"],
|
||||
input[type="date"],
|
||||
input[type="datetime-local"] {
|
||||
background: #ffffff;
|
||||
border: solid 1px rgba(210, 215, 217, 0.75);
|
||||
border-radius: 0.375em;
|
||||
color: inherit;
|
||||
display: block;
|
||||
height: 2.75em;
|
||||
outline: 0;
|
||||
padding: 0 1em;
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input[type="number"]:focus,
|
||||
input[type="date"]:focus {
|
||||
border-color: #38761d;
|
||||
box-shadow: 0 0 0 1px #38761d;
|
||||
}
|
||||
|
||||
/* In Tabellen (Striche/Einzahlungen erfassen) bleiben die Zahlenfelder
|
||||
kompakt, sonst sprengen sie die Spaltenbreite. */
|
||||
td input[type="number"] {
|
||||
width: 100%;
|
||||
min-width: 5em;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Checkbox-Zeilen
|
||||
*
|
||||
* Checkbox und zugehoeriges Label stehen nebeneinander; ohne Ausgleich sitzt
|
||||
* die Beschriftung nicht auf einer Linie mit den Feldern der Nachbarspalte.
|
||||
* ------------------------------------------------------------------------ */
|
||||
input[type="checkbox"] + label,
|
||||
input[type="radio"] + label {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Hinweisboxen
|
||||
*
|
||||
* main.css definiert den Grundstil und die Varianten .info/.warning/.success,
|
||||
* aber keine .error-Variante - obwohl sie in der App am haeufigsten verwendet
|
||||
* wird. Ohne sie erscheinen Fehlermeldungen ungefaerbt wie neutrale Hinweise.
|
||||
* Farbgebung analog zu den vorhandenen Varianten.
|
||||
* ------------------------------------------------------------------------ */
|
||||
.hint-box.error {
|
||||
background: #fdecea;
|
||||
border-left: 5px solid #d32f2f;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
|
||||
/* Der Grundstil ist ein Flex-Container; Absaetze darin sollen unten keinen
|
||||
zusaetzlichen Abstand erzeugen. */
|
||||
.hint-box > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Mehrere Absaetze/Listen in einer Box sollen untereinander stehen statt
|
||||
nebeneinander (Flex-Grundstil).
|
||||
Bewusst auf div eingegrenzt: .hint-box macht aus einem Element einen
|
||||
Flex-Container. Auf einer Tabellenzeile zerstoert das die Spalten - fuer
|
||||
hervorgehobene Zeilen gibt es .tarif-aktuell weiter unten. */
|
||||
div.hint-box {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Kopfzeile mit Mandanten-Logo (headerline.php)
|
||||
*
|
||||
* Das Logo soll erkennbar, aber kein Blickfang sein - die Kaffeeliste bleibt
|
||||
* eine Arbeitsoberflaeche. Feste Maximalhoehe, damit ein hochkantes oder sehr
|
||||
* grosses Logo nicht die halbe Seite einnimmt.
|
||||
* ------------------------------------------------------------------------ */
|
||||
.tenant-brand-line {
|
||||
margin-bottom: 1.5em;
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
|
||||
.tenant-brand-logo {
|
||||
display: block;
|
||||
max-height: 60px;
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Hervorgehobene Tabellenzeile (aktuell gebuchter Tarif)
|
||||
*
|
||||
* Muss eine normale Tabellenzeile bleiben, damit die Zellen unter ihren
|
||||
* Spaltenueberschriften stehen.
|
||||
* ------------------------------------------------------------------------ */
|
||||
tr.tarif-aktuell > td {
|
||||
background: #e8fbe8;
|
||||
color: #1b5e20;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
tr.tarif-aktuell > td:first-child {
|
||||
box-shadow: inset 5px 0 0 0 #4caf50;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Wasserzeichen im kostenlosen Tarif (app/plan-watermark.php)
|
||||
*
|
||||
* Fest unten rechts, damit es unabhaengig von der Seitenlaenge sichtbar
|
||||
* bleibt. Zurueckhaltend gehalten: die Kaffeeliste ist eine Arbeitsflaeche,
|
||||
* der Hinweis soll erkennbar sein, aber nichts verdecken - deshalb halb
|
||||
* transparent, erst beim Darueberfahren voll sichtbar.
|
||||
* ------------------------------------------------------------------------ */
|
||||
.plan-watermark {
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 999px;
|
||||
bottom: 0.9rem;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
|
||||
color: #4a4a4a;
|
||||
display: inline-flex;
|
||||
font-size: 0.7rem;
|
||||
gap: 0.4em;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
opacity: 0.65;
|
||||
padding: 0.45em 0.8em;
|
||||
position: fixed;
|
||||
right: 0.9rem;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
a.plan-watermark {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.plan-watermark:hover,
|
||||
.plan-watermark:focus {
|
||||
color: #4a4a4a;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.plan-watermark-logo {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Auf schmalen Bildschirmen bleibt nur das Logo - der Schriftzug wuerde dort
|
||||
ueber den Inhalt laufen. */
|
||||
@media screen and (max-width: 480px) {
|
||||
.plan-watermark-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Festgestellte Kaestchen (z. B. der Hinweis auf die Kaffeeliste im
|
||||
* kostenlosen Tarif)
|
||||
*
|
||||
* Das Theme zeichnet das Kaestchen ueber das folgende <label>; ein disabled
|
||||
* gesetztes Feld saehe sonst genauso aus wie ein bedienbares. Der gedimmte
|
||||
* Zustand macht sichtbar, dass hier nichts zu klicken ist - die Begruendung
|
||||
* steht als Hinweis daneben.
|
||||
* ------------------------------------------------------------------------ */
|
||||
input[type="checkbox"]:disabled + label {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
+171
-47
@@ -1,5 +1,4 @@
|
||||
@import url(fontawesome-all.min.css);
|
||||
@import url("https://fonts.googleapis.com/css?family=Open+Sans:400,600,400italic,600italic|Roboto+Slab:400,700");
|
||||
/*
|
||||
Editorial by HTML5 UP
|
||||
html5up.net | @ajlkn
|
||||
@@ -1111,38 +1110,32 @@ input[type="radio"] {
|
||||
padding-right: 0.75em;
|
||||
position: relative; }
|
||||
input[type="checkbox"] + label:before,
|
||||
input[type="radio"] + label:before {
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
display: inline-block;
|
||||
font-style: normal;
|
||||
font-variant: normal;
|
||||
text-rendering: auto;
|
||||
line-height: 1;
|
||||
text-transform: none !important;
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-weight: 900; }
|
||||
input[type="checkbox"] + label:before,
|
||||
input[type="radio"] + label:before {
|
||||
background: #ffffff;
|
||||
border-radius: 0.375em;
|
||||
border: solid 1px rgba(210, 215, 217, 0.75);
|
||||
content: '';
|
||||
display: inline-block;
|
||||
font-size: 0.8em;
|
||||
height: 2.0625em;
|
||||
left: 0;
|
||||
line-height: 2.0625em;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
top: 0;
|
||||
width: 2.0625em; }
|
||||
input[type="checkbox"]:checked + label:before,
|
||||
input[type="radio"]:checked + label:before {
|
||||
background: #3d4449;
|
||||
border-color: #3d4449;
|
||||
color: #ffffff;
|
||||
content: '\f00c'; }
|
||||
border-color: #3d4449; }
|
||||
input[type="checkbox"]:checked + label:after,
|
||||
input[type="radio"]:checked + label:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.72em;
|
||||
top: 0.62em;
|
||||
width: 0.45em;
|
||||
height: 0.9em;
|
||||
border: solid #ffffff;
|
||||
border-width: 0 0.15em 0.15em 0;
|
||||
transform: rotate(45deg); }
|
||||
input[type="checkbox"]:focus + label:before,
|
||||
input[type="radio"]:focus + label:before {
|
||||
border-color: #38761d;
|
||||
@@ -1976,26 +1969,16 @@ button,
|
||||
width: 6em;
|
||||
z-index: 10000; }
|
||||
#sidebar .toggle:before {
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
display: inline-block;
|
||||
font-style: normal;
|
||||
font-variant: normal;
|
||||
text-rendering: auto;
|
||||
line-height: 1;
|
||||
text-transform: none !important;
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-weight: 900; }
|
||||
#sidebar .toggle:before {
|
||||
content: '\f0c9';
|
||||
font-size: 2rem;
|
||||
height: inherit;
|
||||
left: 0;
|
||||
line-height: inherit;
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
text-indent: 0;
|
||||
top: 0;
|
||||
width: inherit; }
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 1.6rem;
|
||||
height: 2px;
|
||||
background: #7f888f;
|
||||
box-shadow: 0 -0.5rem 0 0 #7f888f, 0 0.5rem 0 0 #7f888f;
|
||||
transform: translate(-50%, -50%); }
|
||||
#sidebar.inactive {
|
||||
margin-left: -26em; }
|
||||
@media screen and (max-width: 1680px) {
|
||||
@@ -2014,8 +1997,6 @@ button,
|
||||
line-height: 6.25em;
|
||||
text-indent: 5em;
|
||||
width: 5em; }
|
||||
#sidebar .toggle:before {
|
||||
font-size: 1.5rem; }
|
||||
#sidebar.inactive {
|
||||
margin-left: -24em; } }
|
||||
@media screen and (max-width: 1280px) {
|
||||
@@ -2044,9 +2025,6 @@ button,
|
||||
#sidebar .toggle {
|
||||
text-indent: 6em;
|
||||
width: 6em; }
|
||||
#sidebar .toggle:before {
|
||||
font-size: 1.5rem;
|
||||
margin-left: -0.4375em; }
|
||||
body.is-preload #sidebar {
|
||||
display: none; } }
|
||||
@media screen and (max-width: 736px) {
|
||||
@@ -2054,10 +2032,6 @@ button,
|
||||
text-indent: 7.25em;
|
||||
width: 7.25em; }
|
||||
#sidebar .toggle:before {
|
||||
color: #7f888f;
|
||||
margin-left: -0.0625em;
|
||||
margin-top: -0.25em;
|
||||
font-size: 1.1rem;
|
||||
z-index: 1; }
|
||||
#sidebar .toggle:after {
|
||||
background: rgba(222, 225, 226, 0.75);
|
||||
@@ -2271,6 +2245,14 @@ button,
|
||||
border-top: 0;
|
||||
margin-top: 0;
|
||||
padding-top: 0; }
|
||||
#menu > ul > li > ul.menu-group-items {
|
||||
display: block; }
|
||||
#menu ul span.menu-group-label {
|
||||
color: #38761d;
|
||||
cursor: default;
|
||||
font-weight: 700; }
|
||||
#menu ul span.menu-group-label:hover {
|
||||
color: #38761d; }
|
||||
|
||||
|
||||
/* Hinweisbox Grundstil */
|
||||
@@ -2310,3 +2292,145 @@ button,
|
||||
border-left: 5px solid #4caf50;
|
||||
color: #1b5e20;
|
||||
}
|
||||
|
||||
.hint-box.error {
|
||||
background: #fdeaea;
|
||||
border-left: 5px solid #c0392b;
|
||||
color: #641e16;
|
||||
}
|
||||
|
||||
input[type="submit"].alt,
|
||||
input[type="reset"].alt,
|
||||
input[type="button"].alt,
|
||||
button.alt,
|
||||
.button.alt {
|
||||
box-shadow: inset 0 0 0 2px #9fa3a6;
|
||||
color: #5f676d !important;
|
||||
}
|
||||
|
||||
.admin-summary-grid {
|
||||
display: grid;
|
||||
gap: 0.75em;
|
||||
grid-template-columns: repeat(auto-fit, minmax(8.5em, 1fr));
|
||||
margin: 1.25em 0 1.75em;
|
||||
}
|
||||
|
||||
.admin-summary-grid > div {
|
||||
border: solid 1px rgba(210, 215, 217, 0.85);
|
||||
border-radius: 6px;
|
||||
padding: 0.9em 1em;
|
||||
}
|
||||
|
||||
.admin-summary-grid strong {
|
||||
color: #38761d;
|
||||
display: block;
|
||||
font-family: "Roboto Slab", serif;
|
||||
font-size: 1.45em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.admin-summary-grid span {
|
||||
color: #7f888f;
|
||||
display: block;
|
||||
font-size: 0.85em;
|
||||
margin-top: 0.2em;
|
||||
}
|
||||
|
||||
.admin-filter-bar {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75em;
|
||||
margin: 1.25em 0;
|
||||
}
|
||||
|
||||
.admin-filter-bar label {
|
||||
font-size: 0.85em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-filter-bar input[type="search"],
|
||||
.admin-filter-bar select {
|
||||
min-width: 11em;
|
||||
}
|
||||
|
||||
.admin-table td,
|
||||
.admin-table th {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
font-size: 0.78em;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin: 0 0.25em 0.25em 0;
|
||||
padding: 0.2em 0.55em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-badge.success {
|
||||
background: #e8f5e9;
|
||||
color: #1b5e20;
|
||||
}
|
||||
|
||||
.status-badge.warning {
|
||||
background: #fff4e5;
|
||||
color: #663c00;
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background: #fdeaea;
|
||||
color: #641e16;
|
||||
}
|
||||
|
||||
.status-badge.info {
|
||||
background: #e8f4ff;
|
||||
color: #0d3c61;
|
||||
}
|
||||
|
||||
.status-badge.muted {
|
||||
background: #edf0f2;
|
||||
color: #5f676d;
|
||||
}
|
||||
|
||||
.inline-admin-form {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5em;
|
||||
margin: 0 0 0.5em 0;
|
||||
}
|
||||
|
||||
.inline-admin-form select {
|
||||
min-width: 13em;
|
||||
}
|
||||
|
||||
.compact-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5em;
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.compact-actions li {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 736px) {
|
||||
.admin-filter-bar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-filter-bar input[type="search"],
|
||||
.admin-filter-bar select,
|
||||
.admin-filter-bar button,
|
||||
.admin-filter-bar .button,
|
||||
.inline-admin-form select,
|
||||
.inline-admin-form button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
.analytics-consent[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.analytics-consent {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 10000;
|
||||
background: #ffffff;
|
||||
border-top: solid 1px rgba(210, 215, 217, 0.95);
|
||||
box-shadow: 0 -0.75rem 2rem rgba(61, 68, 73, 0.14);
|
||||
color: #3d4449;
|
||||
padding: 1.25rem 6vw;
|
||||
}
|
||||
|
||||
.analytics-consent__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.analytics-consent__copy {
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
.analytics-consent h2 {
|
||||
color: #3d4449;
|
||||
font-size: 1.15rem;
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
|
||||
.analytics-consent p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.analytics-consent a {
|
||||
color: #2f6418;
|
||||
}
|
||||
|
||||
.analytics-consent__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.analytics-consent__actions button {
|
||||
appearance: none;
|
||||
background: #38761d;
|
||||
border: solid 2px #38761d;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: none;
|
||||
color: #ffffff !important;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
height: auto;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.25;
|
||||
padding: 0.8rem 1rem;
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.analytics-consent__actions button:hover {
|
||||
background: #2f6418;
|
||||
border-color: #2f6418;
|
||||
}
|
||||
|
||||
.analytics-consent__actions [data-analytics-consent="denied"] {
|
||||
background: #ffffff;
|
||||
color: #2f6418 !important;
|
||||
}
|
||||
|
||||
.analytics-consent__actions [data-analytics-consent="denied"]:hover {
|
||||
background: #f2f7ef;
|
||||
border-color: #2f6418;
|
||||
color: #244f13 !important;
|
||||
}
|
||||
|
||||
.analytics-consent__actions button:focus-visible {
|
||||
outline: solid 3px #f0b429;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.footer-cookie-settings {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: dotted 1px;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
color: inherit !important;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: inherit;
|
||||
height: auto;
|
||||
letter-spacing: inherit;
|
||||
line-height: inherit;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.footer-cookie-settings:hover {
|
||||
background: transparent;
|
||||
border-bottom-color: transparent;
|
||||
color: #38761d !important;
|
||||
}
|
||||
|
||||
.footer-cookie-settings:focus-visible {
|
||||
outline: solid 3px #f0b429;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.public-footer-links .footer-cookie-settings {
|
||||
color: #3d4449 !important;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 736px) {
|
||||
.analytics-consent {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.analytics-consent__inner,
|
||||
.analytics-consent__actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.analytics-consent__inner {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.analytics-consent__actions {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.analytics-consent__actions button {
|
||||
width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
+223
-1
@@ -63,19 +63,46 @@ body.public-page {
|
||||
|
||||
.public-hero p {
|
||||
color: #3d4449;
|
||||
font-size: 1.15em;
|
||||
max-width: 34rem;
|
||||
}
|
||||
|
||||
.public-hero-lead {
|
||||
font-size: 1.15em;
|
||||
}
|
||||
|
||||
.public-hero-sub {
|
||||
font-size: 1.05em;
|
||||
color: #38761d;
|
||||
margin-top: -0.4rem;
|
||||
}
|
||||
|
||||
.public-hero-reassure {
|
||||
font-size: 0.85em;
|
||||
color: #7f888f;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.public-section {
|
||||
padding: 4rem 6vw;
|
||||
}
|
||||
|
||||
/* Section intros (Titel + Unterzeile) sind ueberall zentriert, damit die
|
||||
Seite nicht zwischen linksbuendigen und zentrierten Abschnitten springt.
|
||||
Die Karteninhalte darunter bleiben davon unberuehrt links ausgerichtet. */
|
||||
.public-section > header.major {
|
||||
text-align: center;
|
||||
max-width: 42rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.public-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1.25rem;
|
||||
max-width: 72rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.public-feature,
|
||||
@@ -84,6 +111,13 @@ body.public-page {
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
background: #ffffff;
|
||||
text-align: left;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.public-feature:hover {
|
||||
transform: translateY(-0.2rem);
|
||||
box-shadow: 0 0.75rem 1.75rem rgba(61, 68, 73, 0.1);
|
||||
}
|
||||
|
||||
.public-feature h3 {
|
||||
@@ -94,6 +128,90 @@ body.public-page {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.public-highlight {
|
||||
background: #eef4ea;
|
||||
}
|
||||
|
||||
/* Trust-Band direkt unter dem Hero: faktische Vertrauensanker, kein Fließtext. */
|
||||
.public-trust {
|
||||
background: #38761d;
|
||||
padding: 1rem 6vw;
|
||||
}
|
||||
|
||||
.public-trust ul {
|
||||
list-style: none;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
max-width: 72rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5rem 1.75rem;
|
||||
}
|
||||
|
||||
.public-trust li {
|
||||
color: #ffffff;
|
||||
font-size: 0.9em;
|
||||
padding-left: 1.4rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.public-trust li:before {
|
||||
content: "\2713";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Zielgruppen-Chips zur Selbst-Identifikation des Besuchers. */
|
||||
.public-audience-list {
|
||||
list-style: none;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
max-width: 60rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.public-audience-list li {
|
||||
border: solid 1px rgba(210, 215, 217, 0.9);
|
||||
border-radius: 999px;
|
||||
padding: 0.5rem 1.1rem;
|
||||
color: #3d4449;
|
||||
background: #ffffff;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.public-price {
|
||||
font-family: "Roboto Slab", serif;
|
||||
font-size: 1.5em;
|
||||
color: #38761d;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.public-price span {
|
||||
font-family: inherit;
|
||||
font-size: 0.55em;
|
||||
color: #7f888f;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.public-cta-reassure {
|
||||
font-size: 0.85em;
|
||||
color: #7f888f;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.public-fineprint {
|
||||
max-width: 72rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
color: #7f888f;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.public-auth {
|
||||
min-height: 100vh;
|
||||
background-image: url("../images/landing-hero.png");
|
||||
@@ -147,7 +265,111 @@ body.public-page {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ul.actions bringt von Haus aus keinen oberen Abstand mit (main.css setzt
|
||||
nur margin-left/padding-left), deshalb klebte die Button-Zeile direkt am
|
||||
letzten Eingabefeld. Bewusst auf .public-auth beschraenkt: das sind die
|
||||
Formularpanels (Login, Registrierung, Passwort), waehrend die CTA-Panels
|
||||
auf der Landingpage ihr enges Layout behalten. */
|
||||
.public-auth .public-panel .actions {
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
|
||||
.public-steps .public-feature {
|
||||
position: relative;
|
||||
padding-top: 2.75rem;
|
||||
}
|
||||
|
||||
.public-step-number {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 50%;
|
||||
background: #38761d;
|
||||
color: #ffffff;
|
||||
font-family: "Roboto Slab", serif;
|
||||
font-weight: 700;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.public-demo-frame {
|
||||
max-width: 72rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
border: solid 1px rgba(210, 215, 217, 0.75);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0.75rem 2.25rem rgba(61, 68, 73, 0.08);
|
||||
}
|
||||
|
||||
.public-demo-frame img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.public-faq {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.25rem;
|
||||
max-width: 72rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.public-faq .public-panel h3 {
|
||||
font-size: 1.05em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.public-faq .public-panel p {
|
||||
margin-bottom: 0;
|
||||
color: #3d4449;
|
||||
}
|
||||
|
||||
.public-legal {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
padding-top: 6rem;
|
||||
}
|
||||
|
||||
.public-legal h2 {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.public-footer-links {
|
||||
text-align: center;
|
||||
padding: 1.5rem 6vw 2.5rem 6vw;
|
||||
}
|
||||
|
||||
.public-footer-links a {
|
||||
color: #3d4449;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.public-footer-links .legal-action-link {
|
||||
border: solid 1px #38761d;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
}
|
||||
|
||||
.public-cta ul.actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 980px) {
|
||||
.public-faq {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
.public-hero {
|
||||
min-height: 78vh;
|
||||
padding: 5.5rem 2rem 3rem 2rem;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,190 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var configurationElement = document.getElementById('analytics-configuration');
|
||||
var banner = document.getElementById('analytics-consent');
|
||||
var settingsButtons = document.querySelectorAll('[data-analytics-settings]');
|
||||
|
||||
if (!configurationElement || !banner) {
|
||||
return;
|
||||
}
|
||||
|
||||
var configuration;
|
||||
try {
|
||||
configuration = JSON.parse(configurationElement.textContent || '');
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
var cookieName = configuration.consentCookie;
|
||||
var visitorCookieName = configuration.visitorCookie;
|
||||
var consentVersion = configuration.consentVersion;
|
||||
var pageViewSent = false;
|
||||
|
||||
function readCookie(name) {
|
||||
var cookies = document.cookie ? document.cookie.split(';') : [];
|
||||
for (var index = 0; index < cookies.length; index += 1) {
|
||||
var parts = cookies[index].trim().split('=');
|
||||
var currentName = decodeURIComponent(parts.shift() || '');
|
||||
if (currentName === name) {
|
||||
return decodeURIComponent(parts.join('='));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readDecision() {
|
||||
var value = readCookie(cookieName);
|
||||
var expectedPrefix = consentVersion + ':';
|
||||
if (!value || value.indexOf(expectedPrefix) !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var decision = value.slice(expectedPrefix.length);
|
||||
return decision === 'granted' || decision === 'denied' ? decision : null;
|
||||
}
|
||||
|
||||
function cookieSuffix() {
|
||||
var suffix = '; Path=/; Max-Age=' + String(configuration.consentMaxAge) + '; SameSite=Lax';
|
||||
if (configuration.cookieDomain) {
|
||||
suffix += '; Domain=' + configuration.cookieDomain;
|
||||
}
|
||||
if (configuration.secureCookies || window.location.protocol === 'https:') {
|
||||
suffix += '; Secure';
|
||||
}
|
||||
return suffix;
|
||||
}
|
||||
|
||||
function writeCookie(name, value) {
|
||||
document.cookie = encodeURIComponent(name) + '=' + encodeURIComponent(value) + cookieSuffix();
|
||||
}
|
||||
|
||||
function writeDecision(decision) {
|
||||
writeCookie(cookieName, consentVersion + ':' + decision);
|
||||
}
|
||||
|
||||
function randomVisitorId() {
|
||||
var bytes = new Uint8Array(8);
|
||||
window.crypto.getRandomValues(bytes);
|
||||
return Array.prototype.map.call(bytes, function (value) {
|
||||
return value.toString(16).padStart(2, '0');
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function ensureVisitorId() {
|
||||
var visitorId = readCookie(visitorCookieName);
|
||||
if (!visitorId || !/^[0-9a-f]{16}$/.test(visitorId)) {
|
||||
visitorId = randomVisitorId();
|
||||
writeCookie(visitorCookieName, visitorId);
|
||||
}
|
||||
return visitorId;
|
||||
}
|
||||
|
||||
function URLWithoutParameters(value) {
|
||||
try {
|
||||
var url = new URL(value, window.location.href);
|
||||
return url.origin + url.pathname;
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function sendPageView() {
|
||||
if (pageViewSent || readDecision() !== 'granted') {
|
||||
return;
|
||||
}
|
||||
pageViewSent = true;
|
||||
|
||||
var now = new Date();
|
||||
var payload = {
|
||||
visitorId: ensureVisitorId(),
|
||||
pageUrl: URLWithoutParameters(window.location.href),
|
||||
pageTitle: document.title || 'Kaffeeliste',
|
||||
referrerUrl: URLWithoutParameters(document.referrer),
|
||||
resolution: window.screen && window.screen.width && window.screen.height
|
||||
? String(window.screen.width) + 'x' + String(window.screen.height)
|
||||
: '',
|
||||
language: window.navigator.language || '',
|
||||
h: now.getHours(),
|
||||
m: now.getMinutes(),
|
||||
s: now.getSeconds()
|
||||
};
|
||||
|
||||
window.fetch(configuration.trackingEndpoint, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload),
|
||||
keepalive: true
|
||||
}).catch(function () {
|
||||
// Analysefehler duerfen die Website niemals beeintraechtigen.
|
||||
});
|
||||
}
|
||||
|
||||
function expireCookie(name, domain) {
|
||||
var value = encodeURIComponent(name) + '=; Path=/; Max-Age=0; SameSite=Lax';
|
||||
if (domain) {
|
||||
value += '; Domain=' + domain;
|
||||
}
|
||||
if (configuration.secureCookies || window.location.protocol === 'https:') {
|
||||
value += '; Secure';
|
||||
}
|
||||
document.cookie = value;
|
||||
}
|
||||
|
||||
function removeAnalysisCookies() {
|
||||
var cookies = document.cookie ? document.cookie.split(';') : [];
|
||||
cookies.forEach(function (cookie) {
|
||||
var name = decodeURIComponent(cookie.trim().split('=')[0] || '');
|
||||
if (name !== visitorCookieName && name.indexOf('_pk_') !== 0 && name.indexOf('mtm_') !== 0) {
|
||||
return;
|
||||
}
|
||||
expireCookie(name, '');
|
||||
if (configuration.cookieDomain) {
|
||||
expireCookie(name, configuration.cookieDomain);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeBanner() {
|
||||
banner.hidden = true;
|
||||
}
|
||||
|
||||
function choose(decision) {
|
||||
writeDecision(decision);
|
||||
closeBanner();
|
||||
if (decision === 'granted') {
|
||||
sendPageView();
|
||||
} else {
|
||||
removeAnalysisCookies();
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('kaffeeliste:analytics-consent', {
|
||||
detail: {analytics: decision}
|
||||
}));
|
||||
}
|
||||
|
||||
banner.querySelectorAll('[data-analytics-consent]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
choose(button.getAttribute('data-analytics-consent'));
|
||||
});
|
||||
});
|
||||
|
||||
settingsButtons.forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
banner.hidden = false;
|
||||
var firstButton = banner.querySelector('[data-analytics-consent="denied"]');
|
||||
if (firstButton) {
|
||||
firstButton.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var initialDecision = readDecision();
|
||||
if (initialDecision === 'granted') {
|
||||
sendPageView();
|
||||
} else if (initialDecision === 'denied') {
|
||||
removeAnalysisCookies();
|
||||
} else {
|
||||
banner.hidden = false;
|
||||
}
|
||||
}());
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/app/legal.php';
|
||||
require_once __DIR__ . '/app/public-page-views.php';
|
||||
app_record_public_page_view('dpa');
|
||||
?>
|
||||
<!DOCTYPE HTML>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<title>Auftragsverarbeitungsvertrag – Kaffeeliste</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="assets/css/main.css">
|
||||
<link rel="stylesheet" href="assets/css/public.css">
|
||||
<?php echo app_analytics_head_html(); ?>
|
||||
</head>
|
||||
<body class="is-preload public-page">
|
||||
<div class="public-shell">
|
||||
<section class="public-section public-legal">
|
||||
<nav class="public-nav" aria-label="Hauptnavigation">
|
||||
<strong><a href="landing.php">Kaffeeliste</a></strong>
|
||||
<ul class="actions"><li><a href="login.php" class="button">Login</a></li></ul>
|
||||
</nav>
|
||||
<header class="major"><h1>Auftragsverarbeitungsvertrag</h1></header>
|
||||
<?php echo app_render_legal_document('dpa'); ?>
|
||||
<p><a href="legal/avv-<?php echo htmlspecialchars(APP_DPA_VERSION, ENT_QUOTES, 'UTF-8'); ?>.txt" download>AVV als Textdatei speichern</a></p>
|
||||
<p><a href="landing.php">← Zurück zur Startseite</a></p>
|
||||
</section>
|
||||
</div>
|
||||
<?php echo app_public_legal_footer(); ?>
|
||||
<?php echo app_analytics_body_html(); ?>
|
||||
<script src="assets/js/jquery.min.js"></script>
|
||||
<script src="assets/js/browser.min.js"></script>
|
||||
<script src="assets/js/breakpoints.min.js"></script>
|
||||
<script src="assets/js/util.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/platform-admin.php';
|
||||
require_once __DIR__ . '/app/data-export.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = app_require_platform_admin($pdo);
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
exit('Nur POST erlaubt.');
|
||||
}
|
||||
|
||||
app_require_csrf();
|
||||
|
||||
$tenantId = filter_input(INPUT_POST, 'tenant_id', FILTER_VALIDATE_INT);
|
||||
if ($tenantId === null || $tenantId === false || $tenantId <= 0) {
|
||||
http_response_code(400);
|
||||
exit('Ungültige Mandanten-ID.');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT slug FROM tenants WHERE id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
$slug = $stmt->fetchColumn();
|
||||
if ($slug === false) {
|
||||
http_response_code(404);
|
||||
exit('Mandant wurde nicht gefunden.');
|
||||
}
|
||||
|
||||
$data = app_export_tenant_data($pdo, $tenantId);
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'backoffice.tenant_exported', 'tenant', $tenantId);
|
||||
|
||||
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$filename = 'backoffice-export-' . $slug . '-' . date('Ymd_His') . '.json';
|
||||
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
header('Content-Length: ' . strlen((string)$json));
|
||||
echo $json;
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/platform-admin.php';
|
||||
require_once __DIR__ . '/app/features.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = app_require_platform_admin($pdo);
|
||||
|
||||
// Die Mandanten-ID kommt beim Speichern der Funktions-Schalter aus dem
|
||||
// Formular, sonst aus der URL.
|
||||
$tenantId = filter_input(INPUT_POST, 'tenant_id', FILTER_VALIDATE_INT)
|
||||
?: filter_input(INPUT_GET, 'tenant_id', FILTER_VALIDATE_INT);
|
||||
if ($tenantId === null || $tenantId === false || $tenantId <= 0) {
|
||||
http_response_code(400);
|
||||
exit('Ungültige Mandanten-ID.');
|
||||
}
|
||||
|
||||
$featureMeldung = null;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['aktion'] ?? '') === 'features_speichern') {
|
||||
app_require_csrf();
|
||||
|
||||
$geaendert = app_features_update($pdo, $tenantId, (array)($_POST['features'] ?? []));
|
||||
if ($geaendert !== []) {
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'backoffice.features_updated', 'tenant', $tenantId, [
|
||||
'changed' => $geaendert,
|
||||
]);
|
||||
}
|
||||
$featureMeldung = $geaendert === []
|
||||
? 'Keine Änderung an den Funktionen.'
|
||||
: 'Die Funktionen wurden gespeichert.';
|
||||
}
|
||||
|
||||
$detail = app_backoffice_fetch_tenant_detail($pdo, $tenantId);
|
||||
if ($detail === null) {
|
||||
http_response_code(404);
|
||||
exit('Mandant wurde nicht gefunden.');
|
||||
}
|
||||
|
||||
// Jede Ansicht eines Mandanten durch das Back-Office wird im Audit-Log
|
||||
// dieses Mandanten protokolliert, damit der Zugriff fuer den Kunden
|
||||
// nachvollziehbar bleibt (siehe AVV-Transparenzpflicht).
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'backoffice.tenant_viewed', 'tenant', $tenantId);
|
||||
|
||||
$tenantFeatures = app_features_for_tenant($pdo, $tenantId);
|
||||
|
||||
$rollenLabels = ['owner' => 'Inhaber', 'admin' => 'Administrator', 'treasurer' => 'Kassenwart', 'member' => 'Mitglied', 'viewer' => 'Betrachter'];
|
||||
|
||||
include 'header.php';
|
||||
include 'headerline.php';
|
||||
include 'nav.php';
|
||||
?>
|
||||
|
||||
<section id="banner">
|
||||
<div class="content">
|
||||
<h2>Back-Office: <?php echo saas_html($detail['tenant']['name']); ?></h2>
|
||||
<p><a href="backoffice.php">← Zurück zur Mandantenübersicht</a></p>
|
||||
|
||||
<table>
|
||||
<tr><th>Kürzel</th><td><?php echo saas_html($detail['tenant']['slug']); ?></td></tr>
|
||||
<tr><th>Status</th><td><?php echo saas_html($detail['tenant']['status']); ?></td></tr>
|
||||
<tr><th>Erstellt</th><td><?php echo saas_html($detail['tenant']['created_at']); ?></td></tr>
|
||||
<?php if ($detail['settings'] !== null): ?>
|
||||
<tr><th>Preis pro Strich</th><td><?php echo saas_html(saas_format_money_cents((int)$detail['settings']['mark_price_cents'])); ?> €</td></tr>
|
||||
<tr><th>PayPal aktiv</th><td><?php echo (int)$detail['settings']['paypal_enabled'] === 1 ? 'ja' : 'nein'; ?></td></tr>
|
||||
<?php endif; ?>
|
||||
</table>
|
||||
|
||||
<form method="post" action="backoffice-export.php">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="tenant_id" value="<?php echo (int)$tenantId; ?>">
|
||||
<button type="submit">Datenexport dieses Mandanten herunterladen</button>
|
||||
</form>
|
||||
|
||||
<h3>Freigeschaltete Funktionen</h3>
|
||||
<p>Zentral vom Betreiber gesteuert: abgehakte Funktionen sind für diesen Mandanten
|
||||
gesperrt und verschwinden dort aus Menü und Seiten. Die Einstellungen des Kunden
|
||||
bleiben dabei erhalten.</p>
|
||||
|
||||
<?php if ($featureMeldung !== null): ?>
|
||||
<div class="hint-box success"><p><?php echo saas_html($featureMeldung); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="backoffice-mandant.php?tenant_id=<?php echo (int)$tenantId; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="features_speichern">
|
||||
<input type="hidden" name="tenant_id" value="<?php echo (int)$tenantId; ?>">
|
||||
<div class="row">
|
||||
<?php foreach (app_feature_catalog() as $featureKey => $feature): ?>
|
||||
<div class="col-6 col-12-small">
|
||||
<input type="checkbox"
|
||||
id="feature_<?php echo saas_html($featureKey); ?>"
|
||||
name="features[<?php echo saas_html($featureKey); ?>]"
|
||||
value="1"
|
||||
<?php echo ($tenantFeatures[$featureKey] ?? true) ? 'checked' : ''; ?>>
|
||||
<label for="feature_<?php echo saas_html($featureKey); ?>">
|
||||
<?php echo saas_html($feature['label']); ?>
|
||||
</label>
|
||||
<small><?php echo saas_html($feature['description']); ?></small>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<ul class="actions">
|
||||
<li><button type="submit">Funktionen speichern</button></li>
|
||||
</ul>
|
||||
</form>
|
||||
|
||||
<h3>Mitglieder mit Zugang</h3>
|
||||
<table>
|
||||
<tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Status</th></tr>
|
||||
<?php if ($detail['members'] === []): ?>
|
||||
<tr><td colspan="4">Kein Mitglied mit Login-Zugang.</td></tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($detail['members'] as $member): ?>
|
||||
<tr>
|
||||
<td><?php echo saas_html($member['display_name']); ?></td>
|
||||
<td><?php echo saas_html($member['email']); ?></td>
|
||||
<td><?php echo saas_html($rollenLabels[$member['role']] ?? $member['role']); ?></td>
|
||||
<td><?php echo saas_html($member['membership_status']); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
|
||||
<h3>Letzte Buchungen</h3>
|
||||
<table>
|
||||
<tr><th>Datum</th><th>Mitglied</th><th>Typ</th><th>Betrag</th><th>Quelle</th></tr>
|
||||
<?php if ($detail['recent_entries'] === []): ?>
|
||||
<tr><td colspan="5">Keine Buchungen vorhanden.</td></tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($detail['recent_entries'] as $entry): ?>
|
||||
<tr>
|
||||
<td><?php echo saas_html($entry['booked_at']); ?></td>
|
||||
<td><?php echo saas_html($entry['display_name']); ?></td>
|
||||
<td><?php echo saas_html($entry['type']); ?></td>
|
||||
<td><?php echo saas_html(saas_format_money_cents((int)$entry['amount_cents'])); ?> €</td>
|
||||
<td><?php echo saas_html($entry['source']); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
|
||||
<h3>Letzte Admin-Aktionen</h3>
|
||||
<table>
|
||||
<tr><th>Datum</th><th>Wer</th><th>Aktion</th></tr>
|
||||
<?php if ($detail['recent_audit'] === []): ?>
|
||||
<tr><td colspan="3">Noch keine protokollierten Aktionen.</td></tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($detail['recent_audit'] as $entry): ?>
|
||||
<tr>
|
||||
<td><?php echo saas_html($entry['created_at']); ?></td>
|
||||
<td><?php echo saas_html($entry['actor_name'] ?? '—'); ?></td>
|
||||
<td><?php echo saas_html($entry['action']); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php include 'footer.php'; ?>
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/platform-admin.php';
|
||||
require_once __DIR__ . '/app/billing.php';
|
||||
require_once __DIR__ . '/app/public-page-views.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = app_require_platform_admin($pdo);
|
||||
|
||||
$tenants = app_backoffice_fetch_tenants($pdo);
|
||||
$billingPlans = billing_plans();
|
||||
$pageViewSummary = null;
|
||||
$pageViewError = false;
|
||||
try {
|
||||
$pageViewSummary = app_public_page_view_summary($pdo);
|
||||
} catch (Throwable $e) {
|
||||
$pageViewError = true;
|
||||
error_log('Back-Office-Seitenaufrufe konnten nicht geladen werden: ' . $e->getMessage());
|
||||
}
|
||||
$legalRequests = $pdo->query(
|
||||
"SELECT lr.id, lr.reference_code, lr.request_type, lr.requester_name,
|
||||
lr.requester_email, lr.contract_reference, lr.request_reason,
|
||||
lr.requested_end, lr.status, lr.received_at, t.name AS tenant_name
|
||||
FROM legal_requests lr
|
||||
LEFT JOIN tenants t ON t.id = lr.tenant_id
|
||||
WHERE lr.status <> 'processed'
|
||||
ORDER BY lr.received_at ASC
|
||||
LIMIT 100"
|
||||
)->fetchAll();
|
||||
|
||||
include 'header.php';
|
||||
include 'headerline.php';
|
||||
include 'nav.php';
|
||||
?>
|
||||
|
||||
<section id="banner">
|
||||
<div class="content">
|
||||
<h2>Back-Office</h2>
|
||||
<p>Nur für Betreiber-Admins sichtbar: Übersicht aller Mandanten, unabhängig von deren eigener Mitgliedschaft.
|
||||
Jeder Zugriff wird im Audit-Log des jeweiligen Mandanten protokolliert.</p>
|
||||
|
||||
<h3>Öffentliche Seitenaufrufe</h3>
|
||||
<p><small>Anonyme Seitenaufrufe ohne Cookies, IP-Adressen oder Besucherkennungen. Wiederholte Aufrufe und automatisierte Abrufe können enthalten sein; die Zahlen sind keine eindeutigen Besucher.</small></p>
|
||||
<?php if ($pageViewError || $pageViewSummary === null): ?>
|
||||
<div class="hint-box warning"><p>Die Seitenaufrufe sind noch nicht verfügbar. Bitte Migration 0032 ausführen.</p></div>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>Heute</th><th>Letzte 30 Tage</th><th>Seit Beginn der Zählung</th></tr>
|
||||
<tr>
|
||||
<td><?php echo number_format($pageViewSummary['totals']['today'], 0, ',', '.'); ?></td>
|
||||
<td><?php echo number_format($pageViewSummary['totals']['last_30_days'], 0, ',', '.'); ?></td>
|
||||
<td><?php echo number_format($pageViewSummary['totals']['all_time'], 0, ',', '.'); ?></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<?php if ($pageViewSummary['pages'] === []): ?>
|
||||
<div class="hint-box info"><p>Seit dem Start des Zählers wurden noch keine öffentlichen Seiten aufgerufen.</p></div>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>Seite</th><th>Heute</th><th>Letzte 30 Tage</th><th>Gesamt</th></tr>
|
||||
<?php foreach ($pageViewSummary['pages'] as $pageView): ?>
|
||||
<tr>
|
||||
<td><?php echo saas_html($pageView['label']); ?></td>
|
||||
<td><?php echo number_format($pageView['today'], 0, ',', '.'); ?></td>
|
||||
<td><?php echo number_format($pageView['last_30_days'], 0, ',', '.'); ?></td>
|
||||
<td><?php echo number_format($pageView['all_time'], 0, ',', '.'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3>Offene Kündigungen und Widerrufe</h3>
|
||||
<?php if ($legalRequests === []): ?>
|
||||
<div class="hint-box success"><p>Keine offenen Rechtserklärungen.</p></div>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>Eingang</th><th>Art / Referenz</th><th>Kunde</th><th>Erklärung</th><th>Status</th><th></th></tr>
|
||||
<?php foreach ($legalRequests as $request): ?>
|
||||
<tr>
|
||||
<td><?php echo saas_html($request['received_at']); ?></td>
|
||||
<td><?php echo $request['request_type'] === 'withdrawal' ? 'Widerruf' : 'Kündigung'; ?><br><small><?php echo saas_html($request['reference_code']); ?></small></td>
|
||||
<td><?php echo saas_html($request['requester_name']); ?><br><a href="mailto:<?php echo saas_html($request['requester_email']); ?>"><?php echo saas_html($request['requester_email']); ?></a><br><small><?php echo saas_html($request['tenant_name'] ?? $request['contract_reference']); ?></small></td>
|
||||
<td><?php echo $request['requested_end'] !== null ? 'Ende: ' . saas_html($request['requested_end']) . '<br>' : ''; ?><?php echo nl2br(saas_html($request['request_reason'] ?? '')); ?></td>
|
||||
<td><?php echo saas_html($request['status']); ?></td>
|
||||
<td>
|
||||
<form method="post" action="rechtserklaerung-bearbeiten.php">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="request_id" value="<?php echo (int)$request['id']; ?>">
|
||||
<button type="submit" class="button small">Als erledigt markieren</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Kunde</th>
|
||||
<th>Kürzel</th>
|
||||
<th>Status</th>
|
||||
<th>Tarif</th>
|
||||
<th>Erstellt</th>
|
||||
<th>Teilnehmer (aktiv/gesamt)</th>
|
||||
<th>Saldensumme</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<?php foreach ($tenants as $tenant): ?>
|
||||
<?php $tenantRequiredPlan = billing_required_plan_code((int)$tenant['active_participant_count']); ?>
|
||||
<tr>
|
||||
<td><?php echo saas_html($tenant['name']); ?></td>
|
||||
<td><?php echo saas_html($tenant['slug']); ?></td>
|
||||
<td><?php echo saas_html($tenant['status']); ?></td>
|
||||
<td>
|
||||
<?php echo saas_html($billingPlans[$tenant['plan_code']]['label'] ?? $tenant['plan_code']); ?>
|
||||
<?php if ($tenantRequiredPlan !== $tenant['plan_code']): ?>
|
||||
<br><small>benötigt: <?php echo saas_html($billingPlans[$tenantRequiredPlan]['label'] ?? $tenantRequiredPlan); ?></small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo saas_html($tenant['created_at']); ?></td>
|
||||
<td><?php echo (int)$tenant['active_participant_count']; ?> / <?php echo (int)$tenant['participant_count']; ?></td>
|
||||
<td><?php echo saas_html(saas_format_money_cents((int)$tenant['balance_cents'])); ?> €</td>
|
||||
<td><a href="backoffice-mandant.php?tenant_id=<?php echo (int)$tenant['id']; ?>" class="button">Ansehen</a></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php include 'footer.php'; ?>
|
||||
-42
@@ -3,45 +3,3 @@
|
||||
require_once __DIR__ . '/app/bootstrap.php';
|
||||
|
||||
app_start_session();
|
||||
|
||||
if ((getenv('APP_DB_DRIVER') ?: '') === 'mysql' && !function_exists('sqlsrv_connect')) {
|
||||
require_once __DIR__ . '/lib/sqlsrv_mysql_compat.php';
|
||||
}
|
||||
|
||||
### ZUGANGSDATEN AD
|
||||
$aduser = '';
|
||||
$adpassword = '';
|
||||
$domain = '';
|
||||
## OU für die Suche der Benutzerkonten
|
||||
#$basedn = '';
|
||||
$basedn = '';
|
||||
## OU für die Suche der Benutzerkonten
|
||||
#$basednandereKasse = '';
|
||||
## OU für die Suche der Gruppen
|
||||
$basedngroup = '';
|
||||
## Zugriffsgruppe für Automatisierungsclient
|
||||
$zugriffsACgroup = '';
|
||||
## Zugriffsgruppe für Automatische Abwesenheitsnachricht
|
||||
$zugriffsAbwesenheitgroup = '';
|
||||
## Zugriffsgruppe für alle Anfragen des Automatisierungsclient
|
||||
$zugriffsalleACgroup = '';
|
||||
## Zugriffsgruppe für Anfragen der Abwesenheitsnachrichten
|
||||
$zugriffsalleAbwesenheitgroup = '';
|
||||
## Zugriff auf alles
|
||||
$zugriffsAdminsgroup = '';
|
||||
|
||||
// Verbindung zur Datenbank herstellen (ersetze die Platzhalter durch deine Daten)
|
||||
$serverName = getenv('DB_HOST') ?: "";
|
||||
$connectionOptions = array(
|
||||
"Database" => getenv('DB_NAME') ?: "",
|
||||
"Uid" => getenv('DB_USER') ?: "",
|
||||
"PWD" => getenv('DB_PASS') ?: "",
|
||||
"TrustServerCertificate"=>true
|
||||
);
|
||||
|
||||
$conn = sqlsrv_connect($serverName, $connectionOptions);
|
||||
// Überprüfen der Verbindung
|
||||
if (!$conn) {
|
||||
die(print_r(sqlsrv_errors(), true));
|
||||
}
|
||||
?>
|
||||
|
||||
+367
-227
@@ -1,6 +1,12 @@
|
||||
<?php
|
||||
|
||||
ob_start();
|
||||
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/imports.php";
|
||||
require_once __DIR__ . "/app/audit.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
include "header.php";
|
||||
include "headerline.php";
|
||||
@@ -14,284 +20,418 @@ include "nav.php";
|
||||
<div class="content">
|
||||
|
||||
<?php
|
||||
if(checkKaffeelisteAdmin($conn, $mailadress)){
|
||||
|
||||
function csv_h($value): string
|
||||
{
|
||||
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
|
||||
$pdo = app_db_pdo();
|
||||
$saasUser = saas_current_user($pdo);
|
||||
$tenantId = 0;
|
||||
$hasAccess = false;
|
||||
|
||||
if ($saasUser !== null && saas_user_has_role(['owner', 'admin', 'treasurer'], $saasUser)) {
|
||||
$tenantId = (int)$saasUser['tenant_id'];
|
||||
$hasAccess = true;
|
||||
}
|
||||
|
||||
if (!$hasAccess) {
|
||||
echo "<h2>Kein Zugriff</h2>";
|
||||
include "footer.php";
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!app_feature_enabled($pdo, $tenantId, 'csv_import')) {
|
||||
echo app_feature_notice_html('csv_import');
|
||||
include "footer.php";
|
||||
exit;
|
||||
}
|
||||
|
||||
function csv_upload_dir(): string
|
||||
{
|
||||
return APP_ROOT . '/var/uploads';
|
||||
return APP_ROOT . '/var/uploads';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: ?string, 1: ?string} [Pfad, Fehlermeldung]
|
||||
*/
|
||||
function csv_prepare_upload(array $file): array
|
||||
{
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
return [null, 'Fehler beim Hochladen der Datei.'];
|
||||
}
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
return [null, 'Fehler beim Hochladen der Datei.'];
|
||||
}
|
||||
|
||||
if (($file['size'] ?? 0) <= 0 || $file['size'] > 5 * 1024 * 1024) {
|
||||
return [null, 'Die CSV-Datei ist leer oder größer als 5 MB.'];
|
||||
}
|
||||
if (($file['size'] ?? 0) <= 0 || $file['size'] > 5 * 1024 * 1024) {
|
||||
return [null, 'Die CSV-Datei ist leer oder größer als 5 MB.'];
|
||||
}
|
||||
|
||||
$originalName = (string)($file['name'] ?? '');
|
||||
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
if ($extension !== 'csv') {
|
||||
return [null, 'Es sind nur CSV-Dateien erlaubt.'];
|
||||
}
|
||||
$originalName = (string)($file['name'] ?? '');
|
||||
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
if ($extension !== 'csv') {
|
||||
return [null, 'Es sind nur CSV-Dateien erlaubt.'];
|
||||
}
|
||||
|
||||
$tmpName = (string)($file['tmp_name'] ?? '');
|
||||
if (!is_uploaded_file($tmpName)) {
|
||||
return [null, 'Die hochgeladene Datei konnte nicht verifiziert werden.'];
|
||||
}
|
||||
$tmpName = (string)($file['tmp_name'] ?? '');
|
||||
if (!is_uploaded_file($tmpName)) {
|
||||
return [null, 'Die hochgeladene Datei konnte nicht verifiziert werden.'];
|
||||
}
|
||||
|
||||
if (function_exists('finfo_open')) {
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo ? finfo_file($finfo, $tmpName) : false;
|
||||
if ($finfo) {
|
||||
finfo_close($finfo);
|
||||
}
|
||||
if (function_exists('finfo_open')) {
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo ? finfo_file($finfo, $tmpName) : false;
|
||||
if ($finfo) {
|
||||
finfo_close($finfo);
|
||||
}
|
||||
|
||||
$allowedMimeTypes = [
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'text/x-csv',
|
||||
'application/csv',
|
||||
'application/vnd.ms-excel',
|
||||
'application/octet-stream',
|
||||
];
|
||||
if (is_string($mime) && !in_array($mime, $allowedMimeTypes, true)) {
|
||||
return [null, 'Der Dateityp wurde nicht als CSV erkannt.'];
|
||||
}
|
||||
}
|
||||
$allowedMimeTypes = [
|
||||
'text/plain', 'text/csv', 'text/x-csv', 'application/csv',
|
||||
'application/vnd.ms-excel', 'application/octet-stream',
|
||||
];
|
||||
if (is_string($mime) && !in_array($mime, $allowedMimeTypes, true)) {
|
||||
return [null, 'Der Dateityp wurde nicht als CSV erkannt.'];
|
||||
}
|
||||
}
|
||||
|
||||
$uploadDir = csv_upload_dir();
|
||||
if (!is_dir($uploadDir)) {
|
||||
@mkdir($uploadDir, 0700, true);
|
||||
}
|
||||
if (!is_dir($uploadDir) || !is_writable($uploadDir)) {
|
||||
return [null, 'Der Upload-Ordner ist nicht beschreibbar.'];
|
||||
}
|
||||
$uploadDir = csv_upload_dir();
|
||||
if (!is_dir($uploadDir)) {
|
||||
@mkdir($uploadDir, 0700, true);
|
||||
}
|
||||
if (!is_dir($uploadDir) || !is_writable($uploadDir)) {
|
||||
return [null, 'Der Upload-Ordner ist nicht beschreibbar.'];
|
||||
}
|
||||
|
||||
$targetFile = $uploadDir . '/paypal_' . date('Ymd_His') . '_' . bin2hex(random_bytes(8)) . '.csv';
|
||||
if (!move_uploaded_file($tmpName, $targetFile)) {
|
||||
return [null, 'Die CSV-Datei konnte nicht gespeichert werden.'];
|
||||
}
|
||||
$targetFile = $uploadDir . '/paypal_' . date('Ymd_His') . '_' . bin2hex(random_bytes(8)) . '.csv';
|
||||
if (!move_uploaded_file($tmpName, $targetFile)) {
|
||||
return [null, 'Die CSV-Datei konnte nicht gespeichert werden.'];
|
||||
}
|
||||
|
||||
return [$targetFile, null];
|
||||
return [$targetFile, null];
|
||||
}
|
||||
|
||||
|
||||
// Funktion zum Überprüfen, ob der Mitarbeiter in der Tabelle vorhanden ist
|
||||
function isMitarbeiterExist($conn, $name)
|
||||
function csv_status_label(string $status): string
|
||||
{
|
||||
$sql = "SELECT MitarbeiterID FROM kl_Mitarbeiter WHERE Name = ? or paypalname = ?";
|
||||
$params = array($name, $name);
|
||||
$stmt = sqlsrv_query($conn, $sql, $params);
|
||||
|
||||
if ($stmt === false) {
|
||||
die(print_r(sqlsrv_errors(), true));
|
||||
}
|
||||
|
||||
$row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC);
|
||||
|
||||
return ($row !== false);
|
||||
return match ($status) {
|
||||
'matched' => 'Wird importiert',
|
||||
'duplicate' => 'Bereits vorhanden',
|
||||
'unmatched' => 'Mitglied nicht gefunden',
|
||||
'invalid' => 'Ungültige Zeile',
|
||||
'imported' => 'Importiert',
|
||||
'ignored' => 'Ignoriert',
|
||||
default => $status,
|
||||
};
|
||||
}
|
||||
|
||||
// Funktion zum Überprüfen, ob ein identischer Eintrag bereits vorhanden ist
|
||||
function isDuplicateEntry($conn, $mitarbeiterID, $betrag, $datum)
|
||||
function csv_status_class(string $status): string
|
||||
{
|
||||
// Um nur den gleichen Tag zu vergleichen, konvertieren wir den Datum-String zu einem DateTime-Objekt und verwenden die Funktion CONVERT
|
||||
$date = date_create($datum);
|
||||
if ($date === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$convertedDatum = date_format($date, 'Y-m-d');
|
||||
|
||||
#$sql = "SELECT count FROM dbo.kl_Einzahlungen WHERE MitarbeiterID = ? AND Betrag = ? AND CONVERT(VARCHAR, Datum, 23) = ?";
|
||||
$sql = "SELECT * FROM dbo.kl_Einzahlungen WHERE MitarbeiterID = ? AND Betrag = ? AND CONVERT(VARCHAR, Datum, 23) = ?";
|
||||
$params = array($mitarbeiterID, $betrag, $convertedDatum);
|
||||
$stmt = sqlsrv_query($conn, $sql, $params);
|
||||
|
||||
if ($stmt === false) {
|
||||
die(print_r(sqlsrv_errors(), true));
|
||||
}
|
||||
|
||||
$row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC);
|
||||
|
||||
return $row !== null && (int)$row['MitarbeiterID'] > 0;
|
||||
return match ($status) {
|
||||
'matched', 'imported' => 'success',
|
||||
'duplicate' => 'warning',
|
||||
'unmatched', 'invalid' => 'error',
|
||||
'ignored' => 'muted',
|
||||
default => 'muted',
|
||||
};
|
||||
}
|
||||
|
||||
// Funktion zum Verarbeiten der CSV-Datei und Rückgabe der Ergebnisse
|
||||
function processCSV($conn, $file)
|
||||
function csv_redirect_to_batch(?int $batchId = null): void
|
||||
{
|
||||
$handle = fopen($file, "r");
|
||||
$success_entries = [];
|
||||
$failed_entries = [];
|
||||
$target = 'csvupload.php';
|
||||
if ($batchId !== null && $batchId > 0) {
|
||||
$target .= '?batch_id=' . urlencode((string)$batchId);
|
||||
}
|
||||
header('Location: ' . $target);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Überspringe die erste Zeile (Header) der CSV-Datei
|
||||
fgetcsv($handle);
|
||||
function csv_select_options(array $members, array $suggestions = [], int $selectedId = 0): string
|
||||
{
|
||||
$suggestedIds = [];
|
||||
foreach ($suggestions as $suggestion) {
|
||||
$suggestedIds[(int)$suggestion['participant_id']] = true;
|
||||
}
|
||||
|
||||
while (($data = fgetcsv($handle, 1000, ",")) !== FALSE) {
|
||||
if (count($data) < 8) {
|
||||
$failed_entries[] = array($data[0] ?? '', $data[3] ?? '', $data[7] ?? '', 4);
|
||||
continue;
|
||||
}
|
||||
$html = '<option value="">- Mitglied wählen -</option>';
|
||||
if ($suggestions !== []) {
|
||||
$html .= '<optgroup label="Vorschläge">';
|
||||
foreach ($suggestions as $suggestion) {
|
||||
$id = (int)$suggestion['participant_id'];
|
||||
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
|
||||
. saas_html((string)$suggestion['display_name'])
|
||||
. '</option>';
|
||||
}
|
||||
$html .= '</optgroup>';
|
||||
}
|
||||
|
||||
$name = $data[3]; // Index 3 entspricht dem "Name"-Feld in der CSV
|
||||
$betrag = $data[7]; // Index 7 entspricht dem "Brutto"-Feld in der CSV
|
||||
$betrag = str_replace(",", ".", $betrag);
|
||||
#echo "<br>";
|
||||
#$Ausgabe = array($data[0], $data[3], $data[7]);
|
||||
// Überprüfen, ob der Mitarbeiter existiert und der Betrag positiv ist
|
||||
if (isMitarbeiterExist($conn, $name) && $betrag > 0) {
|
||||
$html .= '<optgroup label="Alle aktiven Mitglieder">';
|
||||
foreach ($members as $member) {
|
||||
$id = (int)$member['participant_id'];
|
||||
if (isset($suggestedIds[$id])) {
|
||||
continue;
|
||||
}
|
||||
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
|
||||
. saas_html((string)$member['display_name'])
|
||||
. '</option>';
|
||||
}
|
||||
$html .= '</optgroup>';
|
||||
|
||||
$mitarbeiterID = getMitarbeiterID($conn, $name);
|
||||
$datum = $data[0]; // Index 0 entspricht dem "Datum"-Feld in der CSV
|
||||
return $html;
|
||||
}
|
||||
|
||||
// Überprüfen, ob ein identischer Eintrag bereits vorhanden ist
|
||||
if (!isDuplicateEntry($conn, $mitarbeiterID, $betrag, $datum)) {
|
||||
$meldung = null;
|
||||
$fehler = null;
|
||||
$vorschauBatchId = isset($_GET['batch_id']) ? (int)$_GET['batch_id'] : null;
|
||||
|
||||
// Eintrag in die Tabelle dbo.kl_Einzahlungen einfügen
|
||||
$sql = "INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)";
|
||||
$params = array($mitarbeiterID, $betrag, $datum);
|
||||
$stmt = sqlsrv_query($conn, $sql, $params);
|
||||
if (isset($_SESSION['flash_csvupload']) && is_array($_SESSION['flash_csvupload'])) {
|
||||
$flash = $_SESSION['flash_csvupload'];
|
||||
unset($_SESSION['flash_csvupload']);
|
||||
$meldung = isset($flash['meldung']) ? (string)$flash['meldung'] : null;
|
||||
$fehler = isset($flash['fehler']) ? (string)$flash['fehler'] : null;
|
||||
}
|
||||
|
||||
if ($stmt) {
|
||||
$success_entries[] = array($data[0], $data[3], $data[7], 0);
|
||||
} else {
|
||||
$failed_entries[] = array($data[0], $data[3], $data[7], 1);
|
||||
#die(print_r(sqlsrv_errors(), true));
|
||||
if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
$aktion = $_POST['aktion'] ?? 'hochladen';
|
||||
$vorschauBatchId = (int)($_POST['batch_id'] ?? 0);
|
||||
|
||||
if ($aktion === 'hochladen' && isset($_FILES["csv_file"])) {
|
||||
[$csvFile, $uploadError] = csv_prepare_upload($_FILES["csv_file"]);
|
||||
|
||||
if ($uploadError !== null) {
|
||||
$fehler = $uploadError;
|
||||
$_SESSION['flash_csvupload'] = ['fehler' => $fehler];
|
||||
csv_redirect_to_batch();
|
||||
} else {
|
||||
try {
|
||||
$checksum = hash_file('sha256', $csvFile);
|
||||
$rows = imports_parse_csv($csvFile);
|
||||
|
||||
$pdo->beginTransaction();
|
||||
$batchId = imports_create_batch(
|
||||
$pdo,
|
||||
$tenantId,
|
||||
basename((string)$_FILES["csv_file"]["name"]),
|
||||
(string)$checksum,
|
||||
$saasUser['user_id'] ?? null
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$amountCents = imports_normalize_amount($row['amount_raw']);
|
||||
$timestamp = $row['date_raw'] !== '' ? strtotime($row['date_raw']) : false;
|
||||
|
||||
if ($amountCents === null || $timestamp === false) {
|
||||
imports_store_row($pdo, $batchId, $row['row_number'], null, $row['raw_name'], $amountCents ?? 0, date('Y-m-d H:i:s', $timestamp ?: time()), 'invalid', $row['raw']);
|
||||
continue;
|
||||
}
|
||||
|
||||
$bookedAt = date('Y-m-d H:i:s', $timestamp);
|
||||
$participant = imports_find_participant($pdo, $tenantId, $row['raw_name']);
|
||||
|
||||
if ($participant === null) {
|
||||
imports_store_row($pdo, $batchId, $row['row_number'], null, $row['raw_name'], $amountCents, $bookedAt, 'unmatched', $row['raw']);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (imports_is_duplicate($pdo, $tenantId, $participant['participant_id'], $amountCents, date('Y-m-d', $timestamp))) {
|
||||
imports_store_row($pdo, $batchId, $row['row_number'], $participant['participant_id'], $row['raw_name'], $amountCents, $bookedAt, 'duplicate', $row['raw']);
|
||||
continue;
|
||||
}
|
||||
|
||||
imports_store_row($pdo, $batchId, $row['row_number'], $participant['participant_id'], $row['raw_name'], $amountCents, $bookedAt, 'matched', $row['raw']);
|
||||
}
|
||||
}else {
|
||||
#echo "Eintrag bereits vorhanden: " . print_r($data, true) . "\n";
|
||||
$failed_entries[] = array($data[0], $data[3], $data[7], 2);
|
||||
|
||||
$pdo->commit();
|
||||
$vorschauBatchId = $batchId;
|
||||
$meldung = 'CSV-Datei wurde eingelesen. Bitte die Vorschau prüfen.';
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
$fehler = 'Die CSV-Datei konnte nicht verarbeitet werden.';
|
||||
} finally {
|
||||
@unlink($csvFile);
|
||||
}
|
||||
|
||||
} else {
|
||||
$failed_entries[] = array($data[0], $data[3], $data[7], 3);
|
||||
}
|
||||
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
|
||||
csv_redirect_to_batch($vorschauBatchId);
|
||||
}
|
||||
} elseif ($aktion === 'zeile_zuordnen') {
|
||||
$rowId = (int)($_POST['row_id'] ?? 0);
|
||||
$participantId = (int)($_POST['participant_id'] ?? 0);
|
||||
if ($vorschauBatchId <= 0 || $rowId <= 0 || $participantId <= 0) {
|
||||
$fehler = 'Bitte eine Importzeile und ein Mitglied auswählen.';
|
||||
} else {
|
||||
$result = imports_assign_row($pdo, $tenantId, $vorschauBatchId, $rowId, $participantId);
|
||||
if ($result['ok']) {
|
||||
$meldung = 'Importzeile wurde zugeordnet.';
|
||||
} else {
|
||||
$fehler = $result['error'] ?? 'Die Importzeile konnte nicht zugeordnet werden.';
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
|
||||
csv_redirect_to_batch($vorschauBatchId);
|
||||
} elseif ($aktion === 'zeile_ignorieren') {
|
||||
$rowId = (int)($_POST['row_id'] ?? 0);
|
||||
if ($vorschauBatchId <= 0 || $rowId <= 0) {
|
||||
$fehler = 'Bitte eine Importzeile auswählen.';
|
||||
} else {
|
||||
$result = imports_ignore_row($pdo, $tenantId, $vorschauBatchId, $rowId);
|
||||
if ($result['ok']) {
|
||||
$meldung = 'Importzeile wurde ignoriert.';
|
||||
} else {
|
||||
$fehler = $result['error'] ?? 'Die Importzeile konnte nicht ignoriert werden.';
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
|
||||
csv_redirect_to_batch($vorschauBatchId);
|
||||
} elseif ($aktion === 'importieren') {
|
||||
$batchId = $vorschauBatchId;
|
||||
try {
|
||||
$ergebnis = imports_commit_batch($pdo, $tenantId, $batchId);
|
||||
app_audit_log($pdo, $tenantId, $saasUser['user_id'] ?? null, 'csv_import.committed', 'payment_import_batch', $batchId, ['imported' => $ergebnis['imported']]);
|
||||
$meldung = $ergebnis['imported'] . ' Einzahlungen wurden importiert.';
|
||||
} catch (Throwable $e) {
|
||||
$fehler = $e->getMessage();
|
||||
$vorschauBatchId = $batchId;
|
||||
}
|
||||
|
||||
return ['success' => $success_entries, 'failed' => $failed_entries];
|
||||
}
|
||||
|
||||
// Funktion zum Abrufen der MitarbeiterID
|
||||
function getMitarbeiterID($conn, $name)
|
||||
{
|
||||
$sql = "SELECT MitarbeiterID FROM kl_Mitarbeiter WHERE Name = ? or paypalname = ?";
|
||||
$params = array($name, $name);
|
||||
$stmt = sqlsrv_query($conn, $sql, $params);
|
||||
|
||||
|
||||
if ($stmt === false) {
|
||||
die(print_r(sqlsrv_errors(), true));
|
||||
}
|
||||
|
||||
while( $row = sqlsrv_fetch_array( $stmt, SQLSRV_FETCH_ASSOC))
|
||||
{
|
||||
return $row['MitarbeiterID'];
|
||||
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
|
||||
csv_redirect_to_batch($vorschauBatchId);
|
||||
}
|
||||
#echo $mitarbeiterid;
|
||||
|
||||
}
|
||||
|
||||
// Überprüfen, ob das Formular abgeschickt wurde
|
||||
if ($_SERVER["REQUEST_METHOD"] == "POST") {
|
||||
// Überprüfen, ob eine Datei hochgeladen wurde
|
||||
if (isset($_FILES["csv_file"])) {
|
||||
[$csvFile, $uploadError] = csv_prepare_upload($_FILES["csv_file"]);
|
||||
$vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vorschauBatchId) : null;
|
||||
$letzteBatches = imports_fetch_recent_batches($pdo, $tenantId, 8);
|
||||
$mitglieder = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
|
||||
$vorschauStatusZaehler = [];
|
||||
if ($vorschau !== null) {
|
||||
foreach ($vorschau['rows'] as $row) {
|
||||
$status = (string)$row['status'];
|
||||
$vorschauStatusZaehler[$status] = ($vorschauStatusZaehler[$status] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if ($uploadError !== null) {
|
||||
echo csv_h($uploadError);
|
||||
} else {
|
||||
// CSV-Datei verarbeiten
|
||||
$result = processCSV($conn, $csvFile);
|
||||
@unlink($csvFile);
|
||||
?>
|
||||
|
||||
echo "CSV-Datei erfolgreich verarbeitet.\n";
|
||||
<h2>CSV-Import</h2>
|
||||
|
||||
echo "<h3>Auswertung</h3>";
|
||||
echo "<table>
|
||||
<?php if ($meldung !== null): ?>
|
||||
<div class="hint-box success"><p><?php echo saas_html($meldung); ?></p></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($fehler !== null): ?>
|
||||
<div class="hint-box error"><p><?php echo saas_html($fehler); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($vorschau !== null): ?>
|
||||
<h3>Vorschau: <?php echo saas_html($vorschau['batch']['original_filename']); ?></h3>
|
||||
<p>Status: <?php echo saas_html($vorschau['batch']['status']); ?> ·
|
||||
<?php echo (int)($vorschauStatusZaehler['matched'] ?? 0); ?> bereit ·
|
||||
<?php echo (int)($vorschauStatusZaehler['unmatched'] ?? 0); ?> ohne Zuordnung ·
|
||||
<?php echo (int)($vorschauStatusZaehler['duplicate'] ?? 0); ?> Duplikatverdacht ·
|
||||
<?php echo (int)($vorschauStatusZaehler['invalid'] ?? 0); ?> ungültig
|
||||
</p>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table class="admin-table">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Datum</th>
|
||||
<th>Zeile</th>
|
||||
<th>Name (CSV)</th>
|
||||
<th>Zugeordnetes Mitglied</th>
|
||||
<th>Betrag</th>
|
||||
<th>Ergebnis</th>
|
||||
</tr>";
|
||||
<th>Datum</th>
|
||||
<th>Status</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
<?php foreach ($vorschau['rows'] as $row): ?>
|
||||
<?php
|
||||
$rowStatus = (string)$row['status'];
|
||||
$suggestions = in_array($rowStatus, ['unmatched', 'duplicate'], true)
|
||||
? imports_suggest_participants($pdo, $tenantId, (string)$row['raw_name'], null, 4)
|
||||
: [];
|
||||
$selectedId = count($suggestions) === 1 ? (int)$suggestions[0]['participant_id'] : 0;
|
||||
?>
|
||||
<tr>
|
||||
<td><?php echo (int)$row['row_num']; ?></td>
|
||||
<td><?php echo saas_html($row['raw_name']); ?></td>
|
||||
<td><?php echo saas_html($row['display_name'] ?? '—'); ?></td>
|
||||
<td><?php echo saas_html(number_format(((int)$row['amount_cents']) / 100, 2, ',', '.')); ?> €</td>
|
||||
<td><?php echo saas_html($row['booked_at']); ?></td>
|
||||
<td><span class="status-badge <?php echo saas_html(csv_status_class($rowStatus)); ?>"><?php echo saas_html(csv_status_label($rowStatus)); ?></span></td>
|
||||
<td>
|
||||
<?php if ($vorschau['batch']['status'] === 'previewed' && in_array($rowStatus, ['unmatched', 'duplicate'], true)): ?>
|
||||
<form method="post" action="csvupload.php" class="inline-admin-form"<?php echo $rowStatus === 'duplicate' ? ' onsubmit="return confirm(\'Diese Zeile wurde als Duplikat erkannt. Trotzdem als neue Einzahlung buchen?\');"' : ''; ?>>
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="zeile_zuordnen">
|
||||
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
|
||||
<input type="hidden" name="row_id" value="<?php echo (int)$row['id']; ?>">
|
||||
<select name="participant_id" required>
|
||||
<?php echo csv_select_options($mitglieder, $suggestions, $selectedId); ?>
|
||||
</select>
|
||||
<button type="submit">Zuordnen</button>
|
||||
</form>
|
||||
<form method="post" action="csvupload.php" class="inline-admin-form" onsubmit="return confirm('Diese Importzeile ignorieren?');">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="zeile_ignorieren">
|
||||
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
|
||||
<input type="hidden" name="row_id" value="<?php echo (int)$row['id']; ?>">
|
||||
<button type="submit" class="alt">Ignorieren</button>
|
||||
</form>
|
||||
<?php elseif ($vorschau['batch']['status'] === 'previewed' && $rowStatus === 'invalid'): ?>
|
||||
<form method="post" action="csvupload.php" class="inline-admin-form" onsubmit="return confirm('Diese ungültige Importzeile ignorieren?');">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="zeile_ignorieren">
|
||||
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
|
||||
<input type="hidden" name="row_id" value="<?php echo (int)$row['id']; ?>">
|
||||
<button type="submit" class="alt">Ignorieren</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
—
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
foreach ($result['success'] as $eintrag){
|
||||
echo "<tr>
|
||||
<td>" . csv_h($eintrag[0]) . "</td>
|
||||
<td>" . csv_h($eintrag[1]) . "</td>
|
||||
<td>" . csv_h($eintrag[2]) . "</td>
|
||||
<td>Erfolgreich gespeichert</td>
|
||||
</tr>";
|
||||
}
|
||||
foreach ($result['failed'] as $eintrag){
|
||||
echo "<tr>
|
||||
<td>" . csv_h($eintrag[0]) . "</td>
|
||||
<td>" . csv_h($eintrag[1]) . "</td>
|
||||
<td>" . csv_h($eintrag[2]) . "</td>
|
||||
<td>";
|
||||
if($eintrag[3] == 1){
|
||||
echo "SQL Fehler";
|
||||
}elseif($eintrag[3] == 2){
|
||||
echo "Eintrag schon vorhanden";
|
||||
}elseif($eintrag[3] == 3){
|
||||
echo "Benutzer nicht gefunden";
|
||||
}elseif($eintrag[3] == 4){
|
||||
echo "Ungültige CSV-Zeile";
|
||||
}
|
||||
<?php if ($vorschau['batch']['status'] === 'previewed'): ?>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="importieren">
|
||||
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
|
||||
<button type="submit">Import bestätigen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<br>
|
||||
<?php endif; ?>
|
||||
|
||||
echo "</td>
|
||||
</tr>";
|
||||
}
|
||||
echo "</table>";
|
||||
}
|
||||
<?php if ($letzteBatches !== []): ?>
|
||||
<h3>Letzte Importvorschauen</h3>
|
||||
<div class="table-wrapper">
|
||||
<table class="admin-table">
|
||||
<tr>
|
||||
<th>Datei</th>
|
||||
<th>Status</th>
|
||||
<th>Zeilen</th>
|
||||
<th>Bereit</th>
|
||||
<th>Offen</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<?php foreach ($letzteBatches as $batch): ?>
|
||||
<tr>
|
||||
<td><?php echo saas_html($batch['original_filename']); ?><br><small><?php echo saas_html($batch['created_at']); ?></small></td>
|
||||
<td><?php echo saas_html($batch['status']); ?></td>
|
||||
<td><?php echo (int)$batch['total_rows']; ?></td>
|
||||
<td><?php echo (int)$batch['matched_rows'] + (int)$batch['imported_rows']; ?></td>
|
||||
<td><?php echo (int)$batch['unresolved_rows']; ?></td>
|
||||
<td><a class="button small" href="csvupload.php?batch_id=<?php echo (int)$batch['id']; ?>">Öffnen</a></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
} else {
|
||||
echo "Fehler beim Hochladen der Datei.";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
?>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CSV Verarbeitung</title>
|
||||
</head>
|
||||
<body>
|
||||
<form action="<?php echo $_SERVER["PHP_SELF"]; ?>" method="post" enctype="multipart/form-data">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<label for="csv_file">CSV-Datei auswählen:</label>
|
||||
<input type="file" name="csv_file" accept=".csv" required>
|
||||
<button type="submit">Datei hochladen</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<?php
|
||||
|
||||
|
||||
|
||||
|
||||
}else{
|
||||
echo "<h2>Kein Zugriff</h2>";
|
||||
|
||||
}
|
||||
?>
|
||||
<form action="csvupload.php" method="post" enctype="multipart/form-data">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="hochladen">
|
||||
<label for="csv_file">CSV-Datei auswählen:</label>
|
||||
<input type="file" name="csv_file" accept=".csv" required>
|
||||
<button type="submit">Datei hochladen</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE IF NOT EXISTS notices (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
valid_from DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
valid_until DATETIME NOT NULL,
|
||||
created_by_user_id INT NULL,
|
||||
deleted_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_notices_tenant_valid_until (tenant_id, valid_until),
|
||||
KEY idx_notices_created_by_user (created_by_user_id),
|
||||
CONSTRAINT fk_notices_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_notices_created_by_user
|
||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
|
||||
ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- One-time carry-over so currently visible legacy banners are not lost when
|
||||
-- hinweise.php switches from kl_hinweise to the tenant-scoped notices table.
|
||||
-- kl_hinweise itself is left untouched; it stays as the golden-master
|
||||
-- reference for the legacy system.
|
||||
INSERT INTO notices (tenant_id, message, valid_from, valid_until)
|
||||
SELECT t.id, h.nachricht, NOW(), h.gueltig_bis
|
||||
FROM kl_hinweise h
|
||||
JOIN tenants t ON t.slug = 'default'
|
||||
WHERE h.gueltig_bis >= NOW();
|
||||
@@ -0,0 +1,61 @@
|
||||
CREATE TABLE IF NOT EXISTS payment_import_batches (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
original_filename VARCHAR(255) NOT NULL,
|
||||
checksum CHAR(64) NOT NULL,
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'previewed',
|
||||
created_by_user_id INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
committed_at DATETIME NULL,
|
||||
KEY idx_payment_import_batches_tenant (tenant_id, created_at),
|
||||
CONSTRAINT fk_payment_import_batches_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_payment_import_batches_created_by_user
|
||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
|
||||
ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payment_import_rows (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
batch_id INT NOT NULL,
|
||||
row_num INT NOT NULL,
|
||||
participant_id INT NULL,
|
||||
raw_name VARCHAR(255) NOT NULL,
|
||||
amount_cents INT NOT NULL,
|
||||
booked_at DATETIME NOT NULL,
|
||||
status VARCHAR(30) NOT NULL,
|
||||
raw_json TEXT NULL,
|
||||
ledger_entry_id INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_payment_import_rows_batch (batch_id),
|
||||
KEY idx_payment_import_rows_participant (participant_id),
|
||||
CONSTRAINT fk_payment_import_rows_batch
|
||||
FOREIGN KEY (batch_id) REFERENCES payment_import_batches(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_payment_import_rows_participant
|
||||
FOREIGN KEY (participant_id) REFERENCES participants(id)
|
||||
ON DELETE SET NULL,
|
||||
CONSTRAINT fk_payment_import_rows_ledger_entry
|
||||
FOREIGN KEY (ledger_entry_id) REFERENCES ledger_entries(id)
|
||||
ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
SET @schema_name = DATABASE();
|
||||
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = @schema_name
|
||||
AND TABLE_NAME = 'ledger_entries'
|
||||
AND CONSTRAINT_NAME = 'fk_ledger_entries_import_batch'
|
||||
),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE ledger_entries ADD CONSTRAINT fk_ledger_entries_import_batch FOREIGN KEY (import_batch_id) REFERENCES payment_import_batches(id) ON DELETE SET NULL'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE IF NOT EXISTS outbound_emails (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
participant_id INT NULL,
|
||||
template VARCHAR(60) NOT NULL,
|
||||
subject VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(30) NOT NULL,
|
||||
sent_at DATETIME NULL,
|
||||
error VARCHAR(500) NULL,
|
||||
created_by_user_id INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_outbound_emails_tenant_created (tenant_id, created_at),
|
||||
KEY idx_outbound_emails_participant (participant_id),
|
||||
CONSTRAINT fk_outbound_emails_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_outbound_emails_participant
|
||||
FOREIGN KEY (participant_id) REFERENCES participants(id)
|
||||
ON DELETE SET NULL,
|
||||
CONSTRAINT fk_outbound_emails_created_by_user
|
||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
|
||||
ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE IF NOT EXISTS rate_limit_attempts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
bucket VARCHAR(191) NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_rate_limit_attempts_bucket_created (bucket, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NULL,
|
||||
actor_user_id INT NULL,
|
||||
action VARCHAR(60) NOT NULL,
|
||||
subject_type VARCHAR(60) NOT NULL,
|
||||
subject_id INT NULL,
|
||||
metadata_json TEXT NULL,
|
||||
ip VARCHAR(45) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_audit_log_tenant_created (tenant_id, created_at),
|
||||
KEY idx_audit_log_actor_user (actor_user_id),
|
||||
KEY idx_audit_log_subject (subject_type, subject_id),
|
||||
CONSTRAINT fk_audit_log_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_audit_log_actor_user
|
||||
FOREIGN KEY (actor_user_id) REFERENCES users(id)
|
||||
ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,41 @@
|
||||
CREATE TABLE IF NOT EXISTS faq_entries (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
question VARCHAR(500) NOT NULL,
|
||||
answer TEXT NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
deleted_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_faq_entries_tenant_sort (tenant_id, sort_order),
|
||||
CONSTRAINT fk_faq_entries_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Die bisherige faq.php war komplett AOK-spezifisch (Kaffeemaschine,
|
||||
-- interner Ansprechpartner). Dieser Inhalt gehoert nur zum migrierten
|
||||
-- Default-Mandanten und wird als dessen erste FAQ-Eintraege uebernommen,
|
||||
-- statt fuer alle Mandanten sichtbar zu bleiben.
|
||||
INSERT INTO faq_entries (tenant_id, question, answer, sort_order)
|
||||
SELECT t.id, x.question, x.answer, x.sort_order
|
||||
FROM tenants t
|
||||
CROSS JOIN (
|
||||
SELECT 1 AS sort_order, 'Wie werde ich Mitglied der Kaffeeliste?' AS question,
|
||||
'Neumitglieder können sich einfach in diese Liste eintragen und zahlen aktuell keine Aufnahmegebühr. Es ist keine weitere Rücksprache nötig. Schon mal ausgetretene Mitglieder können erst nach einem Jahr wiederaufgenommen werden.' AS answer
|
||||
UNION ALL SELECT 2, 'Wo finde ich Milch, Zucker und Kaffee?',
|
||||
'Milch findet ihr im Kühlschrank, Zucker und Süßstoff steht neben der Kaffeemaschine. Nachschub findet ihr im Schrank über der Maschine. Sollte dort nichts mehr vorhanden sein, informiert bitte den Ansprechpartner der Kaffeeliste.'
|
||||
UNION ALL SELECT 3, 'Welche Arten von Kaffee bekomme ich an der Kaffeemaschine?',
|
||||
'Es gibt vier Tasten: Espresso (ca. 100ml), Coffee (ca. 150ml), Hot Water (ca. 200ml warmes Wasser) und Americano (ca. 300ml). Eine persönliche Einstellung der Kaffeemenge ist nicht möglich.'
|
||||
UNION ALL SELECT 4, 'Wie viel kostet ein Kaffee?',
|
||||
'Den aktuellen Preis pro Strich findest du oben in der Kaffeeliste. Ein einfacher Kaffee kann ein oder auch zwei Striche kosten.'
|
||||
UNION ALL SELECT 5, 'Wie erfolgt die Abrechnung?',
|
||||
'Je nachdem, was du an der Maschine bezogen hast, machst du die entsprechenden Striche auf der Liste. Etwa einmal im Monat wird die Liste aktualisiert; du bekommst eine E-Mail, wenn du bezahlen musst.'
|
||||
UNION ALL SELECT 6, 'Wie kann ich bezahlen?',
|
||||
'Aktuell kann per PayPal bezahlt werden. Du erhältst einen entsprechenden Link per Mail. Bitte immer über die Freunde-Funktion von PayPal bezahlen, sonst wird eine Gebühr fällig.'
|
||||
UNION ALL SELECT 7, 'Ich habe eine rote Markierung in der Liste. Was bedeutet das?',
|
||||
'Du hast einen höheren offenen Betrag. Bitte gleiche deinen ausstehenden Betrag zeitnah aus.'
|
||||
UNION ALL SELECT 8, 'Ich habe das Gefühl, mein Betrag/Guthaben passt nicht. Was soll ich machen?',
|
||||
'Fehler können leider auch in der Kaffeeliste passieren. Melde dich bei deinem Ansprechpartner, dann wird die Abrechnung gemeinsam geprüft.'
|
||||
) x
|
||||
WHERE t.slug = 'default';
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS platform_admins (
|
||||
user_id INT PRIMARY KEY,
|
||||
granted_by_user_id INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_platform_admins_user
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_platform_admins_granted_by
|
||||
FOREIGN KEY (granted_by_user_id) REFERENCES users(id)
|
||||
ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE IF NOT EXISTS tenant_billing (
|
||||
tenant_id INT PRIMARY KEY,
|
||||
plan_code VARCHAR(30) NOT NULL DEFAULT 'free',
|
||||
subscription_status VARCHAR(30) NOT NULL DEFAULT 'active',
|
||||
stripe_customer_id VARCHAR(255) NULL,
|
||||
stripe_subscription_id VARCHAR(255) NULL,
|
||||
current_period_end DATETIME NULL,
|
||||
dolibarr_thirdparty_id VARCHAR(50) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_tenant_billing_plan (plan_code),
|
||||
CONSTRAINT fk_tenant_billing_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Alle bestehenden Mandanten starten auf dem freien Tarif; die tatsaechlich
|
||||
-- erforderliche Stufe wird zur Laufzeit anhand der aktiven Teilnehmerzahl
|
||||
-- berechnet (siehe app/billing.php), nicht fest hier eingetragen.
|
||||
INSERT INTO tenant_billing (tenant_id, plan_code, subscription_status)
|
||||
SELECT id, 'free', 'active' FROM tenants;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE tenant_settings
|
||||
ADD COLUMN pdf_show_empty_rows TINYINT(1) NOT NULL DEFAULT 1 AFTER negative_warning_cents,
|
||||
ADD COLUMN pdf_split_mode VARCHAR(20) NOT NULL DEFAULT 'drinking_behavior' AFTER pdf_show_empty_rows;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tenant_settings
|
||||
ADD COLUMN pdf_row_height_px SMALLINT NOT NULL DEFAULT 16 AFTER pdf_split_mode;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE tenant_settings
|
||||
ADD COLUMN pdf_watermark_text VARCHAR(500) NOT NULL DEFAULT 'Fragen oder Unklarheiten? Bitte lies zuerst die FAQs hinter dieser Liste!' AFTER pdf_row_height_px,
|
||||
ADD COLUMN pdf_footer_text VARCHAR(500) NOT NULL DEFAULT '' AFTER pdf_watermark_text;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Idempotenz fuer den Stripe-Webhook: verarbeitete Event-IDs werden hier
|
||||
-- festgehalten, damit eine Wiederholte Zustellung (Stripe retryt bei Timeout)
|
||||
-- nicht ein zweites Mal gebucht wird oder eine doppelte Dolibarr-Rechnung
|
||||
-- erzeugt.
|
||||
CREATE TABLE IF NOT EXISTS stripe_webhook_events (
|
||||
event_id VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
event_type VARCHAR(100) NOT NULL,
|
||||
processed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Automatische Zahlungserinnerung: opt-in pro Mandant. Als Schwelle dient die
|
||||
-- bereits vorhandene negative_warning_cents; das Intervall verhindert, dass ein
|
||||
-- Mitglied taeglich erinnert wird.
|
||||
ALTER TABLE tenant_settings
|
||||
ADD COLUMN payment_reminder_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER pdf_footer_text,
|
||||
ADD COLUMN payment_reminder_interval_days SMALLINT NOT NULL DEFAULT 7 AFTER payment_reminder_enabled;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Optionales Logo als PDF-Wasserzeichen (Dateiname im geschuetzten Verzeichnis
|
||||
-- var/tenant_logos/, leer = kein Logo). Wird ausschliesslich ueber die
|
||||
-- Mandant-Einstellungen gesetzt, nicht ueber saas_update_tenant_settings, damit
|
||||
-- der normale Settings-Speichervorgang das Logo nicht ueberschreibt.
|
||||
ALTER TABLE tenant_settings
|
||||
ADD COLUMN pdf_watermark_logo VARCHAR(255) NOT NULL DEFAULT '' AFTER payment_reminder_interval_days;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Erweiterte Bezahloptionen pro Mandant: Barzahlung (mit Ansprechpartner) und
|
||||
-- Ueberweisung (mit IBAN und Kontoinhaber). PayPal existiert bereits ueber
|
||||
-- paypal_enabled/paypal_url_template.
|
||||
ALTER TABLE tenant_settings
|
||||
ADD COLUMN cash_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER pdf_watermark_logo,
|
||||
ADD COLUMN cash_contact VARCHAR(255) NOT NULL DEFAULT '' AFTER cash_enabled,
|
||||
ADD COLUMN bank_transfer_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER cash_contact,
|
||||
ADD COLUMN bank_account_holder VARCHAR(255) NOT NULL DEFAULT '' AFTER bank_transfer_enabled,
|
||||
ADD COLUMN bank_iban VARCHAR(40) NOT NULL DEFAULT '' AFTER bank_account_holder;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Automatischer PayPal-Abgleich per weitergeleiteter Benachrichtigungsmail.
|
||||
--
|
||||
-- Jeder Mandant bekommt einen eindeutigen Token fuer die Plus-Adresse
|
||||
-- (z. B. zahlungen+<token>@domain). Der Admin richtet eine Weiterleitung von
|
||||
-- PayPal an diese Adresse ein; der Token ordnet die Mail eindeutig dem
|
||||
-- Mandanten zu. Der Token ist nicht sensibel und wird bei Bedarf erzeugt.
|
||||
ALTER TABLE tenants
|
||||
ADD COLUMN paypal_inbox_token VARCHAR(32) NULL AFTER slug,
|
||||
ADD UNIQUE KEY uq_tenants_paypal_inbox_token (paypal_inbox_token);
|
||||
|
||||
-- Erkannte PayPal-Zahlungen: Dedup ueber den global eindeutigen
|
||||
-- Transaktionscode und gleichzeitig Warteschlange fuer nicht eindeutig
|
||||
-- zuordenbare Zahlungen (participant_id IS NULL, status 'unmatched').
|
||||
CREATE TABLE IF NOT EXISTS paypal_payments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
transaction_code VARCHAR(40) NOT NULL,
|
||||
payer_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
note VARCHAR(255) NULL,
|
||||
gross_cents INT NOT NULL DEFAULT 0,
|
||||
fee_cents INT NULL,
|
||||
net_cents INT NOT NULL DEFAULT 0,
|
||||
paid_at DATE NULL,
|
||||
participant_id INT NULL,
|
||||
ledger_entry_id INT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'unmatched',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_paypal_payments_txn (transaction_code),
|
||||
KEY idx_paypal_payments_tenant_status (tenant_id, status),
|
||||
CONSTRAINT fk_paypal_payments_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_paypal_payments_participant
|
||||
FOREIGN KEY (participant_id) REFERENCES participants(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Bemerkung/Titel fuer Einzahlungen.
|
||||
--
|
||||
-- ledger_entries.note existiert bereits (siehe 0006), wurde bei Einzahlungen
|
||||
-- aber nie befuellt. Beim Default-Mandanten laeuft die Buchung als Dual-Write
|
||||
-- ueber die Legacy-Tabelle kl_Einzahlungen, und ledger_mirror_legacy_payment
|
||||
-- ueberschreibt note beim Re-Sync mit dem Legacy-Wert. Ohne eine Spalte auf
|
||||
-- der Legacy-Seite wuerde die Bemerkung dort also stillschweigend wieder
|
||||
-- verschwinden.
|
||||
ALTER TABLE kl_Einzahlungen
|
||||
ADD COLUMN Bemerkung VARCHAR(1000) NULL AFTER Betrag;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Legacy-Abbau Schritt 4: Altwelt vollstaendig entfernen.
|
||||
--
|
||||
-- Voraussetzung: kein Laufzeit-Code liest oder schreibt mehr die kl_*-Tabellen
|
||||
-- oder die legacy_*-Spalten (Schritte 1-3). Die Migration ist forward-only.
|
||||
|
||||
-- participants: Verknuepfung zur alten Mitarbeitertabelle entfernen.
|
||||
ALTER TABLE participants
|
||||
DROP INDEX uq_participants_tenant_legacy,
|
||||
DROP COLUMN legacy_mitarbeiter_id;
|
||||
|
||||
-- ledger_entries: Herkunftszeiger auf die Legacy-Buchungstabellen entfernen.
|
||||
ALTER TABLE ledger_entries
|
||||
DROP INDEX uq_ledger_entries_tenant_legacy,
|
||||
DROP COLUMN legacy_table,
|
||||
DROP COLUMN legacy_id;
|
||||
|
||||
-- Die alten Windows/IIS-Tabellen selbst.
|
||||
DROP TABLE IF EXISTS kl_Einzahlungen;
|
||||
DROP TABLE IF EXISTS kl_Kaffeeverbrauch;
|
||||
DROP TABLE IF EXISTS kl_hinweise;
|
||||
DROP TABLE IF EXISTS kl_config;
|
||||
DROP TABLE IF EXISTS kl_Mitarbeiter;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Funktions-Schalter je Mandant, gepflegt vom Betreiber im Back-Office.
|
||||
--
|
||||
-- Bewusst getrennt von tenant_settings: dort stellt der Kunde selbst ein, wie
|
||||
-- seine Kaffeeliste arbeitet. Hier entscheidet der Betreiber, welche
|
||||
-- Funktionen dem Mandanten ueberhaupt zur Verfuegung stehen (z. B. zum
|
||||
-- Abschalten einer Funktion, die bei einem Kunden Probleme macht).
|
||||
--
|
||||
-- Ohne Zeile gilt eine Funktion als aktiv (siehe app/features.php); die
|
||||
-- Tabelle haelt also nur die Abweichungen vom Standard.
|
||||
CREATE TABLE IF NOT EXISTS tenant_features (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
feature_key VARCHAR(64) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_tenant_feature (tenant_id, feature_key),
|
||||
CONSTRAINT fk_tenant_features_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Eigenes Design je Mandant: Logo und Akzentfarbe fuer die App-Oberflaeche.
|
||||
--
|
||||
-- Getrennt von pdf_watermark_logo: das Wasserzeichen ist ein blasses
|
||||
-- Hintergrundbild fuer den Ausdruck, das Marken-Logo dagegen wird in der
|
||||
-- Kopfzeile jeder Seite scharf angezeigt. Leere Werte = Standarddesign.
|
||||
ALTER TABLE tenant_settings
|
||||
ADD COLUMN brand_logo VARCHAR(255) NOT NULL DEFAULT '' AFTER bank_iban,
|
||||
ADD COLUMN brand_color VARCHAR(7) NOT NULL DEFAULT '' AFTER brand_logo;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Schalter fuer den Hinweis auf die Kaffeeliste (Logo unten rechts).
|
||||
--
|
||||
-- Standard ist "an": die Marke ist Teil der Oberflaeche, wer sie nicht
|
||||
-- moechte, blendet sie aus. Im kostenlosen Tarif bleibt sie unabhaengig von
|
||||
-- diesem Wert eingeblendet - erzwungen in saas_update_tenant_settings() und
|
||||
-- app/plan-watermark.php, damit auch ein manipulierter Formular-POST sie
|
||||
-- dort nicht abschalten kann.
|
||||
ALTER TABLE tenant_settings
|
||||
ADD COLUMN watermark_enabled TINYINT(1) NOT NULL DEFAULT 1 AFTER brand_color;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Mailadresse des Zahlers aus der PayPal-Benachrichtigung.
|
||||
--
|
||||
-- Die automatische Zuordnung ging bisher nur ueber den Namen; die Adresse
|
||||
-- ist das deutlich verlaesslichere Merkmal (Mitglieder sind im Mandanten
|
||||
-- ueber tenant_id + email_norm eindeutig). Leer, wenn die Mail keine
|
||||
-- brauchbare Adresse enthaelt - dann bleibt es beim Namensvergleich.
|
||||
ALTER TABLE paypal_payments
|
||||
ADD COLUMN payer_email VARCHAR(255) NOT NULL DEFAULT '' AFTER payer_name;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user