Compare commits
120
Commits
f1cc15a1f3
..
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 | ||
|
|
539117b409 | ||
|
|
f9544f24fd | ||
|
|
1aa4a4989e | ||
|
|
5824b066f4 | ||
|
|
726c5a9508 | ||
|
|
e4b0c81b48 | ||
|
|
b0e21bbb0a | ||
|
|
9f2e1bdfb1 | ||
|
|
8ab4a8a9bb | ||
|
|
19ff11c21a | ||
|
|
5baf6542ce | ||
|
|
eef0338e31 | ||
|
|
8511b876f3 | ||
|
|
30378683e6 | ||
|
|
e5fd47ecf6 | ||
|
|
981c770ce5 | ||
|
|
444f0a269b | ||
|
|
174fff6f31 | ||
|
|
66bdd38d54 | ||
|
|
84d957e3ec | ||
|
|
d9e70ce353 | ||
|
|
2e6d79e896 | ||
|
|
6f99e7246d | ||
|
|
92d94f1c95 | ||
|
|
cbea23083d | ||
|
|
1891ec0a51 | ||
|
|
06645f1e9c | ||
|
|
e4fa714419 |
@@ -1,15 +0,0 @@
|
||||
APP_NAME="Kaffeekasse SaaS"
|
||||
APP_ENV=production
|
||||
APP_DEBUG=0
|
||||
APP_URL=http://localhost:8080
|
||||
APP_TIMEZONE=Europe/Berlin
|
||||
APP_KEY=
|
||||
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_NAME=kaffeeliste
|
||||
DB_USER=root
|
||||
DB_PASS=
|
||||
|
||||
MAIL_FROM=noreply@example.com
|
||||
RFID_SHARED_SECRET=change-me
|
||||
+8
-14
@@ -1,14 +1,8 @@
|
||||
/vendor/
|
||||
/.env
|
||||
/build/
|
||||
/storage/cache/*
|
||||
/storage/logs/*
|
||||
/storage/uploads/*
|
||||
/storage/cache/.gitkeep
|
||||
/storage/logs/.gitkeep
|
||||
/storage/uploads/.gitkeep
|
||||
/storage/cron.lock
|
||||
/storage/installed.lock
|
||||
/.idea/
|
||||
/.vscode/
|
||||
/.DS_Store
|
||||
.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,55 +0,0 @@
|
||||
# Deployment auf Netcup Webspace
|
||||
|
||||
## Zielbild
|
||||
|
||||
- Code unter einem Release-Pfad wie `/apps/kaffeekasse/current`
|
||||
- Document Root in Plesk auf `/apps/kaffeekasse/current/public`
|
||||
- `.env` ausserhalb des Webroots oder direkt im Projektverzeichnis ohne
|
||||
Webzugriff
|
||||
- Migrationen ueber `bin/migrate.php`
|
||||
- zyklische Aufgaben ueber `bin/cron.php`
|
||||
|
||||
## Erstinstallation
|
||||
|
||||
1. Neue Domain oder Subdomain im Netcup WCP/Plesk anlegen.
|
||||
2. Document Root auf das Verzeichnis `public/` des Releases zeigen lassen.
|
||||
3. MySQL-Datenbank und separaten User anlegen.
|
||||
4. Projekt per FTPES oder SFTP hochladen.
|
||||
5. Installer unter `/install` aufrufen oder `.env` manuell setzen.
|
||||
6. Alternativ oder zusaetzlich:
|
||||
`/usr/local/php83/bin/php /apps/kaffeekasse/current/bin/migrate.php`
|
||||
7. Plattform-Admin einloggen und ersten Mandanten registrieren.
|
||||
|
||||
## Scheduled Tasks
|
||||
|
||||
- Alle 5 Minuten:
|
||||
`/usr/local/php83/bin/php /apps/kaffeekasse/current/bin/cron.php`
|
||||
- Optional Healthcheck:
|
||||
`/usr/local/php83/bin/php /apps/kaffeekasse/current/bin/healthcheck.php`
|
||||
|
||||
## Release-Prozess
|
||||
|
||||
1. `scripts/build-release.sh` erzeugt ein auslieferbares Paket.
|
||||
2. Paket hochladen und entpacken.
|
||||
3. `.env` und persistente `storage/`-Pfade sichern.
|
||||
4. `bin/migrate.php` ausfuehren.
|
||||
5. Healthcheck und Login pruefen.
|
||||
|
||||
## Sicherheitshinweise
|
||||
|
||||
- `public/` bleibt der einzige Webroot.
|
||||
- `.env`, Dumps, Backups und `.git` duerfen nicht oeffentlich erreichbar sein.
|
||||
- Fuer produktive Domains HTTPS erzwingen.
|
||||
- Session-Cookies sind auf `HttpOnly`, `SameSite=Lax` und `Secure` ausgelegt.
|
||||
- Tenant-Daten werden serverseitig ueber Session und Memberships zugeordnet.
|
||||
|
||||
## Git-Remote vorbereiten
|
||||
|
||||
Beispiel fuer spaeteres Pushen auf `git.ctb-it.de`:
|
||||
|
||||
```bash
|
||||
git remote add origin ssh://git@git.ctb-it.de/namespace/kaffeekasse-saas.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
Die genaue URL haengt von eurem Namespace und dem Zielsystem ab.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Vendored
+159
File diff suppressed because one or more lines are too long
Vendored
+358
File diff suppressed because one or more lines are too long
@@ -1,52 +1,37 @@
|
||||
# Kaffeekasse SaaS Neustart
|
||||
# Kaffeeliste
|
||||
|
||||
Frischer Neustart fuer eine mandantenfaehige Kaffeeliste mit:
|
||||
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.
|
||||
|
||||
- klassischer Papierliste inklusive Nacherfassung
|
||||
- digitaler Selbstbuchung fuer Browser, Tablet oder Kiosk
|
||||
- vorbereitetem RFID-Pfad ueber Device-, Tag- und Event-Modelle
|
||||
- Netcup-tauglichem PHP/MySQL-Betrieb ohne Root-Zwang
|
||||
- produktionsnaher Vermarktungs- und Betriebsdokumentation
|
||||
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`.
|
||||
|
||||
## Projektziel
|
||||
## Struktur
|
||||
|
||||
Dieses Repository bildet eine pragmatische SaaS-Basis fuer mehrere Kunden oder
|
||||
Mandanten. Jeder Tenant arbeitet unter `t/{slug}` in einer getrennten
|
||||
Organisation, waehrend Produktlogik, Betrieb und Vermarktung zentral bleiben.
|
||||
- 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`.
|
||||
|
||||
## Kernentscheidungen
|
||||
## Umgang mit Legacy-Seiten
|
||||
|
||||
- `PHP 8.x + MySQL` ohne schwere Runtime-Voraussetzungen
|
||||
- `public/` als einziger Webroot
|
||||
- `tenant_id` auf allen tenantbezogenen Fachtabellen
|
||||
- ein gemeinsames Ledger fuer `digital`, `paper` und spaeter `rfid`
|
||||
- Webspace-freundliche Cron- und CLI-Hooks statt Daemons
|
||||
|
||||
## Hauptfunktionen
|
||||
|
||||
- Plattform-Installation per Browser
|
||||
- Self-Service-Registrierung fuer neue Mandanten
|
||||
- Plattform-Admin mit Tenant-Uebersicht
|
||||
- Tenant-Login mit Rollen `owner`, `admin`, `member`
|
||||
- Mitglieder, Produkte und Preisgrundlagen
|
||||
- digitale Buchungen und Einzahlungen
|
||||
- Papierlisten als Entwurf, Zeilen und Ledger-Posting
|
||||
- RFID-Geraete, Karten und Event-Inbox
|
||||
|
||||
## Schnellstart lokal
|
||||
|
||||
1. `.env.example` nach `.env` kopieren oder den Installer unter `/install` nutzen.
|
||||
2. `database/schema.sql` per `bin/migrate.php` gegen MySQL ausfuehren.
|
||||
3. Webroot auf `public/` zeigen lassen.
|
||||
4. Plattform-Admin anlegen und anschliessend Mandanten registrieren.
|
||||
|
||||
## Wichtige Dateien
|
||||
|
||||
- [public/index.php](/config/workspace/kaffeeliste-neustart/public/index.php)
|
||||
- [database/schema.sql](/config/workspace/kaffeeliste-neustart/database/schema.sql)
|
||||
- [DEPLOY.md](/config/workspace/kaffeeliste-neustart/DEPLOY.md)
|
||||
- [RUNBOOK.md](/config/workspace/kaffeeliste-neustart/RUNBOOK.md)
|
||||
- [docs/product-strategy.md](/config/workspace/kaffeeliste-neustart/docs/product-strategy.md)
|
||||
- [docs/marketing-ops.md](/config/workspace/kaffeeliste-neustart/docs/marketing-ops.md)
|
||||
- [docs/production-blueprint.md](/config/workspace/kaffeeliste-neustart/docs/production-blueprint.md)
|
||||
- [docs/rfid-roadmap.md](/config/workspace/kaffeeliste-neustart/docs/rfid-roadmap.md)
|
||||
- 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.
|
||||
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
# Runbook
|
||||
|
||||
## Regelbetrieb
|
||||
|
||||
- Installer nach dem ersten Setup nicht erneut oeffnen.
|
||||
- Healthcheck vor und nach Releases pruefen.
|
||||
- Cron-Lauf auf erfolgreiche Ausgabe und Lock-Freigabe kontrollieren.
|
||||
- Backup-Plan fuer DB, `.env` und Uploads fest einplanen.
|
||||
|
||||
## Monitoring-Minimum
|
||||
|
||||
- Login-Fehler und Access-Denied-Ereignisse im Audit beobachten
|
||||
- offene RFID-Events aus `bin/cron.php` beobachten
|
||||
- Wachstum von `consumption_events`, `ledger_entries` und `audit_events` im Blick
|
||||
behalten
|
||||
|
||||
## Backup-Strategie
|
||||
|
||||
- taegliches Datenbank-Backup
|
||||
- Offsite-Kopie von `.env` und `storage/uploads`
|
||||
- Vor jedem Release ein frisches DB-Backup
|
||||
|
||||
## Wiederherstellung
|
||||
|
||||
1. Letztes stabiles Release erneut deployen.
|
||||
2. Datenbank-Backup einspielen.
|
||||
3. `.env` und Uploads wiederherstellen.
|
||||
4. Healthcheck ausfuehren.
|
||||
5. Admin-Login, Tenant-Login und eine Testbuchung pruefen.
|
||||
|
||||
## Offene Ausbaupfade
|
||||
|
||||
- Mail-Verifikation und Einladungs-Workflow
|
||||
- MFA fuer Owner/Admin
|
||||
- Rate-Limits fuer Login und Registrierung
|
||||
- automatisierte Export- und Reminder-Jobs
|
||||
- regelbasierte Uebernahme von RFID-Events in echte Buchungen
|
||||
@@ -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
+24754
File diff suppressed because one or more lines are too long
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
//============================================================+
|
||||
// File name : tcpdf_autoconfig.php
|
||||
// Version : 1.1.1
|
||||
// Begin : 2013-05-16
|
||||
// Last Update : 2014-12-18
|
||||
// Authors : 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-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 : Try to automatically configure some TCPDF
|
||||
// constants if not defined.
|
||||
//
|
||||
//============================================================+
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Try to automatically configure some TCPDF constants if not defined.
|
||||
* @package com.tecnick.tcpdf
|
||||
* @version 1.1.1
|
||||
*/
|
||||
|
||||
// DOCUMENT_ROOT fix for IIS Webserver
|
||||
if ((!isset($_SERVER['DOCUMENT_ROOT'])) OR (empty($_SERVER['DOCUMENT_ROOT']))) {
|
||||
if(isset($_SERVER['SCRIPT_FILENAME'])) {
|
||||
$_SERVER['DOCUMENT_ROOT'] = str_replace( '\\', '/', substr($_SERVER['SCRIPT_FILENAME'], 0, 0-strlen($_SERVER['PHP_SELF'])));
|
||||
} elseif(isset($_SERVER['PATH_TRANSLATED'])) {
|
||||
$_SERVER['DOCUMENT_ROOT'] = str_replace( '\\', '/', substr(str_replace('\\\\', '\\', $_SERVER['PATH_TRANSLATED']), 0, 0-strlen($_SERVER['PHP_SELF'])));
|
||||
} else {
|
||||
// define here your DOCUMENT_ROOT path if the previous fails (e.g. '/var/www')
|
||||
$_SERVER['DOCUMENT_ROOT'] = '/';
|
||||
}
|
||||
}
|
||||
$_SERVER['DOCUMENT_ROOT'] = str_replace('//', '/', $_SERVER['DOCUMENT_ROOT']);
|
||||
if (substr($_SERVER['DOCUMENT_ROOT'], -1) != '/') {
|
||||
$_SERVER['DOCUMENT_ROOT'] .= '/';
|
||||
}
|
||||
|
||||
// Load main configuration file only if the K_TCPDF_EXTERNAL_CONFIG constant is set to false.
|
||||
if (!defined('K_TCPDF_EXTERNAL_CONFIG') OR !K_TCPDF_EXTERNAL_CONFIG) {
|
||||
// define a list of default config files in order of priority
|
||||
$tcpdf_config_files = array(dirname(__FILE__).'/config/tcpdf_config.php', '/etc/php-tcpdf/tcpdf_config.php', '/etc/tcpdf/tcpdf_config.php', '/etc/tcpdf_config.php');
|
||||
foreach ($tcpdf_config_files as $tcpdf_config) {
|
||||
if (@file_exists($tcpdf_config) AND is_readable($tcpdf_config)) {
|
||||
require_once($tcpdf_config);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!defined('K_PATH_MAIN')) {
|
||||
define ('K_PATH_MAIN', dirname(__FILE__).'/');
|
||||
}
|
||||
|
||||
if (!defined('K_PATH_FONTS')) {
|
||||
define ('K_PATH_FONTS', K_PATH_MAIN.'fonts/');
|
||||
}
|
||||
|
||||
if (!defined('K_PATH_URL')) {
|
||||
$k_path_url = K_PATH_MAIN; // default value for console mode
|
||||
if (isset($_SERVER['HTTP_HOST']) AND (!empty($_SERVER['HTTP_HOST']))) {
|
||||
if(isset($_SERVER['HTTPS']) AND (!empty($_SERVER['HTTPS'])) AND (strtolower($_SERVER['HTTPS']) != 'off')) {
|
||||
$k_path_url = 'https://';
|
||||
} else {
|
||||
$k_path_url = 'http://';
|
||||
}
|
||||
$k_path_url .= $_SERVER['HTTP_HOST'];
|
||||
$k_path_url .= str_replace( '\\', '/', substr(K_PATH_MAIN, (strlen($_SERVER['DOCUMENT_ROOT']) - 1)));
|
||||
}
|
||||
define ('K_PATH_URL', $k_path_url);
|
||||
}
|
||||
|
||||
if (!defined('K_PATH_IMAGES')) {
|
||||
$tcpdf_images_dirs = array(K_PATH_MAIN.'examples/images/', K_PATH_MAIN.'images/', '/usr/share/doc/php-tcpdf/examples/images/', '/usr/share/doc/tcpdf/examples/images/', '/usr/share/doc/php/tcpdf/examples/images/', '/var/www/tcpdf/images/', '/var/www/html/tcpdf/images/', '/usr/local/apache2/htdocs/tcpdf/images/', K_PATH_MAIN);
|
||||
foreach ($tcpdf_images_dirs as $tcpdf_images_path) {
|
||||
if (@file_exists($tcpdf_images_path)) {
|
||||
define ('K_PATH_IMAGES', $tcpdf_images_path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!defined('PDF_HEADER_LOGO')) {
|
||||
$tcpdf_header_logo = '';
|
||||
if (@file_exists(K_PATH_IMAGES.'tcpdf_logo.jpg')) {
|
||||
$tcpdf_header_logo = 'tcpdf_logo.jpg';
|
||||
}
|
||||
define ('PDF_HEADER_LOGO', $tcpdf_header_logo);
|
||||
}
|
||||
|
||||
if (!defined('PDF_HEADER_LOGO_WIDTH')) {
|
||||
if (!empty($tcpdf_header_logo)) {
|
||||
define ('PDF_HEADER_LOGO_WIDTH', 30);
|
||||
} else {
|
||||
define ('PDF_HEADER_LOGO_WIDTH', 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (!defined('K_PATH_CACHE')) {
|
||||
$K_PATH_CACHE = ini_get('upload_tmp_dir') ? ini_get('upload_tmp_dir') : sys_get_temp_dir();
|
||||
if (substr($K_PATH_CACHE, -1) != '/') {
|
||||
$K_PATH_CACHE .= '/';
|
||||
}
|
||||
define ('K_PATH_CACHE', $K_PATH_CACHE);
|
||||
}
|
||||
|
||||
if (!defined('K_BLANK_IMAGE')) {
|
||||
define ('K_BLANK_IMAGE', '_blank.png');
|
||||
}
|
||||
|
||||
if (!defined('PDF_PAGE_FORMAT')) {
|
||||
define ('PDF_PAGE_FORMAT', 'A4');
|
||||
}
|
||||
|
||||
if (!defined('PDF_PAGE_ORIENTATION')) {
|
||||
define ('PDF_PAGE_ORIENTATION', 'P');
|
||||
}
|
||||
|
||||
if (!defined('PDF_CREATOR')) {
|
||||
define ('PDF_CREATOR', 'TCPDF');
|
||||
}
|
||||
|
||||
if (!defined('PDF_AUTHOR')) {
|
||||
define ('PDF_AUTHOR', 'TCPDF');
|
||||
}
|
||||
|
||||
if (!defined('PDF_HEADER_TITLE')) {
|
||||
define ('PDF_HEADER_TITLE', 'TCPDF Example');
|
||||
}
|
||||
|
||||
if (!defined('PDF_HEADER_STRING')) {
|
||||
define ('PDF_HEADER_STRING', "by Nicola Asuni - Tecnick.com\nwww.tcpdf.org");
|
||||
}
|
||||
|
||||
if (!defined('PDF_UNIT')) {
|
||||
define ('PDF_UNIT', 'mm');
|
||||
}
|
||||
|
||||
if (!defined('PDF_MARGIN_HEADER')) {
|
||||
define ('PDF_MARGIN_HEADER', 5);
|
||||
}
|
||||
|
||||
if (!defined('PDF_MARGIN_FOOTER')) {
|
||||
define ('PDF_MARGIN_FOOTER', 10);
|
||||
}
|
||||
|
||||
if (!defined('PDF_MARGIN_TOP')) {
|
||||
define ('PDF_MARGIN_TOP', 27);
|
||||
}
|
||||
|
||||
if (!defined('PDF_MARGIN_BOTTOM')) {
|
||||
define ('PDF_MARGIN_BOTTOM', 25);
|
||||
}
|
||||
|
||||
if (!defined('PDF_MARGIN_LEFT')) {
|
||||
define ('PDF_MARGIN_LEFT', 15);
|
||||
}
|
||||
|
||||
if (!defined('PDF_MARGIN_RIGHT')) {
|
||||
define ('PDF_MARGIN_RIGHT', 15);
|
||||
}
|
||||
|
||||
if (!defined('PDF_FONT_NAME_MAIN')) {
|
||||
define ('PDF_FONT_NAME_MAIN', 'helvetica');
|
||||
}
|
||||
|
||||
if (!defined('PDF_FONT_SIZE_MAIN')) {
|
||||
define ('PDF_FONT_SIZE_MAIN', 10);
|
||||
}
|
||||
|
||||
if (!defined('PDF_FONT_NAME_DATA')) {
|
||||
define ('PDF_FONT_NAME_DATA', 'helvetica');
|
||||
}
|
||||
|
||||
if (!defined('PDF_FONT_SIZE_DATA')) {
|
||||
define ('PDF_FONT_SIZE_DATA', 8);
|
||||
}
|
||||
|
||||
if (!defined('PDF_FONT_MONOSPACED')) {
|
||||
define ('PDF_FONT_MONOSPACED', 'courier');
|
||||
}
|
||||
|
||||
if (!defined('PDF_IMAGE_SCALE_RATIO')) {
|
||||
define ('PDF_IMAGE_SCALE_RATIO', 1.25);
|
||||
}
|
||||
|
||||
if (!defined('HEAD_MAGNIFICATION')) {
|
||||
define('HEAD_MAGNIFICATION', 1.1);
|
||||
}
|
||||
|
||||
if (!defined('K_CELL_HEIGHT_RATIO')) {
|
||||
define('K_CELL_HEIGHT_RATIO', 1.25);
|
||||
}
|
||||
|
||||
if (!defined('K_TITLE_MAGNIFICATION')) {
|
||||
define('K_TITLE_MAGNIFICATION', 1.3);
|
||||
}
|
||||
|
||||
if (!defined('K_SMALL_RATIO')) {
|
||||
define('K_SMALL_RATIO', 2/3);
|
||||
}
|
||||
|
||||
if (!defined('K_THAI_TOPCHARS')) {
|
||||
define('K_THAI_TOPCHARS', true);
|
||||
}
|
||||
|
||||
if (!defined('K_TCPDF_CALLS_IN_HTML')) {
|
||||
define('K_TCPDF_CALLS_IN_HTML', false);
|
||||
}
|
||||
|
||||
if (!defined('K_TCPDF_THROW_EXCEPTION_ERROR')) {
|
||||
define('K_TCPDF_THROW_EXCEPTION_ERROR', false);
|
||||
}
|
||||
|
||||
if (!defined('K_TIMEZONE')) {
|
||||
define('K_TIMEZONE', @date_default_timezone_get());
|
||||
}
|
||||
|
||||
//============================================================+
|
||||
// END OF FILE
|
||||
//============================================================+
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,349 @@
|
||||
<?php
|
||||
//============================================================+
|
||||
// File name : tcpdf_barcodes_2d.php
|
||||
// Version : 1.0.015
|
||||
// Begin : 2009-04-07
|
||||
// Last Update : 2014-05-20
|
||||
// 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) 2009-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 GNU Lesser General Public License
|
||||
// along with TCPDF. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// See LICENSE.TXT file for more information.
|
||||
// -------------------------------------------------------------------
|
||||
//
|
||||
// Description : PHP class to creates array representations for
|
||||
// 2D barcodes to be used with TCPDF.
|
||||
//
|
||||
//============================================================+
|
||||
|
||||
/**
|
||||
* @file
|
||||
* PHP class to creates array representations for 2D barcodes to be used with TCPDF.
|
||||
* @package com.tecnick.tcpdf
|
||||
* @author Nicola Asuni
|
||||
* @version 1.0.015
|
||||
*/
|
||||
|
||||
/**
|
||||
* @class TCPDF2DBarcode
|
||||
* PHP class to creates array representations for 2D barcodes to be used with TCPDF (http://www.tcpdf.org).
|
||||
* @package com.tecnick.tcpdf
|
||||
* @version 1.0.015
|
||||
* @author Nicola Asuni
|
||||
*/
|
||||
class TCPDF2DBarcode {
|
||||
|
||||
/**
|
||||
* Array representation of barcode.
|
||||
* @protected
|
||||
*/
|
||||
protected $barcode_array = array();
|
||||
|
||||
/**
|
||||
* This is the class constructor.
|
||||
* Return an array representations for 2D barcodes:<ul>
|
||||
* <li>$arrcode['code'] code to be printed on text label</li>
|
||||
* <li>$arrcode['num_rows'] required number of rows</li>
|
||||
* <li>$arrcode['num_cols'] required number of columns</li>
|
||||
* <li>$arrcode['bcode'][$r][$c] value of the cell is $r row and $c column (0 = transparent, 1 = black)</li></ul>
|
||||
* @param string $code code to print
|
||||
* @param string $type type of barcode: <ul><li>DATAMATRIX : Datamatrix (ISO/IEC 16022)</li><li>PDF417 : PDF417 (ISO/IEC 15438:2006)</li><li>PDF417,a,e,t,s,f,o0,o1,o2,o3,o4,o5,o6 : PDF417 with parameters: a = aspect ratio (width/height); e = error correction level (0-8); t = total number of macro segments; s = macro segment index (0-99998); f = file ID; o0 = File Name (text); o1 = Segment Count (numeric); o2 = Time Stamp (numeric); o3 = Sender (text); o4 = Addressee (text); o5 = File Size (numeric); o6 = Checksum (numeric). NOTES: Parameters t, s and f are required for a Macro Control Block, all other parameters are optional. To use a comma character ',' on text options, replace it with the character 255: "\xff".</li><li>QRCODE : QRcode Low error correction</li><li>QRCODE,L : QRcode Low error correction</li><li>QRCODE,M : QRcode Medium error correction</li><li>QRCODE,Q : QRcode Better error correction</li><li>QRCODE,H : QR-CODE Best error correction</li><li>RAW: raw mode - comma-separad list of array rows</li><li>RAW2: raw mode - array rows are surrounded by square parenthesis.</li><li>TEST : Test matrix</li></ul>
|
||||
*/
|
||||
public function __construct($code, $type) {
|
||||
$this->setBarcode($code, $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an array representations of barcode.
|
||||
* @return array
|
||||
*/
|
||||
public function getBarcodeArray() {
|
||||
return $this->barcode_array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send barcode as SVG image object to the standard output.
|
||||
* @param int $w Width of a single rectangle element in user units.
|
||||
* @param int $h Height of a single rectangle element in user units.
|
||||
* @param string $color Foreground color (in SVG format) for bar elements (background is transparent).
|
||||
* @public
|
||||
*/
|
||||
public function getBarcodeSVG($w=3, $h=3, $color='black') {
|
||||
// send headers
|
||||
$code = $this->getBarcodeSVGcode($w, $h, $color);
|
||||
header('Content-Type: application/svg+xml');
|
||||
header('Cache-Control: public, must-revalidate, max-age=0'); // HTTP/1.1
|
||||
header('Pragma: public');
|
||||
header('Expires: Sat, 26 Jul 1997 05:00:00 GMT'); // Date in the past
|
||||
header('Last-Modified: '.gmdate('D, d M Y H:i:s').' GMT');
|
||||
header('Content-Disposition: inline; filename="'.md5($code).'.svg";');
|
||||
//header('Content-Length: '.strlen($code));
|
||||
echo $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a SVG string representation of barcode.
|
||||
* @param int $w Width of a single rectangle element in user units.
|
||||
* @param int $h Height of a single rectangle element in user units.
|
||||
* @param string $color Foreground color (in SVG format) for bar elements (background is transparent).
|
||||
* @return string SVG code.
|
||||
* @public
|
||||
*/
|
||||
public function getBarcodeSVGcode($w=3, $h=3, $color='black') {
|
||||
// replace table for special characters
|
||||
$repstr = array("\0" => '', '&' => '&', '<' => '<', '>' => '>');
|
||||
$svg = '<'.'?'.'xml version="1.0" standalone="no"'.'?'.'>'."\n";
|
||||
$svg .= '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">'."\n";
|
||||
$svg .= '<svg width="'.round(($this->barcode_array['num_cols'] * $w), 3).'" height="'.round(($this->barcode_array['num_rows'] * $h), 3).'" version="1.1" xmlns="http://www.w3.org/2000/svg">'."\n";
|
||||
$svg .= "\t".'<desc>'.strtr($this->barcode_array['code'], $repstr).'</desc>'."\n";
|
||||
$svg .= "\t".'<g id="elements" fill="'.$color.'" stroke="none">'."\n";
|
||||
// print barcode elements
|
||||
$y = 0;
|
||||
// for each row
|
||||
for ($r = 0; $r < $this->barcode_array['num_rows']; ++$r) {
|
||||
$x = 0;
|
||||
// for each column
|
||||
for ($c = 0; $c < $this->barcode_array['num_cols']; ++$c) {
|
||||
if ($this->barcode_array['bcode'][$r][$c] == 1) {
|
||||
// draw a single barcode cell
|
||||
$svg .= "\t\t".'<rect x="'.$x.'" y="'.$y.'" width="'.$w.'" height="'.$h.'" />'."\n";
|
||||
}
|
||||
$x += $w;
|
||||
}
|
||||
$y += $h;
|
||||
}
|
||||
$svg .= "\t".'</g>'."\n";
|
||||
$svg .= '</svg>'."\n";
|
||||
return $svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an HTML representation of barcode.
|
||||
* @param int $w Width of a single rectangle element in pixels.
|
||||
* @param int $h Height of a single rectangle element in pixels.
|
||||
* @param string $color Foreground color for bar elements (background is transparent).
|
||||
* @return string HTML code.
|
||||
* @public
|
||||
*/
|
||||
public function getBarcodeHTML($w=10, $h=10, $color='black') {
|
||||
$html = '<div style="font-size:0;position:relative;width:'.($w * $this->barcode_array['num_cols']).'px;height:'.($h * $this->barcode_array['num_rows']).'px;">'."\n";
|
||||
// print barcode elements
|
||||
$y = 0;
|
||||
// for each row
|
||||
for ($r = 0; $r < $this->barcode_array['num_rows']; ++$r) {
|
||||
$x = 0;
|
||||
// for each column
|
||||
for ($c = 0; $c < $this->barcode_array['num_cols']; ++$c) {
|
||||
if ($this->barcode_array['bcode'][$r][$c] == 1) {
|
||||
// draw a single barcode cell
|
||||
$html .= '<div style="background-color:'.$color.';width:'.$w.'px;height:'.$h.'px;position:absolute;left:'.$x.'px;top:'.$y.'px;"> </div>'."\n";
|
||||
}
|
||||
$x += $w;
|
||||
}
|
||||
$y += $h;
|
||||
}
|
||||
$html .= '</div>'."\n";
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a PNG image representation of barcode (requires GD or Imagick library).
|
||||
* @param int $w Width of a single rectangle element in pixels.
|
||||
* @param int $h Height of a single rectangle element in pixels.
|
||||
* @param array $color RGB (0-255) foreground color for bar elements (background is transparent).
|
||||
* @public
|
||||
*/
|
||||
public function getBarcodePNG($w=3, $h=3, $color=array(0,0,0)) {
|
||||
$data = $this->getBarcodePngData($w, $h, $color);
|
||||
// send headers
|
||||
header('Content-Type: image/png');
|
||||
header('Cache-Control: public, must-revalidate, max-age=0'); // HTTP/1.1
|
||||
header('Pragma: public');
|
||||
header('Expires: Sat, 26 Jul 1997 05:00:00 GMT'); // Date in the past
|
||||
header('Last-Modified: '.gmdate('D, d M Y H:i:s').' GMT');
|
||||
//header('Content-Length: '.strlen($data));
|
||||
echo $data;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a PNG image representation of barcode (requires GD or Imagick library).
|
||||
* @param int $w Width of a single rectangle element in pixels.
|
||||
* @param int $h Height of a single rectangle element in pixels.
|
||||
* @param array $color RGB (0-255) foreground color for bar elements (background is transparent).
|
||||
* @return string|Imagick|false image or false in case of error.
|
||||
* @public
|
||||
*/
|
||||
public function getBarcodePngData($w=3, $h=3, $color=array(0,0,0)) {
|
||||
// calculate image size
|
||||
$width = ($this->barcode_array['num_cols'] * $w);
|
||||
$height = ($this->barcode_array['num_rows'] * $h);
|
||||
if (function_exists('imagecreate')) {
|
||||
// GD library
|
||||
$imagick = false;
|
||||
$png = imagecreate($width, $height);
|
||||
$bgcol = imagecolorallocate($png, 255, 255, 255);
|
||||
imagecolortransparent($png, $bgcol);
|
||||
$fgcol = imagecolorallocate($png, $color[0], $color[1], $color[2]);
|
||||
} elseif (extension_loaded('imagick')) {
|
||||
$imagick = true;
|
||||
$bgcol = new imagickpixel('rgb(255,255,255');
|
||||
$fgcol = new imagickpixel('rgb('.$color[0].','.$color[1].','.$color[2].')');
|
||||
$png = new Imagick();
|
||||
$png->newImage($width, $height, 'none', 'png');
|
||||
$bar = new imagickdraw();
|
||||
$bar->setfillcolor($fgcol);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
// print barcode elements
|
||||
$y = 0;
|
||||
// for each row
|
||||
for ($r = 0; $r < $this->barcode_array['num_rows']; ++$r) {
|
||||
$x = 0;
|
||||
// for each column
|
||||
for ($c = 0; $c < $this->barcode_array['num_cols']; ++$c) {
|
||||
if ($this->barcode_array['bcode'][$r][$c] == 1) {
|
||||
// draw a single barcode cell
|
||||
if ($imagick) {
|
||||
$bar->rectangle($x, $y, ($x + $w - 1), ($y + $h - 1));
|
||||
} else {
|
||||
imagefilledrectangle($png, $x, $y, ($x + $w - 1), ($y + $h - 1), $fgcol);
|
||||
}
|
||||
}
|
||||
$x += $w;
|
||||
}
|
||||
$y += $h;
|
||||
}
|
||||
if ($imagick) {
|
||||
$png->drawimage($bar);
|
||||
return $png;
|
||||
} else {
|
||||
ob_start();
|
||||
imagepng($png);
|
||||
$imagedata = ob_get_clean();
|
||||
imagedestroy($png);
|
||||
return $imagedata;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the barcode.
|
||||
* @param string $code code to print
|
||||
* @param string $type type of barcode: <ul><li>DATAMATRIX : Datamatrix (ISO/IEC 16022)</li><li>PDF417 : PDF417 (ISO/IEC 15438:2006)</li><li>PDF417,a,e,t,s,f,o0,o1,o2,o3,o4,o5,o6 : PDF417 with parameters: a = aspect ratio (width/height); e = error correction level (0-8); t = total number of macro segments; s = macro segment index (0-99998); f = file ID; o0 = File Name (text); o1 = Segment Count (numeric); o2 = Time Stamp (numeric); o3 = Sender (text); o4 = Addressee (text); o5 = File Size (numeric); o6 = Checksum (numeric). NOTES: Parameters t, s and f are required for a Macro Control Block, all other parameters are optional. To use a comma character ',' on text options, replace it with the character 255: "\xff".</li><li>QRCODE : QRcode Low error correction</li><li>QRCODE,L : QRcode Low error correction</li><li>QRCODE,M : QRcode Medium error correction</li><li>QRCODE,Q : QRcode Better error correction</li><li>QRCODE,H : QR-CODE Best error correction</li><li>RAW: raw mode - comma-separad list of array rows</li><li>RAW2: raw mode - array rows are surrounded by square parenthesis.</li><li>TEST : Test matrix</li></ul>
|
||||
* @return void
|
||||
*/
|
||||
public function setBarcode($code, $type) {
|
||||
$mode = explode(',', $type);
|
||||
$qrtype = strtoupper($mode[0]);
|
||||
switch ($qrtype) {
|
||||
case 'DATAMATRIX': { // DATAMATRIX (ISO/IEC 16022)
|
||||
require_once(dirname(__FILE__).'/include/barcodes/datamatrix.php');
|
||||
$qrcode = new Datamatrix($code);
|
||||
$this->barcode_array = $qrcode->getBarcodeArray();
|
||||
$this->barcode_array['code'] = $code;
|
||||
break;
|
||||
}
|
||||
case 'PDF417': { // PDF417 (ISO/IEC 15438:2006)
|
||||
require_once(dirname(__FILE__).'/include/barcodes/pdf417.php');
|
||||
if (!isset($mode[1]) OR ($mode[1] === '')) {
|
||||
$aspectratio = 2; // default aspect ratio (width / height)
|
||||
} else {
|
||||
$aspectratio = floatval($mode[1]);
|
||||
}
|
||||
if (!isset($mode[2]) OR ($mode[2] === '')) {
|
||||
$ecl = -1; // default error correction level (auto)
|
||||
} else {
|
||||
$ecl = intval($mode[2]);
|
||||
}
|
||||
// set macro block
|
||||
$macro = array();
|
||||
if (isset($mode[3]) AND ($mode[3] !== '') AND isset($mode[4]) AND ($mode[4] !== '') AND isset($mode[5]) AND ($mode[5] !== '')) {
|
||||
$macro['segment_total'] = intval($mode[3]);
|
||||
$macro['segment_index'] = intval($mode[4]);
|
||||
$macro['file_id'] = strtr($mode[5], "\xff", ',');
|
||||
for ($i = 0; $i < 7; ++$i) {
|
||||
$o = $i + 6;
|
||||
if (isset($mode[$o]) AND ($mode[$o] !== '')) {
|
||||
// add option
|
||||
$macro['option_'.$i] = strtr($mode[$o], "\xff", ',');
|
||||
}
|
||||
}
|
||||
}
|
||||
$qrcode = new PDF417($code, $ecl, $aspectratio, $macro);
|
||||
$this->barcode_array = $qrcode->getBarcodeArray();
|
||||
$this->barcode_array['code'] = $code;
|
||||
break;
|
||||
}
|
||||
case 'QRCODE': { // QR-CODE
|
||||
require_once(dirname(__FILE__).'/include/barcodes/qrcode.php');
|
||||
if (!isset($mode[1]) OR (!in_array($mode[1],array('L','M','Q','H')))) {
|
||||
$mode[1] = 'L'; // Ddefault: Low error correction
|
||||
}
|
||||
$qrcode = new QRcode($code, strtoupper($mode[1]));
|
||||
$this->barcode_array = $qrcode->getBarcodeArray();
|
||||
$this->barcode_array['code'] = $code;
|
||||
break;
|
||||
}
|
||||
case 'RAW':
|
||||
case 'RAW2': { // RAW MODE
|
||||
// remove spaces
|
||||
$code = preg_replace('/[\s]*/si', '', $code);
|
||||
if (strlen($code) < 3) {
|
||||
break;
|
||||
}
|
||||
if ($qrtype == 'RAW') {
|
||||
// comma-separated rows
|
||||
$rows = explode(',', $code);
|
||||
} else { // RAW2
|
||||
// rows enclosed in square parentheses
|
||||
$code = substr($code, 1, -1);
|
||||
$rows = explode('][', $code);
|
||||
}
|
||||
$this->barcode_array['num_rows'] = count($rows);
|
||||
$this->barcode_array['num_cols'] = strlen($rows[0]);
|
||||
$this->barcode_array['bcode'] = array();
|
||||
foreach ($rows as $r) {
|
||||
$this->barcode_array['bcode'][] = str_split($r, 1);
|
||||
}
|
||||
$this->barcode_array['code'] = $code;
|
||||
break;
|
||||
}
|
||||
case 'TEST': { // TEST MODE
|
||||
$this->barcode_array['num_rows'] = 5;
|
||||
$this->barcode_array['num_cols'] = 15;
|
||||
$this->barcode_array['bcode'] = array(
|
||||
array(1,1,1,0,1,1,1,0,1,1,1,0,1,1,1),
|
||||
array(0,1,0,0,1,0,0,0,1,0,0,0,0,1,0),
|
||||
array(0,1,0,0,1,1,0,0,1,1,1,0,0,1,0),
|
||||
array(0,1,0,0,1,0,0,0,0,0,1,0,0,1,0),
|
||||
array(0,1,0,0,1,1,1,0,1,1,1,0,0,1,0));
|
||||
$this->barcode_array['code'] = $code;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
$this->barcode_array = array();
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end of class
|
||||
|
||||
//============================================================+
|
||||
// END OF FILE
|
||||
//============================================================+
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
//============================================================+
|
||||
// File name : tcpdf_import.php
|
||||
// Version : 1.0.001
|
||||
// Begin : 2011-05-23
|
||||
// Last Update : 2013-09-17
|
||||
// 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 extension of the TCPDF library to
|
||||
// import existing PDF documents.
|
||||
//
|
||||
//============================================================+
|
||||
|
||||
/**
|
||||
* @file
|
||||
* !!! THIS CLASS IS UNDER DEVELOPMENT !!!
|
||||
* This is a PHP class extension of the TCPDF (http://www.tcpdf.org) library to import existing PDF documents.<br>
|
||||
* @package com.tecnick.tcpdf
|
||||
* @author Nicola Asuni
|
||||
* @version 1.0.001
|
||||
*/
|
||||
|
||||
// include the TCPDF class
|
||||
require_once(dirname(__FILE__).'/tcpdf.php');
|
||||
// include PDF parser class
|
||||
require_once(dirname(__FILE__).'/tcpdf_parser.php');
|
||||
|
||||
/**
|
||||
* @class TCPDF_IMPORT
|
||||
* !!! THIS CLASS IS UNDER DEVELOPMENT !!!
|
||||
* PHP class extension of the TCPDF (http://www.tcpdf.org) library to import existing PDF documents.<br>
|
||||
* @package com.tecnick.tcpdf
|
||||
* @brief PHP class extension of the TCPDF library to import existing PDF documents.
|
||||
* @version 1.0.001
|
||||
* @author Nicola Asuni - info@tecnick.com
|
||||
*/
|
||||
class TCPDF_IMPORT extends TCPDF {
|
||||
|
||||
/**
|
||||
* Import an existing PDF document
|
||||
* @param string $filename Filename of the PDF document to import.
|
||||
* @return void
|
||||
* @public
|
||||
* @since 1.0.000 (2011-05-24)
|
||||
*/
|
||||
public function importPDF($filename) {
|
||||
// load document
|
||||
$rawdata = file_get_contents($filename);
|
||||
if ($rawdata === false) {
|
||||
$this->Error('Unable to get the content of the file: '.$filename);
|
||||
}
|
||||
// configuration parameters for parser
|
||||
$cfg = array(
|
||||
'die_for_errors' => false,
|
||||
'ignore_filter_decoding_errors' => true,
|
||||
'ignore_missing_filter_decoders' => true,
|
||||
);
|
||||
try {
|
||||
// parse PDF data
|
||||
$pdf = new TCPDF_PARSER($rawdata, $cfg);
|
||||
} catch (Exception $e) {
|
||||
die($e->getMessage());
|
||||
}
|
||||
// get the parsed data
|
||||
$data = $pdf->getParsedData();
|
||||
// release some memory
|
||||
unset($rawdata);
|
||||
|
||||
// ...
|
||||
|
||||
|
||||
print_r($data); // DEBUG
|
||||
|
||||
|
||||
unset($pdf);
|
||||
}
|
||||
|
||||
} // END OF CLASS
|
||||
|
||||
//============================================================+
|
||||
// END OF FILE
|
||||
//============================================================+
|
||||
@@ -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"; ?>
|
||||
@@ -1,189 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Auth;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Database;
|
||||
use App\Core\Response;
|
||||
use App\Core\Session;
|
||||
use App\Core\View;
|
||||
use App\Services\AuditService;
|
||||
use App\Services\SetupService;
|
||||
use App\Services\TenantRegistrationService;
|
||||
use RuntimeException;
|
||||
|
||||
final class PlatformController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $rootPath,
|
||||
private readonly array $config,
|
||||
private readonly Database $database,
|
||||
private readonly View $view,
|
||||
private readonly Session $session,
|
||||
private readonly Csrf $csrf
|
||||
) {
|
||||
}
|
||||
|
||||
public function home(): void
|
||||
{
|
||||
$setup = new SetupService($this->rootPath);
|
||||
$stats = null;
|
||||
|
||||
if ($setup->isInstalled() && $this->database->isConfigured()) {
|
||||
try {
|
||||
$pdo = $this->database->pdo();
|
||||
$stats = [
|
||||
'tenants' => (int) $pdo->query('SELECT COUNT(*) FROM tenants')->fetchColumn(),
|
||||
'members' => (int) $pdo->query('SELECT COUNT(*) FROM members')->fetchColumn(),
|
||||
'events' => (int) $pdo->query('SELECT COUNT(*) FROM consumption_events')->fetchColumn(),
|
||||
];
|
||||
} catch (\Throwable) {
|
||||
$stats = null;
|
||||
}
|
||||
}
|
||||
|
||||
$this->view->render('home/index', [
|
||||
'title' => 'Kaffeekasse fuer Teams, Vereine und Coworking',
|
||||
'setupComplete' => $setup->isInstalled(),
|
||||
'stats' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
public function installForm(): void
|
||||
{
|
||||
$setup = new SetupService($this->rootPath);
|
||||
|
||||
if ($setup->isInstalled()) {
|
||||
Response::redirect('/admin/login');
|
||||
}
|
||||
|
||||
$this->view->render('install/index', [
|
||||
'title' => 'Installation',
|
||||
]);
|
||||
}
|
||||
|
||||
public function installSubmit(): void
|
||||
{
|
||||
$this->assertCsrf();
|
||||
|
||||
$setup = new SetupService($this->rootPath);
|
||||
|
||||
try {
|
||||
$setup->install($_POST);
|
||||
$this->session->flash('success', 'Die Installation wurde abgeschlossen. Jetzt kannst du dich als Plattform-Admin anmelden.');
|
||||
Response::redirect('/admin/login');
|
||||
} catch (\Throwable $throwable) {
|
||||
remember_old_input($_POST);
|
||||
$this->session->flash('error', $throwable->getMessage());
|
||||
Response::redirect('/install');
|
||||
}
|
||||
}
|
||||
|
||||
public function registerForm(): void
|
||||
{
|
||||
if (!(new SetupService($this->rootPath))->isInstalled()) {
|
||||
Response::redirect('/install');
|
||||
}
|
||||
|
||||
$this->view->render('home/register', [
|
||||
'title' => 'Mandant registrieren',
|
||||
]);
|
||||
}
|
||||
|
||||
public function registerSubmit(): void
|
||||
{
|
||||
$this->assertCsrf();
|
||||
|
||||
try {
|
||||
$pdo = $this->database->pdo();
|
||||
$audit = new AuditService($pdo);
|
||||
$service = new TenantRegistrationService($pdo, $audit);
|
||||
$slug = $service->register($_POST);
|
||||
$this->session->flash('success', 'Dein Mandant wurde angelegt. Du kannst dich jetzt anmelden.');
|
||||
Response::redirect('/t/' . rawurlencode($slug) . '/login');
|
||||
} catch (\Throwable $throwable) {
|
||||
remember_old_input($_POST);
|
||||
$this->session->flash('error', $throwable->getMessage());
|
||||
Response::redirect('/register');
|
||||
}
|
||||
}
|
||||
|
||||
public function adminLoginForm(): void
|
||||
{
|
||||
$this->view->render('admin/login', [
|
||||
'title' => 'Plattform-Login',
|
||||
]);
|
||||
}
|
||||
|
||||
public function adminLoginSubmit(): void
|
||||
{
|
||||
$this->assertCsrf();
|
||||
|
||||
try {
|
||||
$pdo = $this->database->pdo();
|
||||
$auth = new Auth($pdo, $this->session);
|
||||
$audit = new AuditService($pdo);
|
||||
$success = $auth->attemptPlatformLogin((string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? ''));
|
||||
$audit->log(null, null, 'platform.login', 'user', null, $success ? 'success' : 'failure', [
|
||||
'email' => mb_strtolower(trim((string) ($_POST['email'] ?? ''))),
|
||||
]);
|
||||
|
||||
if (!$success) {
|
||||
throw new RuntimeException('Die Zugangsdaten konnten nicht verifiziert werden.');
|
||||
}
|
||||
|
||||
$this->session->flash('success', 'Willkommen im Plattformbereich.');
|
||||
Response::redirect('/admin');
|
||||
} catch (\Throwable $throwable) {
|
||||
remember_old_input($_POST);
|
||||
$this->session->flash('error', $throwable->getMessage());
|
||||
Response::redirect('/admin/login');
|
||||
}
|
||||
}
|
||||
|
||||
public function adminDashboard(): void
|
||||
{
|
||||
$pdo = $this->database->pdo();
|
||||
$auth = new Auth($pdo, $this->session);
|
||||
|
||||
if (!$auth->checkPlatformAdmin()) {
|
||||
$this->session->flash('error', 'Bitte zuerst als Plattform-Admin anmelden.');
|
||||
Response::redirect('/admin/login');
|
||||
}
|
||||
|
||||
$tenants = $pdo->query(
|
||||
'SELECT t.*,
|
||||
COUNT(DISTINCT m.id) AS member_count,
|
||||
COUNT(DISTINCT p.id) AS product_count
|
||||
FROM tenants t
|
||||
LEFT JOIN members m ON m.tenant_id = t.id
|
||||
LEFT JOIN products p ON p.tenant_id = t.id
|
||||
GROUP BY t.id
|
||||
ORDER BY t.created_at DESC'
|
||||
)->fetchAll();
|
||||
|
||||
$this->view->render('admin/index', [
|
||||
'title' => 'Plattform-Dashboard',
|
||||
'tenants' => $tenants,
|
||||
]);
|
||||
}
|
||||
|
||||
public function adminLogout(): void
|
||||
{
|
||||
$this->assertCsrf();
|
||||
|
||||
$pdo = $this->database->pdo();
|
||||
$auth = new Auth($pdo, $this->session);
|
||||
$auth->logout();
|
||||
$this->session->flash('success', 'Du wurdest aus dem Plattformbereich abgemeldet.');
|
||||
Response::redirect('/admin/login');
|
||||
}
|
||||
|
||||
private function assertCsrf(): void
|
||||
{
|
||||
if (!request_origin_matches_host() || !$this->csrf->validate($_POST['_token'] ?? null)) {
|
||||
throw new RuntimeException('Die Anfrage konnte nicht bestaetigt werden. Bitte erneut versuchen.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,636 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Auth;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Database;
|
||||
use App\Core\Response;
|
||||
use App\Core\Session;
|
||||
use App\Core\TenantContext;
|
||||
use App\Core\View;
|
||||
use App\Services\AuditService;
|
||||
use App\Services\LedgerService;
|
||||
use App\Services\RfidService;
|
||||
use RuntimeException;
|
||||
|
||||
final class TenantController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Database $database,
|
||||
private readonly View $view,
|
||||
private readonly Session $session,
|
||||
private readonly Csrf $csrf
|
||||
) {
|
||||
}
|
||||
|
||||
public function loginForm(string $tenantSlug): void
|
||||
{
|
||||
$tenant = $this->tenant($tenantSlug);
|
||||
|
||||
$this->view->render('tenant/login', [
|
||||
'title' => 'Tenant-Login',
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
}
|
||||
|
||||
public function loginSubmit(string $tenantSlug): void
|
||||
{
|
||||
$this->assertCsrf();
|
||||
$tenant = $this->tenant($tenantSlug);
|
||||
$pdo = $this->database->pdo();
|
||||
$auth = new Auth($pdo, $this->session);
|
||||
$audit = new AuditService($pdo);
|
||||
|
||||
$success = $auth->attemptTenantLogin($tenantSlug, (string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? ''));
|
||||
$audit->log((int) $tenant['id'], null, 'tenant.login', 'user', null, $success ? 'success' : 'failure', [
|
||||
'email' => mb_strtolower(trim((string) ($_POST['email'] ?? ''))),
|
||||
]);
|
||||
|
||||
if (!$success) {
|
||||
remember_old_input($_POST);
|
||||
$this->session->flash('error', 'Die Zugangsdaten konnten nicht verifiziert werden.');
|
||||
Response::redirect(tenant_url($tenantSlug, 'login'));
|
||||
}
|
||||
|
||||
$this->session->flash('success', 'Willkommen zur digitalen Kaffeeliste.');
|
||||
Response::redirect(tenant_url($tenantSlug));
|
||||
}
|
||||
|
||||
public function logout(string $tenantSlug): void
|
||||
{
|
||||
$this->assertCsrf();
|
||||
$pdo = $this->database->pdo();
|
||||
$auth = new Auth($pdo, $this->session);
|
||||
$auth->logout();
|
||||
$this->session->flash('success', 'Du wurdest abgemeldet.');
|
||||
Response::redirect(tenant_url($tenantSlug, 'login'));
|
||||
}
|
||||
|
||||
public function dashboard(string $tenantSlug): void
|
||||
{
|
||||
[$tenant, $authState] = $this->tenantAccess($tenantSlug);
|
||||
$pdo = $this->database->pdo();
|
||||
|
||||
$summaryStatement = $pdo->prepare(
|
||||
'SELECT
|
||||
COUNT(DISTINCT members.id) AS member_count,
|
||||
COUNT(DISTINCT products.id) AS product_count,
|
||||
COUNT(DISTINCT paper_sheets.id) AS paper_sheet_count,
|
||||
COALESCE(SUM(CASE WHEN ledger_lines.account_code = "member_balance" THEN ledger_lines.amount_cents ELSE 0 END), 0) AS open_balance_cents
|
||||
FROM tenants
|
||||
LEFT JOIN members ON members.tenant_id = tenants.id AND members.status = "active"
|
||||
LEFT JOIN products ON products.tenant_id = tenants.id AND products.is_active = 1
|
||||
LEFT JOIN paper_sheets ON paper_sheets.tenant_id = tenants.id
|
||||
LEFT JOIN ledger_lines ON ledger_lines.tenant_id = tenants.id
|
||||
WHERE tenants.id = :tenant_id
|
||||
GROUP BY tenants.id'
|
||||
);
|
||||
$summaryStatement->execute(['tenant_id' => $tenant['id']]);
|
||||
$summary = $summaryStatement->fetch() ?: [
|
||||
'member_count' => 0,
|
||||
'product_count' => 0,
|
||||
'paper_sheet_count' => 0,
|
||||
'open_balance_cents' => 0,
|
||||
];
|
||||
|
||||
$eventsStatement = $pdo->prepare(
|
||||
'SELECT c.*, m.display_name AS member_name, p.name AS product_name, s.name AS source_name
|
||||
FROM consumption_events c
|
||||
INNER JOIN members m ON m.id = c.member_id
|
||||
INNER JOIN products p ON p.id = c.product_id
|
||||
INNER JOIN capture_sources s ON s.id = c.source_id
|
||||
WHERE c.tenant_id = :tenant_id
|
||||
ORDER BY c.recorded_at DESC
|
||||
LIMIT 8'
|
||||
);
|
||||
$eventsStatement->execute(['tenant_id' => $tenant['id']]);
|
||||
|
||||
$balancesStatement = $pdo->prepare(
|
||||
'SELECT m.id, m.display_name, COALESCE(SUM(ll.amount_cents), 0) AS balance_cents
|
||||
FROM members m
|
||||
LEFT JOIN ledger_lines ll
|
||||
ON ll.member_id = m.id
|
||||
AND ll.tenant_id = m.tenant_id
|
||||
AND ll.account_code = "member_balance"
|
||||
WHERE m.tenant_id = :tenant_id
|
||||
GROUP BY m.id
|
||||
ORDER BY balance_cents DESC, m.display_name ASC
|
||||
LIMIT 8'
|
||||
);
|
||||
$balancesStatement->execute(['tenant_id' => $tenant['id']]);
|
||||
|
||||
$this->view->render('tenant/dashboard', [
|
||||
'title' => 'Dashboard',
|
||||
'tenant' => $tenant,
|
||||
'authState' => $authState,
|
||||
'summary' => $summary,
|
||||
'latestEvents' => $eventsStatement->fetchAll(),
|
||||
'memberBalances' => $balancesStatement->fetchAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function members(string $tenantSlug): void
|
||||
{
|
||||
[$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
|
||||
$pdo = $this->database->pdo();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$this->assertCsrf();
|
||||
|
||||
try {
|
||||
$displayName = trim((string) ($_POST['display_name'] ?? ''));
|
||||
$email = trim((string) ($_POST['email'] ?? ''));
|
||||
$accessPin = trim((string) ($_POST['access_pin'] ?? ''));
|
||||
$loginPassword = (string) ($_POST['login_password'] ?? '');
|
||||
|
||||
if ($displayName === '') {
|
||||
throw new RuntimeException('Bitte einen Anzeigenamen angeben.');
|
||||
}
|
||||
|
||||
$userId = null;
|
||||
|
||||
if ($email !== '') {
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new RuntimeException('Die E-Mail-Adresse ist ungueltig.');
|
||||
}
|
||||
|
||||
if ($loginPassword === '' || mb_strlen($loginPassword) < 12) {
|
||||
throw new RuntimeException('Fuer Login-faehige Mitglieder ist ein Passwort mit mindestens 12 Zeichen noetig.');
|
||||
}
|
||||
|
||||
$userInsert = $pdo->prepare(
|
||||
'INSERT INTO users (full_name, email, password_hash, platform_role)
|
||||
VALUES (:full_name, :email, :password_hash, "user")'
|
||||
);
|
||||
$userInsert->execute([
|
||||
'full_name' => $displayName,
|
||||
'email' => mb_strtolower($email),
|
||||
'password_hash' => secure_password_hash($loginPassword),
|
||||
]);
|
||||
$userId = (int) $pdo->lastInsertId();
|
||||
|
||||
$membershipInsert = $pdo->prepare(
|
||||
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status)
|
||||
VALUES (:tenant_id, :user_id, "member", "active")'
|
||||
);
|
||||
$membershipInsert->execute([
|
||||
'tenant_id' => $tenant['id'],
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
$memberInsert = $pdo->prepare(
|
||||
'INSERT INTO members (tenant_id, user_id, display_name, email, access_pin, status)
|
||||
VALUES (:tenant_id, :user_id, :display_name, :email, :access_pin, "active")'
|
||||
);
|
||||
$memberInsert->execute([
|
||||
'tenant_id' => $tenant['id'],
|
||||
'user_id' => $userId,
|
||||
'display_name' => $displayName,
|
||||
'email' => $email !== '' ? mb_strtolower($email) : null,
|
||||
'access_pin' => $accessPin !== '' ? $accessPin : null,
|
||||
]);
|
||||
|
||||
(new AuditService($pdo))->log((int) $tenant['id'], (int) $authState['user_id'], 'member.created', 'member', (int) $pdo->lastInsertId(), 'success');
|
||||
$this->session->flash('success', 'Das Mitglied wurde gespeichert.');
|
||||
} catch (\Throwable $throwable) {
|
||||
remember_old_input($_POST);
|
||||
$this->session->flash('error', $throwable->getMessage());
|
||||
}
|
||||
|
||||
Response::redirect(tenant_url($tenantSlug, 'members'));
|
||||
}
|
||||
|
||||
$membersStatement = $pdo->prepare(
|
||||
'SELECT m.*, u.email AS login_email
|
||||
FROM members m
|
||||
LEFT JOIN users u ON u.id = m.user_id
|
||||
WHERE m.tenant_id = :tenant_id
|
||||
ORDER BY m.display_name ASC'
|
||||
);
|
||||
$membersStatement->execute(['tenant_id' => $tenant['id']]);
|
||||
|
||||
$this->view->render('tenant/members', [
|
||||
'title' => 'Mitglieder',
|
||||
'tenant' => $tenant,
|
||||
'authState' => $authState,
|
||||
'members' => $membersStatement->fetchAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function products(string $tenantSlug): void
|
||||
{
|
||||
[$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
|
||||
$pdo = $this->database->pdo();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$this->assertCsrf();
|
||||
|
||||
try {
|
||||
$name = trim((string) ($_POST['name'] ?? ''));
|
||||
$priceEur = (float) ($_POST['price_eur'] ?? 0);
|
||||
|
||||
if ($name === '' || $priceEur <= 0) {
|
||||
throw new RuntimeException('Bitte Produktname und Preis angeben.');
|
||||
}
|
||||
|
||||
$productInsert = $pdo->prepare(
|
||||
'INSERT INTO products (tenant_id, name, sku, is_active)
|
||||
VALUES (:tenant_id, :name, :sku, 1)'
|
||||
);
|
||||
$productInsert->execute([
|
||||
'tenant_id' => $tenant['id'],
|
||||
'name' => $name,
|
||||
'sku' => strtolower(str_replace(' ', '-', $name)),
|
||||
]);
|
||||
$productId = (int) $pdo->lastInsertId();
|
||||
|
||||
$priceInsert = $pdo->prepare(
|
||||
'INSERT INTO product_prices (tenant_id, product_id, price_cents, valid_from)
|
||||
VALUES (:tenant_id, :product_id, :price_cents, :valid_from)'
|
||||
);
|
||||
$priceInsert->execute([
|
||||
'tenant_id' => $tenant['id'],
|
||||
'product_id' => $productId,
|
||||
'price_cents' => (int) round($priceEur * 100),
|
||||
'valid_from' => gmdate('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
(new AuditService($pdo))->log((int) $tenant['id'], (int) $authState['user_id'], 'product.created', 'product', $productId, 'success');
|
||||
$this->session->flash('success', 'Das Produkt wurde angelegt.');
|
||||
} catch (\Throwable $throwable) {
|
||||
remember_old_input($_POST);
|
||||
$this->session->flash('error', $throwable->getMessage());
|
||||
}
|
||||
|
||||
Response::redirect(tenant_url($tenantSlug, 'products'));
|
||||
}
|
||||
|
||||
$productsStatement = $pdo->prepare(
|
||||
'SELECT p.*, pp.price_cents
|
||||
FROM products p
|
||||
LEFT JOIN product_prices pp
|
||||
ON pp.product_id = p.id
|
||||
AND pp.tenant_id = p.tenant_id
|
||||
AND pp.valid_until IS NULL
|
||||
WHERE p.tenant_id = :tenant_id
|
||||
ORDER BY p.name ASC'
|
||||
);
|
||||
$productsStatement->execute(['tenant_id' => $tenant['id']]);
|
||||
|
||||
$this->view->render('tenant/products', [
|
||||
'title' => 'Produkte',
|
||||
'tenant' => $tenant,
|
||||
'authState' => $authState,
|
||||
'products' => $productsStatement->fetchAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function bookings(string $tenantSlug): void
|
||||
{
|
||||
[$tenant, $authState] = $this->tenantAccess($tenantSlug);
|
||||
$pdo = $this->database->pdo();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$this->assertCsrf();
|
||||
|
||||
try {
|
||||
$service = new LedgerService($pdo, new AuditService($pdo));
|
||||
$service->createConsumption(
|
||||
(int) $tenant['id'],
|
||||
(int) ($_POST['member_id'] ?? 0),
|
||||
(int) ($_POST['product_id'] ?? 0),
|
||||
(string) ($_POST['source_code'] ?? 'digital_self'),
|
||||
normalize_datetime_input((string) ($_POST['effective_at'] ?? '')),
|
||||
(int) $authState['user_id'],
|
||||
(float) ($_POST['quantity'] ?? 1),
|
||||
trim((string) ($_POST['notes'] ?? ''))
|
||||
);
|
||||
$this->session->flash('success', 'Die Buchung wurde gespeichert.');
|
||||
} catch (\Throwable $throwable) {
|
||||
remember_old_input($_POST);
|
||||
$this->session->flash('error', $throwable->getMessage());
|
||||
}
|
||||
|
||||
Response::redirect(tenant_url($tenantSlug, 'bookings'));
|
||||
}
|
||||
|
||||
$members = $this->membersForTenant((int) $tenant['id']);
|
||||
$products = $this->productsForTenant((int) $tenant['id']);
|
||||
|
||||
$bookingsStatement = $pdo->prepare(
|
||||
'SELECT c.*, m.display_name AS member_name, p.name AS product_name, s.name AS source_name
|
||||
FROM consumption_events c
|
||||
INNER JOIN members m ON m.id = c.member_id
|
||||
INNER JOIN products p ON p.id = c.product_id
|
||||
INNER JOIN capture_sources s ON s.id = c.source_id
|
||||
WHERE c.tenant_id = :tenant_id
|
||||
ORDER BY c.recorded_at DESC
|
||||
LIMIT 30'
|
||||
);
|
||||
$bookingsStatement->execute(['tenant_id' => $tenant['id']]);
|
||||
|
||||
$this->view->render('tenant/bookings', [
|
||||
'title' => 'Digitale Buchungen',
|
||||
'tenant' => $tenant,
|
||||
'authState' => $authState,
|
||||
'members' => $members,
|
||||
'products' => $products,
|
||||
'bookings' => $bookingsStatement->fetchAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function payments(string $tenantSlug): void
|
||||
{
|
||||
[$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
|
||||
$pdo = $this->database->pdo();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$this->assertCsrf();
|
||||
|
||||
try {
|
||||
$service = new LedgerService($pdo, new AuditService($pdo));
|
||||
$service->createPayment(
|
||||
(int) $tenant['id'],
|
||||
(int) ($_POST['member_id'] ?? 0),
|
||||
(int) round(((float) ($_POST['amount_eur'] ?? 0)) * 100),
|
||||
normalize_datetime_input((string) ($_POST['occurred_at'] ?? '')),
|
||||
(int) $authState['user_id'],
|
||||
trim((string) ($_POST['notes'] ?? ''))
|
||||
);
|
||||
$this->session->flash('success', 'Die Einzahlung wurde gebucht.');
|
||||
} catch (\Throwable $throwable) {
|
||||
remember_old_input($_POST);
|
||||
$this->session->flash('error', $throwable->getMessage());
|
||||
}
|
||||
|
||||
Response::redirect(tenant_url($tenantSlug, 'payments'));
|
||||
}
|
||||
|
||||
$members = $this->membersForTenant((int) $tenant['id']);
|
||||
$paymentsStatement = $pdo->prepare(
|
||||
'SELECT le.*,
|
||||
m.display_name AS member_name,
|
||||
ABS(COALESCE(SUM(CASE WHEN ll.account_code = "member_balance" THEN ll.amount_cents ELSE 0 END), 0)) AS amount_cents
|
||||
FROM ledger_entries le
|
||||
LEFT JOIN members m ON m.id = le.member_id
|
||||
LEFT JOIN ledger_lines ll ON ll.ledger_entry_id = le.id
|
||||
WHERE le.tenant_id = :tenant_id
|
||||
AND le.type = "payment"
|
||||
GROUP BY le.id
|
||||
ORDER BY le.occurred_at DESC
|
||||
LIMIT 30'
|
||||
);
|
||||
$paymentsStatement->execute(['tenant_id' => $tenant['id']]);
|
||||
|
||||
$this->view->render('tenant/payments', [
|
||||
'title' => 'Einzahlungen',
|
||||
'tenant' => $tenant,
|
||||
'authState' => $authState,
|
||||
'members' => $members,
|
||||
'payments' => $paymentsStatement->fetchAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function paperSheets(string $tenantSlug): void
|
||||
{
|
||||
[$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
|
||||
$pdo = $this->database->pdo();
|
||||
|
||||
$service = new LedgerService($pdo, new AuditService($pdo));
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$this->assertCsrf();
|
||||
|
||||
try {
|
||||
$action = (string) ($_POST['action'] ?? '');
|
||||
|
||||
if ($action === 'create_sheet') {
|
||||
$service->createPaperSheet(
|
||||
(int) $tenant['id'],
|
||||
trim((string) ($_POST['title'] ?? '')),
|
||||
trim((string) ($_POST['period_label'] ?? '')) ?: null,
|
||||
trim((string) ($_POST['period_start'] ?? '')) ?: null,
|
||||
trim((string) ($_POST['period_end'] ?? '')) ?: null,
|
||||
trim((string) ($_POST['notes'] ?? '')) ?: null,
|
||||
(int) $authState['user_id']
|
||||
);
|
||||
$this->session->flash('success', 'Die Papierliste wurde angelegt.');
|
||||
} elseif ($action === 'add_line') {
|
||||
$service->addPaperSheetLine(
|
||||
(int) $tenant['id'],
|
||||
(int) ($_POST['paper_sheet_id'] ?? 0),
|
||||
(int) ($_POST['member_id'] ?? 0),
|
||||
(int) ($_POST['product_id'] ?? 0),
|
||||
(float) ($_POST['quantity'] ?? 1),
|
||||
trim((string) ($_POST['effective_at'] ?? '')) !== '' ? normalize_datetime_input((string) $_POST['effective_at']) : null,
|
||||
trim((string) ($_POST['note'] ?? '')) ?: null,
|
||||
(int) $authState['user_id']
|
||||
);
|
||||
$this->session->flash('success', 'Die Papierlisten-Position wurde gespeichert.');
|
||||
} elseif ($action === 'post_sheet') {
|
||||
$service->postPaperSheet((int) $tenant['id'], (int) ($_POST['paper_sheet_id'] ?? 0), (int) $authState['user_id']);
|
||||
$this->session->flash('success', 'Die Papierliste wurde ins Ledger uebernommen.');
|
||||
}
|
||||
} catch (\Throwable $throwable) {
|
||||
remember_old_input($_POST);
|
||||
$this->session->flash('error', $throwable->getMessage());
|
||||
}
|
||||
|
||||
Response::redirect(tenant_url($tenantSlug, 'paper-sheets'));
|
||||
}
|
||||
|
||||
$members = $this->membersForTenant((int) $tenant['id']);
|
||||
$products = $this->productsForTenant((int) $tenant['id']);
|
||||
$sheetsStatement = $pdo->prepare(
|
||||
'SELECT ps.*, COUNT(psl.id) AS line_count
|
||||
FROM paper_sheets ps
|
||||
LEFT JOIN paper_sheet_lines psl ON psl.paper_sheet_id = ps.id
|
||||
WHERE ps.tenant_id = :tenant_id
|
||||
GROUP BY ps.id
|
||||
ORDER BY ps.created_at DESC'
|
||||
);
|
||||
$sheetsStatement->execute(['tenant_id' => $tenant['id']]);
|
||||
$sheets = $sheetsStatement->fetchAll();
|
||||
|
||||
$linesStatement = $pdo->prepare(
|
||||
'SELECT psl.*, m.display_name AS member_name, p.name AS product_name
|
||||
FROM paper_sheet_lines psl
|
||||
INNER JOIN members m ON m.id = psl.member_id
|
||||
INNER JOIN products p ON p.id = psl.product_id
|
||||
WHERE psl.tenant_id = :tenant_id
|
||||
ORDER BY psl.created_at DESC'
|
||||
);
|
||||
$linesStatement->execute(['tenant_id' => $tenant['id']]);
|
||||
|
||||
$this->view->render('tenant/paper-sheets', [
|
||||
'title' => 'Papierlisten',
|
||||
'tenant' => $tenant,
|
||||
'authState' => $authState,
|
||||
'members' => $members,
|
||||
'products' => $products,
|
||||
'sheets' => $sheets,
|
||||
'sheetLines' => $linesStatement->fetchAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function rfid(string $tenantSlug): void
|
||||
{
|
||||
[$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
|
||||
$pdo = $this->database->pdo();
|
||||
$service = new RfidService($pdo, new AuditService($pdo));
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$this->assertCsrf();
|
||||
|
||||
try {
|
||||
$action = (string) ($_POST['action'] ?? '');
|
||||
|
||||
if ($action === 'create_device') {
|
||||
$token = $service->createDevice(
|
||||
(int) $tenant['id'],
|
||||
trim((string) ($_POST['name'] ?? '')),
|
||||
trim((string) ($_POST['location_label'] ?? '')) ?: null,
|
||||
(int) $authState['user_id']
|
||||
);
|
||||
$this->session->flash('success', 'Das RFID-Geraet wurde vorbereitet. Token: ' . $token);
|
||||
} elseif ($action === 'assign_tag') {
|
||||
$service->assignTag(
|
||||
(int) $tenant['id'],
|
||||
(int) ($_POST['member_id'] ?? 0),
|
||||
trim((string) ($_POST['uid'] ?? '')),
|
||||
trim((string) ($_POST['label'] ?? '')) ?: null,
|
||||
(int) $authState['user_id']
|
||||
);
|
||||
$this->session->flash('success', 'Die RFID-Karte wurde zugewiesen.');
|
||||
}
|
||||
} catch (\Throwable $throwable) {
|
||||
remember_old_input($_POST);
|
||||
$this->session->flash('error', $throwable->getMessage());
|
||||
}
|
||||
|
||||
Response::redirect(tenant_url($tenantSlug, 'rfid'));
|
||||
}
|
||||
|
||||
$members = $this->membersForTenant((int) $tenant['id']);
|
||||
$devices = $pdo->prepare('SELECT * FROM rfid_devices WHERE tenant_id = :tenant_id ORDER BY created_at DESC');
|
||||
$devices->execute(['tenant_id' => $tenant['id']]);
|
||||
$tags = $pdo->prepare(
|
||||
'SELECT t.*, m.display_name AS member_name
|
||||
FROM rfid_tags t
|
||||
LEFT JOIN members m ON m.id = t.member_id
|
||||
WHERE t.tenant_id = :tenant_id
|
||||
ORDER BY t.created_at DESC'
|
||||
);
|
||||
$tags->execute(['tenant_id' => $tenant['id']]);
|
||||
$events = $pdo->prepare(
|
||||
'SELECT e.*, d.name AS device_name
|
||||
FROM rfid_events e
|
||||
LEFT JOIN rfid_devices d ON d.id = e.device_id
|
||||
WHERE e.tenant_id = :tenant_id
|
||||
ORDER BY e.received_at DESC
|
||||
LIMIT 20'
|
||||
);
|
||||
$events->execute(['tenant_id' => $tenant['id']]);
|
||||
|
||||
$this->view->render('tenant/rfid', [
|
||||
'title' => 'RFID-Roadmap',
|
||||
'tenant' => $tenant,
|
||||
'authState' => $authState,
|
||||
'members' => $members,
|
||||
'devices' => $devices->fetchAll(),
|
||||
'tags' => $tags->fetchAll(),
|
||||
'events' => $events->fetchAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function rfidIngest(): void
|
||||
{
|
||||
$payload = json_decode(file_get_contents('php://input') ?: '[]', true);
|
||||
|
||||
if (!is_array($payload)) {
|
||||
http_response_code(422);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'Invalid payload']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = $this->database->pdo();
|
||||
(new RfidService($pdo, new AuditService($pdo)))->ingest($payload);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['status' => 'accepted']);
|
||||
} catch (\Throwable $throwable) {
|
||||
http_response_code(422);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => $throwable->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
private function tenant(string $tenantSlug): array
|
||||
{
|
||||
$pdo = $this->database->pdo();
|
||||
$tenant = (new TenantContext($pdo))->bySlug($tenantSlug);
|
||||
|
||||
if (!$tenant || $tenant['status'] === 'suspended') {
|
||||
throw new RuntimeException('Der Mandant ist nicht verfuegbar.');
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
private function tenantAccess(string $tenantSlug, array $roles = []): array
|
||||
{
|
||||
$tenant = $this->tenant($tenantSlug);
|
||||
$pdo = $this->database->pdo();
|
||||
$auth = new Auth($pdo, $this->session);
|
||||
|
||||
if (!$auth->checkTenantAccess($tenantSlug)) {
|
||||
$this->session->flash('error', 'Bitte zuerst anmelden.');
|
||||
Response::redirect(tenant_url($tenantSlug, 'login'));
|
||||
}
|
||||
|
||||
if ($roles !== [] && !$auth->requireRole($roles)) {
|
||||
$this->session->flash('error', 'Dafuer fehlen die passenden Rechte.');
|
||||
Response::redirect(tenant_url($tenantSlug));
|
||||
}
|
||||
|
||||
return [$tenant, $auth->user()];
|
||||
}
|
||||
|
||||
private function membersForTenant(int $tenantId): array
|
||||
{
|
||||
$statement = $this->database->pdo()->prepare(
|
||||
'SELECT id, display_name FROM members WHERE tenant_id = :tenant_id AND status = "active" ORDER BY display_name ASC'
|
||||
);
|
||||
$statement->execute(['tenant_id' => $tenantId]);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
private function productsForTenant(int $tenantId): array
|
||||
{
|
||||
$statement = $this->database->pdo()->prepare(
|
||||
'SELECT p.id, p.name, pp.price_cents
|
||||
FROM products p
|
||||
LEFT JOIN product_prices pp
|
||||
ON pp.product_id = p.id
|
||||
AND pp.tenant_id = p.tenant_id
|
||||
AND pp.valid_until IS NULL
|
||||
WHERE p.tenant_id = :tenant_id
|
||||
AND p.is_active = 1
|
||||
ORDER BY p.name ASC'
|
||||
);
|
||||
$statement->execute(['tenant_id' => $tenantId]);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
private function assertCsrf(): void
|
||||
{
|
||||
if (!request_origin_matches_host() || !$this->csrf->validate($_POST['_token'] ?? null)) {
|
||||
throw new RuntimeException('Die Anfrage konnte nicht bestaetigt werden.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class Auth
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly Session $session
|
||||
) {
|
||||
}
|
||||
|
||||
public function attemptPlatformLogin(string $email, string $password): bool
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, password_hash, platform_role FROM users WHERE email = :email LIMIT 1'
|
||||
);
|
||||
$statement->execute(['email' => mb_strtolower($email)]);
|
||||
$user = $statement->fetch();
|
||||
|
||||
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (($user['platform_role'] ?? 'user') !== 'platform_admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->session->regenerate();
|
||||
$this->session->put('auth', [
|
||||
'user_id' => (int) $user['id'],
|
||||
'platform_role' => $user['platform_role'],
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function attemptTenantLogin(string $tenantSlug, string $email, string $password): bool
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT u.id,
|
||||
u.password_hash,
|
||||
m.tenant_id,
|
||||
m.role,
|
||||
t.slug AS tenant_slug
|
||||
FROM users u
|
||||
INNER JOIN tenant_memberships m ON m.user_id = u.id AND m.status = "active"
|
||||
INNER JOIN tenants t ON t.id = m.tenant_id AND t.slug = :slug AND t.status = "active"
|
||||
WHERE u.email = :email
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'slug' => $tenantSlug,
|
||||
'email' => mb_strtolower($email),
|
||||
]);
|
||||
$membership = $statement->fetch();
|
||||
|
||||
if (!$membership || !password_verify($password, $membership['password_hash'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->session->regenerate();
|
||||
$this->session->put('auth', [
|
||||
'user_id' => (int) $membership['id'],
|
||||
'tenant_id' => (int) $membership['tenant_id'],
|
||||
'tenant_slug' => $membership['tenant_slug'],
|
||||
'tenant_role' => $membership['role'],
|
||||
'platform_role' => 'user',
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function user(): ?array
|
||||
{
|
||||
$auth = $this->session->get('auth');
|
||||
|
||||
return is_array($auth) ? $auth : null;
|
||||
}
|
||||
|
||||
public function checkPlatformAdmin(): bool
|
||||
{
|
||||
return ($this->user()['platform_role'] ?? null) === 'platform_admin';
|
||||
}
|
||||
|
||||
public function checkTenantAccess(string $tenantSlug): bool
|
||||
{
|
||||
$auth = $this->user();
|
||||
|
||||
return is_array($auth)
|
||||
&& isset($auth['tenant_slug'])
|
||||
&& hash_equals($auth['tenant_slug'], $tenantSlug);
|
||||
}
|
||||
|
||||
public function requireRole(array $allowedRoles): bool
|
||||
{
|
||||
$role = $this->user()['tenant_role'] ?? null;
|
||||
|
||||
return is_string($role) && in_array($role, $allowedRoles, true);
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
$this->session->invalidate();
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class Autoloader
|
||||
{
|
||||
public static function register(string $rootPath): void
|
||||
{
|
||||
spl_autoload_register(static function (string $class) use ($rootPath): void {
|
||||
$prefix = 'App\\';
|
||||
|
||||
if (!str_starts_with($class, $prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relativeClass = substr($class, strlen($prefix));
|
||||
$file = $rootPath . '/app/' . str_replace('\\', '/', $relativeClass) . '.php';
|
||||
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class Csrf
|
||||
{
|
||||
public function __construct(private readonly Session $session)
|
||||
{
|
||||
}
|
||||
|
||||
public function token(): string
|
||||
{
|
||||
$token = $this->session->get('_csrf_token');
|
||||
|
||||
if (!is_string($token) || $token === '') {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$this->session->put('_csrf_token', $token);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function validate(?string $token): bool
|
||||
{
|
||||
$expected = $this->session->get('_csrf_token');
|
||||
|
||||
return is_string($expected) && is_string($token) && hash_equals($expected, $token);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
final class Database
|
||||
{
|
||||
private ?PDO $pdo = null;
|
||||
|
||||
public function __construct(private readonly array $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function pdo(): PDO
|
||||
{
|
||||
if ($this->pdo instanceof PDO) {
|
||||
return $this->pdo;
|
||||
}
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$this->config['host'],
|
||||
$this->config['port'],
|
||||
$this->config['name'],
|
||||
$this->config['charset']
|
||||
);
|
||||
|
||||
try {
|
||||
$this->pdo = new PDO($dsn, $this->config['user'], $this->config['pass'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
} catch (PDOException $exception) {
|
||||
throw new PDOException(
|
||||
'Database connection failed: ' . $exception->getMessage(),
|
||||
(int) $exception->getCode(),
|
||||
$exception
|
||||
);
|
||||
}
|
||||
|
||||
return $this->pdo;
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->config['name'] !== '' && $this->config['user'] !== '';
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class Env
|
||||
{
|
||||
public static function load(string $file): void
|
||||
{
|
||||
if (!is_file($file)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$trimmed = trim($line);
|
||||
|
||||
if ($trimmed === '' || str_starts_with($trimmed, '#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$name, $value] = array_pad(explode('=', $trimmed, 2), 2, '');
|
||||
$name = trim($name);
|
||||
$value = trim($value);
|
||||
|
||||
if ($value !== '' && ($value[0] === '"' || $value[0] === '\'')) {
|
||||
$value = trim($value, "\"'");
|
||||
}
|
||||
|
||||
$_ENV[$name] = $value;
|
||||
$_SERVER[$name] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class Response
|
||||
{
|
||||
public static function redirect(string $url): never
|
||||
{
|
||||
header('Location: ' . $url);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function notFound(string $message = 'Seite nicht gefunden.'): void
|
||||
{
|
||||
http_response_code(404);
|
||||
echo $message;
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class Session
|
||||
{
|
||||
public function start(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_set_cookie_params([
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
|
||||
'path' => '/',
|
||||
]);
|
||||
|
||||
session_start();
|
||||
}
|
||||
}
|
||||
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $_SESSION[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function put(string $key, mixed $value): void
|
||||
{
|
||||
$_SESSION[$key] = $value;
|
||||
}
|
||||
|
||||
public function forget(string $key): void
|
||||
{
|
||||
unset($_SESSION[$key]);
|
||||
}
|
||||
|
||||
public function flash(string $key, mixed $value): void
|
||||
{
|
||||
$_SESSION['_flash'][$key] = $value;
|
||||
}
|
||||
|
||||
public function pullFlash(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$value = $_SESSION['_flash'][$key] ?? $default;
|
||||
unset($_SESSION['_flash'][$key]);
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function regenerate(): void
|
||||
{
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
|
||||
public function invalidate(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class TenantContext
|
||||
{
|
||||
public function __construct(private readonly PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
public function bySlug(string $slug): ?array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT * FROM tenants WHERE slug = :slug LIMIT 1'
|
||||
);
|
||||
$statement->execute(['slug' => $slug]);
|
||||
$tenant = $statement->fetch();
|
||||
|
||||
return $tenant ?: null;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class View
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $rootPath,
|
||||
private readonly array $shared = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function render(string $template, array $data = []): void
|
||||
{
|
||||
$templateFile = $this->rootPath . '/resources/views/' . $template . '.php';
|
||||
|
||||
if (!is_file($templateFile)) {
|
||||
http_response_code(500);
|
||||
echo 'View not found: ' . htmlspecialchars($template, ENT_QUOTES, 'UTF-8');
|
||||
return;
|
||||
}
|
||||
|
||||
$shared = $this->shared;
|
||||
|
||||
extract($shared, EXTR_SKIP);
|
||||
extract($data, EXTR_SKIP);
|
||||
|
||||
require $templateFile;
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class AuditService
|
||||
{
|
||||
public function __construct(private readonly PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
public function log(
|
||||
?int $tenantId,
|
||||
?int $actorUserId,
|
||||
string $action,
|
||||
string $targetType,
|
||||
?int $targetId,
|
||||
string $result,
|
||||
array $meta = []
|
||||
): void {
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO audit_events (
|
||||
tenant_id,
|
||||
actor_user_id,
|
||||
action,
|
||||
target_type,
|
||||
target_id,
|
||||
result,
|
||||
request_id,
|
||||
ip_hash,
|
||||
user_agent_hash,
|
||||
meta_json
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:actor_user_id,
|
||||
:action,
|
||||
:target_type,
|
||||
:target_id,
|
||||
:result,
|
||||
:request_id,
|
||||
:ip_hash,
|
||||
:user_agent_hash,
|
||||
:meta_json
|
||||
)'
|
||||
);
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||
$requestId = $_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(18));
|
||||
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'actor_user_id' => $actorUserId,
|
||||
'action' => $action,
|
||||
'target_type' => $targetType,
|
||||
'target_id' => $targetId,
|
||||
'result' => $result,
|
||||
'request_id' => substr($requestId, 0, 36),
|
||||
'ip_hash' => $ip !== '' ? hash('sha256', $ip) : null,
|
||||
'user_agent_hash' => $userAgent !== '' ? hash('sha256', $userAgent) : null,
|
||||
'meta_json' => $meta !== [] ? json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,478 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class LedgerService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly AuditService $audit
|
||||
) {
|
||||
}
|
||||
|
||||
public function createConsumption(
|
||||
int $tenantId,
|
||||
int $memberId,
|
||||
int $productId,
|
||||
string $sourceCode,
|
||||
string $effectiveAt,
|
||||
int $recordedByUserId,
|
||||
float $quantity,
|
||||
?string $notes = null,
|
||||
?int $paperSheetLineId = null,
|
||||
?string $sourceReference = null
|
||||
): void {
|
||||
if ($quantity <= 0) {
|
||||
throw new RuntimeException('Die Menge muss groesser als 0 sein.');
|
||||
}
|
||||
|
||||
$source = $this->findSource($tenantId, $sourceCode);
|
||||
$priceCents = $this->resolvePriceCents($tenantId, $productId, $effectiveAt);
|
||||
$totalCents = (int) round($priceCents * $quantity);
|
||||
$description = sprintf('Verbrauch %s x #%d', rtrim(rtrim(number_format($quantity, 2, '.', ''), '0'), '.'), $productId);
|
||||
$recordedAt = gmdate('Y-m-d H:i:s');
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$eventInsert = $this->pdo->prepare(
|
||||
'INSERT INTO consumption_events (
|
||||
tenant_id,
|
||||
member_id,
|
||||
product_id,
|
||||
source_id,
|
||||
paper_sheet_line_id,
|
||||
quantity,
|
||||
unit_price_cents,
|
||||
total_cents,
|
||||
effective_at,
|
||||
recorded_at,
|
||||
recorded_by_user_id,
|
||||
source_reference,
|
||||
notes
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:product_id,
|
||||
:source_id,
|
||||
:paper_sheet_line_id,
|
||||
:quantity,
|
||||
:unit_price_cents,
|
||||
:total_cents,
|
||||
:effective_at,
|
||||
:recorded_at,
|
||||
:recorded_by_user_id,
|
||||
:source_reference,
|
||||
:notes
|
||||
)'
|
||||
);
|
||||
$eventInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'product_id' => $productId,
|
||||
'source_id' => $source['id'],
|
||||
'paper_sheet_line_id' => $paperSheetLineId,
|
||||
'quantity' => $quantity,
|
||||
'unit_price_cents' => $priceCents,
|
||||
'total_cents' => $totalCents,
|
||||
'effective_at' => $effectiveAt,
|
||||
'recorded_at' => $recordedAt,
|
||||
'recorded_by_user_id' => $recordedByUserId,
|
||||
'source_reference' => $sourceReference,
|
||||
'notes' => $notes,
|
||||
]);
|
||||
$eventId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$entryInsert = $this->pdo->prepare(
|
||||
'INSERT INTO ledger_entries (
|
||||
tenant_id,
|
||||
member_id,
|
||||
source_id,
|
||||
type,
|
||||
reference_type,
|
||||
reference_id,
|
||||
description,
|
||||
occurred_at,
|
||||
created_by_user_id
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:source_id,
|
||||
"consumption",
|
||||
"consumption_event",
|
||||
:reference_id,
|
||||
:description,
|
||||
:occurred_at,
|
||||
:created_by_user_id
|
||||
)'
|
||||
);
|
||||
$entryInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'source_id' => $source['id'],
|
||||
'reference_id' => $eventId,
|
||||
'description' => $description,
|
||||
'occurred_at' => $effectiveAt,
|
||||
'created_by_user_id' => $recordedByUserId,
|
||||
]);
|
||||
$entryId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$lineInsert = $this->pdo->prepare(
|
||||
'INSERT INTO ledger_lines (
|
||||
ledger_entry_id,
|
||||
tenant_id,
|
||||
member_id,
|
||||
account_code,
|
||||
amount_cents
|
||||
) VALUES (
|
||||
:ledger_entry_id,
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:account_code,
|
||||
:amount_cents
|
||||
)'
|
||||
);
|
||||
|
||||
$lineInsert->execute([
|
||||
'ledger_entry_id' => $entryId,
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'account_code' => 'member_balance',
|
||||
'amount_cents' => $totalCents,
|
||||
]);
|
||||
|
||||
$lineInsert->execute([
|
||||
'ledger_entry_id' => $entryId,
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => null,
|
||||
'account_code' => 'coffee_revenue',
|
||||
'amount_cents' => -$totalCents,
|
||||
]);
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $throwable) {
|
||||
$this->pdo->rollBack();
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
$this->audit->log($tenantId, $recordedByUserId, 'consumption.created', 'consumption_event', $eventId, 'success', [
|
||||
'member_id' => $memberId,
|
||||
'product_id' => $productId,
|
||||
'quantity' => $quantity,
|
||||
'source' => $sourceCode,
|
||||
'total_cents' => $totalCents,
|
||||
]);
|
||||
}
|
||||
|
||||
public function createPayment(
|
||||
int $tenantId,
|
||||
int $memberId,
|
||||
int $amountCents,
|
||||
string $occurredAt,
|
||||
int $recordedByUserId,
|
||||
?string $notes = null
|
||||
): void {
|
||||
if ($amountCents <= 0) {
|
||||
throw new RuntimeException('Der Einzahlungsbetrag muss groesser als 0 sein.');
|
||||
}
|
||||
|
||||
$source = $this->findSource($tenantId, 'admin_backoffice');
|
||||
|
||||
$entryInsert = $this->pdo->prepare(
|
||||
'INSERT INTO ledger_entries (
|
||||
tenant_id,
|
||||
member_id,
|
||||
source_id,
|
||||
type,
|
||||
description,
|
||||
occurred_at,
|
||||
created_by_user_id,
|
||||
reference_type
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:source_id,
|
||||
"payment",
|
||||
:description,
|
||||
:occurred_at,
|
||||
:created_by_user_id,
|
||||
"payment"
|
||||
)'
|
||||
);
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$entryInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'source_id' => $source['id'],
|
||||
'description' => $notes !== null && trim($notes) !== '' ? $notes : 'Einzahlung',
|
||||
'occurred_at' => $occurredAt,
|
||||
'created_by_user_id' => $recordedByUserId,
|
||||
]);
|
||||
|
||||
$entryId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$lineInsert = $this->pdo->prepare(
|
||||
'INSERT INTO ledger_lines (
|
||||
ledger_entry_id,
|
||||
tenant_id,
|
||||
member_id,
|
||||
account_code,
|
||||
amount_cents
|
||||
) VALUES (
|
||||
:ledger_entry_id,
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:account_code,
|
||||
:amount_cents
|
||||
)'
|
||||
);
|
||||
|
||||
$lineInsert->execute([
|
||||
'ledger_entry_id' => $entryId,
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => null,
|
||||
'account_code' => 'cashbox',
|
||||
'amount_cents' => $amountCents,
|
||||
]);
|
||||
|
||||
$lineInsert->execute([
|
||||
'ledger_entry_id' => $entryId,
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'account_code' => 'member_balance',
|
||||
'amount_cents' => -$amountCents,
|
||||
]);
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $throwable) {
|
||||
$this->pdo->rollBack();
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
$this->audit->log($tenantId, $recordedByUserId, 'payment.created', 'ledger_entry', $entryId, 'success', [
|
||||
'member_id' => $memberId,
|
||||
'amount_cents' => $amountCents,
|
||||
]);
|
||||
}
|
||||
|
||||
public function createPaperSheet(
|
||||
int $tenantId,
|
||||
string $title,
|
||||
?string $periodLabel,
|
||||
?string $periodStart,
|
||||
?string $periodEnd,
|
||||
?string $notes,
|
||||
int $createdByUserId
|
||||
): int {
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO paper_sheets (
|
||||
tenant_id,
|
||||
title,
|
||||
period_label,
|
||||
period_start,
|
||||
period_end,
|
||||
notes,
|
||||
created_by_user_id
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:title,
|
||||
:period_label,
|
||||
:period_start,
|
||||
:period_end,
|
||||
:notes,
|
||||
:created_by_user_id
|
||||
)'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'title' => $title,
|
||||
'period_label' => $periodLabel,
|
||||
'period_start' => $periodStart ?: null,
|
||||
'period_end' => $periodEnd ?: null,
|
||||
'notes' => $notes,
|
||||
'created_by_user_id' => $createdByUserId,
|
||||
]);
|
||||
|
||||
$sheetId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$this->audit->log($tenantId, $createdByUserId, 'paper_sheet.created', 'paper_sheet', $sheetId, 'success');
|
||||
|
||||
return $sheetId;
|
||||
}
|
||||
|
||||
public function addPaperSheetLine(
|
||||
int $tenantId,
|
||||
int $sheetId,
|
||||
int $memberId,
|
||||
int $productId,
|
||||
float $quantity,
|
||||
?string $effectiveAt,
|
||||
?string $note,
|
||||
int $actorUserId
|
||||
): void {
|
||||
if ($quantity <= 0) {
|
||||
throw new RuntimeException('Die Menge muss groesser als 0 sein.');
|
||||
}
|
||||
|
||||
$sheet = $this->pdo->prepare(
|
||||
'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1'
|
||||
);
|
||||
$sheet->execute([
|
||||
'id' => $sheetId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
$paperSheet = $sheet->fetch();
|
||||
|
||||
if (!$paperSheet) {
|
||||
throw new RuntimeException('Die Papierliste wurde nicht gefunden.');
|
||||
}
|
||||
|
||||
if ($paperSheet['status'] !== 'draft') {
|
||||
throw new RuntimeException('Nur Entwuerfe koennen erweitert werden.');
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO paper_sheet_lines (
|
||||
paper_sheet_id,
|
||||
tenant_id,
|
||||
member_id,
|
||||
product_id,
|
||||
quantity,
|
||||
effective_at,
|
||||
note
|
||||
) VALUES (
|
||||
:paper_sheet_id,
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:product_id,
|
||||
:quantity,
|
||||
:effective_at,
|
||||
:note
|
||||
)'
|
||||
);
|
||||
$statement->execute([
|
||||
'paper_sheet_id' => $sheetId,
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'product_id' => $productId,
|
||||
'quantity' => $quantity,
|
||||
'effective_at' => $effectiveAt ?: null,
|
||||
'note' => $note,
|
||||
]);
|
||||
|
||||
$lineId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$this->audit->log($tenantId, $actorUserId, 'paper_sheet.line_added', 'paper_sheet_line', $lineId, 'success', [
|
||||
'paper_sheet_id' => $sheetId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function postPaperSheet(int $tenantId, int $sheetId, int $actorUserId): void
|
||||
{
|
||||
$sheetStatement = $this->pdo->prepare(
|
||||
'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1'
|
||||
);
|
||||
$sheetStatement->execute([
|
||||
'id' => $sheetId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
$sheet = $sheetStatement->fetch();
|
||||
|
||||
if (!$sheet) {
|
||||
throw new RuntimeException('Die Papierliste wurde nicht gefunden.');
|
||||
}
|
||||
|
||||
if ($sheet['status'] !== 'draft') {
|
||||
throw new RuntimeException('Die Papierliste wurde bereits verbucht.');
|
||||
}
|
||||
|
||||
$linesStatement = $this->pdo->prepare(
|
||||
'SELECT * FROM paper_sheet_lines WHERE paper_sheet_id = :paper_sheet_id AND tenant_id = :tenant_id ORDER BY id ASC'
|
||||
);
|
||||
$linesStatement->execute([
|
||||
'paper_sheet_id' => $sheetId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
$lines = $linesStatement->fetchAll();
|
||||
|
||||
if ($lines === []) {
|
||||
throw new RuntimeException('Die Papierliste enthaelt noch keine Positionen.');
|
||||
}
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$this->createConsumption(
|
||||
$tenantId,
|
||||
(int) $line['member_id'],
|
||||
(int) $line['product_id'],
|
||||
'paper_sheet',
|
||||
$line['effective_at'] ?: gmdate('Y-m-d H:i:s'),
|
||||
$actorUserId,
|
||||
(float) $line['quantity'],
|
||||
$line['note'] ?: 'Papierliste',
|
||||
(int) $line['id'],
|
||||
'sheet:' . $sheetId
|
||||
);
|
||||
}
|
||||
|
||||
$update = $this->pdo->prepare(
|
||||
'UPDATE paper_sheets SET status = "posted", posted_at = :posted_at WHERE id = :id AND tenant_id = :tenant_id'
|
||||
);
|
||||
$update->execute([
|
||||
'posted_at' => gmdate('Y-m-d H:i:s'),
|
||||
'id' => $sheetId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
|
||||
$this->audit->log($tenantId, $actorUserId, 'paper_sheet.posted', 'paper_sheet', $sheetId, 'success');
|
||||
}
|
||||
|
||||
private function resolvePriceCents(int $tenantId, int $productId, string $effectiveAt): int
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT price_cents
|
||||
FROM product_prices
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND product_id = :product_id
|
||||
AND valid_from <= :effective_at
|
||||
AND (valid_until IS NULL OR valid_until > :effective_at)
|
||||
ORDER BY valid_from DESC
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'product_id' => $productId,
|
||||
'effective_at' => $effectiveAt,
|
||||
]);
|
||||
$price = $statement->fetchColumn();
|
||||
|
||||
if ($price === false) {
|
||||
throw new RuntimeException('Fuer das Produkt ist kein aktiver Preis hinterlegt.');
|
||||
}
|
||||
|
||||
return (int) $price;
|
||||
}
|
||||
|
||||
private function findSource(int $tenantId, string $code): array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, code FROM capture_sources WHERE tenant_id = :tenant_id AND code = :code LIMIT 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'code' => $code,
|
||||
]);
|
||||
$source = $statement->fetch();
|
||||
|
||||
if (!$source) {
|
||||
throw new RuntimeException('Die Erfassungsquelle wurde nicht gefunden.');
|
||||
}
|
||||
|
||||
return $source;
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class RfidService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly AuditService $audit
|
||||
) {
|
||||
}
|
||||
|
||||
public function createDevice(int $tenantId, string $name, ?string $location, int $actorUserId): string
|
||||
{
|
||||
if (trim($name) === '') {
|
||||
throw new RuntimeException('Bitte einen Geraetenamen angeben.');
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(20));
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO rfid_devices (tenant_id, name, location_label, device_token, status)
|
||||
VALUES (:tenant_id, :name, :location_label, :device_token, "planned")'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => trim($name),
|
||||
'location_label' => $location,
|
||||
'device_token' => $token,
|
||||
]);
|
||||
$deviceId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$this->audit->log($tenantId, $actorUserId, 'rfid.device_created', 'rfid_device', $deviceId, 'success');
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function assignTag(int $tenantId, int $memberId, string $uid, ?string $label, int $actorUserId): void
|
||||
{
|
||||
if (trim($uid) === '') {
|
||||
throw new RuntimeException('Die Karten-UID darf nicht leer sein.');
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO rfid_tags (tenant_id, member_id, uid_hash, label, status)
|
||||
VALUES (:tenant_id, :member_id, :uid_hash, :label, "active")'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'uid_hash' => hash('sha256', trim($uid)),
|
||||
'label' => $label,
|
||||
]);
|
||||
|
||||
$this->audit->log($tenantId, $actorUserId, 'rfid.tag_assigned', 'rfid_tag', (int) $this->pdo->lastInsertId(), 'success');
|
||||
}
|
||||
|
||||
public function ingest(array $payload): void
|
||||
{
|
||||
$deviceToken = trim((string) ($payload['device_token'] ?? ''));
|
||||
$uid = trim((string) ($payload['uid'] ?? ''));
|
||||
$externalEventId = trim((string) ($payload['event_id'] ?? ''));
|
||||
|
||||
if ($deviceToken === '' || $uid === '') {
|
||||
throw new RuntimeException('device_token und uid sind Pflichtfelder.');
|
||||
}
|
||||
|
||||
$deviceStatement = $this->pdo->prepare(
|
||||
'SELECT d.id, d.tenant_id, d.status
|
||||
FROM rfid_devices d
|
||||
WHERE d.device_token = :device_token
|
||||
LIMIT 1'
|
||||
);
|
||||
$deviceStatement->execute(['device_token' => $deviceToken]);
|
||||
$device = $deviceStatement->fetch();
|
||||
|
||||
if (!$device) {
|
||||
throw new RuntimeException('Das RFID-Geraet ist unbekannt.');
|
||||
}
|
||||
|
||||
$status = 'received';
|
||||
$tagStatement = $this->pdo->prepare(
|
||||
'SELECT id FROM rfid_tags WHERE tenant_id = :tenant_id AND uid_hash = :uid_hash LIMIT 1'
|
||||
);
|
||||
$tagStatement->execute([
|
||||
'tenant_id' => $device['tenant_id'],
|
||||
'uid_hash' => hash('sha256', $uid),
|
||||
]);
|
||||
|
||||
if ($tagStatement->fetch()) {
|
||||
$status = 'linked';
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO rfid_events (
|
||||
tenant_id,
|
||||
device_id,
|
||||
external_event_id,
|
||||
uid_hash,
|
||||
payload_json,
|
||||
status,
|
||||
received_at
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:device_id,
|
||||
:external_event_id,
|
||||
:uid_hash,
|
||||
:payload_json,
|
||||
:status,
|
||||
:received_at
|
||||
)'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $device['tenant_id'],
|
||||
'device_id' => $device['id'],
|
||||
'external_event_id' => $externalEventId !== '' ? $externalEventId : null,
|
||||
'uid_hash' => hash('sha256', $uid),
|
||||
'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'status' => $status,
|
||||
'received_at' => gmdate('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$this->audit->log((int) $device['tenant_id'], null, 'rfid.event_received', 'rfid_event', (int) $this->pdo->lastInsertId(), 'success', [
|
||||
'status' => $status,
|
||||
'device_id' => (int) $device['id'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class SetupService
|
||||
{
|
||||
public function __construct(private readonly string $rootPath)
|
||||
{
|
||||
}
|
||||
|
||||
public function isInstalled(): bool
|
||||
{
|
||||
return is_file($this->rootPath . '/storage/installed.lock');
|
||||
}
|
||||
|
||||
public function install(array $input): void
|
||||
{
|
||||
$required = [
|
||||
'app_url',
|
||||
'db_host',
|
||||
'db_port',
|
||||
'db_name',
|
||||
'db_user',
|
||||
'admin_name',
|
||||
'admin_email',
|
||||
'admin_password',
|
||||
];
|
||||
|
||||
foreach ($required as $field) {
|
||||
if (trim((string) ($input[$field] ?? '')) === '') {
|
||||
throw new RuntimeException('Bitte alle Pflichtfelder ausfuellen.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!filter_var($input['admin_email'], FILTER_VALIDATE_EMAIL)) {
|
||||
throw new RuntimeException('Die Admin-E-Mail ist ungueltig.');
|
||||
}
|
||||
|
||||
if (mb_strlen($input['admin_password']) < 12) {
|
||||
throw new RuntimeException('Das Admin-Passwort muss mindestens 12 Zeichen lang sein.');
|
||||
}
|
||||
|
||||
$config = [
|
||||
'host' => trim((string) $input['db_host']),
|
||||
'port' => (int) $input['db_port'],
|
||||
'name' => trim((string) $input['db_name']),
|
||||
'user' => trim((string) $input['db_user']),
|
||||
'pass' => (string) ($input['db_pass'] ?? ''),
|
||||
'charset' => 'utf8mb4',
|
||||
];
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$config['host'],
|
||||
$config['port'],
|
||||
$config['name'],
|
||||
$config['charset']
|
||||
);
|
||||
|
||||
$pdo = new PDO($dsn, $config['user'], $config['pass'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
|
||||
$schema = file_get_contents($this->rootPath . '/database/schema.sql');
|
||||
|
||||
if (!is_string($schema) || $schema === '') {
|
||||
throw new RuntimeException('Das Datenbankschema konnte nicht geladen werden.');
|
||||
}
|
||||
|
||||
$pdo->exec($schema);
|
||||
|
||||
$statement = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
|
||||
$statement->execute(['email' => mb_strtolower((string) $input['admin_email'])]);
|
||||
$existing = $statement->fetchColumn();
|
||||
|
||||
if (!$existing) {
|
||||
$insert = $pdo->prepare(
|
||||
'INSERT INTO users (full_name, email, password_hash, platform_role)
|
||||
VALUES (:full_name, :email, :password_hash, "platform_admin")'
|
||||
);
|
||||
$insert->execute([
|
||||
'full_name' => trim((string) $input['admin_name']),
|
||||
'email' => mb_strtolower((string) $input['admin_email']),
|
||||
'password_hash' => secure_password_hash((string) $input['admin_password']),
|
||||
]);
|
||||
}
|
||||
|
||||
$envContent = $this->buildEnvFile($input);
|
||||
|
||||
if (file_put_contents($this->rootPath . '/.env', $envContent) === false) {
|
||||
throw new RuntimeException('Die .env-Datei konnte nicht geschrieben werden.');
|
||||
}
|
||||
|
||||
$lockContent = json_encode([
|
||||
'installed_at' => gmdate(DATE_ATOM),
|
||||
'app_url' => trim((string) $input['app_url']),
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
file_put_contents($this->rootPath . '/storage/installed.lock', (string) $lockContent);
|
||||
}
|
||||
|
||||
private function buildEnvFile(array $input): string
|
||||
{
|
||||
$appKey = bin2hex(random_bytes(32));
|
||||
$rfidSecret = bin2hex(random_bytes(24));
|
||||
|
||||
$lines = [
|
||||
'APP_NAME="Kaffeekasse SaaS"',
|
||||
'APP_ENV=production',
|
||||
'APP_DEBUG=0',
|
||||
'APP_URL=' . trim((string) $input['app_url']),
|
||||
'APP_TIMEZONE=Europe/Berlin',
|
||||
'APP_KEY=' . $appKey,
|
||||
'',
|
||||
'DB_HOST=' . trim((string) $input['db_host']),
|
||||
'DB_PORT=' . (int) $input['db_port'],
|
||||
'DB_NAME=' . trim((string) $input['db_name']),
|
||||
'DB_USER=' . trim((string) $input['db_user']),
|
||||
'DB_PASS=' . (string) ($input['db_pass'] ?? ''),
|
||||
'',
|
||||
'MAIL_FROM=' . trim((string) ($input['mail_from'] ?? 'noreply@example.com')),
|
||||
'RFID_SHARED_SECRET=' . $rfidSecret,
|
||||
];
|
||||
|
||||
return implode(PHP_EOL, $lines) . PHP_EOL;
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class TenantRegistrationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly AuditService $audit
|
||||
) {
|
||||
}
|
||||
|
||||
public function register(array $input): string
|
||||
{
|
||||
$name = trim((string) ($input['tenant_name'] ?? ''));
|
||||
$slug = $this->slugify((string) ($input['tenant_slug'] ?? $name));
|
||||
$ownerName = trim((string) ($input['owner_name'] ?? ''));
|
||||
$ownerEmail = mb_strtolower(trim((string) ($input['owner_email'] ?? '')));
|
||||
$ownerPassword = (string) ($input['owner_password'] ?? '');
|
||||
$plan = (string) ($input['plan'] ?? 'starter');
|
||||
$defaultPriceCents = max(50, (int) round(((float) ($input['default_price_eur'] ?? 1.20)) * 100));
|
||||
|
||||
if ($name === '' || $slug === '' || $ownerName === '' || $ownerEmail === '' || $ownerPassword === '') {
|
||||
throw new RuntimeException('Bitte alle Pflichtfelder ausfuellen.');
|
||||
}
|
||||
|
||||
if (!filter_var($ownerEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new RuntimeException('Die E-Mail-Adresse ist ungueltig.');
|
||||
}
|
||||
|
||||
if (mb_strlen($ownerPassword) < 12) {
|
||||
throw new RuntimeException('Das Passwort muss mindestens 12 Zeichen lang sein.');
|
||||
}
|
||||
|
||||
$allowedPlans = ['starter', 'team', 'business'];
|
||||
|
||||
if (!in_array($plan, $allowedPlans, true)) {
|
||||
$plan = 'starter';
|
||||
}
|
||||
|
||||
$existingTenant = $this->pdo->prepare('SELECT id FROM tenants WHERE slug = :slug LIMIT 1');
|
||||
$existingTenant->execute(['slug' => $slug]);
|
||||
|
||||
if ($existingTenant->fetchColumn()) {
|
||||
throw new RuntimeException('Der gewuenschte Kurzname ist bereits vergeben.');
|
||||
}
|
||||
|
||||
$existingUser = $this->pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
|
||||
$existingUser->execute(['email' => $ownerEmail]);
|
||||
|
||||
if ($existingUser->fetchColumn()) {
|
||||
throw new RuntimeException('Die E-Mail-Adresse ist bereits vorhanden. Fuer weitere Tenants bitte spaeter Einladungen nutzen.');
|
||||
}
|
||||
|
||||
$now = gmdate('Y-m-d H:i:s');
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$userInsert = $this->pdo->prepare(
|
||||
'INSERT INTO users (full_name, email, password_hash, platform_role)
|
||||
VALUES (:full_name, :email, :password_hash, "user")'
|
||||
);
|
||||
$userInsert->execute([
|
||||
'full_name' => $ownerName,
|
||||
'email' => $ownerEmail,
|
||||
'password_hash' => secure_password_hash($ownerPassword),
|
||||
]);
|
||||
$userId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$tenantInsert = $this->pdo->prepare(
|
||||
'INSERT INTO tenants (name, slug, plan, status, created_at)
|
||||
VALUES (:name, :slug, :plan, "active", :created_at)'
|
||||
);
|
||||
$tenantInsert->execute([
|
||||
'name' => $name,
|
||||
'slug' => $slug,
|
||||
'plan' => $plan,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
$tenantId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$membershipInsert = $this->pdo->prepare(
|
||||
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status)
|
||||
VALUES (:tenant_id, :user_id, "owner", "active")'
|
||||
);
|
||||
$membershipInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
$settingsInsert = $this->pdo->prepare(
|
||||
'INSERT INTO tenant_settings (
|
||||
tenant_id,
|
||||
currency_code,
|
||||
default_product_price_cents,
|
||||
allow_self_service,
|
||||
allow_paper_lists,
|
||||
allow_rfid
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
"EUR",
|
||||
:default_product_price_cents,
|
||||
1,
|
||||
1,
|
||||
0
|
||||
)'
|
||||
);
|
||||
$settingsInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'default_product_price_cents' => $defaultPriceCents,
|
||||
]);
|
||||
|
||||
$memberInsert = $this->pdo->prepare(
|
||||
'INSERT INTO members (tenant_id, user_id, display_name, email, status)
|
||||
VALUES (:tenant_id, :user_id, :display_name, :email, "active")'
|
||||
);
|
||||
$memberInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
'display_name' => $ownerName,
|
||||
'email' => $ownerEmail,
|
||||
]);
|
||||
|
||||
$sourceInsert = $this->pdo->prepare(
|
||||
'INSERT INTO capture_sources (tenant_id, code, name, channel, is_system)
|
||||
VALUES (:tenant_id, :code, :name, :channel, 1)'
|
||||
);
|
||||
|
||||
foreach ([
|
||||
['digital_self', 'Digitale Selbstbuchung', 'digital'],
|
||||
['paper_sheet', 'Papierliste / Nacherfassung', 'paper'],
|
||||
['admin_backoffice', 'Backoffice-Buchung', 'admin'],
|
||||
['rfid_reader', 'RFID-Geraet', 'rfid'],
|
||||
] as [$code, $sourceName, $channel]) {
|
||||
$sourceInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'code' => $code,
|
||||
'name' => $sourceName,
|
||||
'channel' => $channel,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ([
|
||||
['Kaffee', $defaultPriceCents],
|
||||
['Espresso', $defaultPriceCents],
|
||||
['Tee', max(80, $defaultPriceCents - 20)],
|
||||
] as [$productName, $priceCents]) {
|
||||
$productInsert = $this->pdo->prepare(
|
||||
'INSERT INTO products (tenant_id, name, sku, is_active)
|
||||
VALUES (:tenant_id, :name, :sku, 1)'
|
||||
);
|
||||
$productInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => $productName,
|
||||
'sku' => strtolower(str_replace(' ', '-', $productName)),
|
||||
]);
|
||||
$productId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$priceInsert = $this->pdo->prepare(
|
||||
'INSERT INTO product_prices (tenant_id, product_id, price_cents, valid_from)
|
||||
VALUES (:tenant_id, :product_id, :price_cents, :valid_from)'
|
||||
);
|
||||
$priceInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'product_id' => $productId,
|
||||
'price_cents' => $priceCents,
|
||||
'valid_from' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $throwable) {
|
||||
$this->pdo->rollBack();
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
$this->audit->log($tenantId, $userId, 'tenant.registered', 'tenant', $tenantId, 'success', [
|
||||
'plan' => $plan,
|
||||
'slug' => $slug,
|
||||
]);
|
||||
|
||||
return $slug;
|
||||
}
|
||||
|
||||
private function slugify(string $value): string
|
||||
{
|
||||
$value = mb_strtolower($value);
|
||||
$value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?? '';
|
||||
$value = trim($value, '-');
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Session;
|
||||
|
||||
function e(?string $value): string
|
||||
{
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function current_path(): string
|
||||
{
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||
|
||||
return is_string($uri) ? $uri : '/';
|
||||
}
|
||||
|
||||
function asset_url(string $path): string
|
||||
{
|
||||
return '/assets/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
function old(string $key, mixed $default = ''): mixed
|
||||
{
|
||||
return $_SESSION['_old'][$key] ?? $default;
|
||||
}
|
||||
|
||||
function remember_old_input(array $input): void
|
||||
{
|
||||
foreach (['password', 'admin_password', 'login_password', 'owner_password', 'db_pass'] as $sensitiveKey) {
|
||||
if (array_key_exists($sensitiveKey, $input)) {
|
||||
unset($input[$sensitiveKey]);
|
||||
}
|
||||
}
|
||||
|
||||
$_SESSION['_old'] = $input;
|
||||
}
|
||||
|
||||
function clear_old_input(): void
|
||||
{
|
||||
unset($_SESSION['_old']);
|
||||
}
|
||||
|
||||
function csrf_field(Csrf $csrf): string
|
||||
{
|
||||
return '<input type="hidden" name="_token" value="' . e($csrf->token()) . '">';
|
||||
}
|
||||
|
||||
function flash(Session $session, string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $session->pullFlash($key, $default);
|
||||
}
|
||||
|
||||
function tenant_url(string $tenantSlug, string $path = ''): string
|
||||
{
|
||||
$suffix = $path === '' ? '' : '/' . ltrim($path, '/');
|
||||
|
||||
return '/t/' . rawurlencode($tenantSlug) . $suffix;
|
||||
}
|
||||
|
||||
function money_from_cents(int $amountCents): string
|
||||
{
|
||||
return number_format($amountCents / 100, 2, ',', '.') . ' EUR';
|
||||
}
|
||||
|
||||
function secure_password_hash(string $password): string
|
||||
{
|
||||
$algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT;
|
||||
|
||||
return password_hash($password, $algo);
|
||||
}
|
||||
|
||||
function normalize_datetime_input(?string $value): string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
if ($value === '') {
|
||||
return gmdate('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$normalized = str_replace('T', ' ', $value);
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $normalized)) {
|
||||
return $normalized . ':00';
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
function request_origin_matches_host(): bool
|
||||
{
|
||||
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||
|
||||
if ($host === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (['HTTP_ORIGIN', 'HTTP_REFERER'] as $header) {
|
||||
if (!empty($_SERVER[$header])) {
|
||||
$originHost = parse_url((string) $_SERVER[$header], PHP_URL_HOST);
|
||||
|
||||
if (is_string($originHost) && $originHost !== '' && !hash_equals($host, $originHost)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+196
-33
@@ -2,46 +2,209 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Core\Autoloader;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Database;
|
||||
use App\Core\Env;
|
||||
use App\Core\Session;
|
||||
use App\Core\View;
|
||||
if (!defined('APP_ROOT')) {
|
||||
define('APP_ROOT', dirname(__DIR__));
|
||||
}
|
||||
|
||||
$rootPath = 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';
|
||||
}
|
||||
|
||||
require_once $rootPath . '/app/Support/helpers.php';
|
||||
require_once $rootPath . '/app/Core/Autoloader.php';
|
||||
function app_env(string $name, ?string $default = null): ?string
|
||||
{
|
||||
$value = getenv($name);
|
||||
if ($value === false || $value === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
Autoloader::register($rootPath);
|
||||
Env::load($rootPath . '/.env');
|
||||
return $value;
|
||||
}
|
||||
|
||||
$config = require $rootPath . '/config/app.php';
|
||||
// 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');
|
||||
}
|
||||
|
||||
date_default_timezone_set($config['timezone']);
|
||||
function app_is_dev(): bool
|
||||
{
|
||||
return app_env('APP_ENV', 'prod') === 'dev';
|
||||
}
|
||||
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
ini_set('session.use_only_cookies', '1');
|
||||
ini_set('session.use_trans_sid', '0');
|
||||
session_name('kaffeekasse_session');
|
||||
function app_is_https(): bool
|
||||
{
|
||||
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (($_SERVER['SERVER_PORT'] ?? null) === '443');
|
||||
}
|
||||
|
||||
$session = new Session();
|
||||
$session->start();
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
$csrf = new Csrf($session);
|
||||
$database = new Database($config['db']);
|
||||
$view = new View($rootPath, [
|
||||
'config' => $config,
|
||||
'session' => $session,
|
||||
'csrf' => $csrf,
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sessionPath = app_env('APP_SESSION_PATH', APP_ROOT . '/var/sessions');
|
||||
if ($sessionPath !== null && !is_dir($sessionPath)) {
|
||||
@mkdir($sessionPath, 0700, true);
|
||||
}
|
||||
|
||||
if ($sessionPath !== null && is_dir($sessionPath) && is_writable($sessionPath)) {
|
||||
session_save_path($sessionPath);
|
||||
}
|
||||
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/',
|
||||
'domain' => '',
|
||||
'secure' => app_is_https(),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
|
||||
return [
|
||||
'rootPath' => $rootPath,
|
||||
'config' => $config,
|
||||
'session' => $session,
|
||||
'csrf' => $csrf,
|
||||
'database' => $database,
|
||||
'view' => $view,
|
||||
];
|
||||
session_name(app_env('APP_SESSION_NAME', 'kaffeeliste_session') ?? 'kaffeeliste_session');
|
||||
session_start();
|
||||
}
|
||||
|
||||
function app_csrf_token(): string
|
||||
{
|
||||
app_start_session();
|
||||
|
||||
if (!isset($_SESSION) || !is_array($_SESSION)) {
|
||||
$_SESSION = [];
|
||||
}
|
||||
|
||||
if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
return $_SESSION['csrf_token'];
|
||||
}
|
||||
|
||||
function app_csrf_field(): string
|
||||
{
|
||||
return '<input type="hidden" name="csrf_token" value="'
|
||||
. htmlspecialchars(app_csrf_token(), ENT_QUOTES, 'UTF-8')
|
||||
. '">';
|
||||
}
|
||||
|
||||
function app_verify_csrf(?string $token): bool
|
||||
{
|
||||
app_start_session();
|
||||
|
||||
if (!isset($_SESSION) || !is_array($_SESSION)) {
|
||||
$_SESSION = [];
|
||||
}
|
||||
|
||||
return is_string($token)
|
||||
&& isset($_SESSION['csrf_token'])
|
||||
&& is_string($_SESSION['csrf_token'])
|
||||
&& hash_equals($_SESSION['csrf_token'], $token);
|
||||
}
|
||||
|
||||
function app_require_csrf(): void
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!app_verify_csrf($_POST['csrf_token'] ?? null)) {
|
||||
http_response_code(419);
|
||||
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,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/prefixed-pdo.php';
|
||||
|
||||
function app_db_pdo(): PDO
|
||||
{
|
||||
static $pdo = null;
|
||||
|
||||
if ($pdo instanceof PDO) {
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
$host = app_env('DB_HOST', '');
|
||||
$database = app_env('DB_NAME', '');
|
||||
$user = app_env('DB_USER', '');
|
||||
$password = app_env('DB_PASS', '');
|
||||
$port = app_env('DB_PORT', '3306');
|
||||
|
||||
if ($host === '' || $database === '' || $user === '') {
|
||||
throw new RuntimeException('Database environment is incomplete.');
|
||||
}
|
||||
|
||||
$dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $database);
|
||||
$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;
|
||||
}
|
||||
+682
@@ -0,0 +1,682 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
function ledger_normalize_email_norms(array $emails): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($emails as $email) {
|
||||
$emailNorm = strtolower(trim((string)$email));
|
||||
if ($emailNorm !== '') {
|
||||
$normalized[$emailNorm] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
function ledger_normalize_ids(array $ids): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($ids as $id) {
|
||||
$id = (int)$id;
|
||||
if ($id > 0) {
|
||||
$normalized[$id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($normalized);
|
||||
}
|
||||
|
||||
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}
|
||||
*/
|
||||
function ledger_empty_totals(): array
|
||||
{
|
||||
return [
|
||||
'total_payments_cents' => 0,
|
||||
'total_costs_cents' => 0,
|
||||
'total_marks' => 0,
|
||||
'balance_cents' => 0,
|
||||
'year_payments_cents' => 0,
|
||||
'year_costs_cents' => 0,
|
||||
'year_marks' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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}
|
||||
*/
|
||||
function ledger_fetch_tenant_totals(PDO $pdo, int $tenantId, ?int $year = null): array
|
||||
{
|
||||
$year = $year ?? ledger_current_year();
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT
|
||||
COALESCE(SUM(CASE WHEN type = 'payment' THEN amount_cents ELSE 0 END), 0) AS total_payments_cents,
|
||||
COALESCE(SUM(CASE WHEN type = 'consumption' THEN -amount_cents ELSE 0 END), 0) AS total_costs_cents,
|
||||
COALESCE(SUM(CASE WHEN type = 'consumption' THEN marks_count ELSE 0 END), 0) AS total_marks,
|
||||
COALESCE(SUM(amount_cents), 0) AS balance_cents,
|
||||
COALESCE(SUM(CASE WHEN type = 'payment' AND YEAR(booked_at) = ? THEN amount_cents ELSE 0 END), 0) AS year_payments_cents,
|
||||
COALESCE(SUM(CASE WHEN type = 'consumption' AND YEAR(booked_at) = ? THEN -amount_cents ELSE 0 END), 0) AS year_costs_cents,
|
||||
COALESCE(SUM(CASE WHEN type = 'consumption' AND YEAR(booked_at) = ? THEN marks_count ELSE 0 END), 0) AS year_marks
|
||||
FROM ledger_entries
|
||||
WHERE tenant_id = ?
|
||||
AND voided_at IS NULL"
|
||||
);
|
||||
$stmt->execute([$year, $year, $year, $tenantId]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if ($row === false) {
|
||||
return ledger_empty_totals();
|
||||
}
|
||||
|
||||
return [
|
||||
'total_payments_cents' => (int)$row['total_payments_cents'],
|
||||
'total_costs_cents' => (int)$row['total_costs_cents'],
|
||||
'total_marks' => (int)$row['total_marks'],
|
||||
'balance_cents' => (int)$row['balance_cents'],
|
||||
'year_payments_cents' => (int)$row['year_payments_cents'],
|
||||
'year_costs_cents' => (int)$row['year_costs_cents'],
|
||||
'year_marks' => (int)$row['year_marks'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options:
|
||||
* - year: int
|
||||
* - active_only: bool|null
|
||||
* - participant_ids: list<int>
|
||||
* - email_norms: list<string>
|
||||
*
|
||||
* @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
|
||||
{
|
||||
$year = isset($options['year']) ? (int)$options['year'] : ledger_current_year();
|
||||
$where = ['p.tenant_id = ?'];
|
||||
$params = [$year, $year, $year, $tenantId];
|
||||
|
||||
if (array_key_exists('active_only', $options) && $options['active_only'] !== null) {
|
||||
$where[] = 'p.active = ?';
|
||||
$params[] = $options['active_only'] ? 1 : 0;
|
||||
}
|
||||
|
||||
if (array_key_exists('participant_ids', $options)) {
|
||||
$participantIds = is_array($options['participant_ids'])
|
||||
? ledger_normalize_ids($options['participant_ids'])
|
||||
: [];
|
||||
if ($participantIds === []) {
|
||||
return [];
|
||||
}
|
||||
$where[] = 'p.id IN (' . implode(',', array_fill(0, count($participantIds), '?')) . ')';
|
||||
array_push($params, ...$participantIds);
|
||||
}
|
||||
|
||||
if (array_key_exists('email_norms', $options)) {
|
||||
$emailNorms = is_array($options['email_norms'])
|
||||
? ledger_normalize_email_norms($options['email_norms'])
|
||||
: [];
|
||||
if ($emailNorms === []) {
|
||||
return [];
|
||||
}
|
||||
$where[] = 'p.email_norm IN (' . implode(',', array_fill(0, count($emailNorms), '?')) . ')';
|
||||
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.display_name,
|
||||
p.email,
|
||||
p.email_norm,
|
||||
p.paypal_name,
|
||||
p.active,
|
||||
COALESCE(SUM(CASE WHEN le.type = 'payment' THEN le.amount_cents ELSE 0 END), 0) AS total_payments_cents,
|
||||
COALESCE(SUM(CASE WHEN le.type = 'consumption' THEN -le.amount_cents ELSE 0 END), 0) AS total_costs_cents,
|
||||
COALESCE(SUM(CASE WHEN le.type = 'consumption' THEN le.marks_count ELSE 0 END), 0) AS total_marks,
|
||||
COALESCE(SUM(le.amount_cents), 0) AS balance_cents,
|
||||
COALESCE(SUM(CASE WHEN le.type = 'payment' AND YEAR(le.booked_at) = ? THEN le.amount_cents ELSE 0 END), 0) AS year_payments_cents,
|
||||
COALESCE(SUM(CASE WHEN le.type = 'consumption' AND YEAR(le.booked_at) = ? THEN -le.amount_cents ELSE 0 END), 0) AS year_costs_cents,
|
||||
COALESCE(SUM(CASE WHEN le.type = 'consumption' AND YEAR(le.booked_at) = ? THEN le.marks_count ELSE 0 END), 0) AS year_marks
|
||||
FROM participants p
|
||||
LEFT JOIN ledger_entries le
|
||||
ON le.tenant_id = p.tenant_id
|
||||
AND le.participant_id = p.id
|
||||
AND le.voided_at IS NULL
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
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);
|
||||
$stmt->execute($params);
|
||||
|
||||
$summaries = [];
|
||||
while ($row = $stmt->fetch()) {
|
||||
$summaries[] = [
|
||||
'participant_id' => (int)$row['participant_id'],
|
||||
'tenant_id' => (int)$row['tenant_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,
|
||||
'total_payments_cents' => (int)$row['total_payments_cents'],
|
||||
'total_costs_cents' => (int)$row['total_costs_cents'],
|
||||
'total_marks' => (int)$row['total_marks'],
|
||||
'balance_cents' => (int)$row['balance_cents'],
|
||||
'year_payments_cents' => (int)$row['year_payments_cents'],
|
||||
'year_costs_cents' => (int)$row['year_costs_cents'],
|
||||
'year_marks' => (int)$row['year_marks'],
|
||||
];
|
||||
}
|
||||
|
||||
return $summaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
{
|
||||
$summaries = ledger_fetch_participant_summaries($pdo, $tenantId, [
|
||||
'participant_ids' => [$participantId],
|
||||
'year' => $year ?? ledger_current_year(),
|
||||
]);
|
||||
|
||||
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
|
||||
* - type: string
|
||||
* - 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, 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;
|
||||
$limit = max(1, min($limit, 500));
|
||||
$where = ['le.tenant_id = ?'];
|
||||
$params = [$tenantId];
|
||||
|
||||
if (empty($options['include_voided'])) {
|
||||
$where[] = 'le.voided_at IS NULL';
|
||||
}
|
||||
|
||||
if (!empty($options['participant_id'])) {
|
||||
$where[] = 'le.participant_id = ?';
|
||||
$params[] = (int)$options['participant_id'];
|
||||
}
|
||||
|
||||
if (!empty($options['type'])) {
|
||||
$where[] = 'le.type = ?';
|
||||
$params[] = trim((string)$options['type']);
|
||||
}
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
le.id,
|
||||
le.tenant_id,
|
||||
le.participant_id,
|
||||
p.display_name,
|
||||
le.type,
|
||||
le.amount_cents,
|
||||
le.marks_count,
|
||||
le.unit_price_cents,
|
||||
le.booked_at,
|
||||
le.source,
|
||||
le.note,
|
||||
le.voided_at
|
||||
FROM ledger_entries le
|
||||
JOIN participants p
|
||||
ON p.tenant_id = le.tenant_id
|
||||
AND p.id = le.participant_id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY le.booked_at DESC, le.id DESC
|
||||
LIMIT {$limit}";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
$entries = [];
|
||||
while ($row = $stmt->fetch()) {
|
||||
$entries[] = [
|
||||
'id' => (int)$row['id'],
|
||||
'tenant_id' => (int)$row['tenant_id'],
|
||||
'participant_id' => (int)$row['participant_id'],
|
||||
'display_name' => (string)$row['display_name'],
|
||||
'type' => (string)$row['type'],
|
||||
'amount_cents' => (int)$row['amount_cents'],
|
||||
'marks_count' => $row['marks_count'] !== null ? (int)$row['marks_count'] : null,
|
||||
'unit_price_cents' => $row['unit_price_cents'] !== null ? (int)$row['unit_price_cents'] : null,
|
||||
'booked_at' => (string)$row['booked_at'],
|
||||
'source' => (string)$row['source'],
|
||||
'note' => $row['note'] !== null ? (string)$row['note'] : null,
|
||||
'voided_at' => $row['voided_at'] !== null ? (string)$row['voided_at'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+1401
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,391 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/legal.php';
|
||||
|
||||
function saas_mail_transport(): string
|
||||
{
|
||||
$default = app_is_dev() ? 'log' : 'mail';
|
||||
|
||||
return strtolower((string)app_env('APP_MAIL_TRANSPORT', $default));
|
||||
}
|
||||
|
||||
function saas_mail_from(): string
|
||||
{
|
||||
return (string)app_env('APP_MAIL_FROM', 'noreply@kaffeeliste.local');
|
||||
}
|
||||
|
||||
function saas_mail_from_name(): string
|
||||
{
|
||||
return (string)app_env('APP_MAIL_FROM_NAME', 'Kaffeeliste');
|
||||
}
|
||||
|
||||
function saas_app_base_url(): string
|
||||
{
|
||||
$configured = app_env('APP_BASE_URL');
|
||||
if ($configured !== null) {
|
||||
return rtrim($configured, '/');
|
||||
}
|
||||
|
||||
if (PHP_SAPI !== 'cli' && !empty($_SERVER['HTTP_HOST'])) {
|
||||
$scheme = app_is_https() ? 'https' : 'http';
|
||||
|
||||
return $scheme . '://' . rtrim((string)$_SERVER['HTTP_HOST'], '/');
|
||||
}
|
||||
|
||||
return 'http://localhost';
|
||||
}
|
||||
|
||||
function saas_app_url(string $path): string
|
||||
{
|
||||
return saas_app_base_url() . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
function saas_mail_headers(): string
|
||||
{
|
||||
$fromName = str_replace(["\r", "\n"], '', saas_mail_from_name());
|
||||
$from = str_replace(["\r", "\n"], '', saas_mail_from());
|
||||
|
||||
return implode("\r\n", [
|
||||
"From: {$fromName} <{$from}>",
|
||||
"Reply-To: {$from}",
|
||||
'Content-Type: text/plain; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
function saas_log_mail(string $to, string $subject, string $body): array
|
||||
{
|
||||
$logDir = (string)app_env('APP_MAIL_LOG_DIR', APP_ROOT . '/var/mail');
|
||||
if (!is_dir($logDir)) {
|
||||
@mkdir($logDir, 0700, true);
|
||||
}
|
||||
|
||||
if (!is_dir($logDir) || !is_writable($logDir)) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'transport' => 'log',
|
||||
'error' => 'mail log directory is not writable',
|
||||
];
|
||||
}
|
||||
|
||||
$file = rtrim($logDir, '/') . '/mail_' . date('Ymd_His') . '_' . bin2hex(random_bytes(6)) . '.txt';
|
||||
$content = "To: {$to}\nSubject: {$subject}\n\n{$body}\n";
|
||||
|
||||
if (file_put_contents($file, $content) === false) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'transport' => 'log',
|
||||
'error' => 'mail log write failed',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'transport' => 'log',
|
||||
'path' => $file,
|
||||
];
|
||||
}
|
||||
|
||||
function saas_send_mail(string $to, string $subject, string $body): array
|
||||
{
|
||||
$to = trim($to);
|
||||
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'transport' => saas_mail_transport(),
|
||||
'error' => 'invalid recipient',
|
||||
];
|
||||
}
|
||||
|
||||
if (saas_mail_transport() === 'log') {
|
||||
return saas_log_mail($to, $subject, $body);
|
||||
}
|
||||
|
||||
$sent = mail($to, $subject, $body, saas_mail_headers());
|
||||
|
||||
return [
|
||||
'ok' => $sent,
|
||||
'transport' => 'mail',
|
||||
'error' => $sent ? null : 'mail transport failed',
|
||||
];
|
||||
}
|
||||
|
||||
function saas_send_password_reset_mail(string $to, string $token): array
|
||||
{
|
||||
$link = saas_app_url('passwort-zuruecksetzen.php?token=' . urlencode($token));
|
||||
$body = implode("\n", [
|
||||
'Hallo,',
|
||||
'',
|
||||
'für dein Kaffeeliste-Konto wurde ein Passwort-Reset angefordert.',
|
||||
'Öffne diesen Link, um ein neues Passwort zu setzen:',
|
||||
'',
|
||||
$link,
|
||||
'',
|
||||
'Der Link ist zeitlich begrenzt und kann nur einmal verwendet werden.',
|
||||
'Falls du den Reset nicht angefordert hast, kannst du diese Nachricht ignorieren.',
|
||||
'',
|
||||
'Deine Kaffeeliste',
|
||||
]);
|
||||
|
||||
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));
|
||||
$body = implode("\n", [
|
||||
'Hallo,',
|
||||
'',
|
||||
'bitte bestätige deine E-Mail-Adresse für Kaffeeliste.',
|
||||
'Öffne dazu diesen Link:',
|
||||
'',
|
||||
$link,
|
||||
'',
|
||||
'Der Link ist zeitlich begrenzt und kann nur einmal verwendet werden.',
|
||||
'',
|
||||
'Deine Kaffeeliste',
|
||||
]);
|
||||
|
||||
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;
|
||||
}
|
||||
Vendored
+101
File diff suppressed because one or more lines are too long
+2436
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
body.public-page {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.public-shell {
|
||||
min-height: 100vh;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.public-hero {
|
||||
min-height: 82vh;
|
||||
background-image: url("../images/landing-hero.png");
|
||||
background-size: cover;
|
||||
background-position: center right;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4rem 6vw;
|
||||
}
|
||||
|
||||
.public-hero:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.public-nav {
|
||||
position: absolute;
|
||||
top: 1.5rem;
|
||||
left: 6vw;
|
||||
right: 6vw;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.public-nav strong {
|
||||
color: #3d4449;
|
||||
font-family: "Roboto Slab", serif;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.public-nav strong a {
|
||||
border-bottom: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.public-nav .actions {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.public-hero-content {
|
||||
position: relative;
|
||||
max-width: 42rem;
|
||||
}
|
||||
|
||||
.public-hero h1 {
|
||||
margin-bottom: 0.35em;
|
||||
}
|
||||
|
||||
.public-hero p {
|
||||
color: #3d4449;
|
||||
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,
|
||||
.public-panel {
|
||||
border: solid 1px rgba(210, 215, 217, 0.75);
|
||||
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 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.public-feature p {
|
||||
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");
|
||||
background-size: cover;
|
||||
background-position: center right;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6rem 6vw 4rem 6vw;
|
||||
}
|
||||
|
||||
.public-auth:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.public-auth-grid {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(18rem, 0.85fr) minmax(22rem, 1.15fr);
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
width: min(72rem, 100%);
|
||||
}
|
||||
|
||||
.public-auth-copy {
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
|
||||
.public-auth-copy p {
|
||||
color: #3d4449;
|
||||
font-size: 1.05em;
|
||||
max-width: 30rem;
|
||||
}
|
||||
|
||||
.public-panel {
|
||||
box-shadow: 0 0.75rem 2.25rem rgba(61, 68, 73, 0.08);
|
||||
}
|
||||
|
||||
.public-panel h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.public-panel form {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.public-panel .actions {
|
||||
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;
|
||||
background-position: center right 30%;
|
||||
}
|
||||
|
||||
.public-auth {
|
||||
padding: 5.5rem 2rem 3rem 2rem;
|
||||
background-position: center right 30%;
|
||||
}
|
||||
|
||||
.public-nav {
|
||||
left: 2rem;
|
||||
right: 2rem;
|
||||
}
|
||||
|
||||
.public-grid,
|
||||
.public-auth-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.public-section {
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/* breakpoints.js v1.0 | @ajlkn | MIT licensed */
|
||||
var breakpoints=function(){"use strict";function e(e){t.init(e)}var t={list:null,media:{},events:[],init:function(e){t.list=e,window.addEventListener("resize",t.poll),window.addEventListener("orientationchange",t.poll),window.addEventListener("load",t.poll),window.addEventListener("fullscreenchange",t.poll)},active:function(e){var n,a,s,i,r,d,c;if(!(e in t.media)){if(">="==e.substr(0,2)?(a="gte",n=e.substr(2)):"<="==e.substr(0,2)?(a="lte",n=e.substr(2)):">"==e.substr(0,1)?(a="gt",n=e.substr(1)):"<"==e.substr(0,1)?(a="lt",n=e.substr(1)):"!"==e.substr(0,1)?(a="not",n=e.substr(1)):(a="eq",n=e),n&&n in t.list)if(i=t.list[n],Array.isArray(i)){if(r=parseInt(i[0]),d=parseInt(i[1]),isNaN(r)){if(isNaN(d))return;c=i[1].substr(String(d).length)}else c=i[0].substr(String(r).length);if(isNaN(r))switch(a){case"gte":s="screen";break;case"lte":s="screen and (max-width: "+d+c+")";break;case"gt":s="screen and (min-width: "+(d+1)+c+")";break;case"lt":s="screen and (max-width: -1px)";break;case"not":s="screen and (min-width: "+(d+1)+c+")";break;default:s="screen and (max-width: "+d+c+")"}else if(isNaN(d))switch(a){case"gte":s="screen and (min-width: "+r+c+")";break;case"lte":s="screen";break;case"gt":s="screen and (max-width: -1px)";break;case"lt":s="screen and (max-width: "+(r-1)+c+")";break;case"not":s="screen and (max-width: "+(r-1)+c+")";break;default:s="screen and (min-width: "+r+c+")"}else switch(a){case"gte":s="screen and (min-width: "+r+c+")";break;case"lte":s="screen and (max-width: "+d+c+")";break;case"gt":s="screen and (min-width: "+(d+1)+c+")";break;case"lt":s="screen and (max-width: "+(r-1)+c+")";break;case"not":s="screen and (max-width: "+(r-1)+c+"), screen and (min-width: "+(d+1)+c+")";break;default:s="screen and (min-width: "+r+c+") and (max-width: "+d+c+")"}}else s="("==i.charAt(0)?"screen and "+i:i;t.media[e]=!!s&&s}return t.media[e]!==!1&&window.matchMedia(t.media[e]).matches},on:function(e,n){t.events.push({query:e,handler:n,state:!1}),t.active(e)&&n()},poll:function(){var e,n;for(e=0;e<t.events.length;e++)n=t.events[e],t.active(n.query)?n.state||(n.state=!0,n.handler()):n.state&&(n.state=!1)}};return e._=t,e.on=function(e,n){t.on(e,n)},e.active=function(e){return t.active(e)},e}();!function(e,t){"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?module.exports=t():e.breakpoints=t()}(this,function(){return breakpoints});
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/* browser.js v1.0.1 | @ajlkn | MIT licensed */
|
||||
var browser=function(){"use strict";var t={name:null,version:null,os:null,osVersion:null,touch:null,mobile:null,_canUse:null,canUse:function(e){t._canUse||(t._canUse=document.createElement("div"));var n=t._canUse.style,r=e.charAt(0).toUpperCase()+e.slice(1);return e in n||"Moz"+r in n||"Webkit"+r in n||"O"+r in n||"ms"+r in n},init:function(){for(var e=navigator.userAgent,n="other",r=0,i=[["firefox",/Firefox\/([0-9\.]+)/],["bb",/BlackBerry.+Version\/([0-9\.]+)/],["bb",/BB[0-9]+.+Version\/([0-9\.]+)/],["opera",/OPR\/([0-9\.]+)/],["opera",/Opera\/([0-9\.]+)/],["edge",/Edge\/([0-9\.]+)/],["safari",/Version\/([0-9\.]+).+Safari/],["chrome",/Chrome\/([0-9\.]+)/],["ie",/MSIE ([0-9]+)/],["ie",/Trident\/.+rv:([0-9]+)/]],o=0;o<i.length;o++)if(e.match(i[o][1])){n=i[o][0],r=parseFloat(RegExp.$1);break}for(t.name=n,t.version=r,n="other",i=[["ios",/([0-9_]+) like Mac OS X/,function(e){return e.replace("_",".").replace("_","")}],["ios",/CPU like Mac OS X/,function(e){return 0}],["wp",/Windows Phone ([0-9\.]+)/,null],["android",/Android ([0-9\.]+)/,null],["mac",/Macintosh.+Mac OS X ([0-9_]+)/,function(e){return e.replace("_",".").replace("_","")}],["windows",/Windows NT ([0-9\.]+)/,null],["bb",/BlackBerry.+Version\/([0-9\.]+)/,null],["bb",/BB[0-9]+.+Version\/([0-9\.]+)/,null],["linux",/Linux/,null],["bsd",/BSD/,null],["unix",/X11/,null]],o=r=0;o<i.length;o++)if(e.match(i[o][1])){n=i[o][0],r=parseFloat(i[o][2]?i[o][2](RegExp.$1):RegExp.$1);break}"mac"==n&&"ontouchstart"in window&&(1024==screen.width&&1366==screen.height||834==screen.width&&1112==screen.height||810==screen.width&&1080==screen.height||768==screen.width&&1024==screen.height)&&(n="ios"),t.os=n,t.osVersion=r,t.touch="wp"==t.os?0<navigator.msMaxTouchPoints:!!("ontouchstart"in window),t.mobile="wp"==t.os||"android"==t.os||"ios"==t.os||"bb"==t.os}};return t.init(),t}();!function(e,n){"function"==typeof define&&define.amd?define([],n):"object"==typeof exports?module.exports=n():e.browser=n()}(this,function(){return browser});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user