Compare commits

...
92 Commits
Author SHA1 Message Date
clemens 70770236eb Schwebenden Cookie-Schalter entfernen 2026-08-27 13:34:45 +02:00
clemens 2179744ddf Cookie-Einstellungen in Footer aufnehmen 2026-08-27 12:51:02 +02:00
clemens da7132d4ce Cookie-Banner klarer gestalten 2026-08-27 12:46:44 +02:00
clemens c86803bbd6 Matomo-Tracking und Aufrufzaehlung absichern 2026-08-27 12:20:56 +02:00
clemens 528af07890 Matomo-Tracking mit Einwilligung einbauen 2026-08-27 09:20:46 +02:00
clemensandClaude Opus 5 a2dcc4df65 Zwei-Faktor-Anmeldung per TOTP
app/totp.php implementiert RFC 6238 selbst statt per Bibliothek: der
Algorithmus ist ein HMAC plus eine Truncation, und ein zweiter Faktor ist
die letzte Stelle fuer ungepruefte Abhaengigkeiten. Der QR-Code entsteht
aus dem ohnehin vorhandenen TCPDF, damit kein externer Dienst das
Geheimnis sieht.

Beim Login wird die Anmeldung bei aktivem zweitem Faktor nicht
abgeschlossen; der Zwischenzustand gewaehrt keinerlei Zugriff und ist
byte-identisch zu einem unangemeldeten Aufruf. users.totp_last_step
verhindert die Wiederverwendung eines abgefangenen Codes innerhalb seines
Gueltigkeitsfensters. Abschalten verlangt Passwort und Code.

APP_REQUIRE_2FA_FOR_ADMINS macht den Faktor fuer Platform-Admins
verbindlich, per Weiterleitung auf die Einrichtung statt als harte Sperre
- sonst koennte der Schalter den einzigen Admin aussperren. Standard aus.

check-konto-und-mandantenwechsel erwartete beim Login noch das entfernte
Kundenkuerzel-Feld und damit einen direkten Sprung aufs Dashboard; der
Check bildet jetzt den tatsaechlichen Weg ueber die Mandantenauswahl ab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:51:45 +02:00
clemensandClaude Opus 5 86334c2752 Offene Registrierung gegen Bot-Massenanlage absichern
Auf der Testumgebung liefen seit Juli 2026 taeglich 10-20 automatisierte
Registrierungen, die zufaellige Namen mit fremden echten E-Mail-Adressen
kombinierten - der Server verschickte Vertrags- und Verifikationsmail an
Unbeteiligte. Das IP-Rate-Limit griff nicht, weil jede Anfrage ueber eine
eigene Rechenzentrums-IP kam.

app/spam-guard.php ergaenzt daher Honigtopf, Zeitfalle, eine globale
Notbremse ueber alle IPs hinweg und eine MX/A-Pruefung der Mail-Domain.
Die ersten drei antworten mit derselben generischen Meldung wie das
Rate-Limit, damit die Antwort nicht verraet, welche Huerde angeschlagen
hat; Treffer landen im Error-Log. Bewusst ohne Captcha, um keinen
Drittanbieter in den Registrierungspfad zu holen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:09:38 +02:00
clemensandClaude Opus 5 3346b51b21 Standangabe auch auf der Bestellseite entfernen
Analog zur Registrierung: AGB und Widerrufsbelehrung nennen die
Dokumentversion nicht mehr im Checkbox-Label. Aufgezeichnet wird sie
weiterhin in abo-upgrade.php ueber app_record_legal_acceptance().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 22:28:16 +02:00
clemensandClaude Opus 5 d1111983b0 Standangabe bei den Rechtstexten der Registrierung entfernen
Die Checkbox-Labels fuer AGB, Datenschutzerklaerung und AVV nannten die
Dokumentversion in Klammern. Die Angabe entfaellt in der Anzeige; die
tatsaechlich akzeptierte Version wird davon unberuehrt weiterhin ueber
app_record_legal_acceptance() gespeichert und in der Vertragsmail
genannt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:56:23 +02:00
clemensandClaude Opus 5 27292470f6 Abstand zwischen Formular und Button-Zeile in den Auth-Panels
ul.actions bringt keinen oberen Abstand mit, deshalb klebte die
Button-Zeile direkt am letzten Eingabefeld - im Login besonders
sichtbar, seit das Kundenkuerzel-Feld entfallen ist. Die Regel ist auf
.public-auth beschraenkt, damit die CTA-Panels der Landingpage ihr
Layout behalten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:09:17 +02:00
clemensandClaude Opus 5 47dff5a1ca Kundenkuerzel aus Login und Passwort-vergessen entfernen
Das Feld war optional und musste vom Kunden auswendig gewusst werden.
Ohne Einschraenkung liefert saas_authenticate() bei mehreren Mandanten
needs_tenant_selection, und mandant-auswahl.php laesst per Klarnamen
waehlen - der kundenfreundlichere Weg. Beim Passwort-Reset haengt das
Passwort ohnehin am Benutzerkonto, nicht am Mandanten.

Die optionalen Parameter von saas_authenticate() und
saas_request_password_reset() bleiben erhalten; die check-*-Skripte
pruefen darueber weiterhin die mandantenspezifische Aufloesung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:45:44 +02:00
clemensandClaude Opus 5 93d6bd9127 Pushover-Benachrichtigung bei neuer Registrierung
Neuer Helfer app/pushover.php meldet Selbstregistrierungen an den
Betreiber. Der Aufruf sitzt in register.php statt in
saas_register_tenant_owner(), damit die check-*-Skripte keine Pushes
ausloesen, und wertet das Ergebnis nicht aus: ein fehlgeschlagener Push
darf die Registrierung nicht abbrechen. Ohne PUSHOVER_TOKEN/PUSHOVER_USER
ist die Funktion still deaktiviert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:39:22 +02:00
clemens 213dd10dbd Produktiv- und Testtabellen per Prefix trennen 2026-08-22 15:29:52 +02:00
clemens a31a235422 Rechtstexte und B2C-Vertragsabläufe absichern 2026-08-22 14:31:56 +02:00
clemens d320a4fd7a Windows-1252-PayPal-Mails verarbeiten 2026-08-22 00:17:17 +02:00
clemens a0d8c62082 Absenderpruefung bei PayPal-Mails entfernen 2026-08-22 00:03:48 +02:00
clemensandClaude Opus 5 43d0fc5578 Absenderpruefung erweitern und Postfach auflisten koennen
Die Pruefung suchte woertlich nach "@paypal.de" oder "@paypal.com". Mails
aus einer Versand-Subdomain (e.paypal.de) oder einer Landesvariante
(paypal.at, paypal.co.uk) fielen damit als not_from_paypal durch. Geprueft
wird jetzt die Domain der Absenderadresse an ihrem Ende - "paypal.de.
beispiel.com" ist damit weiterhin kein PayPal, waere bei einer Textsuche
aber durchgerutscht.

check-imap-support.php listet zusaetzlich die letzten 15 Mails mit
Absender, Empfaengerzeilen und Betreff auf und sagt je Mail, ob der
Absender als PayPal gilt. Damit laesst sich ein not_from_paypal in einem
Lauf klaeren statt zu raten. Rein lesend: OP_READONLY, kein \Seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:35:51 +02:00
clemensandClaude Opus 5 d7de0d92cf PayPal-Zahlungen auch ueber die Mailadresse zuordnen
Bisher verglich die Zuordnung ausschliesslich Namen: der Parser las die
Adresse des Zahlers gar nicht aus, und die Suche kannte nur paypal_name und
display_name. Eine Zahlung von genau der Adresse, die beim Mitglied
hinterlegt ist, blieb deshalb liegen.

Die Adresse wird jetzt ausgelesen (neue Spalte paypal_payments.payer_email),
in der Warteschlange angezeigt und zuerst geprueft - sie ist im Mandanten
eindeutig und aendert sich nicht, wenn jemand bei PayPal anders heisst.

Der heikle Teil ist das Aussortieren: In einer weitergeleiteten Mail stehen
mehrere Adressen. Die falsche zu nehmen wuerde fremdes Geld dem Mitglied
hinter dieser Adresse gutschreiben - typischerweise dem Kassenwart. Deshalb
fallen paypal.*-Adressen und die eigene Eingangsadresse raus (geprueft
gegen die tatsaechliche Empfaengeradresse, Plus-Adressierung ignoriert),
und es zaehlt nur eine Adresse in unmittelbarer Naehe des Zahlernamens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:17:48 +02:00
clemensandClaude Opus 5 25bdb6f34b PayPal-Name aus der manuellen Zuordnung lernen
Die automatische Zuordnung verlangt exakte Uebereinstimmung des
Zahlernamens mit Anzeigename oder PayPal-Name des Mitglieds. Heisst jemand
bei PayPal anders, landet jede Zahlung in der Warteschlange - auch die
zehnte, obwohl der Fall laengst einmal von Hand geklaert wurde.

Nach einer manuellen Zuordnung wird der Zahlername deshalb beim Mitglied
hinterlegt. Zurueckhaltend: ein gepflegter PayPal-Name wird nie
ueberschrieben, ein Name gleich dem Anzeigenamen bringt nichts, und passt
der Name auch auf ein anderes Mitglied, wird nichts gelernt - das wuerde
die Zuordnung mehrdeutig machen und damit blockieren.

Was gelernt wurde, steht in der Erfolgsmeldung und im Audit-Log; eine
stillschweigende Aenderung am Mitglied waere sonst schwer nachvollziehbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:03:12 +02:00
clemensandClaude Opus 5 f4b57abd93 imap-Erweiterung in der lokalen PHP-Umgebung ergaenzt
Ohne Root laesst sich das .deb trotzdem auspacken und einbinden. Damit
laeuft der PayPal-Mailabruf jetzt auch lokal testbar - bisher fiel er hier
sofort mit "Die PHP-IMAP-Erweiterung ist nicht installiert" aus.

Die Schritte stehen in docs/dev-mysql.md, weil .local/ von Git ignoriert
wird und die Umgebung sonst nicht reproduzierbar waere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:21:23 +02:00
clemensandClaude Opus 5 2fd606a3aa Diagnose fuer den PayPal-Postfachzugang
Vor dem ersten Abruf soll klar sein, ob der Host das Postfach ueberhaupt
erreicht - und auf welchem Weg. Das Skript meldet PHP-Version, ob die
imap-Erweiterung geladen ist, ob die PAYPAL_*-Einstellungen gesetzt sind,
und oeffnet das Postfach testweise ueber eine reine TLS-Verbindung.

Damit ist auch ohne die Erweiterung belegbar, dass ein Abruf moeglich
waere - seit PHP 8.4 ist imap kein Bestandteil von PHP mehr und laesst
sich auf einem Webhosting nicht einfach nachruesten.

Bewusst nur lesend: EXAMINE statt SELECT, kein Flag wird gesetzt. Das
Passwort wird nie ausgegeben, nur seine Laenge als Tippfehler-Probe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:13:27 +02:00
clemensandClaude Opus 5 9f9e4c7cd0 PayPal-Mailabruf: Parken statt Buchen, echter Probelauf, Regressionstest
Die Mail-Verarbeitung ignorierte bisher die PayPal-Schalter: eingehende
Zahlungen wurden auch dann automatisch gutgeschrieben, wenn der Betreiber
die Funktion gesperrt oder der Mandant PayPal abgeschaltet hatte. Jetzt
wird die Zahlung in dem Fall geparkt - gespeichert, aber ohne Buchung.
Wegwerfen liesse eine echte Zahlung unbemerkt verschwinden, buchen
widersprache der Abschaltung; die Zuordnungsseite bleibt fuer offene
Zahlungen ja erreichbar.

--dry-run war bisher irrefuehrend: es liess die Verarbeitung samt Buchung
laufen und uebersprang nur das Setzen des Gelesen-Flags - ausgerechnet beim
ersten Testlauf haette es also echtes Geld verbucht. Der Probelauf nutzt
jetzt paypal_preview(), das nichts schreibt und meldet, was passieren
wuerde (would_book/would_queue/would_park/duplicate).

scripts/check-paypal-inbox-flow.php deckt die Kette ohne IMAP ab:
Absenderpruefung, Token, Parser, Zuordnung, Netto-Buchung, Dedup, beide
Park-Faelle und die Schreibfreiheit der Vorschau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:43:58 +02:00
clemensandClaude Opus 5 7df9c1fb4e Marke im PDF um Faktor 1,5 vergroessert
Die erste Fassung war auf dem Ausdruck zu klein. Alle Masse haengen jetzt
an PLAN_WATERMARK_SCALE, damit Schrift und Tasse gemeinsam wachsen und die
Proportionen stimmen; das reservierte Band am Seitenfuss waechst mit
(PLAN_WATERMARK_BAND_MM), damit die Tabelle weiterhin Abstand haelt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 08:01:04 +02:00
clemensandClaude Opus 5 9b309bb942 Hinweis auf die Kaffeeliste auch im PDF-Ausdruck
Die Marke stand bisher nur in der Web-Ansicht; auf dem Ausdruck, den die
Mitglieder taeglich vor sich haben, fehlte sie. Sie erscheint jetzt unten
rechts im Seitenfuss - nach derselben Regel wie im Web: im kostenlosen
Tarif fest, sonst nach Mandant-Einstellung.

Die Tasse wird mit TCPDFs Zeichenbefehlen gemalt statt als SVG oder Bild
eingebettet: ImageSVG braucht die XML-Extension, ein Rasterbild GD - beides
ist auf einem Webspace nicht garantiert.

Der Schriftzug sitzt in einer Cell() statt in Text(): dessen y-Bezug haengt
an Schriftmetriken und ergab in der Probe einen um Millimeter versetzten
Schriftzug. Steht die Marke, reserviert export_page_row_budget_mm() 6 mm am
Seitenfuss, damit die Tabelle nicht hineinlaeuft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:46:52 +02:00
clemens 7a52099a59 Anpassung des impressums 2026-08-21 00:31:34 +02:00
clemensandClaude Opus 5 b244451c42 Schalter fuer den Hinweis auf die Kaffeeliste
Der Hinweis unten rechts laesst sich ab einem bezahlten Tarif in den
Mandant-Einstellungen abschalten (neue Spalte tenant_settings.
watermark_enabled, Standard an). Im kostenlosen Tarif bleibt er fest
eingeschaltet: das Kaestchen ist angehakt und disabled, daneben steht der
Grund. Zusaetzlich erzwingt saas_update_tenant_settings() den Wert dort noch
einmal, damit ein nachgebauter POST ihn nicht abschalten kann.

Der Titel des Wasserzeichens spricht nicht mehr vom kostenlosen Tarif -
zahlende Mandanten koennen es jetzt freiwillig zeigen.

Der Smoke-Test prueft die Einstellungsseite jetzt auch angemeldet. Sein
Muster fuer PHP-Meldungen verlangt dafuer einen Doppelpunkt, sonst haette
das Feld "negative_warning" als Warnung gezaehlt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:27:44 +02:00
clemensandClaude Opus 5 1d53b66e3f Wasserzeichen fuer den kostenlosen Tarif
Mandanten im Tarif "free" (bis 10 Teilnehmer) zeigen unten rechts eine
kleine Marke mit Logo und Adresse der Kaffeeliste; zahlende Mandanten
behalten ihre Oberflaeche ohne fremdes Logo.

Die Sichtbarkeit haengt allein an tenant_billing.plan_code und wird als
reine Leseabfrage geprueft - billing_fetch_or_init() waere ein Schreibzugriff
im Seitenaufbau. Laesst sich der Tarif nicht lesen, bleibt die Marke aus.
Das Logo ist ein Inline-SVG, das Linkziel kommt aus APP_MARKETING_URL bzw.
aus APP_HOST ohne App-Subdomain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:14:17 +02:00
clemensandClaude Opus 5 6cd7fcc079 Offene PayPal-Zahlungen bleiben nach dem Abschalten erreichbar
Wird PayPal in den Mandant-Einstellungen abgeschaltet, waehrend noch
Zahlungen unzugeordnet in der Warteschlange liegen, kaeme ohne diese
Ausnahme niemand mehr an sie heran - weder ueber das Menue noch ueber die
URL.

paypal_inbox_accessible() haelt Menuepunkt und Seite deshalb offen, solange
paypal_count_unmatched() etwas findet; die Seite weist per Hinweis darauf
hin, dass sie nach dem Abarbeiten verschwindet. Eine Sperre durch den
Betreiber sticht die Ausnahme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:42:52 +02:00
clemensandClaude Opus 5 3f6866e075 Menue folgt auch den Schaltern des Mandanten
Bisher blendete das Menue nur aus, was der Betreiber gesperrt hatte. Hat
der Mandant selbst eine Funktion abgeschaltet - etwa "PayPal anbieten" in
den Mandant-Einstellungen - blieb der Menuepunkt stehen und fuehrte auf
eine Seite ohne Zweck.

app_feature_available() prueft nun beide Ebenen, app_feature_tenant_switch()
haelt die Zuordnung Funktion -> Mandanten-Schalter. Navigation und Anleitung
nutzen die neue Pruefung; paypal-zuordnung.php sperrt sich beim direkten
Aufruf ebenfalls und verweist dabei auf die Mandant-Einstellungen statt auf
den Betreiber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:16:30 +02:00
clemensandClaude Opus 5 d24ee9bf91 Betreiber-Zentralstelle, Mandanten-Design und schlankerer Einstieg
Funktions-Schalter je Mandant (app/features.php, tenant_features): der
Betreiber schaltet FAQ, Selbsteintrag, PDF, PayPal-Eingang, CSV-Import,
Mailversand, Jahresabschluss, Datenexport und eigenes Design pro Mandant
frei. Gesperrte Funktionen verschwinden aus Menue und Schaltflaechen, ihre
Seiten weisen Aufrufe und POSTs ab. Das Back-Office ist jetzt fuer
Platform-Admins im Menue verlinkt statt nur per URL erreichbar.

Eigenes Design je Mandant: Akzentfarbe und Logo in den Mandant-
Einstellungen, eingebettet ueber app/branding.php; das Logo liegt
geschuetzt in var/tenant_logos und wird nur ueber
tenant-logo-anzeigen.php an den eigenen Mandanten ausgeliefert.

Weniger Startinformationen: Startpaket neuer Mandanten auf zwei
Beispielfragen gekuerzt, Anleitung von ~1400 auf ~750 Woerter gestrafft
und um Abschnitte zu gesperrten Funktionen bereinigt.

Vorder-/Rueckseite erst bei mehr als 50 Personen (vorher schon ab 50).
Die Schwelle liegt jetzt gemeinsam in app/ledger.php und gilt auch fuer
die Vorder-/Rueckseiten-Auswahl beim Erfassen, wo sie bisher unabhaengig
von der Teamgroesse angeboten wurde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:52:55 +02:00
clemens a93e067e04 Verbessere Mitgliederfilter und Zahlungszuordnung 2026-08-13 15:40:43 +02:00
clemens 0f263c3a19 Haerte Verwaltungsflows und Zahlungsabgleich 2026-08-07 12:02:41 +02:00
clemensandClaude Opus 5 39f662d541 Kontofunktionen vor dem Go-Live und Landingpage-Kuerzung
Vier Luecken geschlossen, die im Alltag sofort aufgefallen waeren:

- Passwort aendern war im eingeloggten Zustand gar nicht moeglich; es gab
  nur den Reset per Mail-Link. saas_change_password() prueft das aktuelle
  Passwort, verlangt ein tatsaechlich anderes und erneuert danach die
  Session-ID. Formular in konto.php.
- mandant-auswahl.php war nur direkt nach dem Login erreichbar. Wer bei
  mehreren Mandanten Mitglied ist, musste sich zum Wechseln abmelden. Die
  Seite bedient jetzt beide Wege, die Mandantenpruefung bleibt unveraendert
  ueber saas_identity_for_user_tenant(). Menuepunkt ab zwei Mitgliedschaften.
- email_verified_at wurde nirgends geprueft, nur angezeigt - bei offener
  Selbstregistrierung konnte sich jemand mit fremder Adresse anmelden und
  alles nutzen. Erzwungen wird jetzt gezielt dort, wo eine Aktion nach
  aussen wirkt: Einladung, Info-Mail, Jahresabschluss. Der Login selbst
  bleibt bewusst frei, sonst waeren alle migrierten Bestandsnutzer mit
  NULL-Verifikation ausgesperrt. Zusaetzlich setzt der Passwort-Reset die
  Verifikation mit, weil der Mail-Link den Postfachzugriff nachweist -
  sonst blieben eingeladene Mitglieder dauerhaft unbestaetigt.
- Login landete auf konto.php statt auf dem Dashboard.

Landingpage: die gruene Vertrauenszeile auf "DSGVO-konform" gekuerzt, die
Eintraege zu Paragraf 19 UStG und "Bestehende Ablaeufe bleiben" entfernt.

Geprueft: neues scripts/check-konto-und-mandantenwechsel.php mit 18
Assertions gruen, Passwortformular zusaetzlich manuell inkl. CSRF (419).
Bestehende Suiten unveraendert gruen: HTTP-Smoke 34 Seiten, Rollenmatrix
55, Mandanten-Isolation 12, M3-Auth 9, M3-Settings 15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:03:50 +02:00
clemensandClaude Opus 5 52b1726698 Deployment-Vorbereitung: Ausschlussliste, .htaccess-Haertung, Doku
Vor dem ersten FTP-Deploy nach /testumgebung.kaffeeliste.de/httpdocs/
fehlte jede Absicherung des Upload-Umfangs: sync_config.jsonc hatte eine
leere excludePath-Liste, es waeren also .git (komplett herunterladbar),
.env.local (Dev-DB-Zugangsdaten) und sync_config.jsonc selbst (enthaelt
das FTP-Passwort im Klartext) mit ausgeliefert worden. Von diesen dreien
war keines von den bestehenden .htaccess-Regeln erfasst.

- sync_config.jsonc: excludePath gefuellt; scripts/ und database/ bleiben
  bewusst im Deploy, weil die Plesk-Scheduled-Tasks sie vom Webspace aus
  ausfuehren.
- .htaccess: sync_config.jsonc und Dotfile-Ordner gesperrt, Vendor-
  Verzeichnisse (TCPDF/PHPMailer/DataTables) fuer direkte URL-Aufrufe
  gesperrt, HTTPS-Redirect ergaenzt (ohne ihn bekam ein http-Besucher
  eine Session ohne secure-Flag).
- PHPMailer/ entfernt: enthielt nur noch LICENSE, composer.json und ein
  per URL erreichbares get_oauth_token.php, kein Quellcode. Der Versand
  laeuft seit M6 ueber saas_send_mail().
- docs/deployment.md: Umgebungen, Deploy-Ablauf, Migrationen und
  Cron-Jobs ueber Plesk Scheduled Tasks (kein SSH verfuegbar),
  Mail-/DNS-, Stripe- und PayPal-Voraussetzungen, Go-Live-Checkliste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:45:01 +02:00
clemens 3f564c361f sync config Ausnahme 2026-08-06 15:35:10 +02:00
clemensandClaude Opus 4.8 fb4dcf96fd M9-Plan: SAML/ADFS als Alternative dokumentiert
Ergaenzt den Abschnitt, was eine direkte SAML-Anbindung ans ADFS zusaetzlich
kosten wuerde - als Entscheidungsgrundlage neben dem beschlossenen OIDC-Weg,
nicht als beschlossener Weg.

Kernpunkte: SAML braucht ext-dom, ext-simplexml und ext-mbstring sowie
Composer; in der aktuellen Dev-Umgebung fehlt jede XML-Faehigkeit (nicht
einmal DOMDocument existiert), SAML liesse sich dort weder bauen noch testen.
Dazu eigenes SP-Zertifikat samt Erneuerung, eine Tabelle gegen
Wiedereinspielung, eine umfangreiche Pruefliste fuer eingehende Assertions und
der automatische Zertifikatswechsel von ADFS als haeufigste Stoerungsursache.

Empfehlung bleibt OIDC ueber Entra; SAML waere Stufe 3 nach Magic-Link und
OIDC, mit der Erweiterungspruefung auf dem Zielhost als erster Aufgabe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 10:44:25 +02:00
clemensandClaude Opus 4.8 c6a03c1a38 Tarif-Tabelle: hervorgehobene Zeile war keine Tabellenzeile mehr
Die aktuell gebuchte Tarifzeile trug class="hint-box success" auf dem <tr>.
.hint-box macht aus dem Element einen Flex-Container - auf einer Tabellenzeile
loest das die Spaltenaufteilung auf. Mit dem Grundstil aus main.css lagen die
Zellen noch nebeneinander und das fiel kaum auf; durch das in d18c55e
ergaenzte flex-direction: column standen sie dann untereinander.

Behoben an der Ursache statt am CSS: Die Zeile bekommt eine eigene Klasse
.tarif-aktuell und bleibt eine normale Tabellenzeile, die Hervorhebung laeuft
ueber Hintergrund und einen Akzentstreifen an der ersten Zelle. Zusaetzlich
ist die flex-direction-Regel jetzt auf div.hint-box eingegrenzt, damit die
Klasse auf einem Tabellenelement nicht erneut die Spalten zerstoeren kann.

Geprueft: http-smoke 34/0, role-matrix 55/0, settings-flow 15/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:48:17 +02:00
clemensandClaude Opus 4.8 143d7efc97 Planung M9: Anmeldung per Magic-Link und SSO ueber Entra ID
Noch keine Umsetzung - nur Datenmodell, Ablaeufe und Reihenfolge zum
Abschaetzen. Zwei unabhaengig lieferbare Stufen: erst Magic-Link (klein und
stellt danach den Notzugang bereit), dann OIDC gegen Entra ID.

Festgehaltene Entscheidungen: OIDC statt SAML, ADFS indirekt ueber Entra,
kein automatisches Anlegen von Konten beim SSO-Login, SSO-Pflicht pro Mandant
mit Magic-Link als Notzugang fuer owner/admin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 16:41:34 +02:00
clemensandClaude Opus 4.8 d18c55eef5 Formular-Abstaende korrigiert und Anleitungsseite ergaenzt
Abstaende:
Im Template hat das Label 1em Abstand nach unten, das Eingabefeld aber gar
keinen, und die Formular-Grids nutzen keinen vertikalen Gutter (gtr-uniform).
Dadurch war der Abstand vom Feld zum naechsten Label 0, waehrend Label zu
eigenem Feld 1em betrug - das Label wirkte, als gehoere es zum Feld darueber.
Jetzt sitzt das Label eng an seinem eigenen Feld (0,5em) und die Felder haben
1,5em Abstand nach unten.

Ausserdem stylt main.css die Typen number, date, datetime-local und file gar
nicht; diese Felder erschienen als kleine native Kaestchen zwischen den sonst
einheitlichen Feldern (z. B. "Listenfenster in Tagen" und "Zeilenhoehe im
Ausdruck"). Sie bekommen jetzt dasselbe Aussehen wie die Textfelder.

Die Anpassungen liegen in einer eigenen assets/css/app.css, damit main.css als
Template-Datei unveraendert bleibt. Nebenbei: .hint-box.error war nirgends
definiert, obwohl es die haeufigste Variante ist - Fehlermeldungen erschienen
ungefaerbt wie neutrale Hinweise.

In faq.php, hinweise.php, namenanpassen.php und stricheintragen.php standen
<br>/<br><br> als Abstandsersatz hinter Labels und Feldern; mit den neuen
Abstaenden waere daraus doppelter Leerraum geworden. Entfernt, das Layout
kommt jetzt aus dem CSS.

Anleitung:
Neue Seite anleitung.php mit den wichtigsten Funktionen, gegliedert nach
Zielgruppe. Die Abschnitte fuer Kassenwart bzw. Administration werden nur
eingeblendet, wenn die Rolle sie auch nutzen kann - sonst stuenden dort
Hinweise auf Menuepunkte, die es fuer den Nutzer nicht gibt. Verlinkt im Menue
unter "Kaffeeliste" und im Smoke-Test abgedeckt.

Geprueft: alle 12 Formularseiten rendern fehlerfrei, http-smoke 34/0,
role-matrix 55/0, tenant-isolation 12/0, settings-flow 15/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:51:14 +02:00
clemensandClaude Opus 4.8 064c872c30 Zeitzonen-Angleich PHP/MySQL + veralteten Settings-Test aktualisiert
Zwei vorbestehende, bislang rote Pruefungen behoben.

1) PHP lief in UTC, MySQL in der System-Zeitzone (hier CEST, +2h). Ueberall,
   wo ein in PHP berechneter Zeitstempel gegen MySQL NOW() verglichen wird,
   liefen die Uhren dadurch gegeneinander:
   - Hinweise mit kurzer Restlaufzeit galten sofort als abgelaufen
     (notices: valid_from = NOW() aus MySQL, valid_until aus PHP date()).
   - Auth-Token (Passwort-Reset, E-Mail-Verifikation) liefen bis zu 2h zu
     frueh ab (expires_at aus PHP date(), Pruefung gegen NOW()).
   bootstrap.php pinnt die PHP-Zeitzone jetzt deterministisch aus
   APP_TIMEZONE (Standard Europe/Berlin), app_db_pdo() setzt die DB-Session
   per numerischem Offset auf dieselbe Zeit. Damit stimmen beide Uhren
   ueberein. Behebt check-m8-tenant-isolation (11/1 -> 12/0).

2) check-m3-settings-flow stammte aus M3 und lieferte nicht die spaeter
   hinzugekommenen Pflichtfelder (pdf_row_height_px,
   payment_reminder_interval_days). Dadurch schlug bereits das Update fehl und
   alle Folge-Assertions kippten. Der Test sendet jetzt den vollstaendigen
   Feldsatz wie das Einstellungsformular und prueft die beiden Felder mit.
   Die Update-Funktion selbst war korrekt (7/6 -> 15/0).

Voller Regressionslauf gruen: http-smoke 33/0, role-matrix 55/0,
tenant-isolation 12/0, m3-auth 9/0, password-email 14/0, settings 15/0,
tenant-resolution 12/0, billing 10/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 08:40:55 +02:00
clemensandClaude Opus 4.8 4acd4a2d40 Legacy-Abbau Schritt 3+4: kl_*-Tabellen und legacy_*-Spalten entfernt
Abschluss des Legacy-Abbaus. Die Migration in participants/ledger_entries war
laut Dev-DB vollstaendig (keine legacy_mitarbeiter_id, kein legacy_table), die
kl_*-Tabellen enthielten nur noch Golden-Master-Testfixtures. Da kein echter
Altbestand mehr importiert werden muss (Prod entsteht aus der jetzigen Dev-DB),
kommt die Altwelt komplett raus.

Laufzeit-Code (Schritt 3a):
- ledger.php: Option legacy_mitarbeiter_ids, die Spalten aus allen SELECTs und
  Rueckgaben, ledger_fetch_participant_summary_by_legacy_id sowie das Loeschen
  gespiegelter Legacy-Zeilen in ledger_void_entry/ledger_void_own_self_entry
  entfernt
- imports.php, paypal-inbox.php: legacy_mitarbeiter_id aus Ergebnis und
  Typannotationen entfernt
- ledger-preview.php: Spalte "Legacy" entfernt

Skripte (Schritt 3b/3c):
- die acht reinen Legacy-/Golden-Master-Skripte entfernt (backfill-*,
  seed-golden-master, golden-master-data, check-golden-master, check-m4-*,
  check-m3-saas-basis)
- init-mysql-dev.php legt jetzt einen SaaS-Mandanten mit Owner-Login,
  Teilnehmer, Journalbuchungen und Hinweis an statt kl_*-Zeilen

Schema (Schritt 4, Migration 0024):
- participants.legacy_mitarbeiter_id + uq_participants_tenant_legacy
- ledger_entries.legacy_table/legacy_id + uq_ledger_entries_tenant_legacy
- DROP der Tabellen kl_Einzahlungen, kl_Kaffeeverbrauch, kl_hinweise,
  kl_config, kl_Mitarbeiter

Verifikation unveraendert gruen: http-smoke 33/0, role-matrix 55/0,
tenant-isolation 11/1 (Altfehler), m3-auth/tenant-resolution/billing gruen.
check-m3-settings-flow 7/6 ist vorbestehend (schon bei 3e24910 rot, mit
spaeteren Settings-Feldern nicht synchron) und unabhaengig von diesem Abbau.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 08:31:08 +02:00
clemensandClaude Opus 4.8 3e24910831 Legacy-Abbau Schritt 2: Legacy-Authentifizierung entfernt
Die Zugriffspruefung lief bisher in 19 Dateien auf einen Fallback gegen die
Legacy-Tabelle kl_Mitarbeiter (checkKaffeelisteAdmin/-Access). Der Zugang
haengt jetzt ausschliesslich an der SaaS-Anmeldung.

- Fallback-Bloecke in allen 19 Dateien entfernt, ebenso getUserName/getUserId
- functions.php besteht nur noch aus der SaaS-Anmeldung; die per String
  zusammengebaute Abfrage "WHERE Email like '<eingabe>'" ist damit weg
- config.php baut keine sqlsrv-Verbindung mehr auf, der Kompatibilitaetslayer
  lib/sqlsrv_mysql_compat.php ist verwaist und entfaellt
- Auto-Login per DEV_AUTH_EMAIL gibt es nicht mehr: er meldete jeden Besucher
  an und machte damit den Login-Schutz unpruefbar. Angemeldet wird auch lokal
  ueber login.php; die Variable dient nur noch scripts/init-mysql-dev.php

http-smoke war an die Golden-Master-Daten des Default-Mandanten gebunden und
hatte deshalb sechs dauerhaft rote Pruefungen. Der Test legt sich jetzt einen
eigenen Mandanten mit bekannten Salden an, meldet sich per HTTP an und raeumt
danach auf. Geschuetzte Seiten werden nicht mehr ueber das Wort "Login" im
Text geprueft, sondern ueber die tatsaechliche 302-Umleitung.

  http-smoke        27 PASS / 6 FAIL  ->  33 PASS / 0 FAIL
  role-matrix       55 PASS / 0 FAIL  ->  unveraendert
  tenant-isolation   9 PASS / 1 FAIL  ->  11 PASS / 1 FAIL (Altfehler)

Ausserdem "keine Zugang" -> "keinen Zugang" in zwei Fehlermeldungen; der
Rollentest erkannte die Ablehnung an genau diesem Tippfehler und wurde
mitgezogen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:10:09 +02:00
clemensandClaude Opus 4.8 eec7a2fef2 Legacy-Abbau Schritt 1: Dual-Write in die kl_*-Tabellen entfernt
Die Migration in participants/ledger_entries ist abgeschlossen: kein
Teilnehmer traegt noch eine legacy_mitarbeiter_id, kein Journaleintrag eine
legacy_table. Damit war der jeweils zweite Zweig ("Default-Mandant schreibt
zusaetzlich nach kl_Einzahlungen/kl_Kaffeeverbrauch/kl_Mitarbeiter") in
sechs Dateien nicht mehr erreichbar - er musste aber bei jeder Aenderung
mitgepflegt werden, zuletzt bei der Bemerkung fuer Einzahlungen.

Entfernt:
- Dual-Write beim Buchen: einzahlung.php, stricheintragen.php, index.php,
  jahresauswertung.php, app/imports.php, app/paypal-inbox.php
- Dual-Write in der Mitgliederverwaltung: ledger_create_participant,
  ledger_update_participant, ledger_set_participant_active,
  ledger_anonymize_participant
- die verwaisten Spiegelfunktionen ledger_mirror_legacy_payment,
  ledger_mirror_legacy_consumption und ledger_void_entry_by_legacy_id

Nebenbei behoben: kaffeeliste.php hat die Teilnehmer-Detailseite nur fuer
Mitglieder mit legacy_mitarbeiter_id verlinkt - die hat seit der Migration
niemand mehr, die Seite war also fuer alle unerreichbar. Verlinkt und
adressiert wird jetzt ueber participant_id; "user_id" bleibt als Alias
erhalten. Der Mandanten-Isolationstest prueft jetzt ledger_void_entry, also
den Pfad, den letzteneintraege.php tatsaechlich nutzt.

Pruefskripte unveraendert gegenueber der Baseline vor dem Umbau
(http-smoke 27/6, role-matrix 55/0); die Isolationspruefung steigt von
9 auf 11 PASS bei gleichem vorbestehendem Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:57:49 +02:00
clemensandClaude Opus 4.8 dd6e1e77b0 Bemerkung und Abzuege bei Einzahlungen
Einzahlungen koennen jetzt eine Bemerkung tragen und negativ sein, damit
Abzuege (Auszahlung bei Austritt, Erstattung) nachvollziehbar gebucht werden
koennen.

- ledger_entries.note wurde bisher nur beim Import befuellt und wird nun auch
  bei Einzahlungen geschrieben und angezeigt
- Negative Betraege verlangen zwingend eine Bemerkung, sonst ist spaeter nicht
  mehr nachvollziehbar, warum jemandem Geld abgezogen wurde
- Plausibilitaetsgrenze von 1000 EUR gegen Groessenordnungs-Tippfehler; bei
  einem Fehler in einer Zeile wird gar nichts gebucht und die Eingaben bleiben
  stehen
- Legacy-Tabelle kl_Einzahlungen bekommt eine Bemerkung-Spalte, sonst haette
  ledger_mirror_legacy_payment die Notiz beim Re-Sync wieder mit NULL
  ueberschrieben
- PayPal-Buchungen tragen jetzt Zahler, Datum und Mitteilung als Bemerkung

Ausserdem behoben: letzteneintraege.php hat Einzahlungen und Striche direkt
aus den Legacy-Tabellen gelesen, ohne nach Mandant zu filtern. Jeder
Mandanten-Admin sah damit die Buchungen aus den Legacy-Tabellen, und das
Stornieren lief ueber die nicht mandantengetrennten Legacy-IDs. Die Seite
arbeitet jetzt auf dem mandantengebundenen Journal, und ledger_void_entry
prueft Mandant und Buchungsart, bevor es storniert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:53:10 +02:00
clemensandClaude Opus 4.8 ea7d9a4714 Automatische Verbuchung von PayPal-Zahlungen per Mail-Weiterleitung
Mandanten leiten ihre PayPal-Benachrichtigungsmails an ein zentrales
IMAP-Postfach weiter. Die Zuordnung zum Mandanten erfolgt ueber
Plus-Adressierung (zahlungen+<token>@...), der Token wird pro Mandant
erzeugt und im Backend angezeigt.

- Parser fuer PayPal-Eingangsmails, tolerant gegenueber falsch kodierten
  Waehrungssymbolen und HTML-Struktur weitergeleiteter Mails
- Gutgeschrieben wird der Nettobetrag, also was tatsaechlich ankam
  (bei Waren & Dienstleistungen nach Abzug der PayPal-Gebuehr)
- Deduplizierung ueber den Transaktionscode, damit doppelt weitergeleitete
  Mails nicht doppelt buchen
- Eindeutiger Namens-Match bucht automatisch, alles andere landet in einer
  Warteschlange zur manuellen Zuordnung
- Absenderpruefung gegen paypal.de/.com, da weitergeleitete Mails keine
  gueltige SPF/DKIM-Signatur mehr haben
- IMAP-Zugang ausschliesslich ueber Server-/Env-Einstellungen, nicht pro
  Mandant konfigurierbar

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:33:49 +02:00
clemens c16b105f00 Erweiterte Bezahloptionen: Barzahlung, PayPal und Ueberweisung
Bisher gab es nur PayPal. Jetzt sind pro Mandant drei unabhaengig
aktivierbare Zahlungswege konfigurierbar (mandant-einstellungen.php):

- Barzahlung mit frei waehlbarem Ansprechpartner.
- PayPal wie bisher, mit Zahlungslink.
- Ueberweisung mit Kontoinhaber und IBAN (Format wird geprueft,
  Leerzeichen werden normalisiert).

Das Mitglieder-Dashboard (index.php) zeigt unter "Bezahlen" alle
aktivierten Methoden mit dem jeweils offenen Betrag; die IBAN wird zur
besseren Lesbarkeit in Viererbloecken angezeigt.

Neue tenant_settings-Spalten via Migration 0021 (cash_enabled,
cash_contact, bank_transfer_enabled, bank_account_holder, bank_iban).
2026-07-21 16:13:28 +02:00
clemens bafa5e7e61 Stripe-Konfigurationsfehler in abo-upgrade.php sauber abfangen
billing_stripe_price_id() und der komplette abo-upgrade.php-Ablauf warfen
bisher eine ungefangene RuntimeException (Fatal Error), sobald
STRIPE_SECRET_KEY nicht gesetzt ist (z. B. lokale Dev-Umgebung ohne echte
Stripe-Zugangsdaten) - bestand bereits im alten Code, ist durch die neue
Tarif-Vergleichstabelle mit mehr Buchen-Buttons aber leichter auslösbar
geworden. Jetzt: sauberer 502 'Bezahldienst nicht verfuegbar' statt
Fatal-Error-Seite.

Erstmals lokal per HTTP end-to-end gegen echten Dev-Server + MariaDB
getestet (PHP 8.3 + MariaDB 11.4 als portable Binaries installiert, siehe
docs/dev-mysql.md): eigener Test-Mandant mit 12 aktiven Teilnehmern,
Tarif-Tabelle korrekt gerendert (5 Tarife, Pflicht-Upgrade-Hinweis, Buchen-
Buttons, Enterprise-Anfrage-Link), Buchung von 'free' bei Ueberschreitung
korrekt mit 400 abgelehnt, Buchung eines bezahlten Tarifs ohne Stripe-Key
jetzt sauber mit 502 statt Fatal-Error-Crash. Alle bestehenden
Regressionstests weiterhin gruen: Golden Master (104), Billing-Capacity
(10), M8-Isolation (10), M8-Rollenmatrix (55).
2026-07-20 20:38:12 +00:00
clemens dd2277c803 Selbstbedienungs-Tarifverwaltung: Admin kann Abo im Backend waehlen und bestellen
- mandant-einstellungen.php: vollstaendige Tarif-Vergleichstabelle statt nur
  erzwungenem Upgrade-Hinweis. Jeder Tarif zeigt Limit, Preis und einen
  Aktions-Button (Buchen/Kuendigen/Anfragen); aktueller Tarif markiert,
  zu kleine Tarife fuer die aktuelle Teilnehmerzahl deaktiviert.
- abo-upgrade.php: unterscheidet jetzt Upgrade, Downgrade zwischen bezahlten
  Stufen (Preis wird in-place gewechselt statt neuer Checkout-Session,
  Stripe prorated automatisch) und Wechsel auf 'free' (echte Kuendigung der
  bestehenden Subscription), statt nur eine Checkout-Session zu erzeugen.
- app/stripe.php: neue Helper stripe_get_subscription(),
  stripe_update_subscription_price(), stripe_cancel_subscription().
- app/billing.php: billing_plan_selectable_for() prueft serverseitig, ob
  ein Zieltarif die aktuelle Teilnehmerzahl noch abdeckt (Downgrade-Schutz).
- docs/billing.md: Phase 4 dokumentiert.

Getestet: php -l fuer alle geaenderten Dateien und das gesamte Repo (keine
Syntaxfehler), reine Funktionslogik-Tests ohne DB (billing_plans(),
billing_required_plan_code(), stripe_flatten_params()) gruen. Kein Live-Test
gegen echte Stripe-Testobjekte moeglich (keine PHP/DB-Laufzeitumgebung
verfuegbar) - vor Go-Live im Stripe-Testmodus nachholen.
2026-07-20 20:19:05 +00:00
clemens ef5d0e1822 Sicherheits-/Korrektheits-Fixes und neue Funktionen
Bugfixes aus dem Code-Review:
- XSS: unescaptes $_SERVER['PHP_SELF'] in csvupload.php und
  letzteneintraege.php durch feste Seitennamen ersetzt.
- Stripe-Webhook: Event-Deduplizierung (neue Tabelle stripe_webhook_events)
  gegen doppelte Verarbeitung/Dolibarr-Rechnungen bei Retry-Zustellung.
- Stripe-Webhook: Tarifwechsel aus dem Kundenportal wird lokal nachgezogen
  (plan_code aus dem Preis-lookup_key bei subscription.updated).
- Post-Redirect-Get fuer jahresauswertung, mailversenden und die
  Selbst-Stricheintragung - verhindert Doppelbuchung/Doppelversand per
  Browser-Refresh.
- Jahresbonus: Mails erst nach erfolgreichem Commit; Restcent-Ausgleich
  beim letzten Empfaenger, damit die Summe exakt stimmt.
- Verschachtelte HTML-Dokumente in csvupload/einzahlung/stricheintragen
  entfernt (Layout kommt aus header.php).
- Rate-Limit fuer den Versand von E-Mail-Verifizierungslinks.

Neue Funktionen:
- Mitglieder koennen ihren zuletzt selbst eingetragenen Strich wieder
  stornieren (nur eigene Web-Eintraege, Kassenwart-Eintraege bleiben).
- Monatsuebersicht des eigenen Verbrauchs im Mitglieder-Dashboard.
- Automatische Zahlungserinnerung: opt-in pro Mandant ab der
  Warnschwelle, mit Intervall; Cron-Skript scripts/send-payment-reminders.php.
- CSV-Import fuer Mitgliederlisten inkl. herunterladbarer Vorlage
  (mitglieder-vorlage.php), Semikolon-/Komma- und BOM-Erkennung.
- Logo als PDF-Wasserzeichen pro Mandant (Upload in den Mandant-
  Einstellungen, geschuetzt unter var/tenant_logos/, ersetzt den
  Text-Wasserzeichen im Ausdruck).

Robusterer Teilnehmer-Lookup im Dashboard ueber user_id (Fallback E-Mail).
2026-07-20 21:54:16 +02:00
clemens c4cb12b26e Landing-Marketing / SEO-Infrastruktur / a11y-Viewport) 2026-07-20 19:46:08 +02:00
clemens 9871401fbc Überarbeitung landing und index 2026-07-20 19:36:22 +02:00
clemens c9ab82e97a PDF Erstellung anpassung 2026-07-20 00:50:29 +02:00
clemens e64370aea0 Anassung des Menü 2026-07-20 00:05:00 +02:00
clemens 6127d84d11 PDF Fusszeile und watermak mandantenfähig gemacht. 2026-07-19 23:39:52 +02:00
clemens 0551b13a86 pdf erstellung aufhrbung der 50 Personen Grenze 2026-07-19 23:24:36 +02:00
clemens f0869d8e33 Anpassung optik 2026-07-19 22:51:51 +02:00
clemens 5e6262b6a8 Kaffeeliste-Ausdruck: adaptives Layout nach Mitgliederzahl
Der PDF-Export war bisher immer zweiseitig (Vieltrinker-/Wenigtrinker)
mit fester Mindestzeilenzahl, unabhängig von der tatsächlichen
Mitgliederzahl. Umgestellt auf:

- Bis 49 aktive Mitglieder: eine Seite ohne Trennung.
- Ab 50 Mitgliedern: weiterhin zwei Seiten (Vorder-/Rückseite).
- Zeilenhöhe richtet sich nach der Mitgliederzahl (16-40px), statt fest
  16px für alle.
- Neue Mandanten-Einstellungen: freie Zeilen für neue Mitglieder an/aus,
  Trennmodus bei zwei Seiten (Trinkverhalten oder alphabetisch).

Migration 0015 ergänzt die dafür nötigen tenant_settings-Spalten.
Gegen die Dev-DB verifiziert: 8 Mitglieder -> einseitiges PDF,
53 Mitglieder (temporäre Testdaten) -> zweiseitiges PDF, Einstellungen
inkl. Validierung geprüft.
2026-07-19 22:42:12 +02:00
clemens 7630a35bf4 Anpassung der Infos auf der Startseite 2026-07-19 22:28:53 +02:00
clemens a02fd3e4a4 Favicon (AOK-Logo) ersetzen und Hamburger-Menü auf Mobile reparieren
Favicon zeigte noch das echte AOK-Logo; durch ein neutrales
Kaffeetassen-Icon in der Marken-Grünfarbe der Seite ersetzt.

Das Sidebar-Toggle-Icon nutzte ein Font-Awesome-Glyph, dessen CSS nie
eingebunden war und dessen Webfont-Dateien im Repo fehlen - das Icon
konnte dadurch nie gerendert werden. Durch ein reines CSS-Hamburger-
Symbol (drei Balken via box-shadow) ersetzt, das ohne Font-Abhängigkeit
auskommt. Per Puppeteer im mobilen Viewport (375x812) verifiziert:
Sidebar öffnet sich beim Tippen auf das Icon und Linkklicks navigieren
korrekt.
2026-07-19 10:37:14 +02:00
clemens a74a7f5ee8 Anpassung htaccess 2026-07-17 12:53:09 +02:00
clemens e40955fffe Zwei Mehrmandanten-Bugs vor Go-Live behoben
footer.php: Einzahlung/Striche/Mitglieder/Hinweise/Jahresabschluss
waren nur fuer den alten Default-Mandanten-Admin verlinkt, nicht fuer
SaaS-Mandanten-Owner/Admin/Kassenwart - die Seiten selbst erlaubten
Zugriff laengst, waren aber ueber die Navigation nicht erreichbar.

namenanpassen.php: nie auf das neue Mandantenmodell portiert, nutzte
ausschliesslich die globale kl_Mitarbeiter-Tabelle direkt per SQL und
haette fuer jeden Mandanten ausser dem Standard-Mandanten leer
funktioniert. Jetzt ueber participants/saas-Rollen: normale Mitglieder
koennen nur sich selbst umbenennen, Owner/Admin jedes Mitglied des
eigenen Mandanten. Live getestet inkl. Tenant-Isolation (Mitglied kann
nicht den Owner umbenennen).
2026-07-17 11:48:45 +02:00
clemens 0416504d6a .htaccess ergaenzen: interne Verzeichnisse/Configdateien nicht oeffentlich
Sperrt Direktzugriff auf var/ (Session-/Mail-Dateien), database/,
scripts/, docs/, app/, lib/ sowie env.local*.php, .sql und .md-Dateien.
Lokal beim PHP-Dev-Server ohne Wirkung (kein Apache), greift aber auf
echtem Apache-Webhosting.
2026-07-17 11:41:24 +02:00
clemens b6199623c5 Produktions-Env-Loading fuer echtes Webhosting ergaenzen
PHP-FPM/mod_php auf Shared Hosting erbt keine shell-exportierten
Umgebungsvariablen wie der lokale Dev-Server. app/bootstrap.php laedt
jetzt optional env.local.php (nicht eingecheckt, siehe
env.local.example.php als Vorlage) und setzt die Variablen per
putenv(), bevor irgendein app_env()/getenv()-Aufruf passiert. Greift
jetzt auch fuer CLI-Skripte (migrate.php, grant-platform-admin.php) ueber
scripts/dev-db.php.
2026-07-17 11:40:08 +02:00
clemens cb799c7c66 Deployment-Vorbereitung: Domain-Split und Legacy-/Debug-Seiten entfernt
- index.php zeigt bei abweichendem Host (APP_HOST-Env) die Landingpage
  statt des Dashboards, damit kaffeeliste.de und app.kaffeeliste.de aus
  demselben Webspace bedient werden koennen. Ohne gesetztes APP_HOST
  (lokale Entwicklung) unveraendertes Verhalten.
- landing.php verlinkt Login/Registrierung ueber APP_HOST fest auf die
  App-Domain, damit Besucher der Marketingdomain dort landen.
- functionsLDAP.php entfernt (nur ueber die tote IIS/AUTH_USER-Branch
  erreichbar, vollstaendig durch app/saas-auth.php ersetzt); zugehoerige
  tote AD/LDAP-Variablen aus config.php und der AUTH_USER-Zweig aus
  functions.php entfernt.
- mailausgebe.php, umfrage.php, umfrageergebnisse.php entfernt: aus der
  Navigation nicht erreichbare Debug-/Einzweck-Seiten ohne echte
  Berechtigungspruefung (mailausgebe.php dumpte alle Mitglieder-E-Mails,
  umfrageergebnisse.php hatte nur einen auskommentierten Basic-Auth-
  Block). scripts/http-smoke.php entsprechend bereinigt.
2026-07-17 11:37:41 +02:00
clemens d2330c81cd Harte Teilnehmer-Obergrenze je Tarif statt automatischem Stufenwechsel
Statt automatisch hochzustufen, blockiert das Anlegen/Reaktivieren
weiterer aktiver Mitglieder, sobald das Limit des gebuchten Tarifs
erreicht ist (Neuanlage, Bearbeiten mit Statuswechsel, Aktivieren-
Button). mitarbeiterverwalten.php zeigt die aktuelle Auslastung an.
Neuer Regressionstest scripts/check-billing-capacity.php deckt alle
drei Enforcement-Stellen ab.
2026-07-17 11:19:00 +02:00
clemens 73d599f85b Billing Phase 3: Dolibarr-Rechnungssynchronisation
Bei erfolgreicher Stripe-Zahlung (invoice.paid) wird automatisch ein
Dolibarr-Kunde ermittelt/angelegt und eine validierte Rechnung als
Buchhaltungsspiegel erzeugt. Dolibarr-Fehler blockieren die
Stripe-Webhook-Verarbeitung nicht, sondern landen im Audit-Log zur
manuellen Nachbearbeitung. Getestet gegen die produktive
Dolibarr-Instanz des Kunden (kein Sandbox verfuegbar) mit einem
rechtebeschraenkten API-Key und einem nicht validierten Test-Datensatz.
2026-07-17 10:35:01 +02:00
clemens 80da042cae Phase 3 Plan aktualisiert: kein Dolibarr-Sandbox verfuegbar
Kunde hat nur eine produktive Dolibarr-Instanz. Testansatz angepasst:
rechtebeschraenkter API-Key, Test ueber Entwurfs-Rechnungen gegen einen
dedizierten Test-Kunden statt Sandbox-Vorabtest.
2026-07-17 00:14:01 +02:00
clemensandClaude Sonnet 5 d642814fb5 Billing Phase 2: Stripe Checkout, Kundenportal und Webhook-Verarbeitung
app/stripe.php: minimaler REST-Client fuer die Stripe-API auf Basis von
PHP-Streams statt eines vendorten SDKs - diese PHP-Installation hat keine
curl-Extension, Streams sind zudem portabler und brauchen kein
composer.json.

- Fuer jeden bezahlten Tarif per API ein Stripe-Produkt mit monatlichem
  Preis angelegt, referenziert ueber einen stabilen lookup_key statt
  hartcodierter Price-ID; Erstellung ist idempotent.
- abo-upgrade.php: erstellt eine Stripe-Checkout-Session fuer den
  gewaehlten Tarif, tenant_id/plan_code als Metadaten auf Session UND
  Subscription (damit spaetere Subscription-Events zuordenbar bleiben).
- abo-portal.php: oeffnet das Stripe Customer Portal fuer bestehende
  Kunden (Zahlungsmittel/Kuendigung, ohne eigene UI dafuer).
- stripe-webhook.php: verifiziert die Stripe-Signature per HMAC-SHA256
  mit Zeitstempel-Toleranz, verarbeitet checkout.session.completed,
  customer.subscription.updated/.deleted, invoice.paid/.payment_failed
  und haelt tenant_billing aktuell. Bewusst kein CSRF-/Login-Check
  (Stripe ruft unauthentifiziert auf), stattdessen ausschliesslich
  Signaturpruefung als Echtheitsnachweis.
- mandant-einstellungen.php: echter "Jetzt upgraden"-Button (Stripe
  Checkout) sowie "Zahlungsmethode verwalten/Abo kuendigen" (Customer
  Portal), sobald ein Stripe-Kunde existiert.

Live getestet (Stripe-Testmodus, kein echtes Geld): voller Checkout-Flow
bis zum echten Redirect auf checkout.stripe.com, Webhook-Verarbeitung
durch selbst erzeugte, korrekt signierte Test-Events (da diese
Dev-Umgebung keine oeffentlich erreichbare URL fuer echte Stripe-
Zustellung hat) inklusive Ablehnung falscher Signaturen, Customer Portal
mit echtem per API angelegtem Test-Kunden. Alle Regressionstests
weiterhin gruen (36 Seiten HTTP-Smoke, Golden Master, M8-Isolation/
Rollenmatrix).

Noch offen: echten Webhook-Endpunkt auf die Produktions-Domain eintragen,
sobald diese feststeht; Wechsel auf Live-Keys; Dolibarr-Sync (Phase 3).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 23:56:48 +02:00
clemensandClaude Sonnet 5 ade1fd0ed9 Billing Phase 1: Tarifstatus-Datenmodell und Tariflogik je Mandant
Vorbereitung fuer Stripe Billing (Zahlungseinzug) + bestehendes Dolibarr
des Kunden (Rechnungsstellung/Buchhaltung) statt eines eigenen Rechnungs-
systems oder eines zusaetzlichen ERP nur fuer Kaffeeliste - Begruendung
in docs/billing.md.

- Neue Tabelle tenant_billing (plan_code, subscription_status, sowie
  bereits vorbereitete, noch ungenutzte Felder fuer Stripe/Dolibarr-IDs).
- app/billing.php: billing_plans() als einzige Quelle der Tarifstufen
  (synchron mit preise.php), billing_required_plan_code() anhand aktiver
  Teilnehmerzahl, billing_check_tenant() vergleicht gebuchten mit
  benoetigtem Tarif - rein informativ, kein Enforcement, solange Stripe
  nicht angebunden ist.
- Neue Mandanten starten automatisch auf plan_code='free' bei der
  Registrierung.
- mandant-einstellungen.php zeigt Owner/Admin ihren aktuellen Tarif und
  Teilnehmerstand, mit Hinweis bei Bedarf einer hoeheren Stufe.
- Back-Office zeigt zusaetzlich zur Mandantenliste den gebuchten und
  (falls abweichend) den tatsaechlich benoetigten Tarif.

Live getestet: Tarifgrenzen (10/25/50/150) mit einem Mandanten unter und
einem ueber dem Freikontingent geprueft, UI in Mandant-Einstellungen und
Back-Office verifiziert. Alle M8-Isolations-/Rollenmatrix-Tests weiterhin
gruen.

Phase 2 (Stripe) und Phase 3 (Dolibarr-Sync) folgen, sobald Test-Keys
beziehungsweise Sandbox-Zugang vorliegen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 23:38:28 +02:00
clemensandClaude Sonnet 5 d08621b616 Datenschutzerklaerung und Preisliste ergaenzen, Kleinunternehmer-Status korrigieren
datenschutz.php: Entwurf, der die tatsaechliche technische Verarbeitung
beschreibt (Konto- vs. Teilnehmerdaten, Verantwortlicher/Auftrags-
verarbeiter-Trennung, Rechtsgrundlagen, Aufbewahrungspflichten nach
HGB/AO als Begruendung fuer Anonymisieren-statt-Loeschen, Betroffenen-
rechte mit Verweis auf den Selbstbedienungs-Export). Wie AGB deutlich
als pruefungsbeduerftiger Entwurf gekennzeichnet.

preise.php: neue oeffentliche Preisliste mit den vorgeschlagenen Stufen
(gratis bis 10, 3,99 EUR bis 25, 7,99 EUR bis 50, 12,99 EUR bis 150,
auf Anfrage darueber).

Kleinunternehmer-Korrektur (kein Umsatzsteuerpflichtiger mehr):
- impressum.php: USt-IdNr-Zeile entfernt, Hinweis nach Paragraf 19 UStG ergaenzt.
- agb.php Paragraf 4: keine festen Preise mehr im Text, stattdessen Verweis
  auf preise.php; "zzgl. USt" durch Kleinunternehmer-Hinweis ersetzt.

Footer-Links auf allen oeffentlichen und internen Seiten um Datenschutz
und Preise ergaenzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 23:12:25 +02:00
clemensandClaude Sonnet 5 2e44bb208d Impressum und AGB-Entwurf ergaenzen, von allen Seiten erreichbar
impressum.php: Angaben aus https://ctb-it.de/impressum/ uebernommen
(Clemens Creutzburg, Einzelunternehmer, USt-IdNr DE347068189), Anschrift
auf die aktuelle Adresse (In den Sieben Stuecken 9d, 30655 Hannover)
aktualisiert.

agb.php: Entwurf fuer ein B2B-SaaS-Vertragsverhaeltnis (Leistungs-
beschreibung, gestaffelte Preise nach Teilnehmerzahl, Verfuegbarkeit,
AVV-Verweis, Kuendigung, Haftungsbegrenzung), deutlich als Entwurf
gekennzeichnet mit Empfehlung zur anwaltlichen Pruefung vor
Produktivbetrieb - keine rechtssichere Fertigstellung durch mich.

Footer-Links auf Impressum/AGB ergaenzt: alle Public-Seiten (Landing,
Login, Registrierung, Passwort-Reset, E-Mail-Verifizierung) sowie der
App-interne Footer fuer eingeloggte Seiten, damit die Impressumspflicht
(leichte Erreichbarkeit von jeder Seite) erfuellt ist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 22:23:28 +02:00
clemensandClaude Sonnet 5 cbe6547f3a Back-Office: globaler Platform-Admin-Zugang ueber alle Mandanten
Neue, bewusst von tenant_memberships/saas_user_has_role() komplett
getrennte Platform-Admin-Ebene (neue Tabelle platform_admins), damit die
bestehende, automatisiert getestete Mandanten-Isolation
(check-m8-tenant-isolation.php, check-m8-role-matrix.php) unangetastet
bleibt - Platform-Admin-Rechte wirken ausschliesslich auf den neuen
backoffice-*.php-Seiten.

- backoffice.php: Uebersicht aller Mandanten (Status, Teilnehmerzahl,
  Saldensumme).
- backoffice-mandant.php: reine Leseansicht eines Mandanten (Einstellungen,
  Mitglieder/Rollen, letzte Buchungen, letzte Admin-Aktionen). Bewusst
  kein Schreibzugriff von hier aus.
- backoffice-export.php: nutzt dieselbe app_export_tenant_data() wie der
  Selbstbedienungs-Export, ausgeloest durch den Platform-Admin fuer
  beliebige Mandanten.
- scripts/grant-platform-admin.php: CLI-only Bootstrap fuer den ersten
  Platform-Admin, bewusst keine Web-UI dafuer.
- Jede Back-Office-Ansicht/-Export wird im Audit-Log DES BETROFFENEN
  MANDANTEN protokolliert (Transparenzpflicht), nicht nur beim Betreiber.

Der bestehende Selbstbedienungs-Export (datenexport.php aus M8) bleibt
zusaetzlich bestehen statt ersetzt zu werden: der Mandant ist im AV-
Verhaeltnis Verantwortlicher, Art. 15/20/28 DSGVO verpflichten den
Auftragsverarbeiter zur Unterstuetzung bei Ausk''unfts-/Portabilitaets-
rechten - ein jederzeit verfuegbarer Mandanten-Export erfuellt das direkt.
Details und Begruendung in docs/backoffice.md.

Live getestet: Back-Office zeigt alle Mandanten korrekt (inkl. echter
Bestandsmandanten), Detail/Export fuer Test-Mandant funktioniert,
Audit-Log korrekt geschrieben. Kritischer Test bestanden: derselbe
Platform-Admin sieht auf normalen Mandanten-Seiten weiterhin nur seinen
eigenen Mandanten; ein eingeloggter Nicht-Platform-Admin bekommt 403.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 22:18:48 +02:00
clemensandClaude Sonnet 5 8b0dcd2c70 FAQ pro Mandant statt fest codiertem AOK-Inhalt
faq.php zeigte bislang fuer jeden Mandanten denselben, komplett
AOK-spezifischen Inhalt (Kaffeemaschinen-Bedienung, interner
Ansprechpartner) - fuer andere Kunden unbrauchbar und inhaltlich falsch.

- Neue Tabelle faq_entries (tenant-scoped, Soft-Delete, sort_order).
- Migration uebernimmt die bisherigen AOK-Inhalte einmalig als FAQ des
  migrierten Default-Mandanten; andere Mandanten sehen sie nicht.
- Neue Mandanten bekommen bei der Registrierung automatisch eine kurze
  generische Starter-FAQ (faq_seed_default_entries), frei editierbar.
- faq.php: lesbar fuer alle angemeldeten Mitglieder eines Mandanten,
  Anlegen/Bearbeiten/Entfernen auf owner/admin beschraenkt.
- Landingpage verweist nicht mehr auf faq.php (jetzt interner,
  mandantengebundener Inhalt statt oeffentlicher Marketing-Seite).

Live getestet: AOK-Carry-over fuer Default-Mandant, frisch registrierter
Test-Mandant bekam isolierte generische Starter-FAQ, Anlegen mit
HTML-Payload (korrekt escaped), Bearbeiten und Soft-Delete geprueft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 23:18:09 +02:00
clemensandClaude Sonnet 5 d176619e1d M7 abschliessen: Landingpage mit Kernabläufen, Demo-Screenshot und FAQ
Ergaenzt die beiden letzten offenen M7-Punkte und schaerft nebenbei die
Copy insgesamt:

- Neue Sektion 'So funktioniert's' mit dem im Plan vorgesehenen
  Drei-Schritte-Ablauf (Kaffee nehmen, Strich setzen, bei Bedarf
  bezahlen), bisher komplett gefehlt.
- Funktionsuebersicht von drei auf sechs Karten erweitert (Mitglieder/
  Rollen, Guthaben/Einzahlungen, PayPal, CSV-Import/-Export,
  Mandantenfaehigkeit, Hinweise/Info-Mails) statt der bisherigen,
  eher technischen Kurzbeschreibung.
- Demo-Screenshot: bereinigter Screenshot der Gesamtuebersicht mit
  Testdaten (keine echten Kundendaten) aus docs/m0/screenshots/ als
  assets/images/demo-kaffeeliste.png eingebunden.
- FAQ-Auszug: fuenf produktbezogene Fragen (Einrichtung, Mandanten-
  trennung, Rollen, Export/Loeschung, PayPal) direkt auf der
  Landingpage. Bewusst nicht die bestehende faq.php excerpted, da die
  komplett AOK-spezifisch ist (Kaffeemaschinen-Bedienung, interner
  Ansprechpartner) und fuer Interessenten nicht generisch verstaendlich
  waere.
- Neue CSS-Klassen fuer Schritte/Demo-Rahmen/FAQ-Grid/Abschluss-CTA in
  assets/css/public.css, konsistent mit dem bestehenden Grundton
  (gruener Akzent, schlichte Kartenoptik, keine Marketing-Verspieltheit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 22:04:15 +02:00
clemensandClaude Sonnet 5 3fc65812ef M8: Backup/Restore-Prozess und Monitoring dokumentiert
docs/betrieb-backup-monitoring.md: taeglicher mysqldump-Cron-Job mit
Aufbewahrung, Restore-Befehl inklusive Hinweis auf scripts/migrate.php,
vierteljaehrlicher Restore-Test. Produktive PHP-Fehlerkonfiguration
(display_errors aus, log_errors an) und die aktiv zu beobachtenden
Signale ohne dediziertes APM-Tool: audit_log fuer ungewoehnliche
Admin-Aktionen, rate_limit_attempts fuer Brute-Force-Versuche,
outbound_emails.status=failed fuer Mailversand-Probleme.

Damit ist M8 fuer den geplanten Scope abgeschlossen, mit einer bewusst
offenen Ausnahme (Content-Security-Policy, braucht Template-Bereinigung
der bestehenden Inline-Styles).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 20:19:20 +02:00
clemensandClaude Sonnet 5 f60c0bb85e M8: Loesch-/Anonymisierungsprozess fuer Teilnehmer und Mandanten
Zwei getrennte Flows:

- Teilnehmer anonymisieren statt loeschen (ledger_anonymize_participant):
  Name/E-Mail/PayPal-Name werden durch einen Platzhalter ersetzt, das
  Mitglied deaktiviert und vom Login-Konto getrennt. Buchungshistorie
  bleibt fuer die Kassenfuehrung erhalten, konsistent mit dem
  Storno-statt-Delete-Prinzip. Default-Mandant spiegelt die
  Anonymisierung in kl_Mitarbeiter (Email dort NOT NULL UNIQUE, bekommt
  Platzhalter statt NULL). Inhaber kann nicht anonymisiert werden.
- Mandant vollstaendig loeschen (mandant-loeschen.php, nur Inhaber):
  erfordert exakte Eingabe des Kundenkuerzels, loescht die tenants-Zeile;
  alle tenant-scoped Tabellen kaskadieren per Fremdschluessel. users
  bleiben bestehen (koennen zu mehreren Mandanten gehoeren). Der
  migrierte Default-Mandant ist ausgenommen, da seine kl_Mitarbeiter-
  Historie sonst verwaisen wuerde.

Live getestet: Mitglied mit Buchungshistorie anonymisiert (Historie
blieb erhalten), Mandantenloeschung mit falscher/richtiger Bestaetigung
geprueft, vollstaendiger Cascade-Delete ueber alle tenant-scoped Tabellen
verifiziert, globale users-Zeile bleibt korrekt erhalten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 20:17:57 +02:00
clemensandClaude Sonnet 5 f3045dbab9 M8: Datenexport pro Mandant
Neue Seite datenexport.php (Owner/Admin) laedt einen vollstaendigen
JSON-Export des eigenen Mandanten herunter: Stammdaten, Einstellungen,
Teilnehmer, Mitglieder mit Rolle, alle Ledger-Buchungen, Hinweise,
CSV-Importe, Mail-Versandlog und Admin-Protokoll. Passwort- und
Token-Hashes werden bewusst nicht exportiert; der Export selbst wird im
Audit-Log protokolliert. Link von konto.php aus.

Live getestet: eigens angelegter Test-Mandant, Export heruntergeladen,
Header und JSON-Struktur geprueft, keine Passwoerter im Export gefunden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 20:10:30 +02:00
clemensandClaude Sonnet 5 3917a11db0 M8: Mandanten-Isolation und Rollenmatrix automatisiert testen
scripts/check-m8-tenant-isolation.php: legt zwei frische, isolierte
Test-Mandanten mit je einem Teilnehmer, einer Ledger-Buchung, einem
Hinweis und einem Audit-Log-Eintrag an und prueft 10 Faelle - Lesezugriffe
(Teilnehmerlisten, Einzelabruf, letzte Buchungen, aktive Hinweise,
Audit-Log) und Schreibzugriffe (Buchung, Zugangsvergabe, Storno) sind
strikt auf den jeweils richtigen Mandanten beschraenkt. Raeumt sich selbst
auf. 10/10 gruen.

scripts/check-m8-role-matrix.php: legt einen Test-Mandanten mit je einem
Nutzer pro Rolle an (owner/admin/treasurer/member/viewer), loggt sich per
echtem HTTP-Request ein (manueller Cookie-Jar ueber file_get_contents, da
diese PHP-Installation keine curl-Extension hat) und prueft alle elf
rollen-geschuetzten Seiten gegen die erwartete Rollenliste. 55/55 gruen
(5 Rollen x 11 Seiten) - bestaetigt, dass die Rollenpruefungen ueberall
konsistent mit dem im Plan dokumentierten Rollenmodell durchgesetzt sind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 20:07:10 +02:00
clemensandClaude Sonnet 5 54217d2acb M8: Security-Headers, Rate-Limits und Audit-Log
Erste drei Bausteine der Haertung:

- Security-Headers (X-Content-Type-Options, X-Frame-Options, Referrer-
  Policy, Permissions-Policy, HSTS bei HTTPS) laufen automatisch ueber
  app_send_security_headers() am Ende von app/bootstrap.php fuer jede
  dynamische Seite; landing.php war als einzige Seite ganz ohne PHP und
  bekam einen minimalen Bootstrap-Aufruf. Bewusst kein CSP, da die
  bestehenden Templates durchgaengig auf Inline-style-Attribute setzen.
- DB-gestuetzte Rate-Limits (neue Tabelle rate_limit_attempts) fuer
  Login (10/15min je E-Mail, 20/15min je IP), Registrierung (5/h je IP)
  und Passwort-Reset-Anfrage (5/h je E-Mail, 10/h je IP); bei
  ausgereiztem Reset-Limit erscheint dieselbe generische Meldung wie im
  Erfolgsfall, um kein Konto-Enumeration-Signal zu geben.
- Zentrales Audit-Log (neue Tabelle audit_log) fuer Mitgliederverwaltung,
  Zugangsvergabe/-entzug, Storno, Mandant-Einstellungen, Hinweise,
  CSV-Import, Jahresbonus-Verteilung und Live-Mailversand; sichtbar fuer
  Owner/Admin auf mandant-einstellungen.php.

Live getestet: Rate-Limit greift nach 10 Fehlversuchen, Audit-Log-Eintrag
mit korrekten Metadaten und Nutzernamen ueber einen isolierten Test-
Mandanten geprueft. Alle Regressionstests weiterhin gruen (26/26 Smoke,
104 Golden-Master-Assertions).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 18:04:14 +02:00
clemensandClaude Sonnet 5 7b517bcbf6 Doku: Mailversand im Produktivbetrieb ueber Webspace-Mailserver
Klaerung auf Nutzerfrage: saas_send_mail() nutzt produktiv PHP mail()
statt eines externen SMTP-Relays, laeuft also automatisch ueber den
Mailserver des Webspace-Anbieters, der fuer die eigene Domain i.d.R.
bereits autorisiert ist (SPF/rDNS). Dokumentiert die dafuer noetigen
Env-Variablen, insbesondere APP_MAIL_FROM, das produktiv nicht auf dem
Platzhalter noreply@kaffeeliste.local stehen bleiben darf.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 17:49:49 +02:00
clemensandClaude Sonnet 5 536ef2ead2 M6: Jahresabschluss als generisches Feature statt AOK-spezifischem Bonus-Skript
jahresauswertung.php verband sich bisher mit fest codierten (kaputten)
Zugangsdaten selbst zur Datenbank statt ueber config.php, hatte keine
Zugriffskontrolle und kein CSRF, und verteilte bei jedem Aufruf sofort
einen hart codierten Bonus-Topf (490 Striche a 0,20 Euro) per PHPMailer
(dessen Quelldateien im Repo fehlen) mit AOK-spezifischem Mailtext.

Nach Abstimmung mit dem Kunden als generisches, mandantenfaehiges Feature
neu gebaut statt nur deaktiviert oder rein lesend umgesetzt:

- Admin gibt einen frei waehlbaren Gesamtbetrag ein, das System verteilt
  ihn proportional zu den Jahresstrichen auf alle aktiven Mitglieder.
- Standardmaessig aktive Dry-Run-Checkbox zeigt die Verteilung, ohne zu
  buchen oder Mails zu verschicken.
- Bestaetigter Lauf bucht ueber dasselbe Zweig-Muster wie ueberall
  (Default-Mandant Dual-Write, andere Mandanten ledger_record_payment)
  und verschickt personalisierte Mails ueber saas_send_mail(), protokolliert
  im outbound_emails-Versandlog.
- Zugriffskontrolle ergaenzt (owner/admin/treasurer + Legacy-Fallback).
- http-smoke.php: jahresauswertung.php jetzt regulaerer Check statt
  uebersprungenem unsicherem Aufruf; damit sind keine Seiten mehr
  uebersprungen oder als bekannter offener Punkt markiert (26/26 gruen).

Live getestet: Dry-Run mit korrekter proportionaler Verteilung (Summe
ergibt exakt den Gesamtbetrag), Live-Lauf bucht und versendet korrekt,
Testdaten anschliessend vollstaendig entfernt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 17:21:34 +02:00
clemens d5195e7f9f M6: Mailversand als nachvollziehbaren Versandjob mit Dry-Run/Log
mailversenden.php hatte keine Zugriffskontrolle, versendete bei jedem
GET-Request sofort echte Mails und nutzte PHPMailer, dessen Quelldateien
im Repo gar nicht vorhanden waren (nur composer.json/Lizenz) - der Aufruf
waere also ohnehin mit Fatal Error abgebrochen. Zusaetzlich waren SMTP-
Host, Absender, PayPal-Link und FAQ-URL fest auf einen Alt-Kunden (AOK)
codiert.

- Ersetzt PHPMailer durch die bestehende saas_send_mail()-Abstraktion aus
  M3 (Transport log/mail je nach APP_MAIL_TRANSPORT) statt eine fehlende
  Abhaengigkeit nachzuvendoren.
- Neue Tabelle outbound_emails protokolliert jeden Versandversuch:
  Mandant, Mitglied, Vorlage, Betreff, Status, Fehler - das Versandlog.
- Formular hat eine standardmaessig aktive Dry-Run-Checkbox; im Dry-Run
  wird nur geloggt, saas_send_mail() nicht aufgerufen.
- Mailtext ist jetzt tenant-generisch (Saldo, optionaler PayPal-Link nur
  wenn der Mandant PayPal aktiviert hat, eigener Dashboard-Link) statt
  hartcodierter Alt-Kunden-Inhalte.
- Zugriffskontrolle ergaenzt (owner/admin/treasurer + Legacy-Fallback).
- http-smoke.php: mailversenden.php ist jetzt reguel</EOF>
2026-07-15 16:13:38 +02:00
clemensandClaude Sonnet 5 81dfff51a0 M6: PDF-Export tenant-nativ und TCPDF vollstaendig vendort
exportKaffeeliste.php band config.php direkt ein (keine Bootstrap-Kette)
und hatte ueberhaupt keine Zugriffskontrolle. TCPDF war im Repo nur
teilweise vorhanden (include/-Verzeichnis und Font-Definitionen fehlten
komplett), wodurch der Export bislang immer mit einem Fatal Error abbrach.

- TCPDF/include/ und die 14 PDF-Standard-Fonts (Helvetica, Courier, Times,
  Symbol, ZapfDingbats) aus dem offiziellen TCPDF-6.6.2-Release nachvendort
  (nicht die vollen ~25 MB an Unicode-Fonts, die hier nicht gebraucht
  werden).
- Zugriffskontrolle ergaenzt (owner/admin/treasurer + Legacy-Fallback wie
  bei den anderen Treasurer-Seiten).
- Vieltrinker-/Wenigtrinker-Aufteilung liest jetzt tenant-sicher ueber
  ledger_fetch_participants_by_window_marks() statt Legacy-SQL direkt
  gegen kl_Kaffeeverbrauch; Preis pro Strich kommt aus tenant_settings
  statt kl_config. N+1-Abfragen pro Zeile durch die bereits geladene
  Teilnehmerzusammenfassung ersetzt.
- scripts/http-smoke.php prueft den Export jetzt als regulaeren Check
  (gueltige PDF-Antwort) statt als bekannten offenen Punkt.
- Live getestet: gueltiges zweiseitiges PDF, Namen/Salden im Textstream
  verifiziert, Aufteilung stimmt mit Ledger-Daten ueberein.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 15:23:16 +02:00
clemensandClaude Sonnet 5 92e124753f M6: CSV-Import mit Vorschau, Dublettenpruefung und Audit-Trail
csvupload.php verarbeitete CSV-Zeilen bisher sofort beim Upload, ohne
Vorschau, ohne Zugriffskontrolle (nur CSRF) und schrieb ausschliesslich in
die global unscoped kl_Einzahlungen-Tabelle.

- Neue Tabellen payment_import_batches/payment_import_rows protokollieren
  jeden Import: Datei, Pruefsumme, jede Zeile mit Rohwerten, erkanntem
  Mitglied, Status und erzeugter Ledger-Zeile.
- Zweistufiger Ablauf: Hochladen zeigt nur eine Vorschau (matched/
  duplicate/unmatched/invalid), erst "Import bestaetigen" bucht.
- Zuordnung per paypal_name oder display_name (tenant-scoped), Dubletten-
  pruefung gegen bestehende nicht-stornierte Ledger-Zahlungen.
- Buchung folgt dem etablierten Zweig-Muster: Default-Mandant per
  Dual-Write nach kl_Einzahlungen plus Spiegelung, alle anderen Mandanten
  direkt ueber ledger_record_payment().
- Zugriffskontrolle ergaenzt (owner/admin/treasurer + Legacy-Fallback).
- Live getestet: Treffer, unbekannter Name, ungueltiger Betrag korrekt
  klassifiziert; Import bestaetigt mit korrektem Dual-Write; erneuter
  Upload derselben Datei erkennt die Zeile korrekt als Dublette.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 14:48:41 +02:00
clemensandClaude Sonnet 5 4d5f32ae6d M5: Sammelerfassung (Striche/Einzahlungen) tenant-nativ machen
stricheintragen.php und einzahlung.php lasen ihren Mitarbeiter-Picker
bisher direkt aus der global unscoped kl_Mitarbeiter-Tabelle. Fuer jeden
Mandanten ausser dem Default-Mandanten zeigte das fremde Namen und
Schreiben schlug (sicher, aber unverstaendlich) am Tenant-Check in
ledger_mirror_legacy_* fehl.

- Picker kommt jetzt aus participants (tenant-scoped), Formularfelder
  nutzen participant_id statt MitarbeiterID.
- Schreibpfad pro Teilnehmer: mit legacy_mitarbeiter_id (Default-Mandant)
  weiterhin Dual-Write nach kl_Kaffeeverbrauch/kl_Einzahlungen plus
  Ledger-Spiegelung; ohne Legacy-Verknuepfung (jeder andere Mandant) direkt
  ueber neue ledger_record_consumption()/ledger_record_payment().
- Vorderseite/Rueckseite-Filter (100-Tage-Regel) bleiben fuer den
  Default-Mandanten exakt auf der bisherigen Legacy-Logik; andere
  Mandanten nutzen die neue ledger_fetch_participants_by_window_marks()
  mit tenant_settings.sheet_window_days.
- Nebenbei behoben: einzahlung.php verlinkte auf ?aktion=... statt
  ?action=..., wodurch die Vorderseite/Rueckseite-Buttons nie griffen.
  Preis-pro-Strich-Vorbelegung kommt jetzt aus tenant_settings statt der
  seit M3 nicht mehr gepflegten kl_config-Tabelle.
- Live getestet: isolierter Test-Mandant, Picker zeigt nur eigene
  Teilnehmer, Buchungen rein Ledger-nativ mit korrektem Saldo, 10-Striche-
  Schwelle korrekt sortiert, Default-Mandant-Dual-Write weiterhin gruen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 00:57:51 +02:00
clemensandClaude Sonnet 5 aeb687f42e M5: Hinweise auf tenant-scoped Notices umziehen, Mitgliederzugang auf Rollen umstellen
Hinweise:
- Neue Tabelle notices (tenant-scoped, Soft-Delete via deleted_at) loest
  die global unscoped kl_hinweise als aktive Datenquelle ab; Migration
  uebernimmt einmalig aktuell gueltige kl_hinweise-Eintraege fuer den
  Default-Mandanten. kl_hinweise bleibt als Golden-Master-Referenz stehen.
- hinweise.php und der Banner in header.php sind tenant-scoped umgestellt.

Mitgliederverwaltung:
- mitarbeiterverwalten.php verwaltet jetzt participants (tenant-scoped)
  statt der global unscoped kl_Mitarbeiter-Tabelle als primaere Quelle.
  Das behebt nebenbei ein Mandanten-Datenleck: jeder SaaS-Mandant mit
  Owner/Admin-Rolle haette zuvor die komplette Default-Mandanten-
  Mitgliederliste sehen und bearbeiten koennen.
- Fuer den Default-Mandanten bleibt Dual-Write nach kl_Mitarbeiter
  bestehen, damit stricheintragen.php/einzahlung.php weiter funktionieren;
  andere Mandanten werden rein participant-nativ verwaltet.
- Die Legacy-Administrator-Checkbox ist raus. Stattdessen kann ein Admin
  je Mitglied unabhaengig von Name/E-Mail einen Login-Zugang mit Rolle
  (member/treasurer/admin) gewaehren oder entziehen
  (saas_grant_participant_access / saas_revoke_participant_access).
  Einladung laeuft ueber den bestehenden Passwort-Reset-Mechanismus,
  Entzug setzt die Mitgliedschaft auf revoked statt sie zu loeschen.
- Kompletter Flow live getestet: anlegen, Zugang gewaehren, Einladungsmail,
  Passwort setzen, Login, Rollenschutz, Zugang entziehen, Login-Sperre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 00:18:55 +02:00
clemensandClaude Sonnet 5 dcc8dc3421 Meilensteinplan: M5-Stand mit tatsaechlichem Fortschritt abgleichen
Uebersichtstabelle und M5-Abschnitt markierten Mitgliederverwaltung,
Storno-Korrekturen und die Sammelerfassung noch als offen, obwohl sie
in dieser Session umgesetzt wurden. Ausserdem dokumentiert: der PayPal-
Bereich ist bewusst im Dashboard gebuendelt statt als eigene Route, wie
urspruenglich in der Zielarchitektur skizziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 23:50:43 +02:00
clemensandClaude Sonnet 5 de3dcee7ec M5: Mitgliederverwaltung tenant- und rollenbasiert umstellen
- mitarbeiterverwalten.php war rein ueber Legacy-Admin gesperrt und haette
  neue SaaS-Mandanten ausgeschlossen; jetzt Rollen-/Legacy-Fallback wie in
  kaffeeliste.php (owner/admin).
- Anlegen/Bearbeiten/Aktivieren/Deaktivieren spiegeln transaktional nach
  participants (ledger_mirror_legacy_participant neu ergaenzt), damit
  Ledger-Ansichten sofort den aktuellen Mitgliederstand zeigen.
- Gespeicherte XSS-Luecke behoben: Name/E-Mail waren in Formular und Liste
  ungeschuetzt ausgegeben, jetzt ueber saas_html().
- Admin-Rollenvergabe bleibt bewusst Legacy-only (kein automatischer
  Login-Account ohne Einladungsflow); dokumentiert als offener Punkt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 23:49:33 +02:00
clemensandClaude Sonnet 5 968a55f442 M5 abschliessen: Schreibseiten aufs Ledger umstellen, Storno statt Delete
- stricheintragen.php und einzahlung.php erhielten bisher gar keine
  Zugriffskontrolle (nur CSRF), jetzt Rollen-/Legacy-Fallback wie in
  kaffeeliste.php; Sammeleintraege spiegeln transaktional ins Ledger
  (ledger_mirror_legacy_payment neu ergaenzt).
- letzteneintraege.php war rein ueber Legacy-Admin gesperrt und haette
  neue SaaS-Mandanten ausgeschlossen; Loeschen markiert den gespiegelten
  Ledger-Eintrag jetzt per voided_at statt ihn zu entfernen
  (ledger_void_entry_by_legacy_id).
- check-m4-ledger-migration.php an das Storno-Modell angepasst: verwaiste
  Ledger-Zeilen sind nur noch ein Fehler, wenn sie nicht voided sind.
- Nebenbei: PHP-Warning bei Sammelerfassung behoben, toten Code entfernt,
  veraltete README (verwies auf nicht existierende saas-app/-Struktur)
  korrigiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 23:43:39 +02:00
clemens aa9444f076 Dashboard auf Ledger-Lesemodell umstellen 2026-07-14 23:11:53 +02:00
clemens 078f28551d Teilnehmerauswertung auf Ledger-Lesemodell umstellen 2026-07-14 22:25:51 +02:00
clemens 8688cc78db Teilnehmerauswertung auf Ledger-Lesemodell umstellen 2026-07-14 22:21:28 +02:00
216 changed files with 52465 additions and 5945 deletions
+4
View File
@@ -1,4 +1,8 @@
.env.local
env.local.php
.local/
uploads/
var/
.vscode/
sync_config.jsonc
+64
View File
@@ -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>
-46
View File
@@ -1,46 +0,0 @@
GPL Cooperation Commitment
Version 1.0
Before filing or continuing to prosecute any legal proceeding or claim
(other than a Defensive Action) arising from termination of a Covered
License, we commit to extend to the person or entity ('you') accused
of violating the Covered License the following provisions regarding
cure and reinstatement, taken from GPL version 3. As used here, the
term 'this License' refers to the specific Covered License being
enforced.
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly
and finally terminates your license, and (b) permanently, if the
copyright holder fails to notify you of the violation by some
reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you
have received notice of violation of this License (for any work)
from that copyright holder, and you cure the violation prior to 30
days after your receipt of the notice.
We intend this Commitment to be irrevocable, and binding and
enforceable against us and assignees of or successors to our
copyrights.
Definitions
'Covered License' means the GNU General Public License, version 2
(GPLv2), the GNU Lesser General Public License, version 2.1
(LGPLv2.1), or the GNU Library General Public License, version 2
(LGPLv2), all as published by the Free Software Foundation.
'Defensive Action' means a legal proceeding or claim that We bring
against you in response to a prior proceeding or claim initiated by
you or your affiliate.
'We' means each contributor to this repository as of the date of
inclusion of this file, including subsidiaries of a corporate
contributor.
This work is available under a Creative Commons Attribution-ShareAlike
4.0 International license (https://creativecommons.org/licenses/by-sa/4.0/).
-502
View File
@@ -1,502 +0,0 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 2.1, February 1999
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
[This is the first released version of the Lesser GPL. It also counts
as the successor of the GNU Library Public License, version 2, hence
the version number 2.1.]
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
Licenses are intended to guarantee your freedom to share and change
free software--to make sure the software is free for all its users.
This license, the Lesser General Public License, applies to some
specially designated software packages--typically libraries--of the
Free Software Foundation and other authors who decide to use it. You
can use it too, but we suggest you first think carefully about whether
this license or the ordinary General Public License is the better
strategy to use in any particular case, based on the explanations below.
When we speak of free software, we are referring to freedom of use,
not price. Our General Public Licenses are designed to make sure that
you have the freedom to distribute copies of free software (and charge
for this service if you wish); that you receive source code or can get
it if you want it; that you can change the software and use pieces of
it in new free programs; and that you are informed that you can do
these things.
To protect your rights, we need to make restrictions that forbid
distributors to deny you these rights or to ask you to surrender these
rights. These restrictions translate to certain responsibilities for
you if you distribute copies of the library or if you modify it.
For example, if you distribute copies of the library, whether gratis
or for a fee, you must give the recipients all the rights that we gave
you. You must make sure that they, too, receive or can get the source
code. If you link other code with the library, you must provide
complete object files to the recipients, so that they can relink them
with the library after making changes to the library and recompiling
it. And you must show them these terms so they know their rights.
We protect your rights with a two-step method: (1) we copyright the
library, and (2) we offer you this license, which gives you legal
permission to copy, distribute and/or modify the library.
To protect each distributor, we want to make it very clear that
there is no warranty for the free library. Also, if the library is
modified by someone else and passed on, the recipients should know
that what they have is not the original version, so that the original
author's reputation will not be affected by problems that might be
introduced by others.
Finally, software patents pose a constant threat to the existence of
any free program. We wish to make sure that a company cannot
effectively restrict the users of a free program by obtaining a
restrictive license from a patent holder. Therefore, we insist that
any patent license obtained for a version of the library must be
consistent with the full freedom of use specified in this license.
Most GNU software, including some libraries, is covered by the
ordinary GNU General Public License. This license, the GNU Lesser
General Public License, applies to certain designated libraries, and
is quite different from the ordinary General Public License. We use
this license for certain libraries in order to permit linking those
libraries into non-free programs.
When a program is linked with a library, whether statically or using
a shared library, the combination of the two is legally speaking a
combined work, a derivative of the original library. The ordinary
General Public License therefore permits such linking only if the
entire combination fits its criteria of freedom. The Lesser General
Public License permits more lax criteria for linking other code with
the library.
We call this license the "Lesser" General Public License because it
does Less to protect the user's freedom than the ordinary General
Public License. It also provides other free software developers Less
of an advantage over competing non-free programs. These disadvantages
are the reason we use the ordinary General Public License for many
libraries. However, the Lesser license provides advantages in certain
special circumstances.
For example, on rare occasions, there may be a special need to
encourage the widest possible use of a certain library, so that it becomes
a de-facto standard. To achieve this, non-free programs must be
allowed to use the library. A more frequent case is that a free
library does the same job as widely used non-free libraries. In this
case, there is little to gain by limiting the free library to free
software only, so we use the Lesser General Public License.
In other cases, permission to use a particular library in non-free
programs enables a greater number of people to use a large body of
free software. For example, permission to use the GNU C Library in
non-free programs enables many more people to use the whole GNU
operating system, as well as its variant, the GNU/Linux operating
system.
Although the Lesser General Public License is Less protective of the
users' freedom, it does ensure that the user of a program that is
linked with the Library has the freedom and the wherewithal to run
that program using a modified version of the Library.
The precise terms and conditions for copying, distribution and
modification follow. Pay close attention to the difference between a
"work based on the library" and a "work that uses the library". The
former contains code derived from the library, whereas the latter must
be combined with the library in order to run.
GNU LESSER GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License Agreement applies to any software library or other
program which contains a notice placed by the copyright holder or
other authorized party saying it may be distributed under the terms of
this Lesser General Public License (also called "this License").
Each licensee is addressed as "you".
A "library" means a collection of software functions and/or data
prepared so as to be conveniently linked with application programs
(which use some of those functions and data) to form executables.
The "Library", below, refers to any such software library or work
which has been distributed under these terms. A "work based on the
Library" means either the Library or any derivative work under
copyright law: that is to say, a work containing the Library or a
portion of it, either verbatim or with modifications and/or translated
straightforwardly into another language. (Hereinafter, translation is
included without limitation in the term "modification".)
"Source code" for a work means the preferred form of the work for
making modifications to it. For a library, complete source code means
all the source code for all modules it contains, plus any associated
interface definition files, plus the scripts used to control compilation
and installation of the library.
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running a program using the Library is not restricted, and output from
such a program is covered only if its contents constitute a work based
on the Library (independent of the use of the Library in a tool for
writing it). Whether that is true depends on what the Library does
and what the program that uses the Library does.
1. You may copy and distribute verbatim copies of the Library's
complete source code as you receive it, in any medium, provided that
you conspicuously and appropriately publish on each copy an
appropriate copyright notice and disclaimer of warranty; keep intact
all the notices that refer to this License and to the absence of any
warranty; and distribute a copy of this License along with the
Library.
You may charge a fee for the physical act of transferring a copy,
and you may at your option offer warranty protection in exchange for a
fee.
2. You may modify your copy or copies of the Library or any portion
of it, thus forming a work based on the Library, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) The modified work must itself be a software library.
b) You must cause the files modified to carry prominent notices
stating that you changed the files and the date of any change.
c) You must cause the whole of the work to be licensed at no
charge to all third parties under the terms of this License.
d) If a facility in the modified Library refers to a function or a
table of data to be supplied by an application program that uses
the facility, other than as an argument passed when the facility
is invoked, then you must make a good faith effort to ensure that,
in the event an application does not supply such function or
table, the facility still operates, and performs whatever part of
its purpose remains meaningful.
(For example, a function in a library to compute square roots has
a purpose that is entirely well-defined independent of the
application. Therefore, Subsection 2d requires that any
application-supplied function or table used by this function must
be optional: if the application does not supply it, the square
root function must still compute square roots.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Library,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Library, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote
it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Library.
In addition, mere aggregation of another work not based on the Library
with the Library (or with a work based on the Library) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may opt to apply the terms of the ordinary GNU General Public
License instead of this License to a given copy of the Library. To do
this, you must alter all the notices that refer to this License, so
that they refer to the ordinary GNU General Public License, version 2,
instead of to this License. (If a newer version than version 2 of the
ordinary GNU General Public License has appeared, then you can specify
that version instead if you wish.) Do not make any other change in
these notices.
Once this change is made in a given copy, it is irreversible for
that copy, so the ordinary GNU General Public License applies to all
subsequent copies and derivative works made from that copy.
This option is useful when you wish to copy part of the code of
the Library into a program that is not a library.
4. You may copy and distribute the Library (or a portion or
derivative of it, under Section 2) in object code or executable form
under the terms of Sections 1 and 2 above provided that you accompany
it with the complete corresponding machine-readable source code, which
must be distributed under the terms of Sections 1 and 2 above on a
medium customarily used for software interchange.
If distribution of object code is made by offering access to copy
from a designated place, then offering equivalent access to copy the
source code from the same place satisfies the requirement to
distribute the source code, even though third parties are not
compelled to copy the source along with the object code.
5. A program that contains no derivative of any portion of the
Library, but is designed to work with the Library by being compiled or
linked with it, is called a "work that uses the Library". Such a
work, in isolation, is not a derivative work of the Library, and
therefore falls outside the scope of this License.
However, linking a "work that uses the Library" with the Library
creates an executable that is a derivative of the Library (because it
contains portions of the Library), rather than a "work that uses the
library". The executable is therefore covered by this License.
Section 6 states terms for distribution of such executables.
When a "work that uses the Library" uses material from a header file
that is part of the Library, the object code for the work may be a
derivative work of the Library even though the source code is not.
Whether this is true is especially significant if the work can be
linked without the Library, or if the work is itself a library. The
threshold for this to be true is not precisely defined by law.
If such an object file uses only numerical parameters, data
structure layouts and accessors, and small macros and small inline
functions (ten lines or less in length), then the use of the object
file is unrestricted, regardless of whether it is legally a derivative
work. (Executables containing this object code plus portions of the
Library will still fall under Section 6.)
Otherwise, if the work is a derivative of the Library, you may
distribute the object code for the work under the terms of Section 6.
Any executables containing that work also fall under Section 6,
whether or not they are linked directly with the Library itself.
6. As an exception to the Sections above, you may also combine or
link a "work that uses the Library" with the Library to produce a
work containing portions of the Library, and distribute that work
under terms of your choice, provided that the terms permit
modification of the work for the customer's own use and reverse
engineering for debugging such modifications.
You must give prominent notice with each copy of the work that the
Library is used in it and that the Library and its use are covered by
this License. You must supply a copy of this License. If the work
during execution displays copyright notices, you must include the
copyright notice for the Library among them, as well as a reference
directing the user to the copy of this License. Also, you must do one
of these things:
a) Accompany the work with the complete corresponding
machine-readable source code for the Library including whatever
changes were used in the work (which must be distributed under
Sections 1 and 2 above); and, if the work is an executable linked
with the Library, with the complete machine-readable "work that
uses the Library", as object code and/or source code, so that the
user can modify the Library and then relink to produce a modified
executable containing the modified Library. (It is understood
that the user who changes the contents of definitions files in the
Library will not necessarily be able to recompile the application
to use the modified definitions.)
b) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (1) uses at run time a
copy of the library already present on the user's computer system,
rather than copying library functions into the executable, and (2)
will operate properly with a modified version of the library, if
the user installs one, as long as the modified version is
interface-compatible with the version that the work was made with.
c) Accompany the work with a written offer, valid for at
least three years, to give the same user the materials
specified in Subsection 6a, above, for a charge no more
than the cost of performing this distribution.
d) If distribution of the work is made by offering access to copy
from a designated place, offer equivalent access to copy the above
specified materials from the same place.
e) Verify that the user has already received a copy of these
materials or that you have already sent this user a copy.
For an executable, the required form of the "work that uses the
Library" must include any data and utility programs needed for
reproducing the executable from it. However, as a special exception,
the materials to be distributed need not include anything that is
normally distributed (in either source or binary form) with the major
components (compiler, kernel, and so on) of the operating system on
which the executable runs, unless that component itself accompanies
the executable.
It may happen that this requirement contradicts the license
restrictions of other proprietary libraries that do not normally
accompany the operating system. Such a contradiction means you cannot
use both them and the Library together in an executable that you
distribute.
7. You may place library facilities that are a work based on the
Library side-by-side in a single library together with other library
facilities not covered by this License, and distribute such a combined
library, provided that the separate distribution of the work based on
the Library and of the other library facilities is otherwise
permitted, and provided that you do these two things:
a) Accompany the combined library with a copy of the same work
based on the Library, uncombined with any other library
facilities. This must be distributed under the terms of the
Sections above.
b) Give prominent notice with the combined library of the fact
that part of it is a work based on the Library, and explaining
where to find the accompanying uncombined form of the same work.
8. You may not copy, modify, sublicense, link with, or distribute
the Library except as expressly provided under this License. Any
attempt otherwise to copy, modify, sublicense, link with, or
distribute the Library is void, and will automatically terminate your
rights under this License. However, parties who have received copies,
or rights, from you under this License will not have their licenses
terminated so long as such parties remain in full compliance.
9. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Library or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Library (or any work based on the
Library), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Library or works based on it.
10. Each time you redistribute the Library (or any work based on the
Library), the recipient automatically receives a license from the
original licensor to copy, distribute, link with or modify the Library
subject to these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties with
this License.
11. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Library at all. For example, if a patent
license would not permit royalty-free redistribution of the Library by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Library.
If any portion of this section is held invalid or unenforceable under any
particular circumstance, the balance of the section is intended to apply,
and the section as a whole is intended to apply in other circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
12. If the distribution and/or use of the Library is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Library under this License may add
an explicit geographical distribution limitation excluding those countries,
so that distribution is permitted only in or among countries not thus
excluded. In such case, this License incorporates the limitation as if
written in the body of this License.
13. The Free Software Foundation may publish revised and/or new
versions of the Lesser General Public License from time to time.
Such new versions will be similar in spirit to the present version,
but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Library
specifies a version number of this License which applies to it and
"any later version", you have the option of following the terms and
conditions either of that version or of any later version published by
the Free Software Foundation. If the Library does not specify a
license version number, you may choose any version ever published by
the Free Software Foundation.
14. If you wish to incorporate parts of the Library into other free
programs whose distribution conditions are incompatible with these,
write to the author to ask for permission. For software which is
copyrighted by the Free Software Foundation, write to the Free
Software Foundation; we sometimes make exceptions for this. Our
decision will be guided by the two goals of preserving the free status
of all derivatives of our free software and of promoting the sharing
and reuse of software generally.
NO WARRANTY
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Libraries
If you develop a new library, and you want it to be of the greatest
possible use to the public, we recommend making it free software that
everyone can redistribute and change. You can do so by permitting
redistribution under these terms (or, alternatively, under the terms of the
ordinary General Public License).
To apply these terms, attach the following notices to the library. It is
safest to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
<one line to give the library's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Also add information on how to contact you by electronic and paper mail.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the library, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the
library `Frob' (a library for tweaking knobs) written by James Random Hacker.
<signature of Ty Coon>, 1 April 1990
Ty Coon, President of Vice
That's all there is to it!
-79
View File
@@ -1,79 +0,0 @@
{
"name": "phpmailer/phpmailer",
"type": "library",
"description": "PHPMailer is a full-featured email creation and transfer class for PHP",
"authors": [
{
"name": "Marcus Bointon",
"email": "phpmailer@synchromedia.co.uk"
},
{
"name": "Jim Jagielski",
"email": "jimjag@gmail.com"
},
{
"name": "Andy Prevost",
"email": "codeworxtech@users.sourceforge.net"
},
{
"name": "Brent R. Matzelle"
}
],
"funding": [
{
"url": "https://github.com/Synchro",
"type": "github"
}
],
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
}
},
"require": {
"php": ">=5.5.0",
"ext-ctype": "*",
"ext-filter": "*",
"ext-hash": "*"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"doctrine/annotations": "^1.2.6 || ^1.13.3",
"php-parallel-lint/php-console-highlighter": "^1.0.0",
"php-parallel-lint/php-parallel-lint": "^1.3.2",
"phpcompatibility/php-compatibility": "^9.3.5",
"roave/security-advisories": "dev-latest",
"squizlabs/php_codesniffer": "^3.7.2",
"yoast/phpunit-polyfills": "^1.0.4"
},
"suggest": {
"decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication",
"ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
"ext-openssl": "Needed for secure SMTP sending and DKIM signing",
"greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication",
"hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication",
"league/oauth2-google": "Needed for Google XOAUTH2 authentication",
"psr/log": "For optional PSR-3 debug logging",
"thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication",
"symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)"
},
"autoload": {
"psr-4": {
"PHPMailer\\PHPMailer\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"PHPMailer\\Test\\": "test/"
}
},
"license": "LGPL-2.1-only",
"scripts": {
"check": "./vendor/bin/phpcs",
"test": "./vendor/bin/phpunit --no-coverage",
"coverage": "./vendor/bin/phpunit",
"lint": [
"@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . --show-deprecated -e php,phps --exclude vendor --exclude .git --exclude build"
]
}
}
-182
View File
@@ -1,182 +0,0 @@
<?php
/**
* PHPMailer - PHP email creation and transport class.
* PHP Version 5.5
* @package PHPMailer
* @see https://github.com/PHPMailer/PHPMailer/ The PHPMailer GitHub project
* @author Marcus Bointon (Synchro/coolbru) <phpmailer@synchromedia.co.uk>
* @author Jim Jagielski (jimjag) <jimjag@gmail.com>
* @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
* @author Brent R. Matzelle (original founder)
* @copyright 2012 - 2020 Marcus Bointon
* @copyright 2010 - 2012 Jim Jagielski
* @copyright 2004 - 2009 Andy Prevost
* @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
* @note This program is distributed in the hope that it will be useful - WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE.
*/
/**
* Get an OAuth2 token from an OAuth2 provider.
* * Install this script on your server so that it's accessible
* as [https/http]://<yourdomain>/<folder>/get_oauth_token.php
* e.g.: http://localhost/phpmailer/get_oauth_token.php
* * Ensure dependencies are installed with 'composer install'
* * Set up an app in your Google/Yahoo/Microsoft account
* * Set the script address as the app's redirect URL
* If no refresh token is obtained when running this file,
* revoke access to your app and run the script again.
*/
namespace PHPMailer\PHPMailer;
/**
* Aliases for League Provider Classes
* Make sure you have added these to your composer.json and run `composer install`
* Plenty to choose from here:
* @see http://oauth2-client.thephpleague.com/providers/thirdparty/
*/
//@see https://github.com/thephpleague/oauth2-google
use League\OAuth2\Client\Provider\Google;
//@see https://packagist.org/packages/hayageek/oauth2-yahoo
use Hayageek\OAuth2\Client\Provider\Yahoo;
//@see https://github.com/stevenmaguire/oauth2-microsoft
use Stevenmaguire\OAuth2\Client\Provider\Microsoft;
//@see https://github.com/greew/oauth2-azure-provider
use Greew\OAuth2\Client\Provider\Azure;
if (!isset($_GET['code']) && !isset($_POST['provider'])) {
?>
<html>
<body>
<form method="post">
<h1>Select Provider</h1>
<input type="radio" name="provider" value="Google" id="providerGoogle">
<label for="providerGoogle">Google</label><br>
<input type="radio" name="provider" value="Yahoo" id="providerYahoo">
<label for="providerYahoo">Yahoo</label><br>
<input type="radio" name="provider" value="Microsoft" id="providerMicrosoft">
<label for="providerMicrosoft">Microsoft</label><br>
<input type="radio" name="provider" value="Azure" id="providerAzure">
<label for="providerAzure">Azure</label><br>
<h1>Enter id and secret</h1>
<p>These details are obtained by setting up an app in your provider's developer console.
</p>
<p>ClientId: <input type="text" name="clientId"><p>
<p>ClientSecret: <input type="text" name="clientSecret"></p>
<p>TenantID (only relevant for Azure): <input type="text" name="tenantId"></p>
<input type="submit" value="Continue">
</form>
</body>
</html>
<?php
exit;
}
require 'vendor/autoload.php';
session_start();
$providerName = '';
$clientId = '';
$clientSecret = '';
$tenantId = '';
if (array_key_exists('provider', $_POST)) {
$providerName = $_POST['provider'];
$clientId = $_POST['clientId'];
$clientSecret = $_POST['clientSecret'];
$tenantId = $_POST['tenantId'];
$_SESSION['provider'] = $providerName;
$_SESSION['clientId'] = $clientId;
$_SESSION['clientSecret'] = $clientSecret;
$_SESSION['tenantId'] = $tenantId;
} elseif (array_key_exists('provider', $_SESSION)) {
$providerName = $_SESSION['provider'];
$clientId = $_SESSION['clientId'];
$clientSecret = $_SESSION['clientSecret'];
$tenantId = $_SESSION['tenantId'];
}
//If you don't want to use the built-in form, set your client id and secret here
//$clientId = 'RANDOMCHARS-----duv1n2.apps.googleusercontent.com';
//$clientSecret = 'RANDOMCHARS-----lGyjPcRtvP';
//If this automatic URL doesn't work, set it yourself manually to the URL of this script
$redirectUri = (isset($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['PHP_SELF'];
//$redirectUri = 'http://localhost/PHPMailer/redirect';
$params = [
'clientId' => $clientId,
'clientSecret' => $clientSecret,
'redirectUri' => $redirectUri,
'accessType' => 'offline'
];
$options = [];
$provider = null;
switch ($providerName) {
case 'Google':
$provider = new Google($params);
$options = [
'scope' => [
'https://mail.google.com/'
]
];
break;
case 'Yahoo':
$provider = new Yahoo($params);
break;
case 'Microsoft':
$provider = new Microsoft($params);
$options = [
'scope' => [
'wl.imap',
'wl.offline_access'
]
];
break;
case 'Azure':
$params['tenantId'] = $tenantId;
$provider = new Azure($params);
$options = [
'scope' => [
'https://outlook.office.com/SMTP.Send',
'offline_access'
]
];
break;
}
if (null === $provider) {
exit('Provider missing');
}
if (!isset($_GET['code'])) {
//If we don't have an authorization code then get one
$authUrl = $provider->getAuthorizationUrl($options);
$_SESSION['oauth2state'] = $provider->getState();
header('Location: ' . $authUrl);
exit;
//Check given state against previously stored one to mitigate CSRF attack
} elseif (empty($_GET['state']) || ($_GET['state'] !== $_SESSION['oauth2state'])) {
unset($_SESSION['oauth2state']);
unset($_SESSION['provider']);
exit('Invalid state');
} else {
unset($_SESSION['provider']);
//Try to get an access token (using the authorization code grant)
$token = $provider->getAccessToken(
'authorization_code',
[
'code' => $_GET['code']
]
);
//Use this to interact with an API on the users behalf
//Use this to get a new access token if the old one expires
echo 'Refresh Token: ', $token->getRefreshToken();
}
+31 -19
View File
@@ -1,25 +1,37 @@
# Legacy App Archiv
# Kaffeeliste
Dieses Verzeichnis enthält den bisherigen PHP-Bestand der Kaffeeliste.
Dieses Repository enthält den laufenden Umbau der Kaffeelisten-App von einer
Windows/IIS/LDAP-gebundenen Legacy-App zu einer mehrkundenfähigen SaaS-
Anwendung. Der Umbau erfolgt schrittweise im selben Repository, nicht in
einem getrennten Zielverzeichnis: Legacy-Seiten und neue SaaS-Bausteine
liegen nebeneinander, bis eine Seite vollständig auf das neue Modell
umgestellt ist.
Es bleibt aus drei Gründen im Repository:
Den vollständigen Plan mit Zielbild, Datenmodell, Rollenmodell und
Meilensteinen beschreibt `docs/saas-umstrukturierungsplan.md`. Der
Fortschritt je Meilenstein steht in `docs/m2-technical-foundation.md` bis
`docs/m5-app-kern.md`.
- als Referenz für die bestehende Fachlogik
- als Quelle für Datenmigrationen in die SaaS-Version
- als Rückfalloption während der Übergangsphase
## Struktur
## Wichtige Legacy-Bereiche
- Legacy- und SaaS-Seiten liegen als flache PHP-Dateien im Webroot, zum
Beispiel `index.php`, `stricheintragen.php`, `kaffeeliste.php`,
`mitarbeiterverwalten.php`.
- `app/`: zentrale Bausteine für Bootstrap, DB-Zugriff, Auth, Mail und das
neue Ledger-Modell (`app/ledger.php`).
- `database/migrations/`: versionierte Schemaänderungen, anzuwenden über
`scripts/migrate.php`.
- `scripts/`: Migrations-, Backfill- und Prüfskripte (Golden Master,
HTTP-Smoke, M3/M4-Checks).
- `docs/`: Planungs- und Meilensteindokumentation. Ausrollen und
Inbetriebnahme auf dem Webspace beschreibt `docs/deployment.md`.
- `index.php`: persönliches Dashboard
- `stricheintragen.php`: Sammelerfassung für Kaffee-Striche
- `einzahlung.php`: Sammelerfassung für Einzahlungen
- `kaffeeliste.php`: operative Gesamtübersicht
- `mitarbeiterverwalten.php`: Mitglieder- und Rollenpflege
- `letzteneintraege.php`: Korrektur letzter Buchungen
- `hinweise.php`: Banner/Hinweise
## Umgang mit Legacy-Seiten
## Umgang Mit Dem Archiv
- Keine neuen Produktfunktionen mehr hier entwickeln.
- Nur noch für Referenz, Datenabgleich oder Notfallbetrieb verwenden.
- Neue Arbeit findet ausschließlich in `../saas-app/` und `../docs/` statt.
- Seiten, die noch direkt auf `kl_*`-Tabellen schreiben, werden schrittweise
auf tenant-sicheres Lesen/Schreiben über `app/ledger.php` und
`app/saas-auth.php` umgestellt; siehe die Meilensteindokumente für den
aktuellen Stand je Seite.
- Neue Produktfunktionen entstehen gegen das neue Modell (Tenants, Users,
Participants, Ledger), nicht mehr direkt gegen die `kl_*`-Tabellen.
- Lokale Entwicklung gegen MySQL ist in `docs/dev-mysql.md` beschrieben.
+12
View File
@@ -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 ---
+12
View File
@@ -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 ---
+12
View File
@@ -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 ---
+12
View File
@@ -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 ---
+13
View File
@@ -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 ---
+12
View File
@@ -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 ---
+12
View File
@@ -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 ---
+12
View File
@@ -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 ---
+12
View File
@@ -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 ---
+12
View File
@@ -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 ---
+12
View File
@@ -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 ---
+12
View File
@@ -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 ---
+12
View File
@@ -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 ---
+12
View File
@@ -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
+996
View File
@@ -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.
+462
View File
@@ -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
//============================================================+
+481
View 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
+359
View File
@@ -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
+108
View 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>
+41
View File
@@ -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
View File
@@ -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.');
}
+37
View File
@@ -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">&larr; 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
View File
@@ -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&nbsp;€ 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"; ?>
+279
View File
@@ -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,
];
}
+170
View File
@@ -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";
}
+54
View File
@@ -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
View File
@@ -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;
}
+106
View File
@@ -6,6 +6,17 @@ if (!defined('APP_ROOT')) {
define('APP_ROOT', dirname(__DIR__));
}
// Auf echtem Webhosting (PHP-FPM/mod_php) gibt es meist keine bequeme
// Moeglichkeit, Prozess-Umgebungsvariablen zu setzen wie beim lokalen
// `source .env.local` vor dem PHP-Dev-Server. env.local.php ist die
// Produktions-Entsprechung: eine nicht eingecheckte Datei mit putenv()-
// Aufrufen, siehe env.local.example.php. Existiert sie nicht (lokale
// Entwicklung), passiert hier nichts und getenv() liest wie bisher aus
// der Shell-Umgebung.
if (is_file(APP_ROOT . '/env.local.php')) {
require_once APP_ROOT . '/env.local.php';
}
function app_env(string $name, ?string $default = null): ?string
{
$value = getenv($name);
@@ -16,6 +27,16 @@ function app_env(string $name, ?string $default = null): ?string
return $value;
}
// PHP und MySQL muessen dieselbe Uhr sehen. Sonst laufen alle Stellen, die
// einen in PHP berechneten Zeitstempel gegen MySQL NOW() vergleichen,
// gegeneinander - z. B. Token-Ablaeufe (Passwort-Reset, E-Mail-Verifikation)
// und die Gueltigkeit von Hinweisen. Wir pinnen die PHP-Zeitzone
// deterministisch; app_db_pdo() setzt die DB-Session auf denselben Offset.
$appTimezone = app_env('APP_TIMEZONE', 'Europe/Berlin');
if (@date_default_timezone_set((string)$appTimezone) === false) {
date_default_timezone_set('UTC');
}
function app_is_dev(): bool
{
return app_env('APP_ENV', 'prod') === 'dev';
@@ -27,6 +48,84 @@ function app_is_https(): bool
|| (($_SERVER['SERVER_PORT'] ?? null) === '443');
}
/**
* Sends baseline security headers on every dynamic request. Deliberately
* does not set a Content-Security-Policy: the existing templates rely on
* inline style="" attributes throughout (banners, table cells, etc.), and
* locking that down would need a broader template pass. Runs once per
* request via the auto-invocation at the bottom of this file.
*/
function app_send_security_headers(): void
{
if (PHP_SAPI === 'cli' || headers_sent()) {
return;
}
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: geolocation=(), microphone=(), camera=()');
if (app_is_https()) {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
}
/**
* The application's own primary hostname in production (e.g.
* app.kaffeeliste.de), configured via APP_HOST. Unset in dev, where
* index.php should keep behaving exactly as before (no host split).
*/
function app_primary_host(): ?string
{
return app_env('APP_HOST');
}
/**
* Builds a URL that always points at the app's own host, even when the
* current request came in on a different domain (e.g. the marketing
* domain linking to /login.php). Falls back to a relative path when
* APP_HOST isn't configured, so local dev is unaffected.
*/
function app_primary_url(string $path): string
{
$host = app_primary_host();
if ($host === null) {
return ltrim($path, '/');
}
$scheme = app_is_https() ? 'https' : 'http';
return $scheme . '://' . $host . '/' . ltrim($path, '/');
}
/**
* Adresse der Marketing-Domain (z. B. https://kaffeeliste.de) - Ziel des
* Wasserzeichens im kostenlosen Tarif. Explizit ueber APP_MARKETING_URL
* setzbar; sonst wird sie aus APP_HOST abgeleitet, indem die
* App-Subdomain wegfaellt (app.kaffeeliste.de -> kaffeeliste.de). In der
* lokalen Entwicklung ohne APP_HOST bleibt der aktuelle Host uebrig, im
* CLI-Betrieb ein leerer String.
*/
function app_marketing_url(): string
{
$configured = app_env('APP_MARKETING_URL');
if ($configured !== null) {
return rtrim($configured, '/');
}
$scheme = app_is_https() ? 'https' : 'http';
$host = app_primary_host();
if ($host !== null) {
return $scheme . '://' . preg_replace('/^app\./i', '', $host);
}
$requestHost = (string)($_SERVER['HTTP_HOST'] ?? '');
return $requestHost !== '' ? $scheme . '://' . $requestHost : '';
}
function app_start_session(): void
{
if (PHP_SAPI === 'cli' || session_status() === PHP_SESSION_ACTIVE) {
@@ -102,3 +201,10 @@ function app_require_csrf(): void
die('CSRF validation failed.');
}
}
app_send_security_headers();
// Der Analyse-Baustein wird zentral geladen, damit alle HTML-Templates nur
// noch die beiden bewusst platzierten Head-/Body-Fragmente ausgeben muessen.
// analytics.php bindet bootstrap.php seinerseits per require_once ein.
require_once __DIR__ . '/analytics.php';
+105
View File
@@ -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;
}
+135
View File
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/tenant-logo.php';
/**
* Assembles a full export of everything stored for one tenant (data
* portability / GDPR Art. 20). Excludes password_hash and token hashes;
* everything else that is tenant-scoped is included as-is.
*
* @return array<string, mixed>
*/
function app_export_tenant_data(PDO $pdo, int $tenantId): array
{
$stmt = $pdo->prepare('SELECT id, slug, paypal_inbox_token, name, customer_type, status, timezone, locale, currency_code, created_at, updated_at FROM tenants WHERE id = ?');
$stmt->execute([$tenantId]);
$tenant = $stmt->fetch();
$stmt = $pdo->prepare('SELECT * FROM tenant_settings WHERE tenant_id = ?');
$stmt->execute([$tenantId]);
$settings = $stmt->fetch();
$stmt = $pdo->prepare('SELECT * FROM participants WHERE tenant_id = ? ORDER BY id');
$stmt->execute([$tenantId]);
$participants = $stmt->fetchAll();
$stmt = $pdo->prepare(
'SELECT u.id, u.email, u.display_name, u.status, u.email_verified_at, u.last_login_at, u.created_at,
tm.role, tm.status AS membership_status, tm.invited_at, tm.joined_at
FROM tenant_memberships tm
JOIN users u ON u.id = tm.user_id
WHERE tm.tenant_id = ?
ORDER BY u.id'
);
$stmt->execute([$tenantId]);
$members = $stmt->fetchAll();
$stmt = $pdo->prepare('SELECT * FROM ledger_entries WHERE tenant_id = ? ORDER BY id');
$stmt->execute([$tenantId]);
$ledgerEntries = $stmt->fetchAll();
$stmt = $pdo->prepare('SELECT id, message, valid_from, valid_until, deleted_at, created_at FROM notices WHERE tenant_id = ? ORDER BY id');
$stmt->execute([$tenantId]);
$notices = $stmt->fetchAll();
$stmt = $pdo->prepare('SELECT id, original_filename, status, created_at, committed_at FROM payment_import_batches WHERE tenant_id = ? ORDER BY id');
$stmt->execute([$tenantId]);
$importBatches = $stmt->fetchAll();
$stmt = $pdo->prepare(
'SELECT r.id, r.batch_id, r.row_num, r.raw_name, r.amount_cents, r.booked_at, r.status
FROM payment_import_rows r
JOIN payment_import_batches b ON b.id = r.batch_id
WHERE b.tenant_id = ?
ORDER BY r.id'
);
$stmt->execute([$tenantId]);
$importRows = $stmt->fetchAll();
$stmt = $pdo->prepare('SELECT id, participant_id, template, subject, status, sent_at, error, created_at FROM outbound_emails WHERE tenant_id = ? ORDER BY id');
$stmt->execute([$tenantId]);
$outboundEmails = $stmt->fetchAll();
$stmt = $pdo->prepare('SELECT id, actor_user_id, action, subject_type, subject_id, metadata_json, ip, created_at FROM audit_log WHERE tenant_id = ? ORDER BY id');
$stmt->execute([$tenantId]);
$auditLog = $stmt->fetchAll();
$stmt = $pdo->prepare('SELECT * FROM faq_entries WHERE tenant_id = ? ORDER BY id');
$stmt->execute([$tenantId]);
$faqEntries = $stmt->fetchAll();
$stmt = $pdo->prepare('SELECT * FROM paypal_payments WHERE tenant_id = ? ORDER BY id');
$stmt->execute([$tenantId]);
$paypalPayments = $stmt->fetchAll();
$stmt = $pdo->prepare('SELECT * FROM tenant_billing WHERE tenant_id = ?');
$stmt->execute([$tenantId]);
$billing = $stmt->fetch();
$stmt = $pdo->prepare('SELECT * FROM tenant_features WHERE tenant_id = ? ORDER BY id');
$stmt->execute([$tenantId]);
$features = $stmt->fetchAll();
$stmt = $pdo->prepare(
'SELECT document_type, document_version, context, metadata_json, accepted_at
FROM legal_acceptances WHERE tenant_id = ? ORDER BY id'
);
$stmt->execute([$tenantId]);
$legalAcceptances = $stmt->fetchAll();
$stmt = $pdo->prepare(
'SELECT reference_code, request_type, requester_name, requester_email,
contract_reference, request_reason, requested_end, status,
metadata_json, received_at, processed_at
FROM legal_requests WHERE tenant_id = ? ORDER BY id'
);
$stmt->execute([$tenantId]);
$legalRequests = $stmt->fetchAll();
$logos = [];
foreach (['brand_logo', 'pdf_watermark_logo'] as $logoField) {
$filename = (string)($settings[$logoField] ?? '');
$path = tenant_logo_path($filename);
if ($path !== null) {
$logos[$logoField] = [
'filename' => $filename,
'mime_type' => mime_content_type($path) ?: 'application/octet-stream',
'base64' => base64_encode((string)file_get_contents($path)),
];
}
}
return [
'exported_at' => date('c'),
'tenant' => $tenant ?: null,
'tenant_settings' => $settings ?: null,
'participants' => $participants,
'members' => $members,
'ledger_entries' => $ledgerEntries,
'notices' => $notices,
'payment_import_batches' => $importBatches,
'payment_import_rows' => $importRows,
'outbound_emails' => $outboundEmails,
'audit_log' => $auditLog,
'faq_entries' => $faqEntries,
'paypal_payments' => $paypalPayments,
'billing' => $billing ?: null,
'features' => $features,
'legal_acceptances' => $legalAcceptances,
'legal_requests' => $legalRequests,
'logos' => $logos,
];
}
+10 -1
View File
@@ -3,6 +3,7 @@
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/prefixed-pdo.php';
function app_db_pdo(): PDO
{
@@ -23,11 +24,19 @@ function app_db_pdo(): PDO
}
$dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $database);
$pdo = new PDO($dsn, $user, $password ?? '', [
$pdo = new AppPrefixedPDO($dsn, $user, $password ?? '', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => true,
]);
$pdo->setTablePrefix(app_db_table_prefix());
// DB-Session auf denselben Offset wie die (in bootstrap.php gepinnte)
// PHP-Zeitzone setzen, damit MySQL NOW() und PHP-Zeitstempel
// uebereinstimmen. Numerischer Offset, damit keine MySQL-Zeitzonentabellen
// noetig sind.
$offset = (new DateTimeImmutable('now'))->format('P');
$pdo->exec("SET time_zone = '{$offset}'");
return $pdo;
}
+182
View File
@@ -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
View File
@@ -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]);
}
}
+251
View File
@@ -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
View File
@@ -0,0 +1,489 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/ledger.php';
/**
* Parses a decimal amount ("12,34" or "12.34") into cents. Returns null for
* anything that is not a plausible positive amount.
*/
function imports_normalize_amount(string $raw): ?int
{
$normalized = str_replace(',', '.', trim($raw));
if (!preg_match('/^-?[0-9]+(?:\.[0-9]{1,2})?$/', $normalized)) {
return null;
}
$cents = (int)round(((float)$normalized) * 100);
return $cents > 0 ? $cents : null;
}
/**
* Matches a CSV name against a participant's paypal_name or display_name
* (case-insensitive).
*
* @return array{participant_id: int, display_name: string}|null
*/
function imports_find_participant(PDO $pdo, int $tenantId, string $name): ?array
{
$name = trim($name);
if ($name === '') {
return null;
}
$stmt = $pdo->prepare(
'SELECT id, display_name
FROM participants
WHERE tenant_id = ?
AND (LOWER(paypal_name) = LOWER(?) OR LOWER(display_name) = LOWER(?))
ORDER BY id
LIMIT 2'
);
$stmt->execute([$tenantId, $name, $name]);
$rows = $stmt->fetchAll();
if (count($rows) !== 1) {
return null;
}
$row = $rows[0];
return [
'participant_id' => (int)$row['id'],
'display_name' => (string)$row['display_name'],
];
}
function imports_normalize_match_text(string $value): string
{
$value = trim($value);
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
return function_exists('mb_strtolower')
? mb_strtolower($value, 'UTF-8')
: strtolower($value);
}
/**
* Liefert wahrscheinliche Teilnehmer fuer eine manuelle Zuordnung. Die
* automatische Buchung bleibt strenger; diese Vorschlaege sind nur eine Hilfe
* fuer Admins/Kassenwarte in CSV- und PayPal-Warteschlangen.
*
* @return list<array{participant_id:int, display_name:string, email:?string, paypal_name:?string, score:int}>
*/
function imports_suggest_participants(PDO $pdo, int $tenantId, string $rawName, ?string $note = null, int $limit = 5): array
{
$haystack = imports_normalize_match_text(trim($rawName . ' ' . (string)$note));
if ($haystack === '') {
return [];
}
$tokens = array_values(array_unique(array_filter(
preg_split('/\s+/', $haystack) ?: [],
static fn(string $token): bool => strlen($token) >= 3
)));
$stmt = $pdo->prepare(
'SELECT id, display_name, email, paypal_name
FROM participants
WHERE tenant_id = ? AND active = 1
ORDER BY display_name'
);
$stmt->execute([$tenantId]);
$candidates = [];
foreach ($stmt->fetchAll() as $row) {
$displayName = (string)$row['display_name'];
$paypalName = (string)($row['paypal_name'] ?? '');
$email = (string)($row['email'] ?? '');
$displayNorm = imports_normalize_match_text($displayName);
$paypalNorm = imports_normalize_match_text($paypalName);
$emailNorm = imports_normalize_match_text($email);
$score = 0;
if ($displayNorm !== '' && $displayNorm === imports_normalize_match_text($rawName)) {
$score += 90;
}
if ($paypalNorm !== '' && $paypalNorm === imports_normalize_match_text($rawName)) {
$score += 100;
}
if ($displayNorm !== '' && str_contains($haystack, $displayNorm)) {
$score += 45;
}
if ($paypalNorm !== '' && str_contains($haystack, $paypalNorm)) {
$score += 55;
}
foreach ($tokens as $token) {
if ($displayNorm !== '' && str_contains($displayNorm, $token)) {
$score += 12;
}
if ($paypalNorm !== '' && str_contains($paypalNorm, $token)) {
$score += 16;
}
if ($emailNorm !== '' && str_contains($emailNorm, $token)) {
$score += 6;
}
}
if ($score <= 0) {
continue;
}
$candidates[] = [
'participant_id' => (int)$row['id'],
'display_name' => $displayName,
'email' => $row['email'] !== null ? (string)$row['email'] : null,
'paypal_name' => $row['paypal_name'] !== null ? (string)$row['paypal_name'] : null,
'score' => $score,
];
}
usort($candidates, static function (array $a, array $b): int {
return $b['score'] <=> $a['score']
?: strcasecmp((string)$a['display_name'], (string)$b['display_name']);
});
return array_slice($candidates, 0, max(1, $limit));
}
/**
* A payment counts as a duplicate if the same participant already has a
* non-voided ledger payment of the same amount on the same day.
*/
function imports_is_duplicate(PDO $pdo, int $tenantId, int $participantId, int $amountCents, string $bookedAtDate): bool
{
$stmt = $pdo->prepare(
"SELECT COUNT(*)
FROM ledger_entries
WHERE tenant_id = ?
AND participant_id = ?
AND type = 'payment'
AND amount_cents = ?
AND DATE(booked_at) = ?
AND voided_at IS NULL"
);
$stmt->execute([$tenantId, $participantId, $amountCents, $bookedAtDate]);
return (int)$stmt->fetchColumn() > 0;
}
/**
* Parses a PayPal-style export CSV: column 0 is the booking date, column 3
* the payer name, column 7 the gross amount. The first line is a header and
* is skipped.
*
* @return list<array{row_number: int, raw_name: string, amount_raw: string, date_raw: string, raw: list<string>}>
*/
function imports_parse_csv(string $filePath): array
{
$handle = fopen($filePath, 'r');
if ($handle === false) {
throw new RuntimeException('Die CSV-Datei konnte nicht gelesen werden.');
}
$rows = [];
$rowNumber = 0;
fgetcsv($handle); // Header ueberspringen
while (($data = fgetcsv($handle, 1000, ',')) !== false) {
$rowNumber++;
$rows[] = [
'row_number' => $rowNumber,
'raw_name' => trim((string)($data[3] ?? '')),
'amount_raw' => trim((string)($data[7] ?? '')),
'date_raw' => trim((string)($data[0] ?? '')),
'raw' => $data,
];
}
fclose($handle);
return $rows;
}
function imports_create_batch(PDO $pdo, int $tenantId, string $originalFilename, string $checksum, ?int $createdByUserId): int
{
$stmt = $pdo->prepare(
'INSERT INTO payment_import_batches (tenant_id, original_filename, checksum, status, created_by_user_id)
VALUES (?, ?, ?, ?, ?)'
);
$stmt->execute([$tenantId, $originalFilename, $checksum, 'previewed', $createdByUserId]);
return (int)$pdo->lastInsertId();
}
function imports_store_row(
PDO $pdo,
int $batchId,
int $rowNumber,
?int $participantId,
string $rawName,
int $amountCents,
string $bookedAt,
string $status,
array $rawRow
): int {
$stmt = $pdo->prepare(
'INSERT INTO payment_import_rows
(batch_id, row_num, participant_id, raw_name, amount_cents, booked_at, status, raw_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$batchId,
$rowNumber,
$participantId,
$rawName,
$amountCents,
$bookedAt,
$status,
json_encode($rawRow, JSON_UNESCAPED_UNICODE),
]);
return (int)$pdo->lastInsertId();
}
/**
* @return array{batch: array{id: int, original_filename: string, status: string, created_at: string}, rows: list<array>}|null
*/
function imports_fetch_batch(PDO $pdo, int $tenantId, int $batchId): ?array
{
$stmt = $pdo->prepare(
'SELECT id, original_filename, status, created_at
FROM payment_import_batches
WHERE id = ? AND tenant_id = ?'
);
$stmt->execute([$batchId, $tenantId]);
$batch = $stmt->fetch();
if ($batch === false) {
return null;
}
$stmt = $pdo->prepare(
'SELECT r.id, r.row_num, r.participant_id, r.raw_name, r.amount_cents, r.booked_at, r.status, p.display_name
FROM payment_import_rows r
LEFT JOIN participants p ON p.id = r.participant_id
WHERE r.batch_id = ?
ORDER BY r.row_num'
);
$stmt->execute([$batchId]);
return [
'batch' => [
'id' => (int)$batch['id'],
'original_filename' => (string)$batch['original_filename'],
'status' => (string)$batch['status'],
'created_at' => (string)$batch['created_at'],
],
'rows' => $stmt->fetchAll(),
];
}
/**
* @return list<array{id:int, original_filename:string, status:string, created_at:string, committed_at:?string, total_rows:int, matched_rows:int, unresolved_rows:int, imported_rows:int, ignored_rows:int}>
*/
function imports_fetch_recent_batches(PDO $pdo, int $tenantId, int $limit = 10): array
{
$stmt = $pdo->prepare(
"SELECT b.id, b.original_filename, b.status, b.created_at, b.committed_at,
COUNT(r.id) AS total_rows,
SUM(CASE WHEN r.status = 'matched' THEN 1 ELSE 0 END) AS matched_rows,
SUM(CASE WHEN r.status IN ('unmatched', 'duplicate', 'invalid') THEN 1 ELSE 0 END) AS unresolved_rows,
SUM(CASE WHEN r.status = 'imported' THEN 1 ELSE 0 END) AS imported_rows,
SUM(CASE WHEN r.status = 'ignored' THEN 1 ELSE 0 END) AS ignored_rows
FROM payment_import_batches b
LEFT JOIN payment_import_rows r ON r.batch_id = b.id
WHERE b.tenant_id = ?
GROUP BY b.id, b.original_filename, b.status, b.created_at, b.committed_at
ORDER BY b.created_at DESC, b.id DESC
LIMIT " . max(1, min(50, $limit))
);
$stmt->execute([$tenantId]);
return array_map(static function (array $row): array {
return [
'id' => (int)$row['id'],
'original_filename' => (string)$row['original_filename'],
'status' => (string)$row['status'],
'created_at' => (string)$row['created_at'],
'committed_at' => $row['committed_at'] !== null ? (string)$row['committed_at'] : null,
'total_rows' => (int)$row['total_rows'],
'matched_rows' => (int)$row['matched_rows'],
'unresolved_rows' => (int)$row['unresolved_rows'],
'imported_rows' => (int)$row['imported_rows'],
'ignored_rows' => (int)$row['ignored_rows'],
];
}, $stmt->fetchAll());
}
/**
* @return array{ok:bool, error?:string}
*/
function imports_assign_row(PDO $pdo, int $tenantId, int $batchId, int $rowId, int $participantId): array
{
$batch = imports_fetch_batch($pdo, $tenantId, $batchId);
if ($batch === null || $batch['batch']['status'] !== 'previewed') {
return ['ok' => false, 'error' => 'Dieser Import kann nicht mehr bearbeitet werden.'];
}
$summaries = ledger_fetch_participant_summaries($pdo, $tenantId, ['participant_ids' => [$participantId]]);
$participant = $summaries[0] ?? null;
if ($participant === null || empty($participant['active'])) {
return ['ok' => false, 'error' => 'Das gewählte Mitglied wurde nicht gefunden.'];
}
$stmt = $pdo->prepare(
"UPDATE payment_import_rows r
JOIN payment_import_batches b ON b.id = r.batch_id
SET r.participant_id = ?, r.status = 'matched'
WHERE r.id = ?
AND r.batch_id = ?
AND b.tenant_id = ?
AND b.status = 'previewed'
AND r.status IN ('unmatched', 'duplicate')"
);
$stmt->execute([$participantId, $rowId, $batchId, $tenantId]);
if ($stmt->rowCount() !== 1) {
return ['ok' => false, 'error' => 'Diese Zeile kann nicht zugeordnet werden.'];
}
return ['ok' => true];
}
/**
* @return array{ok:bool, error?:string}
*/
function imports_ignore_row(PDO $pdo, int $tenantId, int $batchId, int $rowId): array
{
$stmt = $pdo->prepare(
"UPDATE payment_import_rows r
JOIN payment_import_batches b ON b.id = r.batch_id
SET r.participant_id = NULL, r.status = 'ignored'
WHERE r.id = ?
AND r.batch_id = ?
AND b.tenant_id = ?
AND b.status = 'previewed'
AND r.status IN ('unmatched', 'duplicate', 'invalid')"
);
$stmt->execute([$rowId, $batchId, $tenantId]);
if ($stmt->rowCount() !== 1) {
return ['ok' => false, 'error' => 'Diese Zeile kann nicht ignoriert werden.'];
}
return ['ok' => true];
}
/**
* Commits every 'matched' row of a previewed batch straight into the ledger.
* Already-committed batches are rejected.
*
* @return array{imported: int}
*/
function imports_commit_batch(PDO $pdo, int $tenantId, int $batchId): array
{
$stmt = $pdo->prepare('SELECT status FROM payment_import_batches WHERE id = ? AND tenant_id = ?');
$stmt->execute([$batchId, $tenantId]);
$status = $stmt->fetchColumn();
if ($status === false) {
throw new RuntimeException('Der Import wurde nicht gefunden.');
}
if ($status !== 'previewed') {
throw new RuntimeException('Dieser Import wurde bereits verarbeitet.');
}
$stmt = $pdo->prepare(
"SELECT r.id, r.participant_id, r.amount_cents, r.booked_at
FROM payment_import_rows r
JOIN participants p ON p.id = r.participant_id
WHERE r.batch_id = ? AND r.status = 'matched'"
);
$stmt->execute([$batchId]);
$rows = $stmt->fetchAll();
$imported = 0;
$pdo->beginTransaction();
try {
$updateRow = $pdo->prepare(
"UPDATE payment_import_rows SET status = 'imported', ledger_entry_id = ? WHERE id = ?"
);
foreach ($rows as $row) {
$ledgerEntryId = ledger_record_payment(
$pdo,
$tenantId,
(int)$row['participant_id'],
(int)$row['amount_cents'],
'csv_import'
);
$updateRow->execute([$ledgerEntryId, $row['id']]);
$imported++;
}
$pdo->prepare("UPDATE payment_import_batches SET status = 'committed', committed_at = NOW() WHERE id = ?")
->execute([$batchId]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
return ['imported' => $imported];
}
/**
* Parst eine Mitglieder-CSV (Vorlage: Name, E-Mail, PayPal-Name, Aktiv). Erste
* Zeile ist die Kopfzeile und wird uebersprungen. Trennzeichen wird anhand der
* Kopfzeile automatisch erkannt (Komma oder Semikolon - Excel im deutschen
* Gebietsschema exportiert Semikolon).
*
* @return list<array{row_number:int, name:string, email:string, paypal_name:string, active:bool}>
*/
function imports_parse_member_csv(string $filePath): array
{
$handle = fopen($filePath, 'r');
if ($handle === false) {
throw new RuntimeException('Die CSV-Datei konnte nicht gelesen werden.');
}
$firstLine = fgets($handle);
if ($firstLine === false) {
fclose($handle);
return [];
}
// BOM entfernen, Trennzeichen erkennen.
$firstLine = preg_replace('/^\xEF\xBB\xBF/', '', $firstLine);
$delimiter = (substr_count($firstLine, ';') > substr_count($firstLine, ',')) ? ';' : ',';
$rows = [];
$rowNumber = 0;
while (($data = fgetcsv($handle, 2000, $delimiter)) !== false) {
// Komplett leere Zeilen ueberspringen.
if ($data === [null] || (count($data) === 1 && trim((string)$data[0]) === '')) {
continue;
}
$rowNumber++;
$aktivRaw = strtolower(trim((string)($data[3] ?? 'ja')));
$active = !in_array($aktivRaw, ['nein', 'no', '0', 'false', 'inaktiv'], true);
$rows[] = [
'row_number' => $rowNumber,
'name' => trim((string)($data[0] ?? '')),
'email' => trim((string)($data[1] ?? '')),
'paypal_name' => trim((string)($data[2] ?? '')),
'active' => $active,
];
}
fclose($handle);
return $rows;
}
+409 -10
View File
@@ -41,6 +41,73 @@ function ledger_current_year(): int
return (int)date('Y');
}
/**
* Bemerkung fuer einen Journaleintrag saeubern: leere Angaben werden zu NULL,
* die Laenge auf die Spaltenbreite (1000) begrenzt. Zeilenumbrueche werden zu
* Leerzeichen, damit die Notiz in Tabellen und im PDF einzeilig bleibt.
*/
function ledger_normalize_note(?string $note): ?string
{
if ($note === null) {
return null;
}
$note = preg_replace('/\s+/u', ' ', $note) ?? $note;
$note = trim($note);
if ($note === '') {
return null;
}
if (strlen($note) > 1000) {
// Byteweise kuerzen und danach eine ggf. angeschnittene Multibyte-
// Sequenz am Ende wegnehmen (preg_match mit /u schlaegt bei
// ungueltigem UTF-8 fehl). 1000 Bytes sind hoechstens 1000 Zeichen,
// passen also immer in die Spalte.
$note = substr($note, 0, 1000);
while ($note !== '' && preg_match('//u', $note) !== 1) {
$note = substr($note, 0, -1);
}
}
return $note;
}
function ledger_default_tenant_slug(): string
{
$tenantSlug = app_env('M4_DEFAULT_TENANT_SLUG');
if ($tenantSlug === null || trim($tenantSlug) === '') {
$tenantSlug = app_env('M3_DEFAULT_TENANT_SLUG', 'default');
}
return trim((string)$tenantSlug) !== '' ? trim((string)$tenantSlug) : 'default';
}
/**
* @return array{id: int, name: string, slug: string}|null
*/
function ledger_fetch_tenant_by_slug(PDO $pdo, string $slug): ?array
{
$stmt = $pdo->prepare('SELECT id, name, slug FROM tenants WHERE slug = ? LIMIT 1');
$stmt->execute([trim($slug)]);
$tenant = $stmt->fetch();
if ($tenant === false) {
return null;
}
return [
'id' => (int)$tenant['id'],
'name' => (string)$tenant['name'],
'slug' => (string)$tenant['slug'],
];
}
/**
* @return array{id: int, name: string, slug: string}|null
*/
function ledger_fetch_default_tenant(PDO $pdo): ?array
{
return ledger_fetch_tenant_by_slug($pdo, ledger_default_tenant_slug());
}
/**
* @return array{total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}
*/
@@ -101,7 +168,7 @@ function ledger_fetch_tenant_totals(PDO $pdo, int $tenantId, ?int $year = null):
* - participant_ids: list<int>
* - email_norms: list<string>
*
* @return list<array{participant_id: int, tenant_id: int, legacy_mitarbeiter_id: ?int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}>
* @return list<array{participant_id: int, tenant_id: int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}>
*/
function ledger_fetch_participant_summaries(PDO $pdo, int $tenantId, array $options = []): array
{
@@ -136,11 +203,21 @@ function ledger_fetch_participant_summaries(PDO $pdo, int $tenantId, array $opti
array_push($params, ...$emailNorms);
}
if (array_key_exists('user_ids', $options)) {
$userIds = is_array($options['user_ids'])
? ledger_normalize_ids($options['user_ids'])
: [];
if ($userIds === []) {
return [];
}
$where[] = 'p.user_id IN (' . implode(',', array_fill(0, count($userIds), '?')) . ')';
array_push($params, ...$userIds);
}
$sql = "
SELECT
p.id AS participant_id,
p.tenant_id,
p.legacy_mitarbeiter_id,
p.display_name,
p.email,
p.email_norm,
@@ -159,7 +236,7 @@ function ledger_fetch_participant_summaries(PDO $pdo, int $tenantId, array $opti
AND le.participant_id = p.id
AND le.voided_at IS NULL
WHERE " . implode(' AND ', $where) . "
GROUP BY p.id, p.tenant_id, p.legacy_mitarbeiter_id, p.display_name, p.email, p.email_norm, p.paypal_name, p.active
GROUP BY p.id, p.tenant_id, p.display_name, p.email, p.email_norm, p.paypal_name, p.active
ORDER BY p.display_name, p.id";
$stmt = $pdo->prepare($sql);
@@ -170,7 +247,6 @@ function ledger_fetch_participant_summaries(PDO $pdo, int $tenantId, array $opti
$summaries[] = [
'participant_id' => (int)$row['participant_id'],
'tenant_id' => (int)$row['tenant_id'],
'legacy_mitarbeiter_id' => $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null,
'display_name' => (string)$row['display_name'],
'email' => $row['email'] !== null ? (string)$row['email'] : null,
'email_norm' => $row['email_norm'] !== null ? (string)$row['email_norm'] : null,
@@ -190,7 +266,7 @@ function ledger_fetch_participant_summaries(PDO $pdo, int $tenantId, array $opti
}
/**
* @return array{participant_id: int, tenant_id: int, legacy_mitarbeiter_id: ?int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}|null
* @return array{participant_id: int, tenant_id: int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}|null
*/
function ledger_fetch_participant_summary(PDO $pdo, int $tenantId, int $participantId, ?int $year = null): ?array
{
@@ -202,6 +278,299 @@ function ledger_fetch_participant_summary(PDO $pdo, int $tenantId, int $particip
return $summaries[0] ?? null;
}
function ledger_is_default_tenant(PDO $pdo, int $tenantId): bool
{
$tenant = ledger_fetch_default_tenant($pdo);
return $tenant !== null && (int)$tenant['id'] === $tenantId;
}
/**
* @return list<array{participant_id: int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, user_id: ?int, role: ?string, membership_status: ?string}>
*/
function ledger_fetch_participants_for_admin(PDO $pdo, int $tenantId): array
{
$stmt = $pdo->prepare(
"SELECT
p.id AS participant_id,
p.display_name,
p.email,
p.email_norm,
p.paypal_name,
p.active,
p.user_id,
tm.role,
tm.status AS membership_status
FROM participants p
LEFT JOIN tenant_memberships tm
ON tm.tenant_id = p.tenant_id
AND tm.user_id = p.user_id
WHERE p.tenant_id = ?
ORDER BY p.display_name, p.id"
);
$stmt->execute([$tenantId]);
$rows = [];
while ($row = $stmt->fetch()) {
$rows[] = [
'participant_id' => (int)$row['participant_id'],
'display_name' => (string)$row['display_name'],
'email' => $row['email'] !== null ? (string)$row['email'] : null,
'email_norm' => $row['email_norm'] !== null ? (string)$row['email_norm'] : null,
'paypal_name' => $row['paypal_name'] !== null ? (string)$row['paypal_name'] : null,
'active' => (int)$row['active'] === 1,
'user_id' => $row['user_id'] !== null ? (int)$row['user_id'] : null,
'role' => $row['role'] !== null ? (string)$row['role'] : null,
'membership_status' => $row['membership_status'] !== null ? (string)$row['membership_status'] : null,
];
}
return $rows;
}
/**
* Creates a tenant-scoped participant.
*
* @throws Throwable on constraint violations (e.g. duplicate email)
*/
function ledger_create_participant(PDO $pdo, int $tenantId, string $displayName, string $email, ?string $paypalName, bool $active): int
{
$displayName = trim($displayName);
$email = trim($email);
$emailNorm = strtolower($email);
$paypalName = $paypalName !== null ? trim($paypalName) : null;
$paypalName = $paypalName !== '' ? $paypalName : null;
$stmt = $pdo->prepare(
'INSERT INTO participants
(tenant_id, display_name, email, email_norm, paypal_name, active)
VALUES (?, ?, ?, ?, ?, ?)'
);
$stmt->execute([$tenantId, $displayName, $email, $emailNorm, $paypalName, $active ? 1 : 0]);
return (int)$pdo->lastInsertId();
}
function ledger_update_participant(PDO $pdo, int $tenantId, int $participantId, string $displayName, string $email, ?string $paypalName, bool $active): bool
{
$displayName = trim($displayName);
$email = trim($email);
$emailNorm = strtolower($email);
$paypalName = $paypalName !== null ? trim($paypalName) : null;
$paypalName = $paypalName !== '' ? $paypalName : null;
$stmt = $pdo->prepare(
'UPDATE participants
SET display_name = ?, email = ?, email_norm = ?, paypal_name = ?, active = ?
WHERE id = ? AND tenant_id = ?'
);
$stmt->execute([$displayName, $email, $emailNorm, $paypalName, $active ? 1 : 0, $participantId, $tenantId]);
return $stmt->rowCount() >= 0;
}
function ledger_set_participant_active(PDO $pdo, int $tenantId, int $participantId, bool $active): bool
{
$stmt = $pdo->prepare('UPDATE participants SET active = ? WHERE id = ? AND tenant_id = ?');
$stmt->execute([$active ? 1 : 0, $participantId, $tenantId]);
return true;
}
/**
* Anonymizes a participant (GDPR-style erasure) instead of deleting them:
* name and email are replaced with a non-identifying placeholder and the
* participant is deactivated, but their ledger history stays intact for
* the tenant's bookkeeping.
*/
function ledger_anonymize_participant(PDO $pdo, int $tenantId, int $participantId): bool
{
$placeholderName = 'Gelöschter Teilnehmer #' . $participantId;
$stmt = $pdo->prepare(
'UPDATE participants
SET display_name = ?, email = NULL, email_norm = NULL, paypal_name = NULL, active = 0, user_id = NULL
WHERE id = ? AND tenant_id = ?'
);
$stmt->execute([$placeholderName, $participantId, $tenantId]);
return $stmt->rowCount() > 0;
}
/**
* Storniert einen Journaleintrag anhand seiner Ledger-ID - immer auf den
* Mandanten eingegrenzt, damit ueber eine untergeschobene ID nicht in fremden
* Mandanten storniert werden kann. Markiert den Eintrag als storniert statt
* ihn zu loeschen, damit Korrekturen nachvollziehbar bleiben. Idempotent: ein
* bereits stornierter oder fehlender Eintrag ergibt false.
*
* @param string|null $expectedType Absichern, dass z. B. ueber das
* Einzahlungs-Formular kein Strich storniert
* werden kann.
*/
function ledger_void_entry(PDO $pdo, int $tenantId, int $entryId, ?string $expectedType = null): bool
{
$stmt = $pdo->prepare(
"SELECT id, type
FROM ledger_entries
WHERE id = ? AND tenant_id = ? AND voided_at IS NULL
LIMIT 1"
);
$stmt->execute([$entryId, $tenantId]);
$entry = $stmt->fetch();
if ($entry === false) {
return false;
}
if ($expectedType !== null && (string)$entry['type'] !== $expectedType) {
return false;
}
$stmt = $pdo->prepare('UPDATE ledger_entries SET voided_at = NOW() WHERE id = ? AND tenant_id = ? AND voided_at IS NULL');
$stmt->execute([$entryId, $tenantId]);
return $stmt->rowCount() > 0;
}
/**
* Storniert den juengsten, noch nicht stornierten Web-Selbsteintrag eines
* Teilnehmers (Quelle 'self_entry'). Bewusst nur eigene Web-Eintraege - vom
* Kassenwart per Sammelerfassung gesetzte Striche ('manual_bulk') bleiben
* unangetastet.
*
* @return array{ok: bool, marks?: int, error?: string}
*/
function ledger_void_own_self_entry(PDO $pdo, int $tenantId, int $participantId): array
{
$stmt = $pdo->prepare(
"SELECT id, marks_count
FROM ledger_entries
WHERE tenant_id = ?
AND participant_id = ?
AND type = 'consumption'
AND voided_at IS NULL
AND source = 'self_entry'
ORDER BY booked_at DESC, id DESC
LIMIT 1"
);
$stmt->execute([$tenantId, $participantId]);
$entry = $stmt->fetch();
if ($entry === false) {
return ['ok' => false, 'error' => 'Es gibt keinen selbst eingetragenen Strich, der storniert werden könnte.'];
}
try {
$pdo->beginTransaction();
$pdo->prepare("UPDATE ledger_entries SET voided_at = NOW() WHERE id = ? AND voided_at IS NULL")
->execute([(int)$entry['id']]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
return ['ok' => false, 'error' => 'Der Strich konnte nicht storniert werden.'];
}
return ['ok' => true, 'marks' => (int)($entry['marks_count'] ?? 0)];
}
/**
* Records a consumption entry straight into the ledger.
*/
function ledger_record_consumption(PDO $pdo, int $tenantId, int $participantId, int $marks, int $unitPriceCents, string $source, ?int $createdByUserId = null): int
{
$stmt = $pdo->prepare(
"INSERT INTO ledger_entries
(tenant_id, participant_id, type, amount_cents, marks_count, unit_price_cents, booked_at, source, created_by_user_id)
SELECT ?, id, 'consumption', ?, ?, ?, NOW(), ?, ?
FROM participants
WHERE id = ? AND tenant_id = ?"
);
$stmt->execute([$tenantId, -($marks * $unitPriceCents), $marks, $unitPriceCents, $source, $createdByUserId, $participantId, $tenantId]);
if ($stmt->rowCount() !== 1) {
throw new RuntimeException('Der Strich-Eintrag konnte nicht gespeichert werden.');
}
return (int)$pdo->lastInsertId();
}
/**
* Bucht eine Einzahlung. $amountCents darf negativ sein (Abzug, Auszahlung,
* Korrektur) - fuer Tippfehler ist aber ledger_void_entry der richtige Weg,
* damit die Auswertung nicht durch Gegenbuchungen aufgeblaeht wird.
*/
function ledger_record_payment(PDO $pdo, int $tenantId, int $participantId, int $amountCents, string $source, ?int $createdByUserId = null, ?string $note = null): int
{
$note = ledger_normalize_note($note);
$stmt = $pdo->prepare(
"INSERT INTO ledger_entries
(tenant_id, participant_id, type, amount_cents, booked_at, source, created_by_user_id, note)
SELECT ?, id, 'payment', ?, NOW(), ?, ?, ?
FROM participants
WHERE id = ? AND tenant_id = ?"
);
$stmt->execute([$tenantId, $amountCents, $source, $createdByUserId, $note, $participantId, $tenantId]);
if ($stmt->rowCount() !== 1) {
throw new RuntimeException('Die Einzahlung konnte nicht gespeichert werden.');
}
return (int)$pdo->lastInsertId();
}
/**
* Bis zu dieser Mitgliederzahl passt die Strichliste auf ein einzelnes Blatt.
* Erst darueber wird sie auf Vorder- und Rueckseite aufgeteilt - und nur dann
* ergeben auch die Vorder-/Rueckseiten-Ansichten beim Erfassen einen Sinn.
*/
const LEDGER_SINGLE_PAGE_MAX_MEMBERS = 50;
/**
* Braucht dieser Mandant eine zweiseitige Liste (Vorder- und Rueckseite)?
*/
function ledger_uses_two_sided_sheet(int $activeMemberCount): bool
{
return $activeMemberCount > LEDGER_SINGLE_PAGE_MAX_MEMBERS;
}
/**
* "100-Tage-Liste" Vorder-/Rueckseiten-Aufteilung: aktive Teilnehmer, deren
* summierte Striche im nachlaufenden Fenster die Zehner-Schwelle erreichen
* ($atLeastTen) oder darunter liegen.
*
* @return list<array{participant_id: int, tenant_id: int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}>
*/
function ledger_fetch_participants_by_window_marks(PDO $pdo, int $tenantId, int $windowDays, bool $atLeastTen): array
{
$stmt = $pdo->prepare(
"SELECT p.id
FROM participants p
LEFT JOIN ledger_entries le
ON le.tenant_id = p.tenant_id
AND le.participant_id = p.id
AND le.type = 'consumption'
AND le.voided_at IS NULL
AND le.booked_at >= DATE_SUB(NOW(), INTERVAL ? DAY)
WHERE p.tenant_id = ?
AND p.active = 1
GROUP BY p.id
HAVING COALESCE(SUM(le.marks_count), 0) " . ($atLeastTen ? '>= 10' : '< 10')
);
$stmt->execute([$windowDays, $tenantId]);
$ids = array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN));
if ($ids === []) {
return [];
}
return ledger_fetch_participant_summaries($pdo, $tenantId, ['participant_ids' => $ids]);
}
/**
* Options:
* - participant_id: int
@@ -209,8 +578,42 @@ function ledger_fetch_participant_summary(PDO $pdo, int $tenantId, int $particip
* - include_voided: bool
* - limit: int
*
* @return list<array{id: int, tenant_id: int, participant_id: int, display_name: string, type: string, amount_cents: int, marks_count: ?int, unit_price_cents: ?int, booked_at: string, source: string, note: ?string, legacy_table: ?string, legacy_id: ?int, voided_at: ?string}>
* @return list<array{id: int, tenant_id: int, participant_id: int, display_name: string, type: string, amount_cents: int, marks_count: ?int, unit_price_cents: ?int, booked_at: string, source: string, note: ?string, voided_at: ?string}>
*/
/**
* Verbrauch eines Teilnehmers pro Monat eines Jahres (nur nicht stornierte
* Consumption-Buchungen). Fuer die Monatsuebersicht im Mitglieder-Dashboard.
*
* @return array<int, array{marks: int, cost_cents: int}> Schluessel 1..12
*/
function ledger_fetch_monthly_consumption(PDO $pdo, int $tenantId, int $participantId, int $year): array
{
$stmt = $pdo->prepare(
"SELECT MONTH(booked_at) AS m,
COALESCE(SUM(marks_count), 0) AS marks,
COALESCE(SUM(-amount_cents), 0) AS cost_cents
FROM ledger_entries
WHERE tenant_id = ?
AND participant_id = ?
AND type = 'consumption'
AND voided_at IS NULL
AND YEAR(booked_at) = ?
GROUP BY MONTH(booked_at)"
);
$stmt->execute([$tenantId, $participantId, $year]);
$months = [];
for ($m = 1; $m <= 12; $m++) {
$months[$m] = ['marks' => 0, 'cost_cents' => 0];
}
foreach ($stmt->fetchAll() as $row) {
$m = (int)$row['m'];
$months[$m] = ['marks' => (int)$row['marks'], 'cost_cents' => (int)$row['cost_cents']];
}
return $months;
}
function ledger_fetch_recent_entries(PDO $pdo, int $tenantId, array $options = []): array
{
$limit = isset($options['limit']) ? (int)$options['limit'] : 100;
@@ -245,8 +648,6 @@ function ledger_fetch_recent_entries(PDO $pdo, int $tenantId, array $options = [
le.booked_at,
le.source,
le.note,
le.legacy_table,
le.legacy_id,
le.voided_at
FROM ledger_entries le
JOIN participants p
@@ -273,8 +674,6 @@ function ledger_fetch_recent_entries(PDO $pdo, int $tenantId, array $options = [
'booked_at' => (string)$row['booked_at'],
'source' => (string)$row['source'],
'note' => $row['note'] !== null ? (string)$row['note'] : null,
'legacy_table' => $row['legacy_table'] !== null ? (string)$row['legacy_table'] : null,
'legacy_id' => $row['legacy_id'] !== null ? (int)$row['legacy_id'] : null,
'voided_at' => $row['voided_at'] !== null ? (string)$row['voided_at'] : null,
];
}
+301
View File
@@ -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
View File
@@ -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;
}
+72
View File
@@ -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;
}
+615
View File
@@ -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;
}
+242
View File
@@ -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,
];
}
+120
View File
@@ -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>';
}
+141
View File
@@ -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,
];
}
+158
View File
@@ -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));
}
}
+106
View File
@@ -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,
];
}
+95
View File
@@ -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'
);
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
function app_client_ip(): string
{
return (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown');
}
/**
* Records an attempt in the given bucket and reports whether the caller is
* still within the allowed rate. Call before doing the sensitive work (auth
* check, mail dispatch, account creation); if this returns false, show a
* generic "too many attempts" message without processing the request, so a
* bucket also can't be used to enumerate valid accounts by timing.
*/
function app_rate_limit_check(PDO $pdo, string $bucket, int $maxAttempts, int $windowSeconds): bool
{
$pdo->prepare('INSERT INTO rate_limit_attempts (bucket) VALUES (?)')->execute([$bucket]);
// Globaler Maximalhorizont: unabhängig davon, ob derselbe Bucket je wieder
// benutzt wird, bleibt keine IP-basierte Rate-Limit-Zeile länger als 24h.
$pdo->exec('DELETE FROM rate_limit_attempts WHERE created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)');
$stmt = $pdo->prepare(
'SELECT COUNT(*) FROM rate_limit_attempts WHERE bucket = ? AND created_at >= DATE_SUB(NOW(), INTERVAL ? SECOND)'
);
$stmt->execute([$bucket, $windowSeconds]);
return (int)$stmt->fetchColumn() <= $maxAttempts;
}
+420 -14
View File
@@ -4,6 +4,9 @@ declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/database.php';
require_once __DIR__ . '/faq.php';
require_once __DIR__ . '/billing.php';
require_once __DIR__ . '/legal.php';
function saas_email_norm(string $email): string
{
@@ -53,7 +56,7 @@ function saas_normalize_host(?string $host): string
function saas_primary_app_host(): string
{
return saas_normalize_host(app_env('APP_PRIMARY_HOST', ''));
return saas_normalize_host(app_env('APP_PRIMARY_HOST', app_primary_host() ?? ''));
}
function saas_is_primary_app_host(?string $host = null): bool
@@ -90,6 +93,7 @@ function saas_resolve_tenant_domain(PDO $pdo, ?string $host = null): ?array
WHERE td.domain_norm = ?
AND td.status = ?
AND t.status = ?
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())
LIMIT 1'
);
$stmt->execute([$host, 'active', 'active']);
@@ -164,6 +168,8 @@ function saas_current_user(?PDO $pdo = null): ?array
t.id AS tenant_id,
t.slug AS tenant_slug,
t.name AS tenant_name,
t.customer_type,
t.contract_ends_at,
t.status AS tenant_status,
tm.role,
tm.status AS membership_status
@@ -175,6 +181,7 @@ function saas_current_user(?PDO $pdo = null): ?array
AND u.status = ?
AND tm.status = ?
AND t.status = ?
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())
LIMIT 1'
);
$stmt->execute([$userId, $tenantId, 'active', 'active', 'active']);
@@ -262,6 +269,7 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
t.id AS tenant_id,
t.slug,
t.name,
t.customer_type,
t.status,
t.timezone,
t.locale,
@@ -271,7 +279,23 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
ts.paypal_enabled,
ts.paypal_url_template,
ts.sheet_window_days,
ts.negative_warning_cents
ts.negative_warning_cents,
ts.pdf_show_empty_rows,
ts.pdf_split_mode,
ts.pdf_row_height_px,
ts.pdf_watermark_text,
ts.pdf_footer_text,
ts.payment_reminder_enabled,
ts.payment_reminder_interval_days,
ts.pdf_watermark_logo,
ts.cash_enabled,
ts.cash_contact,
ts.bank_transfer_enabled,
ts.bank_account_holder,
ts.bank_iban,
ts.brand_logo,
ts.brand_color,
ts.watermark_enabled
FROM tenants t
LEFT JOIN tenant_settings ts ON ts.tenant_id = t.id
WHERE t.id = ?
@@ -294,6 +318,7 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
'tenant_id' => (int)$settings['tenant_id'],
'slug' => (string)$settings['slug'],
'name' => (string)$settings['name'],
'customer_type' => (string)$settings['customer_type'],
'status' => (string)$settings['status'],
'timezone' => (string)$settings['timezone'],
'locale' => (string)$settings['locale'],
@@ -304,12 +329,29 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
'paypal_url_template' => (string)$settings['paypal_url_template'],
'sheet_window_days' => (int)$settings['sheet_window_days'],
'negative_warning_cents' => (int)$settings['negative_warning_cents'],
'pdf_show_empty_rows' => (int)$settings['pdf_show_empty_rows'],
'pdf_split_mode' => (string)$settings['pdf_split_mode'],
'pdf_row_height_px' => (int)$settings['pdf_row_height_px'],
'pdf_watermark_text' => (string)$settings['pdf_watermark_text'],
'pdf_footer_text' => (string)$settings['pdf_footer_text'],
'payment_reminder_enabled' => (int)$settings['payment_reminder_enabled'],
'payment_reminder_interval_days' => (int)$settings['payment_reminder_interval_days'],
'pdf_watermark_logo' => (string)$settings['pdf_watermark_logo'],
'cash_enabled' => (int)$settings['cash_enabled'],
'cash_contact' => (string)$settings['cash_contact'],
'bank_transfer_enabled' => (int)$settings['bank_transfer_enabled'],
'bank_account_holder' => (string)$settings['bank_account_holder'],
'bank_iban' => (string)$settings['bank_iban'],
'brand_logo' => (string)$settings['brand_logo'],
'brand_color' => (string)$settings['brand_color'],
'watermark_enabled' => (int)$settings['watermark_enabled'],
];
}
function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): array
{
$tenantName = trim((string)($input['tenant_name'] ?? ''));
$customerType = (string)($input['customer_type'] ?? '');
$timezone = trim((string)($input['timezone'] ?? 'Europe/Berlin'));
$locale = trim((string)($input['locale'] ?? 'de-DE'));
$currencyCode = strtoupper(trim((string)($input['currency_code'] ?? 'EUR')));
@@ -317,11 +359,28 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
$sheetWindowDays = filter_var($input['sheet_window_days'] ?? null, FILTER_VALIDATE_INT);
$negativeWarningCents = saas_parse_money_cents($input['negative_warning'] ?? '');
$paypalUrlTemplate = trim((string)($input['paypal_url_template'] ?? ''));
$cashContact = trim((string)($input['cash_contact'] ?? ''));
$bankAccountHolder = trim((string)($input['bank_account_holder'] ?? ''));
$bankIban = strtoupper(preg_replace('/\s+/', '', (string)($input['bank_iban'] ?? '')));
$pdfSplitMode = trim((string)($input['pdf_split_mode'] ?? 'drinking_behavior'));
$pdfRowHeightPx = filter_var($input['pdf_row_height_px'] ?? null, FILTER_VALIDATE_INT);
$pdfWatermarkText = trim((string)($input['pdf_watermark_text'] ?? ''));
$pdfFooterText = trim((string)($input['pdf_footer_text'] ?? ''));
$paymentReminderIntervalDays = filter_var($input['payment_reminder_interval_days'] ?? null, FILTER_VALIDATE_INT);
$brandColor = strtolower(trim((string)($input['brand_color'] ?? '')));
$errors = [];
// Leer = Standarddesign. Sonst ein vollstaendiger Hex-Wert, der ohne
// weitere Aufbereitung in das erzeugte Stylesheet geschrieben werden kann.
if ($brandColor !== '' && !preg_match('/^#[0-9a-f]{6}$/', $brandColor)) {
$errors[] = 'Die Akzentfarbe muss ein Hex-Wert wie #38761d sein.';
}
if (strlen($tenantName) < 3 || strlen($tenantName) > 255) {
$errors[] = 'Der Kundenname muss zwischen 3 und 255 Zeichen lang sein.';
}
if (!in_array($customerType, ['business', 'consumer'], true)) {
$errors[] = 'Bitte gib an, ob der Vertrag geschäftlich oder als Verbraucher geführt wird.';
}
if (!in_array($timezone, DateTimeZone::listIdentifiers(), true)) {
$errors[] = 'Die Zeitzone ist ungültig.';
}
@@ -343,6 +402,30 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
if (strlen($paypalUrlTemplate) > 1000) {
$errors[] = 'Der PayPal-Link ist zu lang.';
}
if (!in_array($pdfSplitMode, ['drinking_behavior', 'alphabetical'], true)) {
$errors[] = 'Die Sortierung für den Ausdruck ist ungültig.';
}
if (strlen($pdfWatermarkText) > 500) {
$errors[] = 'Der Wasserzeichen-Text im Ausdruck ist zu lang (max. 500 Zeichen).';
}
if (strlen($pdfFooterText) > 500) {
$errors[] = 'Der Fußzeilen-Hinweis im Ausdruck ist zu lang (max. 500 Zeichen).';
}
if ($pdfRowHeightPx === false || $pdfRowHeightPx < 10 || $pdfRowHeightPx > 60) {
$errors[] = 'Die Zeilenhöhe im Ausdruck muss zwischen 10 und 60 Pixel liegen.';
}
if ($paymentReminderIntervalDays === false || $paymentReminderIntervalDays < 1 || $paymentReminderIntervalDays > 90) {
$errors[] = 'Das Erinnerungs-Intervall muss zwischen 1 und 90 Tagen liegen.';
}
if (strlen($cashContact) > 255) {
$errors[] = 'Der Bar-Ansprechpartner ist zu lang (max. 255 Zeichen).';
}
if (strlen($bankAccountHolder) > 255) {
$errors[] = 'Der Kontoinhaber ist zu lang (max. 255 Zeichen).';
}
if ($bankIban !== '' && !preg_match('/^[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}$/', $bankIban)) {
$errors[] = 'Die IBAN hat kein gültiges Format.';
}
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
@@ -350,29 +433,62 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
$selfEntryEnabled = !empty($input['self_entry_enabled']) ? 1 : 0;
$paypalEnabled = !empty($input['paypal_enabled']) ? 1 : 0;
$cashEnabled = !empty($input['cash_enabled']) ? 1 : 0;
$bankTransferEnabled = !empty($input['bank_transfer_enabled']) ? 1 : 0;
$pdfShowEmptyRows = !empty($input['pdf_show_empty_rows']) ? 1 : 0;
$paymentReminderEnabled = !empty($input['payment_reminder_enabled']) ? 1 : 0;
// Der Hinweis auf die Kaffeeliste laesst sich abschalten - ausser im
// kostenlosen Tarif. Die Pruefung steht hier und nicht nur im Formular,
// damit auch ein nachgebauter POST das Kaestchen nicht leeren kann. Vor
// der Transaktion, weil billing_fetch_or_init() eine fehlende Zeile
// anlegt.
$watermarkEnabled = !empty($input['watermark_enabled']) ? 1 : 0;
if (billing_fetch_or_init($pdo, $tenantId)['plan_code'] === 'free') {
$watermarkEnabled = 1;
}
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare(
'UPDATE tenants
SET name = ?, timezone = ?, locale = ?, currency_code = ?
SET name = ?, customer_type = ?, timezone = ?, locale = ?, currency_code = ?
WHERE id = ?'
);
$stmt->execute([$tenantName, $timezone, $locale, $currencyCode, $tenantId]);
$stmt->execute([$tenantName, $customerType, $timezone, $locale, $currencyCode, $tenantId]);
$stmt = $pdo->prepare(
'INSERT INTO tenant_settings
(tenant_id, mark_price_cents, self_entry_enabled, paypal_enabled,
paypal_url_template, sheet_window_days, negative_warning_cents)
VALUES (?, ?, ?, ?, ?, ?, ?)
paypal_url_template, sheet_window_days, negative_warning_cents,
pdf_show_empty_rows, pdf_split_mode, pdf_row_height_px,
pdf_watermark_text, pdf_footer_text,
payment_reminder_enabled, payment_reminder_interval_days,
cash_enabled, cash_contact, bank_transfer_enabled,
bank_account_holder, bank_iban, brand_color, watermark_enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
mark_price_cents = VALUES(mark_price_cents),
self_entry_enabled = VALUES(self_entry_enabled),
paypal_enabled = VALUES(paypal_enabled),
paypal_url_template = VALUES(paypal_url_template),
sheet_window_days = VALUES(sheet_window_days),
negative_warning_cents = VALUES(negative_warning_cents)'
negative_warning_cents = VALUES(negative_warning_cents),
pdf_show_empty_rows = VALUES(pdf_show_empty_rows),
pdf_split_mode = VALUES(pdf_split_mode),
pdf_row_height_px = VALUES(pdf_row_height_px),
pdf_watermark_text = VALUES(pdf_watermark_text),
pdf_footer_text = VALUES(pdf_footer_text),
payment_reminder_enabled = VALUES(payment_reminder_enabled),
payment_reminder_interval_days = VALUES(payment_reminder_interval_days),
cash_enabled = VALUES(cash_enabled),
cash_contact = VALUES(cash_contact),
bank_transfer_enabled = VALUES(bank_transfer_enabled),
bank_account_holder = VALUES(bank_account_holder),
bank_iban = VALUES(bank_iban),
brand_color = VALUES(brand_color),
watermark_enabled = VALUES(watermark_enabled)'
);
$stmt->execute([
$tenantId,
@@ -382,6 +498,20 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
$paypalUrlTemplate,
(int)$sheetWindowDays,
-abs($negativeWarningCents),
$pdfShowEmptyRows,
$pdfSplitMode,
(int)$pdfRowHeightPx,
$pdfWatermarkText,
$pdfFooterText,
$paymentReminderEnabled,
(int)$paymentReminderIntervalDays,
$cashEnabled,
$cashContact,
$bankTransferEnabled,
$bankAccountHolder,
$bankIban,
$brandColor,
$watermarkEnabled,
]);
$pdo->commit();
@@ -435,7 +565,8 @@ function saas_find_auth_identity(PDO $pdo, string $email, string $tenantSlug = '
WHERE u.email_norm = ?
AND u.status = ?
AND tm.status = ?
AND t.status = ?' . $tenantFilter . '
AND t.status = ?
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())' . $tenantFilter . '
ORDER BY
CASE tm.role
WHEN \'owner\' THEN 1
@@ -474,6 +605,7 @@ function saas_list_user_memberships(PDO $pdo, int $userId): array
AND u.status = ?
AND tm.status = ?
AND t.status = ?
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())
ORDER BY
CASE tm.role
WHEN \'owner\' THEN 1
@@ -511,6 +643,7 @@ function saas_identity_for_user_tenant(PDO $pdo, int $userId, int $tenantId): ?a
AND u.status = ?
AND tm.status = ?
AND t.status = ?
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())
LIMIT 1'
);
$stmt->execute([$userId, $tenantId, 'active', 'active', 'active']);
@@ -667,8 +800,17 @@ function saas_reset_password_with_token(PDO $pdo, string $token, string $passwor
try {
$pdo->beginTransaction();
$pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
->execute([password_hash($password, PASSWORD_DEFAULT), (int)$tokenRow['user_id']]);
// Wer den per Mail zugestellten Link oeffnen konnte, hat den Zugriff
// auf das Postfach nachgewiesen - fachlich dasselbe wie eine
// Verifikation. Das betrifft vor allem eingeladene Mitglieder: die
// Zugangsvergabe laeuft ueber genau diesen Reset-Link, ohne dass je
// eine separate Verifikationsmail verschickt wird.
$pdo->prepare(
'UPDATE users
SET password_hash = ?,
email_verified_at = COALESCE(email_verified_at, NOW())
WHERE id = ?'
)->execute([password_hash($password, PASSWORD_DEFAULT), (int)$tokenRow['user_id']]);
$pdo->prepare('UPDATE user_auth_tokens SET consumed_at = NOW() WHERE id = ?')
->execute([(int)$tokenRow['id']]);
$pdo->commit();
@@ -686,6 +828,121 @@ function saas_reset_password_with_token(PDO $pdo, string $token, string $passwor
return ['ok' => true, 'errors' => []];
}
/**
* Passwortwechsel fuer den bereits angemeldeten Nutzer. Bewusst getrennt vom
* Token-Reset: hier weist das aktuelle Passwort die Identitaet nach, nicht
* ein Mail-Link. Setzt anders als der Reset keine E-Mail-Verifikation, weil
* dabei nichts ueber das Postfach nachgewiesen wird.
*/
function saas_change_password(
PDO $pdo,
int $userId,
string $currentPassword,
string $password,
string $passwordConfirm
): array {
$errors = [];
if (strlen($password) < 8) {
$errors[] = 'Das neue Passwort muss mindestens 8 Zeichen lang sein.';
}
if ($password !== $passwordConfirm) {
$errors[] = 'Die Passwort-Wiederholung stimmt nicht.';
}
if ($currentPassword !== '' && $password === $currentPassword) {
$errors[] = 'Das neue Passwort muss sich vom bisherigen unterscheiden.';
}
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
}
$stmt = $pdo->prepare('SELECT password_hash, status FROM users WHERE id = ? LIMIT 1');
$stmt->execute([$userId]);
$user = $stmt->fetch();
if ($user === false || (string)$user['status'] !== 'active') {
return ['ok' => false, 'errors' => ['Dieses Benutzerkonto ist nicht aktiv.']];
}
$hash = (string)($user['password_hash'] ?? '');
if ($hash === '' || !password_verify($currentPassword, $hash)) {
return ['ok' => false, 'errors' => ['Das aktuelle Passwort ist nicht korrekt.']];
}
try {
$pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
->execute([password_hash($password, PASSWORD_DEFAULT), $userId]);
} catch (Throwable $e) {
return ['ok' => false, 'errors' => ['Das Passwort konnte nicht gespeichert werden.']];
}
// Die eigene Session bleibt bestehen, bekommt aber eine neue ID, damit ein
// eventuell mitgelesener Session-Bezeichner nach dem Wechsel wertlos ist.
if (PHP_SAPI !== 'cli' && session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true);
}
return ['ok' => true, 'errors' => []];
}
/**
* Bestaetigt das Passwort eines bereits angemeldeten Kontos, ohne es zu
* aendern. Gedacht fuer Aktionen, die eine bestehende Sitzung allein nicht
* rechtfertigen soll - etwa das Abschalten des zweiten Faktors.
*/
function saas_password_matches(PDO $pdo, int $userId, string $password): bool
{
if ($password === '') {
return false;
}
$stmt = $pdo->prepare('SELECT password_hash, status FROM users WHERE id = ? LIMIT 1');
$stmt->execute([$userId]);
$user = $stmt->fetch();
if ($user === false || (string)$user['status'] !== 'active') {
return false;
}
$hash = (string)($user['password_hash'] ?? '');
return $hash !== '' && password_verify($password, $hash);
}
/**
* Hat der Nutzer seine E-Mail-Adresse nachgewiesen? Wird fuer Aktionen
* geprueft, die Mails an Dritte ausloesen oder fremde Konten anlegen -
* siehe saas_require_verified_email().
*/
function saas_email_verified(?array $user = null): bool
{
$user = $user ?? saas_current_user();
return $user !== null && ($user['email_verified_at'] ?? null) !== null;
}
/**
* Sperrt Aktionen, die aus dem Konto heraus nach aussen wirken (Mitglieder
* einladen, Massenmail, Jahresabschluss-Mails), solange die eigene Adresse
* nicht bestaetigt ist. Bewusst nicht auf den gesamten Login angewendet: die
* eigene Kaffeeliste darf man auch unbestaetigt fuehren, nur nicht im Namen
* einer moeglicherweise fremden Adresse Mails an Dritte ausloesen.
*/
function saas_require_verified_email(?array $user = null): array
{
$user = $user ?? saas_require_login();
if (saas_email_verified($user)) {
return $user;
}
http_response_code(403);
die(
'Für diese Aktion muss die eigene E-Mail-Adresse bestätigt sein. '
. 'Den Bestätigungslink kannst du unter "Kundenkonto" erneut anfordern.'
);
}
function saas_request_email_verification(PDO $pdo, int $userId, ?int $tenantId = null): array
{
$stmt = $pdo->prepare(
@@ -802,7 +1059,8 @@ function saas_authenticate(PDO $pdo, string $email, string $password, string $te
JOIN tenants t ON t.id = tm.tenant_id
WHERE tm.user_id = ?
AND tm.status = ?
AND t.status = ?' . $tenantFilter . '
AND t.status = ?
AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())' . $tenantFilter . '
ORDER BY
CASE tm.role
WHEN \'owner\' THEN 1
@@ -848,6 +1106,132 @@ function saas_authenticate(PDO $pdo, string $email, string $password, string $te
];
}
/**
* Roles an admin can hand out through the member management UI. "owner" is
* deliberately excluded here; it is only ever set during registration.
*
* @return list<string>
*/
function saas_grantable_roles(): array
{
return ['member', 'treasurer', 'admin'];
}
/**
* Grants (or updates) tenant access for an existing participant: creates the
* login account if needed, links it to the participant, and (re)activates
* the tenant membership with the given role. The participant's name/email
* stay the notification identity regardless of login state.
*
* @return array{ok: bool, errors: list<string>, token?: string, user_email?: string}
*/
function saas_grant_participant_access(PDO $pdo, int $tenantId, int $participantId, string $role): array
{
if (!in_array($role, saas_grantable_roles(), true)) {
return ['ok' => false, 'errors' => ['Ungültige Rolle.']];
}
$stmt = $pdo->prepare(
'SELECT id, display_name, email, email_norm
FROM participants
WHERE id = ? AND tenant_id = ?'
);
$stmt->execute([$participantId, $tenantId]);
$participant = $stmt->fetch();
if ($participant === false) {
return ['ok' => false, 'errors' => ['Teilnehmer wurde nicht gefunden.']];
}
$emailNorm = (string)($participant['email_norm'] ?? '');
if ($emailNorm === '' || !filter_var($participant['email'], FILTER_VALIDATE_EMAIL)) {
return ['ok' => false, 'errors' => ['Für den Zugang wird eine gültige E-Mail-Adresse benötigt.']];
}
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare('SELECT id, status FROM users WHERE email_norm = ? LIMIT 1');
$stmt->execute([$emailNorm]);
$user = $stmt->fetch();
if ($user === false) {
$stmt = $pdo->prepare(
'INSERT INTO users (email, email_norm, display_name, status) VALUES (?, ?, ?, ?)'
);
$stmt->execute([$participant['email'], $emailNorm, $participant['display_name'], 'active']);
$userId = (int)$pdo->lastInsertId();
} elseif ((string)$user['status'] !== 'active') {
$pdo->rollBack();
return ['ok' => false, 'errors' => ['Dieses Benutzerkonto ist nicht aktiv.']];
} else {
$userId = (int)$user['id'];
}
$stmt = $pdo->prepare(
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status, invited_at, joined_at)
VALUES (?, ?, ?, ?, NOW(), NOW())
ON DUPLICATE KEY UPDATE
role = VALUES(role),
status = VALUES(status),
invited_at = COALESCE(tenant_memberships.invited_at, VALUES(invited_at)),
joined_at = COALESCE(tenant_memberships.joined_at, VALUES(joined_at))'
);
$stmt->execute([$tenantId, $userId, $role, 'active']);
$pdo->prepare('UPDATE participants SET user_id = ? WHERE id = ? AND tenant_id = ?')
->execute([$userId, $participantId, $tenantId]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
return ['ok' => false, 'errors' => ['Der Zugang konnte nicht angelegt werden.']];
}
$token = saas_create_auth_token($pdo, $userId, 'password_reset', $tenantId, 1440);
return [
'ok' => true,
'errors' => [],
'token' => $token['token'],
'user_email' => (string)$participant['email'],
];
}
/**
* Revokes tenant access without touching the participant's name/email or
* deleting the membership row, so the grant can be reinstated later and the
* history stays auditable. Owners cannot be revoked here.
*
* @return array{ok: bool, errors: list<string>}
*/
function saas_revoke_participant_access(PDO $pdo, int $tenantId, int $participantId): array
{
$stmt = $pdo->prepare('SELECT user_id FROM participants WHERE id = ? AND tenant_id = ?');
$stmt->execute([$participantId, $tenantId]);
$participant = $stmt->fetch();
if ($participant === false || $participant['user_id'] === null) {
return ['ok' => true, 'errors' => []];
}
$userId = (int)$participant['user_id'];
$stmt = $pdo->prepare('SELECT role FROM tenant_memberships WHERE tenant_id = ? AND user_id = ?');
$stmt->execute([$tenantId, $userId]);
$membership = $stmt->fetch();
if ($membership !== false && (string)$membership['role'] === 'owner') {
return ['ok' => false, 'errors' => ['Dem Inhaber kann der Zugang nicht entzogen werden.']];
}
$pdo->prepare("UPDATE tenant_memberships SET status = 'revoked' WHERE tenant_id = ? AND user_id = ?")
->execute([$tenantId, $userId]);
return ['ok' => true, 'errors' => []];
}
function saas_register_tenant_owner(PDO $pdo, array $input): array
{
$tenantName = trim((string)($input['tenant_name'] ?? ''));
@@ -861,6 +1245,7 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
$emailNorm = saas_email_norm($email);
$password = (string)($input['password'] ?? '');
$passwordConfirm = (string)($input['password_confirm'] ?? '');
$customerType = (string)($input['customer_type'] ?? '');
$errors = [];
if (strlen($tenantName) < 3 || strlen($tenantName) > 255) {
@@ -881,6 +1266,18 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
if ($password !== $passwordConfirm) {
$errors[] = 'Die Passwort-Wiederholung stimmt nicht.';
}
if (!in_array($customerType, ['business', 'consumer'], true)) {
$errors[] = 'Bitte gib an, ob du als Unternehmen oder als Verbraucher handelst.';
}
if (empty($input['accept_terms'])) {
$errors[] = 'Bitte akzeptiere die AGB.';
}
if (empty($input['acknowledge_privacy'])) {
$errors[] = 'Bitte bestätige, dass du die Datenschutzerklärung zur Kenntnis genommen hast.';
}
if (empty($input['accept_dpa'])) {
$errors[] = 'Bitte vereinbare den AVV für den Fall, dass du Daten anderer Personen verwaltest.';
}
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
@@ -920,10 +1317,10 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
$pdo->beginTransaction();
$stmt = $pdo->prepare(
'INSERT INTO tenants (slug, name, status, timezone, locale, currency_code)
VALUES (?, ?, ?, ?, ?, ?)'
'INSERT INTO tenants (slug, name, customer_type, status, timezone, locale, currency_code)
VALUES (?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([$tenantSlug, $tenantName, 'active', 'Europe/Berlin', 'de-DE', 'EUR']);
$stmt->execute([$tenantSlug, $tenantName, $customerType, 'active', 'Europe/Berlin', 'de-DE', 'EUR']);
$tenantId = (int)$pdo->lastInsertId();
$stmt = $pdo->prepare(
@@ -933,6 +1330,9 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
);
$stmt->execute([$tenantId, 20, 1, 0, '']);
faq_seed_default_entries($pdo, $tenantId);
billing_fetch_or_init($pdo, $tenantId);
if ($existingUser === false) {
$stmt = $pdo->prepare(
'INSERT INTO users (email, email_norm, display_name, password_hash, status)
@@ -963,6 +1363,11 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
);
$stmt->execute([$tenantId, $userId, $displayName, $email, $emailNorm, 1]);
$acceptanceMetadata = ['customer_type' => $customerType];
app_record_legal_acceptance($pdo, $tenantId, $userId, 'terms', 'registration', $acceptanceMetadata);
app_record_legal_acceptance($pdo, $tenantId, $userId, 'privacy_notice', 'registration', $acceptanceMetadata);
app_record_legal_acceptance($pdo, $tenantId, $userId, 'dpa', 'registration', $acceptanceMetadata);
$verificationToken = saas_create_auth_token($pdo, $userId, 'email_verification', $tenantId, 1440);
$pdo->commit();
@@ -987,6 +1392,7 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array
'tenant_id' => $tenantId,
'tenant_slug' => $tenantSlug,
'tenant_name' => $tenantName,
'customer_type' => $customerType,
'role' => 'owner',
],
'email_verification_token' => $verificationToken['token'],
+240
View File
@@ -3,6 +3,7 @@
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/legal.php';
function saas_mail_transport(): string
{
@@ -131,6 +132,124 @@ function saas_send_password_reset_mail(string $to, string $token): array
return saas_send_mail($to, 'Kaffeeliste Passwort zurücksetzen', $body);
}
/**
* Renders the tenant-generic balance notification body. Replaces the old
* mailversenden.php text, which hardcoded one customer's SMTP host,
* PayPal link and FAQ URL and therefore only ever worked for that one
* tenant.
*/
function saas_render_balance_mail_body(string $displayName, int $balanceCents, array $tenantSettings, string $dashboardUrl): string
{
$amount = saas_format_money_cents(abs($balanceCents)) . ' €';
$lines = ["Hallo {$displayName},", ''];
if ($balanceCents < 0) {
$lines[] = "dein aktueller Kaffeelisten-Stand ist im Minus: {$amount}.";
$lines[] = "Bitte gleiche den Betrag bei Gelegenheit aus.";
if ((int)($tenantSettings['paypal_enabled'] ?? 0) === 1 && trim((string)($tenantSettings['paypal_url_template'] ?? '')) !== '') {
$paypalLink = trim((string)$tenantSettings['paypal_url_template']) . number_format(abs($balanceCents) / 100, 2, '.', '');
$lines[] = '';
$lines[] = "Direkt bezahlen: {$paypalLink}";
}
} else {
$lines[] = "dein aktuelles Guthaben in der Kaffeeliste beträgt {$amount}.";
}
$lines[] = '';
$lines[] = "Deinen aktuellen Stand findest du hier: {$dashboardUrl}";
$lines[] = '';
$lines[] = 'Deine Kaffeeliste';
return implode("\n", $lines);
}
function saas_log_outbound_email(
PDO $pdo,
int $tenantId,
?int $participantId,
string $template,
string $subject,
string $status,
?string $error,
?int $createdByUserId
): int {
$pdo->exec('DELETE FROM outbound_emails WHERE created_at < DATE_SUB(NOW(), INTERVAL 180 DAY)');
$stmt = $pdo->prepare(
'INSERT INTO outbound_emails
(tenant_id, participant_id, template, subject, status, sent_at, error, created_by_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$tenantId,
$participantId,
$template,
$subject,
$status,
in_array($status, ['sent', 'failed'], true) ? date('Y-m-d H:i:s') : null,
$error,
$createdByUserId,
]);
return (int)$pdo->lastInsertId();
}
/**
* @return list<array{id: int, participant_id: ?int, display_name: ?string, template: string, subject: string, status: string, sent_at: ?string, error: ?string, created_at: string}>
*/
function saas_fetch_outbound_email_log(PDO $pdo, int $tenantId, int $limit = 50): array
{
$limit = max(1, min($limit, 200));
$stmt = $pdo->prepare(
"SELECT o.id, o.participant_id, p.display_name, o.template, o.subject, o.status, o.sent_at, o.error, o.created_at
FROM outbound_emails o
LEFT JOIN participants p ON p.id = o.participant_id
WHERE o.tenant_id = ?
ORDER BY o.created_at DESC, o.id DESC
LIMIT {$limit}"
);
$stmt->execute([$tenantId]);
return $stmt->fetchAll();
}
function saas_render_year_end_bonus_mail_body(string $displayName, int $yearMarks, int $bonusCents, string $dashboardUrl): string
{
$bonus = saas_format_money_cents($bonusCents) . ' €';
$lines = [
"Hallo {$displayName},",
'',
"vielen Dank für deine Nutzung der Kaffeeliste in diesem Jahr!",
"Du hast dieses Jahr {$yearMarks} Striche gemacht.",
"Dafür wurden dir {$bonus} auf deinem Kaffeelisten-Konto gutgeschrieben.",
'',
"Deinen aktuellen Stand findest du hier: {$dashboardUrl}",
'',
'Deine Kaffeeliste',
];
return implode("\n", $lines);
}
function saas_send_invite_mail(string $to, string $tenantName, string $role, string $token): array
{
$link = saas_app_url('passwort-zuruecksetzen.php?token=' . urlencode($token));
$body = implode("\n", [
'Hallo,',
'',
"du wurdest für \"{$tenantName}\" in der Kaffeeliste als {$role} eingeladen.",
'Öffne diesen Link, um ein Passwort zu setzen und dich anzumelden:',
'',
$link,
'',
'Der Link ist zeitlich begrenzt und kann nur einmal verwendet werden.',
'Falls du diese Einladung nicht erwartet hast, kannst du diese Nachricht ignorieren.',
'',
'Deine Kaffeeliste',
]);
return saas_send_mail($to, 'Einladung zur Kaffeeliste', $body);
}
function saas_send_email_verification_mail(string $to, string $token): array
{
$link = saas_app_url('email-verifizieren.php?token=' . urlencode($token));
@@ -149,3 +268,124 @@ function saas_send_email_verification_mail(string $to, string $token): array
return saas_send_mail($to, 'Kaffeeliste E-Mail bestätigen', $body);
}
function saas_send_registration_contract_mail(string $to, string $tenantName, string $tenantSlug, string $customerType): array
{
$body = implode("\n", [
'Vertragsbestätigung Kaffeeliste',
'',
'Kunde: ' . $tenantName,
'Kundenkürzel: ' . $tenantSlug,
'Kundentyp: ' . ($customerType === 'consumer' ? 'Verbraucher' : 'Unternehmer'),
'Tarif: Kostenlos, 0,00 € pro Monat, unbefristet, jederzeit kündbar',
'Vertragsschluss: ' . date('d.m.Y H:i') . ' Uhr (Europe/Berlin)',
'',
'Nachfolgend erhältst du die bei Vertragsschluss geltenden Texte auf einem dauerhaften Datenträger.',
'',
'================ AGB ================',
app_legal_document_text('terms'),
'',
'================ AVV ================',
app_legal_document_text('dpa'),
'',
'========== WIDERRUFSBELEHRUNG ==========',
app_legal_document_text('withdrawal_information'),
]);
return saas_send_mail($to, 'Kaffeeliste Vertragsbestätigung kostenloser Tarif', $body);
}
function saas_send_paid_contract_confirmation(PDO $pdo, int $tenantId, string $planCode): array
{
require_once __DIR__ . '/billing.php';
$stmt = $pdo->prepare(
"SELECT t.name, t.slug, t.customer_type, u.email
FROM tenants t
JOIN tenant_memberships tm ON tm.tenant_id = t.id AND tm.role = 'owner' AND tm.status = 'active'
JOIN users u ON u.id = tm.user_id AND u.status = 'active'
WHERE t.id = ? ORDER BY tm.id LIMIT 1"
);
$stmt->execute([$tenantId]);
$contract = $stmt->fetch();
$plan = billing_plans()[$planCode] ?? null;
if ($contract === false || $plan === null) {
return ['ok' => false, 'transport' => saas_mail_transport(), 'error' => 'contract confirmation data missing'];
}
$body = implode("\n", [
'Vertragsbestätigung Kaffeeliste kostenpflichtiger Tarif',
'',
'Kunde: ' . $contract['name'],
'Kundenkürzel: ' . $contract['slug'],
'Kundentyp: ' . ($contract['customer_type'] === 'consumer' ? 'Verbraucher' : 'Unternehmer'),
'Tarif: ' . $plan['label'],
'Gesamtpreis: ' . number_format(((int)$plan['price_cents']) / 100, 2, ',', '') . ' € pro Monat',
'Laufzeit: unbefristet; monatliche Abrechnung; keine längere Mindestlaufzeit',
'Kündigung: jederzeit zum Ende der laufenden Abrechnungsperiode',
'Bestätigung: ' . date('d.m.Y H:i') . ' Uhr (Europe/Berlin)',
'',
'================ AGB ================',
app_legal_document_text('terms'),
'',
'========== WIDERRUFSBELEHRUNG ==========',
app_legal_document_text('withdrawal_information'),
]);
return saas_send_mail((string)$contract['email'], 'Kaffeeliste Vertragsbestätigung ' . $plan['label'], $body);
}
/**
* Textkoerper der automatischen Zahlungserinnerung an ein Mitglied mit
* offenem Betrag oberhalb der Warnschwelle des Mandanten.
*/
function saas_render_payment_reminder_mail_body(string $displayName, int $debtCents, array $tenantSettings, string $dashboardUrl): string
{
$amount = saas_format_money_cents($debtCents) . ' €';
$lines = [
"Hallo {$displayName},",
'',
"auf deinem Kaffeelisten-Konto ist aktuell ein offener Betrag von {$amount} aufgelaufen.",
"Bitte gleiche ihn bei Gelegenheit aus.",
];
if ((int)($tenantSettings['paypal_enabled'] ?? 0) === 1 && trim((string)($tenantSettings['paypal_url_template'] ?? '')) !== '') {
$paypalLink = trim((string)$tenantSettings['paypal_url_template']) . number_format($debtCents / 100, 2, '.', '');
$lines[] = '';
$lines[] = "Direkt bezahlen: {$paypalLink}";
}
$lines[] = '';
$lines[] = "Deinen aktuellen Stand findest du hier: {$dashboardUrl}";
$lines[] = '';
$lines[] = 'Deine Kaffeeliste';
return implode("\n", $lines);
}
/**
* Liefert das Datum der letzten Zahlungserinnerung je Teilnehmer eines
* Mandanten (nur tatsaechlich versendete). Fuer die Intervall-Pruefung im
* Erinnerungs-Cron, damit niemand haeufiger als eingestellt erinnert wird.
*
* @return array<int, string> participant_id => letztes sent_at ('Y-m-d H:i:s')
*/
function saas_fetch_last_payment_reminders(PDO $pdo, int $tenantId): array
{
$stmt = $pdo->prepare(
"SELECT participant_id, MAX(sent_at) AS last_sent
FROM outbound_emails
WHERE tenant_id = ?
AND template = 'payment_reminder'
AND status = 'sent'
AND participant_id IS NOT NULL
GROUP BY participant_id"
);
$stmt->execute([$tenantId]);
$map = [];
foreach ($stmt->fetchAll() as $row) {
$map[(int)$row['participant_id']] = (string)$row['last_sent'];
}
return $map;
}
+118
View File
@@ -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
View File
@@ -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'],
];
}
+102
View File
@@ -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
View File
@@ -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;
}
+231
View File
@@ -0,0 +1,231 @@
/*
* Anpassungen der Kaffeeliste gegenueber dem HTML5-UP-Template (main.css).
* Bewusst eine eigene Datei, damit main.css unveraendert bleibt und bei einem
* Template-Update nicht mit eigenen Aenderungen kollidiert. Wird in header.php
* NACH main.css geladen.
*/
/* ---------------------------------------------------------------------------
* Formular-Abstaende
*
* Im Template hat das Label 1em Abstand nach unten, das Eingabefeld aber gar
* keinen. In einem Formular mit mehreren Feldern klebte das naechste Label
* dadurch direkt am Feld darueber und wirkte, als gehoere es zu diesem statt
* zum eigenen Feld. Jetzt gilt: Label eng am eigenen Feld, deutlicher Abstand
* zur naechsten Gruppe.
* ------------------------------------------------------------------------ */
label {
margin-bottom: 0.5em;
}
input[type="text"],
input[type="password"],
input[type="email"],
input[type="tel"],
input[type="search"],
input[type="url"],
input[type="number"],
input[type="date"],
input[type="datetime-local"],
input[type="file"],
select,
textarea {
margin-bottom: 1.5em;
}
/* Achtung: hier bewusst KEIN ".row > * > :last-child { margin-bottom: 0 }".
In einer Grid-Spalte ist das Eingabefeld das letzte Kind - genau dessen
unterer Abstand erzeugt die Trennung zur naechsten Formularzeile. */
/* In Tabellen und nebeneinanderliegenden Bedienelementen wuerde der
zusaetzliche Abstand die Zeilen auseinanderziehen. */
td label,
td input,
td select,
td textarea,
.form-inline label,
.form-inline input,
.form-inline select {
margin-bottom: 0;
}
/* ---------------------------------------------------------------------------
* Zahlen-, Datums- und Dateifelder
*
* main.css stylt diese Typen nicht, sie erschienen deshalb als kleine native
* Kaestchen zwischen den sonst einheitlich gestalteten Feldern. Hier wird das
* Aussehen der Textfelder uebernommen.
* ------------------------------------------------------------------------ */
input[type="number"],
input[type="date"],
input[type="datetime-local"] {
background: #ffffff;
border: solid 1px rgba(210, 215, 217, 0.75);
border-radius: 0.375em;
color: inherit;
display: block;
height: 2.75em;
outline: 0;
padding: 0 1em;
text-decoration: none;
width: 100%;
}
input[type="number"]:focus,
input[type="date"]:focus {
border-color: #38761d;
box-shadow: 0 0 0 1px #38761d;
}
/* In Tabellen (Striche/Einzahlungen erfassen) bleiben die Zahlenfelder
kompakt, sonst sprengen sie die Spaltenbreite. */
td input[type="number"] {
width: 100%;
min-width: 5em;
}
input[type="file"] {
display: block;
width: 100%;
}
/* ---------------------------------------------------------------------------
* Checkbox-Zeilen
*
* Checkbox und zugehoeriges Label stehen nebeneinander; ohne Ausgleich sitzt
* die Beschriftung nicht auf einer Linie mit den Feldern der Nachbarspalte.
* ------------------------------------------------------------------------ */
input[type="checkbox"] + label,
input[type="radio"] + label {
margin-bottom: 1.5em;
}
/* ---------------------------------------------------------------------------
* Hinweisboxen
*
* main.css definiert den Grundstil und die Varianten .info/.warning/.success,
* aber keine .error-Variante - obwohl sie in der App am haeufigsten verwendet
* wird. Ohne sie erscheinen Fehlermeldungen ungefaerbt wie neutrale Hinweise.
* Farbgebung analog zu den vorhandenen Varianten.
* ------------------------------------------------------------------------ */
.hint-box.error {
background: #fdecea;
border-left: 5px solid #d32f2f;
color: #7f1d1d;
}
/* Der Grundstil ist ein Flex-Container; Absaetze darin sollen unten keinen
zusaetzlichen Abstand erzeugen. */
.hint-box > :last-child {
margin-bottom: 0;
}
/* Mehrere Absaetze/Listen in einer Box sollen untereinander stehen statt
nebeneinander (Flex-Grundstil).
Bewusst auf div eingegrenzt: .hint-box macht aus einem Element einen
Flex-Container. Auf einer Tabellenzeile zerstoert das die Spalten - fuer
hervorgehobene Zeilen gibt es .tarif-aktuell weiter unten. */
div.hint-box {
flex-direction: column;
}
/* ---------------------------------------------------------------------------
* Kopfzeile mit Mandanten-Logo (headerline.php)
*
* Das Logo soll erkennbar, aber kein Blickfang sein - die Kaffeeliste bleibt
* eine Arbeitsoberflaeche. Feste Maximalhoehe, damit ein hochkantes oder sehr
* grosses Logo nicht die halbe Seite einnimmt.
* ------------------------------------------------------------------------ */
.tenant-brand-line {
margin-bottom: 1.5em;
padding-bottom: 1em;
}
.tenant-brand-logo {
display: block;
max-height: 60px;
max-width: 100%;
width: auto;
}
/* ---------------------------------------------------------------------------
* Hervorgehobene Tabellenzeile (aktuell gebuchter Tarif)
*
* Muss eine normale Tabellenzeile bleiben, damit die Zellen unter ihren
* Spaltenueberschriften stehen.
* ------------------------------------------------------------------------ */
tr.tarif-aktuell > td {
background: #e8fbe8;
color: #1b5e20;
font-weight: 600;
}
tr.tarif-aktuell > td:first-child {
box-shadow: inset 5px 0 0 0 #4caf50;
}
/* ---------------------------------------------------------------------------
* Wasserzeichen im kostenlosen Tarif (app/plan-watermark.php)
*
* Fest unten rechts, damit es unabhaengig von der Seitenlaenge sichtbar
* bleibt. Zurueckhaltend gehalten: die Kaffeeliste ist eine Arbeitsflaeche,
* der Hinweis soll erkennbar sein, aber nichts verdecken - deshalb halb
* transparent, erst beim Darueberfahren voll sichtbar.
* ------------------------------------------------------------------------ */
.plan-watermark {
align-items: center;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 999px;
bottom: 0.9rem;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
color: #4a4a4a;
display: inline-flex;
font-size: 0.7rem;
gap: 0.4em;
letter-spacing: 0.02em;
line-height: 1;
opacity: 0.65;
padding: 0.45em 0.8em;
position: fixed;
right: 0.9rem;
text-decoration: none;
transition: opacity 0.2s ease;
z-index: 900;
}
a.plan-watermark {
border-bottom: 0;
}
.plan-watermark:hover,
.plan-watermark:focus {
color: #4a4a4a;
opacity: 1;
}
.plan-watermark-logo {
flex: 0 0 auto;
}
/* Auf schmalen Bildschirmen bleibt nur das Logo - der Schriftzug wuerde dort
ueber den Inhalt laufen. */
@media screen and (max-width: 480px) {
.plan-watermark-text {
display: none;
}
}
/* ---------------------------------------------------------------------------
* Festgestellte Kaestchen (z. B. der Hinweis auf die Kaffeeliste im
* kostenlosen Tarif)
*
* Das Theme zeichnet das Kaestchen ueber das folgende <label>; ein disabled
* gesetztes Feld saehe sonst genauso aus wie ein bedienbares. Der gedimmte
* Zustand macht sichtbar, dass hier nichts zu klicken ist - die Begruendung
* steht als Hinweis daneben.
* ------------------------------------------------------------------------ */
input[type="checkbox"]:disabled + label {
cursor: not-allowed;
opacity: 0.6;
}
+171 -47
View File
@@ -1,5 +1,4 @@
@import url(fontawesome-all.min.css);
@import url("https://fonts.googleapis.com/css?family=Open+Sans:400,600,400italic,600italic|Roboto+Slab:400,700");
/*
Editorial by HTML5 UP
html5up.net | @ajlkn
@@ -1111,38 +1110,32 @@ input[type="radio"] {
padding-right: 0.75em;
position: relative; }
input[type="checkbox"] + label:before,
input[type="radio"] + label:before {
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
display: inline-block;
font-style: normal;
font-variant: normal;
text-rendering: auto;
line-height: 1;
text-transform: none !important;
font-family: 'Font Awesome 5 Free';
font-weight: 900; }
input[type="checkbox"] + label:before,
input[type="radio"] + label:before {
background: #ffffff;
border-radius: 0.375em;
border: solid 1px rgba(210, 215, 217, 0.75);
content: '';
display: inline-block;
font-size: 0.8em;
height: 2.0625em;
left: 0;
line-height: 2.0625em;
position: absolute;
text-align: center;
top: 0;
width: 2.0625em; }
input[type="checkbox"]:checked + label:before,
input[type="radio"]:checked + label:before {
background: #3d4449;
border-color: #3d4449;
color: #ffffff;
content: '\f00c'; }
border-color: #3d4449; }
input[type="checkbox"]:checked + label:after,
input[type="radio"]:checked + label:after {
content: '';
position: absolute;
left: 0.72em;
top: 0.62em;
width: 0.45em;
height: 0.9em;
border: solid #ffffff;
border-width: 0 0.15em 0.15em 0;
transform: rotate(45deg); }
input[type="checkbox"]:focus + label:before,
input[type="radio"]:focus + label:before {
border-color: #38761d;
@@ -1976,26 +1969,16 @@ button,
width: 6em;
z-index: 10000; }
#sidebar .toggle:before {
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
display: inline-block;
font-style: normal;
font-variant: normal;
text-rendering: auto;
line-height: 1;
text-transform: none !important;
font-family: 'Font Awesome 5 Free';
font-weight: 900; }
#sidebar .toggle:before {
content: '\f0c9';
font-size: 2rem;
height: inherit;
left: 0;
line-height: inherit;
content: '';
display: block;
position: absolute;
text-indent: 0;
top: 0;
width: inherit; }
top: 50%;
left: 50%;
width: 1.6rem;
height: 2px;
background: #7f888f;
box-shadow: 0 -0.5rem 0 0 #7f888f, 0 0.5rem 0 0 #7f888f;
transform: translate(-50%, -50%); }
#sidebar.inactive {
margin-left: -26em; }
@media screen and (max-width: 1680px) {
@@ -2014,8 +1997,6 @@ button,
line-height: 6.25em;
text-indent: 5em;
width: 5em; }
#sidebar .toggle:before {
font-size: 1.5rem; }
#sidebar.inactive {
margin-left: -24em; } }
@media screen and (max-width: 1280px) {
@@ -2044,9 +2025,6 @@ button,
#sidebar .toggle {
text-indent: 6em;
width: 6em; }
#sidebar .toggle:before {
font-size: 1.5rem;
margin-left: -0.4375em; }
body.is-preload #sidebar {
display: none; } }
@media screen and (max-width: 736px) {
@@ -2054,10 +2032,6 @@ button,
text-indent: 7.25em;
width: 7.25em; }
#sidebar .toggle:before {
color: #7f888f;
margin-left: -0.0625em;
margin-top: -0.25em;
font-size: 1.1rem;
z-index: 1; }
#sidebar .toggle:after {
background: rgba(222, 225, 226, 0.75);
@@ -2271,6 +2245,14 @@ button,
border-top: 0;
margin-top: 0;
padding-top: 0; }
#menu > ul > li > ul.menu-group-items {
display: block; }
#menu ul span.menu-group-label {
color: #38761d;
cursor: default;
font-weight: 700; }
#menu ul span.menu-group-label:hover {
color: #38761d; }
/* Hinweisbox Grundstil */
@@ -2310,3 +2292,145 @@ button,
border-left: 5px solid #4caf50;
color: #1b5e20;
}
.hint-box.error {
background: #fdeaea;
border-left: 5px solid #c0392b;
color: #641e16;
}
input[type="submit"].alt,
input[type="reset"].alt,
input[type="button"].alt,
button.alt,
.button.alt {
box-shadow: inset 0 0 0 2px #9fa3a6;
color: #5f676d !important;
}
.admin-summary-grid {
display: grid;
gap: 0.75em;
grid-template-columns: repeat(auto-fit, minmax(8.5em, 1fr));
margin: 1.25em 0 1.75em;
}
.admin-summary-grid > div {
border: solid 1px rgba(210, 215, 217, 0.85);
border-radius: 6px;
padding: 0.9em 1em;
}
.admin-summary-grid strong {
color: #38761d;
display: block;
font-family: "Roboto Slab", serif;
font-size: 1.45em;
line-height: 1.1;
}
.admin-summary-grid span {
color: #7f888f;
display: block;
font-size: 0.85em;
margin-top: 0.2em;
}
.admin-filter-bar {
align-items: flex-end;
display: flex;
flex-wrap: wrap;
gap: 0.75em;
margin: 1.25em 0;
}
.admin-filter-bar label {
font-size: 0.85em;
margin: 0;
}
.admin-filter-bar input[type="search"],
.admin-filter-bar select {
min-width: 11em;
}
.admin-table td,
.admin-table th {
vertical-align: top;
}
.status-badge {
border-radius: 6px;
display: inline-block;
font-size: 0.78em;
font-weight: 700;
line-height: 1.4;
margin: 0 0.25em 0.25em 0;
padding: 0.2em 0.55em;
white-space: nowrap;
}
.status-badge.success {
background: #e8f5e9;
color: #1b5e20;
}
.status-badge.warning {
background: #fff4e5;
color: #663c00;
}
.status-badge.error {
background: #fdeaea;
color: #641e16;
}
.status-badge.info {
background: #e8f4ff;
color: #0d3c61;
}
.status-badge.muted {
background: #edf0f2;
color: #5f676d;
}
.inline-admin-form {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.5em;
margin: 0 0 0.5em 0;
}
.inline-admin-form select {
min-width: 13em;
}
.compact-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5em;
margin: 0;
padding-left: 0;
}
.compact-actions li {
padding: 0;
}
@media screen and (max-width: 736px) {
.admin-filter-bar {
align-items: stretch;
flex-direction: column;
}
.admin-filter-bar input[type="search"],
.admin-filter-bar select,
.admin-filter-bar button,
.admin-filter-bar .button,
.inline-admin-form select,
.inline-admin-form button {
width: 100%;
}
}
+149
View File
@@ -0,0 +1,149 @@
.analytics-consent[hidden] {
display: none !important;
}
.analytics-consent {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 10000;
background: #ffffff;
border-top: solid 1px rgba(210, 215, 217, 0.95);
box-shadow: 0 -0.75rem 2rem rgba(61, 68, 73, 0.14);
color: #3d4449;
padding: 1.25rem 6vw;
}
.analytics-consent__inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
max-width: 72rem;
margin: 0 auto;
}
.analytics-consent__copy {
max-width: 48rem;
}
.analytics-consent h2 {
color: #3d4449;
font-size: 1.15rem;
margin: 0 0 0.35rem;
}
.analytics-consent p {
margin: 0;
}
.analytics-consent a {
color: #2f6418;
}
.analytics-consent__actions {
display: flex;
flex: 0 0 auto;
gap: 0.75rem;
}
.analytics-consent__actions button {
appearance: none;
background: #38761d;
border: solid 2px #38761d;
border-radius: 0.375rem;
box-shadow: none;
color: #ffffff !important;
cursor: pointer;
font-family: inherit;
font-size: 0.85rem;
font-weight: 700;
height: auto;
letter-spacing: 0.04em;
line-height: 1.25;
padding: 0.8rem 1rem;
text-transform: none;
white-space: nowrap;
}
.analytics-consent__actions button:hover {
background: #2f6418;
border-color: #2f6418;
}
.analytics-consent__actions [data-analytics-consent="denied"] {
background: #ffffff;
color: #2f6418 !important;
}
.analytics-consent__actions [data-analytics-consent="denied"]:hover {
background: #f2f7ef;
border-color: #2f6418;
color: #244f13 !important;
}
.analytics-consent__actions button:focus-visible {
outline: solid 3px #f0b429;
outline-offset: 3px;
}
.footer-cookie-settings {
appearance: none;
background: transparent;
border: 0;
border-bottom: dotted 1px;
border-radius: 0;
box-shadow: none;
color: inherit !important;
cursor: pointer;
font: inherit;
font-weight: inherit;
height: auto;
letter-spacing: inherit;
line-height: inherit;
margin: 0;
padding: 0;
text-transform: none;
}
.footer-cookie-settings:hover {
background: transparent;
border-bottom-color: transparent;
color: #38761d !important;
}
.footer-cookie-settings:focus-visible {
outline: solid 3px #f0b429;
outline-offset: 3px;
}
.public-footer-links .footer-cookie-settings {
color: #3d4449 !important;
margin: 0 0.5rem;
}
@media screen and (max-width: 736px) {
.analytics-consent {
padding: 1rem;
}
.analytics-consent__inner,
.analytics-consent__actions {
align-items: stretch;
flex-direction: column;
}
.analytics-consent__inner {
gap: 1rem;
}
.analytics-consent__actions {
gap: 0.5rem;
}
.analytics-consent__actions button {
width: 100%;
white-space: normal;
}
}
+223 -1
View File
@@ -63,19 +63,46 @@ body.public-page {
.public-hero p {
color: #3d4449;
font-size: 1.15em;
max-width: 34rem;
}
.public-hero-lead {
font-size: 1.15em;
}
.public-hero-sub {
font-size: 1.05em;
color: #38761d;
margin-top: -0.4rem;
}
.public-hero-reassure {
font-size: 0.85em;
color: #7f888f;
margin-top: 0.85rem;
}
.public-section {
padding: 4rem 6vw;
}
/* Section intros (Titel + Unterzeile) sind ueberall zentriert, damit die
Seite nicht zwischen linksbuendigen und zentrierten Abschnitten springt.
Die Karteninhalte darunter bleiben davon unberuehrt links ausgerichtet. */
.public-section > header.major {
text-align: center;
max-width: 42rem;
margin-left: auto;
margin-right: auto;
}
.public-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1.25rem;
max-width: 72rem;
margin-left: auto;
margin-right: auto;
}
.public-feature,
@@ -84,6 +111,13 @@ body.public-page {
border-radius: 8px;
padding: 1.5rem;
background: #ffffff;
text-align: left;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.public-feature:hover {
transform: translateY(-0.2rem);
box-shadow: 0 0.75rem 1.75rem rgba(61, 68, 73, 0.1);
}
.public-feature h3 {
@@ -94,6 +128,90 @@ body.public-page {
margin-bottom: 0;
}
.public-highlight {
background: #eef4ea;
}
/* Trust-Band direkt unter dem Hero: faktische Vertrauensanker, kein Fließtext. */
.public-trust {
background: #38761d;
padding: 1rem 6vw;
}
.public-trust ul {
list-style: none;
margin: 0 auto;
padding: 0;
max-width: 72rem;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem 1.75rem;
}
.public-trust li {
color: #ffffff;
font-size: 0.9em;
padding-left: 1.4rem;
position: relative;
}
.public-trust li:before {
content: "\2713";
position: absolute;
left: 0;
font-weight: 700;
}
/* Zielgruppen-Chips zur Selbst-Identifikation des Besuchers. */
.public-audience-list {
list-style: none;
margin: 0 auto;
padding: 0;
max-width: 60rem;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.75rem;
}
.public-audience-list li {
border: solid 1px rgba(210, 215, 217, 0.9);
border-radius: 999px;
padding: 0.5rem 1.1rem;
color: #3d4449;
background: #ffffff;
font-size: 0.95em;
}
.public-price {
font-family: "Roboto Slab", serif;
font-size: 1.5em;
color: #38761d;
font-weight: 700;
}
.public-price span {
font-family: inherit;
font-size: 0.55em;
color: #7f888f;
font-weight: 400;
}
.public-cta-reassure {
font-size: 0.85em;
color: #7f888f;
margin-top: 1rem;
}
.public-fineprint {
max-width: 72rem;
margin-left: auto;
margin-right: auto;
color: #7f888f;
font-size: 0.9em;
}
.public-auth {
min-height: 100vh;
background-image: url("../images/landing-hero.png");
@@ -147,7 +265,111 @@ body.public-page {
margin-bottom: 0;
}
/* ul.actions bringt von Haus aus keinen oberen Abstand mit (main.css setzt
nur margin-left/padding-left), deshalb klebte die Button-Zeile direkt am
letzten Eingabefeld. Bewusst auf .public-auth beschraenkt: das sind die
Formularpanels (Login, Registrierung, Passwort), waehrend die CTA-Panels
auf der Landingpage ihr enges Layout behalten. */
.public-auth .public-panel .actions {
margin-top: 1.5em;
}
.public-steps .public-feature {
position: relative;
padding-top: 2.75rem;
}
.public-step-number {
position: absolute;
top: 1rem;
left: 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
background: #38761d;
color: #ffffff;
font-family: "Roboto Slab", serif;
font-weight: 700;
font-size: 0.95em;
}
.public-demo-frame {
max-width: 72rem;
margin-left: auto;
margin-right: auto;
border: solid 1px rgba(210, 215, 217, 0.75);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 0.75rem 2.25rem rgba(61, 68, 73, 0.08);
}
.public-demo-frame img {
display: block;
width: 100%;
height: auto;
}
.public-faq {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.25rem;
max-width: 72rem;
margin-left: auto;
margin-right: auto;
margin-bottom: 1.5rem;
}
.public-faq .public-panel h3 {
font-size: 1.05em;
margin-bottom: 0.5rem;
}
.public-faq .public-panel p {
margin-bottom: 0;
color: #3d4449;
}
.public-legal {
max-width: 46rem;
margin: 0 auto;
padding-top: 6rem;
}
.public-legal h2 {
margin-top: 2rem;
}
.public-footer-links {
text-align: center;
padding: 1.5rem 6vw 2.5rem 6vw;
}
.public-footer-links a {
color: #3d4449;
margin: 0 0.5rem;
}
.public-footer-links .legal-action-link {
border: solid 1px #38761d;
border-radius: 4px;
display: inline-block;
margin-top: 0.5rem;
padding: 0.25rem 0.55rem;
}
.public-cta ul.actions {
justify-content: center;
}
@media screen and (max-width: 980px) {
.public-faq {
grid-template-columns: 1fr;
}
.public-hero {
min-height: 78vh;
padding: 5.5rem 2rem 3rem 2rem;
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

+190
View File
@@ -0,0 +1,190 @@
(function () {
'use strict';
var configurationElement = document.getElementById('analytics-configuration');
var banner = document.getElementById('analytics-consent');
var settingsButtons = document.querySelectorAll('[data-analytics-settings]');
if (!configurationElement || !banner) {
return;
}
var configuration;
try {
configuration = JSON.parse(configurationElement.textContent || '');
} catch (error) {
return;
}
var cookieName = configuration.consentCookie;
var visitorCookieName = configuration.visitorCookie;
var consentVersion = configuration.consentVersion;
var pageViewSent = false;
function readCookie(name) {
var cookies = document.cookie ? document.cookie.split(';') : [];
for (var index = 0; index < cookies.length; index += 1) {
var parts = cookies[index].trim().split('=');
var currentName = decodeURIComponent(parts.shift() || '');
if (currentName === name) {
return decodeURIComponent(parts.join('='));
}
}
return null;
}
function readDecision() {
var value = readCookie(cookieName);
var expectedPrefix = consentVersion + ':';
if (!value || value.indexOf(expectedPrefix) !== 0) {
return null;
}
var decision = value.slice(expectedPrefix.length);
return decision === 'granted' || decision === 'denied' ? decision : null;
}
function cookieSuffix() {
var suffix = '; Path=/; Max-Age=' + String(configuration.consentMaxAge) + '; SameSite=Lax';
if (configuration.cookieDomain) {
suffix += '; Domain=' + configuration.cookieDomain;
}
if (configuration.secureCookies || window.location.protocol === 'https:') {
suffix += '; Secure';
}
return suffix;
}
function writeCookie(name, value) {
document.cookie = encodeURIComponent(name) + '=' + encodeURIComponent(value) + cookieSuffix();
}
function writeDecision(decision) {
writeCookie(cookieName, consentVersion + ':' + decision);
}
function randomVisitorId() {
var bytes = new Uint8Array(8);
window.crypto.getRandomValues(bytes);
return Array.prototype.map.call(bytes, function (value) {
return value.toString(16).padStart(2, '0');
}).join('');
}
function ensureVisitorId() {
var visitorId = readCookie(visitorCookieName);
if (!visitorId || !/^[0-9a-f]{16}$/.test(visitorId)) {
visitorId = randomVisitorId();
writeCookie(visitorCookieName, visitorId);
}
return visitorId;
}
function URLWithoutParameters(value) {
try {
var url = new URL(value, window.location.href);
return url.origin + url.pathname;
} catch (error) {
return '';
}
}
function sendPageView() {
if (pageViewSent || readDecision() !== 'granted') {
return;
}
pageViewSent = true;
var now = new Date();
var payload = {
visitorId: ensureVisitorId(),
pageUrl: URLWithoutParameters(window.location.href),
pageTitle: document.title || 'Kaffeeliste',
referrerUrl: URLWithoutParameters(document.referrer),
resolution: window.screen && window.screen.width && window.screen.height
? String(window.screen.width) + 'x' + String(window.screen.height)
: '',
language: window.navigator.language || '',
h: now.getHours(),
m: now.getMinutes(),
s: now.getSeconds()
};
window.fetch(configuration.trackingEndpoint, {
method: 'POST',
credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
keepalive: true
}).catch(function () {
// Analysefehler duerfen die Website niemals beeintraechtigen.
});
}
function expireCookie(name, domain) {
var value = encodeURIComponent(name) + '=; Path=/; Max-Age=0; SameSite=Lax';
if (domain) {
value += '; Domain=' + domain;
}
if (configuration.secureCookies || window.location.protocol === 'https:') {
value += '; Secure';
}
document.cookie = value;
}
function removeAnalysisCookies() {
var cookies = document.cookie ? document.cookie.split(';') : [];
cookies.forEach(function (cookie) {
var name = decodeURIComponent(cookie.trim().split('=')[0] || '');
if (name !== visitorCookieName && name.indexOf('_pk_') !== 0 && name.indexOf('mtm_') !== 0) {
return;
}
expireCookie(name, '');
if (configuration.cookieDomain) {
expireCookie(name, configuration.cookieDomain);
}
});
}
function closeBanner() {
banner.hidden = true;
}
function choose(decision) {
writeDecision(decision);
closeBanner();
if (decision === 'granted') {
sendPageView();
} else {
removeAnalysisCookies();
}
window.dispatchEvent(new CustomEvent('kaffeeliste:analytics-consent', {
detail: {analytics: decision}
}));
}
banner.querySelectorAll('[data-analytics-consent]').forEach(function (button) {
button.addEventListener('click', function () {
choose(button.getAttribute('data-analytics-consent'));
});
});
settingsButtons.forEach(function (button) {
button.addEventListener('click', function () {
banner.hidden = false;
var firstButton = banner.querySelector('[data-analytics-consent="denied"]');
if (firstButton) {
firstButton.focus();
}
});
});
var initialDecision = readDecision();
if (initialDecision === 'granted') {
sendPageView();
} else if (initialDecision === 'denied') {
removeAnalysisCookies();
} else {
banner.hidden = false;
}
}());
+37
View File
@@ -0,0 +1,37 @@
<?php
require_once __DIR__ . '/app/legal.php';
require_once __DIR__ . '/app/public-page-views.php';
app_record_public_page_view('dpa');
?>
<!DOCTYPE HTML>
<html lang="de">
<head>
<title>Auftragsverarbeitungsvertrag Kaffeeliste</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="assets/css/main.css">
<link rel="stylesheet" href="assets/css/public.css">
<?php echo app_analytics_head_html(); ?>
</head>
<body class="is-preload public-page">
<div class="public-shell">
<section class="public-section public-legal">
<nav class="public-nav" aria-label="Hauptnavigation">
<strong><a href="landing.php">Kaffeeliste</a></strong>
<ul class="actions"><li><a href="login.php" class="button">Login</a></li></ul>
</nav>
<header class="major"><h1>Auftragsverarbeitungsvertrag</h1></header>
<?php echo app_render_legal_document('dpa'); ?>
<p><a href="legal/avv-<?php echo htmlspecialchars(APP_DPA_VERSION, ENT_QUOTES, 'UTF-8'); ?>.txt" download>AVV als Textdatei speichern</a></p>
<p><a href="landing.php">&larr; 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>
+40
View File
@@ -0,0 +1,40 @@
<?php
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/app/platform-admin.php';
require_once __DIR__ . '/app/data-export.php';
$pdo = app_db_pdo();
$user = app_require_platform_admin($pdo);
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Nur POST erlaubt.');
}
app_require_csrf();
$tenantId = filter_input(INPUT_POST, 'tenant_id', FILTER_VALIDATE_INT);
if ($tenantId === null || $tenantId === false || $tenantId <= 0) {
http_response_code(400);
exit('Ungültige Mandanten-ID.');
}
$stmt = $pdo->prepare('SELECT slug FROM tenants WHERE id = ?');
$stmt->execute([$tenantId]);
$slug = $stmt->fetchColumn();
if ($slug === false) {
http_response_code(404);
exit('Mandant wurde nicht gefunden.');
}
$data = app_export_tenant_data($pdo, $tenantId);
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'backoffice.tenant_exported', 'tenant', $tenantId);
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$filename = 'backoffice-export-' . $slug . '-' . date('Ymd_His') . '.json';
header('Content-Type: application/json; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . strlen((string)$json));
echo $json;
+158
View File
@@ -0,0 +1,158 @@
<?php
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/app/platform-admin.php';
require_once __DIR__ . '/app/features.php';
$pdo = app_db_pdo();
$user = app_require_platform_admin($pdo);
// Die Mandanten-ID kommt beim Speichern der Funktions-Schalter aus dem
// Formular, sonst aus der URL.
$tenantId = filter_input(INPUT_POST, 'tenant_id', FILTER_VALIDATE_INT)
?: filter_input(INPUT_GET, 'tenant_id', FILTER_VALIDATE_INT);
if ($tenantId === null || $tenantId === false || $tenantId <= 0) {
http_response_code(400);
exit('Ungültige Mandanten-ID.');
}
$featureMeldung = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['aktion'] ?? '') === 'features_speichern') {
app_require_csrf();
$geaendert = app_features_update($pdo, $tenantId, (array)($_POST['features'] ?? []));
if ($geaendert !== []) {
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'backoffice.features_updated', 'tenant', $tenantId, [
'changed' => $geaendert,
]);
}
$featureMeldung = $geaendert === []
? 'Keine Änderung an den Funktionen.'
: 'Die Funktionen wurden gespeichert.';
}
$detail = app_backoffice_fetch_tenant_detail($pdo, $tenantId);
if ($detail === null) {
http_response_code(404);
exit('Mandant wurde nicht gefunden.');
}
// Jede Ansicht eines Mandanten durch das Back-Office wird im Audit-Log
// dieses Mandanten protokolliert, damit der Zugriff fuer den Kunden
// nachvollziehbar bleibt (siehe AVV-Transparenzpflicht).
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'backoffice.tenant_viewed', 'tenant', $tenantId);
$tenantFeatures = app_features_for_tenant($pdo, $tenantId);
$rollenLabels = ['owner' => 'Inhaber', 'admin' => 'Administrator', 'treasurer' => 'Kassenwart', 'member' => 'Mitglied', 'viewer' => 'Betrachter'];
include 'header.php';
include 'headerline.php';
include 'nav.php';
?>
<section id="banner">
<div class="content">
<h2>Back-Office: <?php echo saas_html($detail['tenant']['name']); ?></h2>
<p><a href="backoffice.php">&larr; Zurück zur Mandantenübersicht</a></p>
<table>
<tr><th>Kürzel</th><td><?php echo saas_html($detail['tenant']['slug']); ?></td></tr>
<tr><th>Status</th><td><?php echo saas_html($detail['tenant']['status']); ?></td></tr>
<tr><th>Erstellt</th><td><?php echo saas_html($detail['tenant']['created_at']); ?></td></tr>
<?php if ($detail['settings'] !== null): ?>
<tr><th>Preis pro Strich</th><td><?php echo saas_html(saas_format_money_cents((int)$detail['settings']['mark_price_cents'])); ?> €</td></tr>
<tr><th>PayPal aktiv</th><td><?php echo (int)$detail['settings']['paypal_enabled'] === 1 ? 'ja' : 'nein'; ?></td></tr>
<?php endif; ?>
</table>
<form method="post" action="backoffice-export.php">
<?php echo app_csrf_field(); ?>
<input type="hidden" name="tenant_id" value="<?php echo (int)$tenantId; ?>">
<button type="submit">Datenexport dieses Mandanten herunterladen</button>
</form>
<h3>Freigeschaltete Funktionen</h3>
<p>Zentral vom Betreiber gesteuert: abgehakte Funktionen sind für diesen Mandanten
gesperrt und verschwinden dort aus Menü und Seiten. Die Einstellungen des Kunden
bleiben dabei erhalten.</p>
<?php if ($featureMeldung !== null): ?>
<div class="hint-box success"><p><?php echo saas_html($featureMeldung); ?></p></div>
<?php endif; ?>
<form method="post" action="backoffice-mandant.php?tenant_id=<?php echo (int)$tenantId; ?>">
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="features_speichern">
<input type="hidden" name="tenant_id" value="<?php echo (int)$tenantId; ?>">
<div class="row">
<?php foreach (app_feature_catalog() as $featureKey => $feature): ?>
<div class="col-6 col-12-small">
<input type="checkbox"
id="feature_<?php echo saas_html($featureKey); ?>"
name="features[<?php echo saas_html($featureKey); ?>]"
value="1"
<?php echo ($tenantFeatures[$featureKey] ?? true) ? 'checked' : ''; ?>>
<label for="feature_<?php echo saas_html($featureKey); ?>">
<?php echo saas_html($feature['label']); ?>
</label>
<small><?php echo saas_html($feature['description']); ?></small>
</div>
<?php endforeach; ?>
</div>
<ul class="actions">
<li><button type="submit">Funktionen speichern</button></li>
</ul>
</form>
<h3>Mitglieder mit Zugang</h3>
<table>
<tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Status</th></tr>
<?php if ($detail['members'] === []): ?>
<tr><td colspan="4">Kein Mitglied mit Login-Zugang.</td></tr>
<?php endif; ?>
<?php foreach ($detail['members'] as $member): ?>
<tr>
<td><?php echo saas_html($member['display_name']); ?></td>
<td><?php echo saas_html($member['email']); ?></td>
<td><?php echo saas_html($rollenLabels[$member['role']] ?? $member['role']); ?></td>
<td><?php echo saas_html($member['membership_status']); ?></td>
</tr>
<?php endforeach; ?>
</table>
<h3>Letzte Buchungen</h3>
<table>
<tr><th>Datum</th><th>Mitglied</th><th>Typ</th><th>Betrag</th><th>Quelle</th></tr>
<?php if ($detail['recent_entries'] === []): ?>
<tr><td colspan="5">Keine Buchungen vorhanden.</td></tr>
<?php endif; ?>
<?php foreach ($detail['recent_entries'] as $entry): ?>
<tr>
<td><?php echo saas_html($entry['booked_at']); ?></td>
<td><?php echo saas_html($entry['display_name']); ?></td>
<td><?php echo saas_html($entry['type']); ?></td>
<td><?php echo saas_html(saas_format_money_cents((int)$entry['amount_cents'])); ?> €</td>
<td><?php echo saas_html($entry['source']); ?></td>
</tr>
<?php endforeach; ?>
</table>
<h3>Letzte Admin-Aktionen</h3>
<table>
<tr><th>Datum</th><th>Wer</th><th>Aktion</th></tr>
<?php if ($detail['recent_audit'] === []): ?>
<tr><td colspan="3">Noch keine protokollierten Aktionen.</td></tr>
<?php endif; ?>
<?php foreach ($detail['recent_audit'] as $entry): ?>
<tr>
<td><?php echo saas_html($entry['created_at']); ?></td>
<td><?php echo saas_html($entry['actor_name'] ?? '—'); ?></td>
<td><?php echo saas_html($entry['action']); ?></td>
</tr>
<?php endforeach; ?>
</table>
</div>
</section>
<?php include 'footer.php'; ?>
+132
View File
@@ -0,0 +1,132 @@
<?php
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/app/platform-admin.php';
require_once __DIR__ . '/app/billing.php';
require_once __DIR__ . '/app/public-page-views.php';
$pdo = app_db_pdo();
$user = app_require_platform_admin($pdo);
$tenants = app_backoffice_fetch_tenants($pdo);
$billingPlans = billing_plans();
$pageViewSummary = null;
$pageViewError = false;
try {
$pageViewSummary = app_public_page_view_summary($pdo);
} catch (Throwable $e) {
$pageViewError = true;
error_log('Back-Office-Seitenaufrufe konnten nicht geladen werden: ' . $e->getMessage());
}
$legalRequests = $pdo->query(
"SELECT lr.id, lr.reference_code, lr.request_type, lr.requester_name,
lr.requester_email, lr.contract_reference, lr.request_reason,
lr.requested_end, lr.status, lr.received_at, t.name AS tenant_name
FROM legal_requests lr
LEFT JOIN tenants t ON t.id = lr.tenant_id
WHERE lr.status <> 'processed'
ORDER BY lr.received_at ASC
LIMIT 100"
)->fetchAll();
include 'header.php';
include 'headerline.php';
include 'nav.php';
?>
<section id="banner">
<div class="content">
<h2>Back-Office</h2>
<p>Nur für Betreiber-Admins sichtbar: Übersicht aller Mandanten, unabhängig von deren eigener Mitgliedschaft.
Jeder Zugriff wird im Audit-Log des jeweiligen Mandanten protokolliert.</p>
<h3>Öffentliche Seitenaufrufe</h3>
<p><small>Anonyme Seitenaufrufe ohne Cookies, IP-Adressen oder Besucherkennungen. Wiederholte Aufrufe und automatisierte Abrufe können enthalten sein; die Zahlen sind keine eindeutigen Besucher.</small></p>
<?php if ($pageViewError || $pageViewSummary === null): ?>
<div class="hint-box warning"><p>Die Seitenaufrufe sind noch nicht verfügbar. Bitte Migration 0032 ausführen.</p></div>
<?php else: ?>
<table>
<tr><th>Heute</th><th>Letzte 30 Tage</th><th>Seit Beginn der Zählung</th></tr>
<tr>
<td><?php echo number_format($pageViewSummary['totals']['today'], 0, ',', '.'); ?></td>
<td><?php echo number_format($pageViewSummary['totals']['last_30_days'], 0, ',', '.'); ?></td>
<td><?php echo number_format($pageViewSummary['totals']['all_time'], 0, ',', '.'); ?></td>
</tr>
</table>
<?php if ($pageViewSummary['pages'] === []): ?>
<div class="hint-box info"><p>Seit dem Start des Zählers wurden noch keine öffentlichen Seiten aufgerufen.</p></div>
<?php else: ?>
<table>
<tr><th>Seite</th><th>Heute</th><th>Letzte 30 Tage</th><th>Gesamt</th></tr>
<?php foreach ($pageViewSummary['pages'] as $pageView): ?>
<tr>
<td><?php echo saas_html($pageView['label']); ?></td>
<td><?php echo number_format($pageView['today'], 0, ',', '.'); ?></td>
<td><?php echo number_format($pageView['last_30_days'], 0, ',', '.'); ?></td>
<td><?php echo number_format($pageView['all_time'], 0, ',', '.'); ?></td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
<?php endif; ?>
<h3>Offene Kündigungen und Widerrufe</h3>
<?php if ($legalRequests === []): ?>
<div class="hint-box success"><p>Keine offenen Rechtserklärungen.</p></div>
<?php else: ?>
<table>
<tr><th>Eingang</th><th>Art / Referenz</th><th>Kunde</th><th>Erklärung</th><th>Status</th><th></th></tr>
<?php foreach ($legalRequests as $request): ?>
<tr>
<td><?php echo saas_html($request['received_at']); ?></td>
<td><?php echo $request['request_type'] === 'withdrawal' ? 'Widerruf' : 'Kündigung'; ?><br><small><?php echo saas_html($request['reference_code']); ?></small></td>
<td><?php echo saas_html($request['requester_name']); ?><br><a href="mailto:<?php echo saas_html($request['requester_email']); ?>"><?php echo saas_html($request['requester_email']); ?></a><br><small><?php echo saas_html($request['tenant_name'] ?? $request['contract_reference']); ?></small></td>
<td><?php echo $request['requested_end'] !== null ? 'Ende: ' . saas_html($request['requested_end']) . '<br>' : ''; ?><?php echo nl2br(saas_html($request['request_reason'] ?? '')); ?></td>
<td><?php echo saas_html($request['status']); ?></td>
<td>
<form method="post" action="rechtserklaerung-bearbeiten.php">
<?php echo app_csrf_field(); ?>
<input type="hidden" name="request_id" value="<?php echo (int)$request['id']; ?>">
<button type="submit" class="button small">Als erledigt markieren</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
<table>
<tr>
<th>Kunde</th>
<th>Kürzel</th>
<th>Status</th>
<th>Tarif</th>
<th>Erstellt</th>
<th>Teilnehmer (aktiv/gesamt)</th>
<th>Saldensumme</th>
<th></th>
</tr>
<?php foreach ($tenants as $tenant): ?>
<?php $tenantRequiredPlan = billing_required_plan_code((int)$tenant['active_participant_count']); ?>
<tr>
<td><?php echo saas_html($tenant['name']); ?></td>
<td><?php echo saas_html($tenant['slug']); ?></td>
<td><?php echo saas_html($tenant['status']); ?></td>
<td>
<?php echo saas_html($billingPlans[$tenant['plan_code']]['label'] ?? $tenant['plan_code']); ?>
<?php if ($tenantRequiredPlan !== $tenant['plan_code']): ?>
<br><small>benötigt: <?php echo saas_html($billingPlans[$tenantRequiredPlan]['label'] ?? $tenantRequiredPlan); ?></small>
<?php endif; ?>
</td>
<td><?php echo saas_html($tenant['created_at']); ?></td>
<td><?php echo (int)$tenant['active_participant_count']; ?> / <?php echo (int)$tenant['participant_count']; ?></td>
<td><?php echo saas_html(saas_format_money_cents((int)$tenant['balance_cents'])); ?> €</td>
<td><a href="backoffice-mandant.php?tenant_id=<?php echo (int)$tenant['id']; ?>" class="button">Ansehen</a></td>
</tr>
<?php endforeach; ?>
</table>
</div>
</section>
<?php include 'footer.php'; ?>
-42
View File
@@ -3,45 +3,3 @@
require_once __DIR__ . '/app/bootstrap.php';
app_start_session();
if ((getenv('APP_DB_DRIVER') ?: '') === 'mysql' && !function_exists('sqlsrv_connect')) {
require_once __DIR__ . '/lib/sqlsrv_mysql_compat.php';
}
### ZUGANGSDATEN AD
$aduser = '';
$adpassword = '';
$domain = '';
## OU für die Suche der Benutzerkonten
#$basedn = '';
$basedn = '';
## OU für die Suche der Benutzerkonten
#$basednandereKasse = '';
## OU für die Suche der Gruppen
$basedngroup = '';
## Zugriffsgruppe für Automatisierungsclient
$zugriffsACgroup = '';
## Zugriffsgruppe für Automatische Abwesenheitsnachricht
$zugriffsAbwesenheitgroup = '';
## Zugriffsgruppe für alle Anfragen des Automatisierungsclient
$zugriffsalleACgroup = '';
## Zugriffsgruppe für Anfragen der Abwesenheitsnachrichten
$zugriffsalleAbwesenheitgroup = '';
## Zugriff auf alles
$zugriffsAdminsgroup = '';
// Verbindung zur Datenbank herstellen (ersetze die Platzhalter durch deine Daten)
$serverName = getenv('DB_HOST') ?: "";
$connectionOptions = array(
"Database" => getenv('DB_NAME') ?: "",
"Uid" => getenv('DB_USER') ?: "",
"PWD" => getenv('DB_PASS') ?: "",
"TrustServerCertificate"=>true
);
$conn = sqlsrv_connect($serverName, $connectionOptions);
// Überprüfen der Verbindung
if (!$conn) {
die(print_r(sqlsrv_errors(), true));
}
?>
+367 -227
View File
@@ -1,6 +1,12 @@
<?php
ob_start();
include "functions.php";
require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/imports.php";
require_once __DIR__ . "/app/audit.php";
require_once __DIR__ . "/app/features.php";
app_require_csrf();
include "header.php";
include "headerline.php";
@@ -14,284 +20,418 @@ include "nav.php";
<div class="content">
<?php
if(checkKaffeelisteAdmin($conn, $mailadress)){
function csv_h($value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
$pdo = app_db_pdo();
$saasUser = saas_current_user($pdo);
$tenantId = 0;
$hasAccess = false;
if ($saasUser !== null && saas_user_has_role(['owner', 'admin', 'treasurer'], $saasUser)) {
$tenantId = (int)$saasUser['tenant_id'];
$hasAccess = true;
}
if (!$hasAccess) {
echo "<h2>Kein Zugriff</h2>";
include "footer.php";
exit;
}
if (!app_feature_enabled($pdo, $tenantId, 'csv_import')) {
echo app_feature_notice_html('csv_import');
include "footer.php";
exit;
}
function csv_upload_dir(): string
{
return APP_ROOT . '/var/uploads';
return APP_ROOT . '/var/uploads';
}
/**
* @return array{0: ?string, 1: ?string} [Pfad, Fehlermeldung]
*/
function csv_prepare_upload(array $file): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return [null, 'Fehler beim Hochladen der Datei.'];
}
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return [null, 'Fehler beim Hochladen der Datei.'];
}
if (($file['size'] ?? 0) <= 0 || $file['size'] > 5 * 1024 * 1024) {
return [null, 'Die CSV-Datei ist leer oder größer als 5 MB.'];
}
if (($file['size'] ?? 0) <= 0 || $file['size'] > 5 * 1024 * 1024) {
return [null, 'Die CSV-Datei ist leer oder größer als 5 MB.'];
}
$originalName = (string)($file['name'] ?? '');
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
if ($extension !== 'csv') {
return [null, 'Es sind nur CSV-Dateien erlaubt.'];
}
$originalName = (string)($file['name'] ?? '');
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
if ($extension !== 'csv') {
return [null, 'Es sind nur CSV-Dateien erlaubt.'];
}
$tmpName = (string)($file['tmp_name'] ?? '');
if (!is_uploaded_file($tmpName)) {
return [null, 'Die hochgeladene Datei konnte nicht verifiziert werden.'];
}
$tmpName = (string)($file['tmp_name'] ?? '');
if (!is_uploaded_file($tmpName)) {
return [null, 'Die hochgeladene Datei konnte nicht verifiziert werden.'];
}
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = $finfo ? finfo_file($finfo, $tmpName) : false;
if ($finfo) {
finfo_close($finfo);
}
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = $finfo ? finfo_file($finfo, $tmpName) : false;
if ($finfo) {
finfo_close($finfo);
}
$allowedMimeTypes = [
'text/plain',
'text/csv',
'text/x-csv',
'application/csv',
'application/vnd.ms-excel',
'application/octet-stream',
];
if (is_string($mime) && !in_array($mime, $allowedMimeTypes, true)) {
return [null, 'Der Dateityp wurde nicht als CSV erkannt.'];
}
}
$allowedMimeTypes = [
'text/plain', 'text/csv', 'text/x-csv', 'application/csv',
'application/vnd.ms-excel', 'application/octet-stream',
];
if (is_string($mime) && !in_array($mime, $allowedMimeTypes, true)) {
return [null, 'Der Dateityp wurde nicht als CSV erkannt.'];
}
}
$uploadDir = csv_upload_dir();
if (!is_dir($uploadDir)) {
@mkdir($uploadDir, 0700, true);
}
if (!is_dir($uploadDir) || !is_writable($uploadDir)) {
return [null, 'Der Upload-Ordner ist nicht beschreibbar.'];
}
$uploadDir = csv_upload_dir();
if (!is_dir($uploadDir)) {
@mkdir($uploadDir, 0700, true);
}
if (!is_dir($uploadDir) || !is_writable($uploadDir)) {
return [null, 'Der Upload-Ordner ist nicht beschreibbar.'];
}
$targetFile = $uploadDir . '/paypal_' . date('Ymd_His') . '_' . bin2hex(random_bytes(8)) . '.csv';
if (!move_uploaded_file($tmpName, $targetFile)) {
return [null, 'Die CSV-Datei konnte nicht gespeichert werden.'];
}
$targetFile = $uploadDir . '/paypal_' . date('Ymd_His') . '_' . bin2hex(random_bytes(8)) . '.csv';
if (!move_uploaded_file($tmpName, $targetFile)) {
return [null, 'Die CSV-Datei konnte nicht gespeichert werden.'];
}
return [$targetFile, null];
return [$targetFile, null];
}
// Funktion zum Überprüfen, ob der Mitarbeiter in der Tabelle vorhanden ist
function isMitarbeiterExist($conn, $name)
function csv_status_label(string $status): string
{
$sql = "SELECT MitarbeiterID FROM kl_Mitarbeiter WHERE Name = ? or paypalname = ?";
$params = array($name, $name);
$stmt = sqlsrv_query($conn, $sql, $params);
if ($stmt === false) {
die(print_r(sqlsrv_errors(), true));
}
$row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC);
return ($row !== false);
return match ($status) {
'matched' => 'Wird importiert',
'duplicate' => 'Bereits vorhanden',
'unmatched' => 'Mitglied nicht gefunden',
'invalid' => 'Ungültige Zeile',
'imported' => 'Importiert',
'ignored' => 'Ignoriert',
default => $status,
};
}
// Funktion zum Überprüfen, ob ein identischer Eintrag bereits vorhanden ist
function isDuplicateEntry($conn, $mitarbeiterID, $betrag, $datum)
function csv_status_class(string $status): string
{
// Um nur den gleichen Tag zu vergleichen, konvertieren wir den Datum-String zu einem DateTime-Objekt und verwenden die Funktion CONVERT
$date = date_create($datum);
if ($date === false) {
return false;
}
$convertedDatum = date_format($date, 'Y-m-d');
#$sql = "SELECT count FROM dbo.kl_Einzahlungen WHERE MitarbeiterID = ? AND Betrag = ? AND CONVERT(VARCHAR, Datum, 23) = ?";
$sql = "SELECT * FROM dbo.kl_Einzahlungen WHERE MitarbeiterID = ? AND Betrag = ? AND CONVERT(VARCHAR, Datum, 23) = ?";
$params = array($mitarbeiterID, $betrag, $convertedDatum);
$stmt = sqlsrv_query($conn, $sql, $params);
if ($stmt === false) {
die(print_r(sqlsrv_errors(), true));
}
$row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC);
return $row !== null && (int)$row['MitarbeiterID'] > 0;
return match ($status) {
'matched', 'imported' => 'success',
'duplicate' => 'warning',
'unmatched', 'invalid' => 'error',
'ignored' => 'muted',
default => 'muted',
};
}
// Funktion zum Verarbeiten der CSV-Datei und Rückgabe der Ergebnisse
function processCSV($conn, $file)
function csv_redirect_to_batch(?int $batchId = null): void
{
$handle = fopen($file, "r");
$success_entries = [];
$failed_entries = [];
$target = 'csvupload.php';
if ($batchId !== null && $batchId > 0) {
$target .= '?batch_id=' . urlencode((string)$batchId);
}
header('Location: ' . $target);
exit;
}
// Überspringe die erste Zeile (Header) der CSV-Datei
fgetcsv($handle);
function csv_select_options(array $members, array $suggestions = [], int $selectedId = 0): string
{
$suggestedIds = [];
foreach ($suggestions as $suggestion) {
$suggestedIds[(int)$suggestion['participant_id']] = true;
}
while (($data = fgetcsv($handle, 1000, ",")) !== FALSE) {
if (count($data) < 8) {
$failed_entries[] = array($data[0] ?? '', $data[3] ?? '', $data[7] ?? '', 4);
continue;
}
$html = '<option value="">- Mitglied wählen -</option>';
if ($suggestions !== []) {
$html .= '<optgroup label="Vorschläge">';
foreach ($suggestions as $suggestion) {
$id = (int)$suggestion['participant_id'];
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
. saas_html((string)$suggestion['display_name'])
. '</option>';
}
$html .= '</optgroup>';
}
$name = $data[3]; // Index 3 entspricht dem "Name"-Feld in der CSV
$betrag = $data[7]; // Index 7 entspricht dem "Brutto"-Feld in der CSV
$betrag = str_replace(",", ".", $betrag);
#echo "<br>";
#$Ausgabe = array($data[0], $data[3], $data[7]);
// Überprüfen, ob der Mitarbeiter existiert und der Betrag positiv ist
if (isMitarbeiterExist($conn, $name) && $betrag > 0) {
$html .= '<optgroup label="Alle aktiven Mitglieder">';
foreach ($members as $member) {
$id = (int)$member['participant_id'];
if (isset($suggestedIds[$id])) {
continue;
}
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
. saas_html((string)$member['display_name'])
. '</option>';
}
$html .= '</optgroup>';
$mitarbeiterID = getMitarbeiterID($conn, $name);
$datum = $data[0]; // Index 0 entspricht dem "Datum"-Feld in der CSV
return $html;
}
// Überprüfen, ob ein identischer Eintrag bereits vorhanden ist
if (!isDuplicateEntry($conn, $mitarbeiterID, $betrag, $datum)) {
$meldung = null;
$fehler = null;
$vorschauBatchId = isset($_GET['batch_id']) ? (int)$_GET['batch_id'] : null;
// Eintrag in die Tabelle dbo.kl_Einzahlungen einfügen
$sql = "INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)";
$params = array($mitarbeiterID, $betrag, $datum);
$stmt = sqlsrv_query($conn, $sql, $params);
if (isset($_SESSION['flash_csvupload']) && is_array($_SESSION['flash_csvupload'])) {
$flash = $_SESSION['flash_csvupload'];
unset($_SESSION['flash_csvupload']);
$meldung = isset($flash['meldung']) ? (string)$flash['meldung'] : null;
$fehler = isset($flash['fehler']) ? (string)$flash['fehler'] : null;
}
if ($stmt) {
$success_entries[] = array($data[0], $data[3], $data[7], 0);
} else {
$failed_entries[] = array($data[0], $data[3], $data[7], 1);
#die(print_r(sqlsrv_errors(), true));
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$aktion = $_POST['aktion'] ?? 'hochladen';
$vorschauBatchId = (int)($_POST['batch_id'] ?? 0);
if ($aktion === 'hochladen' && isset($_FILES["csv_file"])) {
[$csvFile, $uploadError] = csv_prepare_upload($_FILES["csv_file"]);
if ($uploadError !== null) {
$fehler = $uploadError;
$_SESSION['flash_csvupload'] = ['fehler' => $fehler];
csv_redirect_to_batch();
} else {
try {
$checksum = hash_file('sha256', $csvFile);
$rows = imports_parse_csv($csvFile);
$pdo->beginTransaction();
$batchId = imports_create_batch(
$pdo,
$tenantId,
basename((string)$_FILES["csv_file"]["name"]),
(string)$checksum,
$saasUser['user_id'] ?? null
);
foreach ($rows as $row) {
$amountCents = imports_normalize_amount($row['amount_raw']);
$timestamp = $row['date_raw'] !== '' ? strtotime($row['date_raw']) : false;
if ($amountCents === null || $timestamp === false) {
imports_store_row($pdo, $batchId, $row['row_number'], null, $row['raw_name'], $amountCents ?? 0, date('Y-m-d H:i:s', $timestamp ?: time()), 'invalid', $row['raw']);
continue;
}
$bookedAt = date('Y-m-d H:i:s', $timestamp);
$participant = imports_find_participant($pdo, $tenantId, $row['raw_name']);
if ($participant === null) {
imports_store_row($pdo, $batchId, $row['row_number'], null, $row['raw_name'], $amountCents, $bookedAt, 'unmatched', $row['raw']);
continue;
}
if (imports_is_duplicate($pdo, $tenantId, $participant['participant_id'], $amountCents, date('Y-m-d', $timestamp))) {
imports_store_row($pdo, $batchId, $row['row_number'], $participant['participant_id'], $row['raw_name'], $amountCents, $bookedAt, 'duplicate', $row['raw']);
continue;
}
imports_store_row($pdo, $batchId, $row['row_number'], $participant['participant_id'], $row['raw_name'], $amountCents, $bookedAt, 'matched', $row['raw']);
}
}else {
#echo "Eintrag bereits vorhanden: " . print_r($data, true) . "\n";
$failed_entries[] = array($data[0], $data[3], $data[7], 2);
$pdo->commit();
$vorschauBatchId = $batchId;
$meldung = 'CSV-Datei wurde eingelesen. Bitte die Vorschau prüfen.';
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
$fehler = 'Die CSV-Datei konnte nicht verarbeitet werden.';
} finally {
@unlink($csvFile);
}
} else {
$failed_entries[] = array($data[0], $data[3], $data[7], 3);
}
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
csv_redirect_to_batch($vorschauBatchId);
}
} elseif ($aktion === 'zeile_zuordnen') {
$rowId = (int)($_POST['row_id'] ?? 0);
$participantId = (int)($_POST['participant_id'] ?? 0);
if ($vorschauBatchId <= 0 || $rowId <= 0 || $participantId <= 0) {
$fehler = 'Bitte eine Importzeile und ein Mitglied auswählen.';
} else {
$result = imports_assign_row($pdo, $tenantId, $vorschauBatchId, $rowId, $participantId);
if ($result['ok']) {
$meldung = 'Importzeile wurde zugeordnet.';
} else {
$fehler = $result['error'] ?? 'Die Importzeile konnte nicht zugeordnet werden.';
}
}
}
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
csv_redirect_to_batch($vorschauBatchId);
} elseif ($aktion === 'zeile_ignorieren') {
$rowId = (int)($_POST['row_id'] ?? 0);
if ($vorschauBatchId <= 0 || $rowId <= 0) {
$fehler = 'Bitte eine Importzeile auswählen.';
} else {
$result = imports_ignore_row($pdo, $tenantId, $vorschauBatchId, $rowId);
if ($result['ok']) {
$meldung = 'Importzeile wurde ignoriert.';
} else {
$fehler = $result['error'] ?? 'Die Importzeile konnte nicht ignoriert werden.';
}
}
fclose($handle);
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
csv_redirect_to_batch($vorschauBatchId);
} elseif ($aktion === 'importieren') {
$batchId = $vorschauBatchId;
try {
$ergebnis = imports_commit_batch($pdo, $tenantId, $batchId);
app_audit_log($pdo, $tenantId, $saasUser['user_id'] ?? null, 'csv_import.committed', 'payment_import_batch', $batchId, ['imported' => $ergebnis['imported']]);
$meldung = $ergebnis['imported'] . ' Einzahlungen wurden importiert.';
} catch (Throwable $e) {
$fehler = $e->getMessage();
$vorschauBatchId = $batchId;
}
return ['success' => $success_entries, 'failed' => $failed_entries];
}
// Funktion zum Abrufen der MitarbeiterID
function getMitarbeiterID($conn, $name)
{
$sql = "SELECT MitarbeiterID FROM kl_Mitarbeiter WHERE Name = ? or paypalname = ?";
$params = array($name, $name);
$stmt = sqlsrv_query($conn, $sql, $params);
if ($stmt === false) {
die(print_r(sqlsrv_errors(), true));
}
while( $row = sqlsrv_fetch_array( $stmt, SQLSRV_FETCH_ASSOC))
{
return $row['MitarbeiterID'];
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
csv_redirect_to_batch($vorschauBatchId);
}
#echo $mitarbeiterid;
}
// Überprüfen, ob das Formular abgeschickt wurde
if ($_SERVER["REQUEST_METHOD"] == "POST") {
// Überprüfen, ob eine Datei hochgeladen wurde
if (isset($_FILES["csv_file"])) {
[$csvFile, $uploadError] = csv_prepare_upload($_FILES["csv_file"]);
$vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vorschauBatchId) : null;
$letzteBatches = imports_fetch_recent_batches($pdo, $tenantId, 8);
$mitglieder = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
$vorschauStatusZaehler = [];
if ($vorschau !== null) {
foreach ($vorschau['rows'] as $row) {
$status = (string)$row['status'];
$vorschauStatusZaehler[$status] = ($vorschauStatusZaehler[$status] ?? 0) + 1;
}
}
if ($uploadError !== null) {
echo csv_h($uploadError);
} else {
// CSV-Datei verarbeiten
$result = processCSV($conn, $csvFile);
@unlink($csvFile);
?>
echo "CSV-Datei erfolgreich verarbeitet.\n";
<h2>CSV-Import</h2>
echo "<h3>Auswertung</h3>";
echo "<table>
<?php if ($meldung !== null): ?>
<div class="hint-box success"><p><?php echo saas_html($meldung); ?></p></div>
<?php endif; ?>
<?php if ($fehler !== null): ?>
<div class="hint-box error"><p><?php echo saas_html($fehler); ?></p></div>
<?php endif; ?>
<?php if ($vorschau !== null): ?>
<h3>Vorschau: <?php echo saas_html($vorschau['batch']['original_filename']); ?></h3>
<p>Status: <?php echo saas_html($vorschau['batch']['status']); ?> ·
<?php echo (int)($vorschauStatusZaehler['matched'] ?? 0); ?> bereit ·
<?php echo (int)($vorschauStatusZaehler['unmatched'] ?? 0); ?> ohne Zuordnung ·
<?php echo (int)($vorschauStatusZaehler['duplicate'] ?? 0); ?> Duplikatverdacht ·
<?php echo (int)($vorschauStatusZaehler['invalid'] ?? 0); ?> ungültig
</p>
<div class="table-wrapper">
<table class="admin-table">
<tr>
<th>Name</th>
<th>Datum</th>
<th>Zeile</th>
<th>Name (CSV)</th>
<th>Zugeordnetes Mitglied</th>
<th>Betrag</th>
<th>Ergebnis</th>
</tr>";
<th>Datum</th>
<th>Status</th>
<th>Aktion</th>
</tr>
<?php foreach ($vorschau['rows'] as $row): ?>
<?php
$rowStatus = (string)$row['status'];
$suggestions = in_array($rowStatus, ['unmatched', 'duplicate'], true)
? imports_suggest_participants($pdo, $tenantId, (string)$row['raw_name'], null, 4)
: [];
$selectedId = count($suggestions) === 1 ? (int)$suggestions[0]['participant_id'] : 0;
?>
<tr>
<td><?php echo (int)$row['row_num']; ?></td>
<td><?php echo saas_html($row['raw_name']); ?></td>
<td><?php echo saas_html($row['display_name'] ?? '—'); ?></td>
<td><?php echo saas_html(number_format(((int)$row['amount_cents']) / 100, 2, ',', '.')); ?> €</td>
<td><?php echo saas_html($row['booked_at']); ?></td>
<td><span class="status-badge <?php echo saas_html(csv_status_class($rowStatus)); ?>"><?php echo saas_html(csv_status_label($rowStatus)); ?></span></td>
<td>
<?php if ($vorschau['batch']['status'] === 'previewed' && in_array($rowStatus, ['unmatched', 'duplicate'], true)): ?>
<form method="post" action="csvupload.php" class="inline-admin-form"<?php echo $rowStatus === 'duplicate' ? ' onsubmit="return confirm(\'Diese Zeile wurde als Duplikat erkannt. Trotzdem als neue Einzahlung buchen?\');"' : ''; ?>>
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="zeile_zuordnen">
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
<input type="hidden" name="row_id" value="<?php echo (int)$row['id']; ?>">
<select name="participant_id" required>
<?php echo csv_select_options($mitglieder, $suggestions, $selectedId); ?>
</select>
<button type="submit">Zuordnen</button>
</form>
<form method="post" action="csvupload.php" class="inline-admin-form" onsubmit="return confirm('Diese Importzeile ignorieren?');">
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="zeile_ignorieren">
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
<input type="hidden" name="row_id" value="<?php echo (int)$row['id']; ?>">
<button type="submit" class="alt">Ignorieren</button>
</form>
<?php elseif ($vorschau['batch']['status'] === 'previewed' && $rowStatus === 'invalid'): ?>
<form method="post" action="csvupload.php" class="inline-admin-form" onsubmit="return confirm('Diese ungültige Importzeile ignorieren?');">
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="zeile_ignorieren">
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
<input type="hidden" name="row_id" value="<?php echo (int)$row['id']; ?>">
<button type="submit" class="alt">Ignorieren</button>
</form>
<?php else: ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
foreach ($result['success'] as $eintrag){
echo "<tr>
<td>" . csv_h($eintrag[0]) . "</td>
<td>" . csv_h($eintrag[1]) . "</td>
<td>" . csv_h($eintrag[2]) . "</td>
<td>Erfolgreich gespeichert</td>
</tr>";
}
foreach ($result['failed'] as $eintrag){
echo "<tr>
<td>" . csv_h($eintrag[0]) . "</td>
<td>" . csv_h($eintrag[1]) . "</td>
<td>" . csv_h($eintrag[2]) . "</td>
<td>";
if($eintrag[3] == 1){
echo "SQL Fehler";
}elseif($eintrag[3] == 2){
echo "Eintrag schon vorhanden";
}elseif($eintrag[3] == 3){
echo "Benutzer nicht gefunden";
}elseif($eintrag[3] == 4){
echo "Ungültige CSV-Zeile";
}
<?php if ($vorschau['batch']['status'] === 'previewed'): ?>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="importieren">
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
<button type="submit">Import bestätigen</button>
</form>
<?php endif; ?>
<br>
<?php endif; ?>
echo "</td>
</tr>";
}
echo "</table>";
}
<?php if ($letzteBatches !== []): ?>
<h3>Letzte Importvorschauen</h3>
<div class="table-wrapper">
<table class="admin-table">
<tr>
<th>Datei</th>
<th>Status</th>
<th>Zeilen</th>
<th>Bereit</th>
<th>Offen</th>
<th></th>
</tr>
<?php foreach ($letzteBatches as $batch): ?>
<tr>
<td><?php echo saas_html($batch['original_filename']); ?><br><small><?php echo saas_html($batch['created_at']); ?></small></td>
<td><?php echo saas_html($batch['status']); ?></td>
<td><?php echo (int)$batch['total_rows']; ?></td>
<td><?php echo (int)$batch['matched_rows'] + (int)$batch['imported_rows']; ?></td>
<td><?php echo (int)$batch['unresolved_rows']; ?></td>
<td><a class="button small" href="csvupload.php?batch_id=<?php echo (int)$batch['id']; ?>">Öffnen</a></td>
</tr>
<?php endforeach; ?>
</table>
</div>
<?php endif; ?>
} else {
echo "Fehler beim Hochladen der Datei.";
}
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSV Verarbeitung</title>
</head>
<body>
<form action="<?php echo $_SERVER["PHP_SELF"]; ?>" method="post" enctype="multipart/form-data">
<?php echo app_csrf_field(); ?>
<label for="csv_file">CSV-Datei auswählen:</label>
<input type="file" name="csv_file" accept=".csv" required>
<button type="submit">Datei hochladen</button>
</form>
</body>
</html>
<?php
}else{
echo "<h2>Kein Zugriff</h2>";
}
?>
<form action="csvupload.php" method="post" enctype="multipart/form-data">
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="hochladen">
<label for="csv_file">CSV-Datei auswählen:</label>
<input type="file" name="csv_file" accept=".csv" required>
<button type="submit">Datei hochladen</button>
</form>
</div>
</section>
+29
View File
@@ -0,0 +1,29 @@
CREATE TABLE IF NOT EXISTS notices (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
message TEXT NOT NULL,
valid_from DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
valid_until DATETIME NOT NULL,
created_by_user_id INT NULL,
deleted_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_notices_tenant_valid_until (tenant_id, valid_until),
KEY idx_notices_created_by_user (created_by_user_id),
CONSTRAINT fk_notices_tenant
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
ON DELETE CASCADE,
CONSTRAINT fk_notices_created_by_user
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- One-time carry-over so currently visible legacy banners are not lost when
-- hinweise.php switches from kl_hinweise to the tenant-scoped notices table.
-- kl_hinweise itself is left untouched; it stays as the golden-master
-- reference for the legacy system.
INSERT INTO notices (tenant_id, message, valid_from, valid_until)
SELECT t.id, h.nachricht, NOW(), h.gueltig_bis
FROM kl_hinweise h
JOIN tenants t ON t.slug = 'default'
WHERE h.gueltig_bis >= NOW();
@@ -0,0 +1,61 @@
CREATE TABLE IF NOT EXISTS payment_import_batches (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
original_filename VARCHAR(255) NOT NULL,
checksum CHAR(64) NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'previewed',
created_by_user_id INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
committed_at DATETIME NULL,
KEY idx_payment_import_batches_tenant (tenant_id, created_at),
CONSTRAINT fk_payment_import_batches_tenant
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
ON DELETE CASCADE,
CONSTRAINT fk_payment_import_batches_created_by_user
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS payment_import_rows (
id INT AUTO_INCREMENT PRIMARY KEY,
batch_id INT NOT NULL,
row_num INT NOT NULL,
participant_id INT NULL,
raw_name VARCHAR(255) NOT NULL,
amount_cents INT NOT NULL,
booked_at DATETIME NOT NULL,
status VARCHAR(30) NOT NULL,
raw_json TEXT NULL,
ledger_entry_id INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_payment_import_rows_batch (batch_id),
KEY idx_payment_import_rows_participant (participant_id),
CONSTRAINT fk_payment_import_rows_batch
FOREIGN KEY (batch_id) REFERENCES payment_import_batches(id)
ON DELETE CASCADE,
CONSTRAINT fk_payment_import_rows_participant
FOREIGN KEY (participant_id) REFERENCES participants(id)
ON DELETE SET NULL,
CONSTRAINT fk_payment_import_rows_ledger_entry
FOREIGN KEY (ledger_entry_id) REFERENCES ledger_entries(id)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SET @schema_name = DATABASE();
SET @sql = (
SELECT IF(
EXISTS (
SELECT 1
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = @schema_name
AND TABLE_NAME = 'ledger_entries'
AND CONSTRAINT_NAME = 'fk_ledger_entries_import_batch'
),
'SELECT 1',
'ALTER TABLE ledger_entries ADD CONSTRAINT fk_ledger_entries_import_batch FOREIGN KEY (import_batch_id) REFERENCES payment_import_batches(id) ON DELETE SET NULL'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS outbound_emails (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
participant_id INT NULL,
template VARCHAR(60) NOT NULL,
subject VARCHAR(255) NOT NULL,
status VARCHAR(30) NOT NULL,
sent_at DATETIME NULL,
error VARCHAR(500) NULL,
created_by_user_id INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_outbound_emails_tenant_created (tenant_id, created_at),
KEY idx_outbound_emails_participant (participant_id),
CONSTRAINT fk_outbound_emails_tenant
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
ON DELETE CASCADE,
CONSTRAINT fk_outbound_emails_participant
FOREIGN KEY (participant_id) REFERENCES participants(id)
ON DELETE SET NULL,
CONSTRAINT fk_outbound_emails_created_by_user
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS rate_limit_attempts (
id INT AUTO_INCREMENT PRIMARY KEY,
bucket VARCHAR(191) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_rate_limit_attempts_bucket_created (bucket, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS audit_log (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NULL,
actor_user_id INT NULL,
action VARCHAR(60) NOT NULL,
subject_type VARCHAR(60) NOT NULL,
subject_id INT NULL,
metadata_json TEXT NULL,
ip VARCHAR(45) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_audit_log_tenant_created (tenant_id, created_at),
KEY idx_audit_log_actor_user (actor_user_id),
KEY idx_audit_log_subject (subject_type, subject_id),
CONSTRAINT fk_audit_log_tenant
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
ON DELETE CASCADE,
CONSTRAINT fk_audit_log_actor_user
FOREIGN KEY (actor_user_id) REFERENCES users(id)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,41 @@
CREATE TABLE IF NOT EXISTS faq_entries (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
question VARCHAR(500) NOT NULL,
answer TEXT NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
deleted_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_faq_entries_tenant_sort (tenant_id, sort_order),
CONSTRAINT fk_faq_entries_tenant
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Die bisherige faq.php war komplett AOK-spezifisch (Kaffeemaschine,
-- interner Ansprechpartner). Dieser Inhalt gehoert nur zum migrierten
-- Default-Mandanten und wird als dessen erste FAQ-Eintraege uebernommen,
-- statt fuer alle Mandanten sichtbar zu bleiben.
INSERT INTO faq_entries (tenant_id, question, answer, sort_order)
SELECT t.id, x.question, x.answer, x.sort_order
FROM tenants t
CROSS JOIN (
SELECT 1 AS sort_order, 'Wie werde ich Mitglied der Kaffeeliste?' AS question,
'Neumitglieder können sich einfach in diese Liste eintragen und zahlen aktuell keine Aufnahmegebühr. Es ist keine weitere Rücksprache nötig. Schon mal ausgetretene Mitglieder können erst nach einem Jahr wiederaufgenommen werden.' AS answer
UNION ALL SELECT 2, 'Wo finde ich Milch, Zucker und Kaffee?',
'Milch findet ihr im Kühlschrank, Zucker und Süßstoff steht neben der Kaffeemaschine. Nachschub findet ihr im Schrank über der Maschine. Sollte dort nichts mehr vorhanden sein, informiert bitte den Ansprechpartner der Kaffeeliste.'
UNION ALL SELECT 3, 'Welche Arten von Kaffee bekomme ich an der Kaffeemaschine?',
'Es gibt vier Tasten: Espresso (ca. 100ml), Coffee (ca. 150ml), Hot Water (ca. 200ml warmes Wasser) und Americano (ca. 300ml). Eine persönliche Einstellung der Kaffeemenge ist nicht möglich.'
UNION ALL SELECT 4, 'Wie viel kostet ein Kaffee?',
'Den aktuellen Preis pro Strich findest du oben in der Kaffeeliste. Ein einfacher Kaffee kann ein oder auch zwei Striche kosten.'
UNION ALL SELECT 5, 'Wie erfolgt die Abrechnung?',
'Je nachdem, was du an der Maschine bezogen hast, machst du die entsprechenden Striche auf der Liste. Etwa einmal im Monat wird die Liste aktualisiert; du bekommst eine E-Mail, wenn du bezahlen musst.'
UNION ALL SELECT 6, 'Wie kann ich bezahlen?',
'Aktuell kann per PayPal bezahlt werden. Du erhältst einen entsprechenden Link per Mail. Bitte immer über die Freunde-Funktion von PayPal bezahlen, sonst wird eine Gebühr fällig.'
UNION ALL SELECT 7, 'Ich habe eine rote Markierung in der Liste. Was bedeutet das?',
'Du hast einen höheren offenen Betrag. Bitte gleiche deinen ausstehenden Betrag zeitnah aus.'
UNION ALL SELECT 8, 'Ich habe das Gefühl, mein Betrag/Guthaben passt nicht. Was soll ich machen?',
'Fehler können leider auch in der Kaffeeliste passieren. Melde dich bei deinem Ansprechpartner, dann wird die Abrechnung gemeinsam geprüft.'
) x
WHERE t.slug = 'default';
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS platform_admins (
user_id INT PRIMARY KEY,
granted_by_user_id INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_platform_admins_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE,
CONSTRAINT fk_platform_admins_granted_by
FOREIGN KEY (granted_by_user_id) REFERENCES users(id)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS tenant_billing (
tenant_id INT PRIMARY KEY,
plan_code VARCHAR(30) NOT NULL DEFAULT 'free',
subscription_status VARCHAR(30) NOT NULL DEFAULT 'active',
stripe_customer_id VARCHAR(255) NULL,
stripe_subscription_id VARCHAR(255) NULL,
current_period_end DATETIME NULL,
dolibarr_thirdparty_id VARCHAR(50) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_tenant_billing_plan (plan_code),
CONSTRAINT fk_tenant_billing_tenant
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Alle bestehenden Mandanten starten auf dem freien Tarif; die tatsaechlich
-- erforderliche Stufe wird zur Laufzeit anhand der aktiven Teilnehmerzahl
-- berechnet (siehe app/billing.php), nicht fest hier eingetragen.
INSERT INTO tenant_billing (tenant_id, plan_code, subscription_status)
SELECT id, 'free', 'active' FROM tenants;
@@ -0,0 +1,3 @@
ALTER TABLE tenant_settings
ADD COLUMN pdf_show_empty_rows TINYINT(1) NOT NULL DEFAULT 1 AFTER negative_warning_cents,
ADD COLUMN pdf_split_mode VARCHAR(20) NOT NULL DEFAULT 'drinking_behavior' AFTER pdf_show_empty_rows;
@@ -0,0 +1,2 @@
ALTER TABLE tenant_settings
ADD COLUMN pdf_row_height_px SMALLINT NOT NULL DEFAULT 16 AFTER pdf_split_mode;
@@ -0,0 +1,3 @@
ALTER TABLE tenant_settings
ADD COLUMN pdf_watermark_text VARCHAR(500) NOT NULL DEFAULT 'Fragen oder Unklarheiten? Bitte lies zuerst die FAQs hinter dieser Liste!' AFTER pdf_row_height_px,
ADD COLUMN pdf_footer_text VARCHAR(500) NOT NULL DEFAULT '' AFTER pdf_watermark_text;
@@ -0,0 +1,9 @@
-- Idempotenz fuer den Stripe-Webhook: verarbeitete Event-IDs werden hier
-- festgehalten, damit eine Wiederholte Zustellung (Stripe retryt bei Timeout)
-- nicht ein zweites Mal gebucht wird oder eine doppelte Dolibarr-Rechnung
-- erzeugt.
CREATE TABLE IF NOT EXISTS stripe_webhook_events (
event_id VARCHAR(255) NOT NULL PRIMARY KEY,
event_type VARCHAR(100) NOT NULL,
processed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,6 @@
-- Automatische Zahlungserinnerung: opt-in pro Mandant. Als Schwelle dient die
-- bereits vorhandene negative_warning_cents; das Intervall verhindert, dass ein
-- Mitglied taeglich erinnert wird.
ALTER TABLE tenant_settings
ADD COLUMN payment_reminder_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER pdf_footer_text,
ADD COLUMN payment_reminder_interval_days SMALLINT NOT NULL DEFAULT 7 AFTER payment_reminder_enabled;
@@ -0,0 +1,6 @@
-- Optionales Logo als PDF-Wasserzeichen (Dateiname im geschuetzten Verzeichnis
-- var/tenant_logos/, leer = kein Logo). Wird ausschliesslich ueber die
-- Mandant-Einstellungen gesetzt, nicht ueber saas_update_tenant_settings, damit
-- der normale Settings-Speichervorgang das Logo nicht ueberschreibt.
ALTER TABLE tenant_settings
ADD COLUMN pdf_watermark_logo VARCHAR(255) NOT NULL DEFAULT '' AFTER payment_reminder_interval_days;
@@ -0,0 +1,9 @@
-- Erweiterte Bezahloptionen pro Mandant: Barzahlung (mit Ansprechpartner) und
-- Ueberweisung (mit IBAN und Kontoinhaber). PayPal existiert bereits ueber
-- paypal_enabled/paypal_url_template.
ALTER TABLE tenant_settings
ADD COLUMN cash_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER pdf_watermark_logo,
ADD COLUMN cash_contact VARCHAR(255) NOT NULL DEFAULT '' AFTER cash_enabled,
ADD COLUMN bank_transfer_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER cash_contact,
ADD COLUMN bank_account_holder VARCHAR(255) NOT NULL DEFAULT '' AFTER bank_transfer_enabled,
ADD COLUMN bank_iban VARCHAR(40) NOT NULL DEFAULT '' AFTER bank_account_holder;
+35
View File
@@ -0,0 +1,35 @@
-- Automatischer PayPal-Abgleich per weitergeleiteter Benachrichtigungsmail.
--
-- Jeder Mandant bekommt einen eindeutigen Token fuer die Plus-Adresse
-- (z. B. zahlungen+<token>@domain). Der Admin richtet eine Weiterleitung von
-- PayPal an diese Adresse ein; der Token ordnet die Mail eindeutig dem
-- Mandanten zu. Der Token ist nicht sensibel und wird bei Bedarf erzeugt.
ALTER TABLE tenants
ADD COLUMN paypal_inbox_token VARCHAR(32) NULL AFTER slug,
ADD UNIQUE KEY uq_tenants_paypal_inbox_token (paypal_inbox_token);
-- Erkannte PayPal-Zahlungen: Dedup ueber den global eindeutigen
-- Transaktionscode und gleichzeitig Warteschlange fuer nicht eindeutig
-- zuordenbare Zahlungen (participant_id IS NULL, status 'unmatched').
CREATE TABLE IF NOT EXISTS paypal_payments (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
transaction_code VARCHAR(40) NOT NULL,
payer_name VARCHAR(255) NOT NULL DEFAULT '',
note VARCHAR(255) NULL,
gross_cents INT NOT NULL DEFAULT 0,
fee_cents INT NULL,
net_cents INT NOT NULL DEFAULT 0,
paid_at DATE NULL,
participant_id INT NULL,
ledger_entry_id INT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'unmatched',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_paypal_payments_txn (transaction_code),
KEY idx_paypal_payments_tenant_status (tenant_id, status),
CONSTRAINT fk_paypal_payments_tenant
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
CONSTRAINT fk_paypal_payments_participant
FOREIGN KEY (participant_id) REFERENCES participants(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,10 @@
-- Bemerkung/Titel fuer Einzahlungen.
--
-- ledger_entries.note existiert bereits (siehe 0006), wurde bei Einzahlungen
-- aber nie befuellt. Beim Default-Mandanten laeuft die Buchung als Dual-Write
-- ueber die Legacy-Tabelle kl_Einzahlungen, und ledger_mirror_legacy_payment
-- ueberschreibt note beim Re-Sync mit dem Legacy-Wert. Ohne eine Spalte auf
-- der Legacy-Seite wuerde die Bemerkung dort also stillschweigend wieder
-- verschwinden.
ALTER TABLE kl_Einzahlungen
ADD COLUMN Bemerkung VARCHAR(1000) NULL AFTER Betrag;
+22
View File
@@ -0,0 +1,22 @@
-- Legacy-Abbau Schritt 4: Altwelt vollstaendig entfernen.
--
-- Voraussetzung: kein Laufzeit-Code liest oder schreibt mehr die kl_*-Tabellen
-- oder die legacy_*-Spalten (Schritte 1-3). Die Migration ist forward-only.
-- participants: Verknuepfung zur alten Mitarbeitertabelle entfernen.
ALTER TABLE participants
DROP INDEX uq_participants_tenant_legacy,
DROP COLUMN legacy_mitarbeiter_id;
-- ledger_entries: Herkunftszeiger auf die Legacy-Buchungstabellen entfernen.
ALTER TABLE ledger_entries
DROP INDEX uq_ledger_entries_tenant_legacy,
DROP COLUMN legacy_table,
DROP COLUMN legacy_id;
-- Die alten Windows/IIS-Tabellen selbst.
DROP TABLE IF EXISTS kl_Einzahlungen;
DROP TABLE IF EXISTS kl_Kaffeeverbrauch;
DROP TABLE IF EXISTS kl_hinweise;
DROP TABLE IF EXISTS kl_config;
DROP TABLE IF EXISTS kl_Mitarbeiter;
@@ -0,0 +1,21 @@
-- Funktions-Schalter je Mandant, gepflegt vom Betreiber im Back-Office.
--
-- Bewusst getrennt von tenant_settings: dort stellt der Kunde selbst ein, wie
-- seine Kaffeeliste arbeitet. Hier entscheidet der Betreiber, welche
-- Funktionen dem Mandanten ueberhaupt zur Verfuegung stehen (z. B. zum
-- Abschalten einer Funktion, die bei einem Kunden Probleme macht).
--
-- Ohne Zeile gilt eine Funktion als aktiv (siehe app/features.php); die
-- Tabelle haelt also nur die Abweichungen vom Standard.
CREATE TABLE IF NOT EXISTS tenant_features (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
feature_key VARCHAR(64) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_tenant_feature (tenant_id, feature_key),
CONSTRAINT fk_tenant_features_tenant
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,8 @@
-- Eigenes Design je Mandant: Logo und Akzentfarbe fuer die App-Oberflaeche.
--
-- Getrennt von pdf_watermark_logo: das Wasserzeichen ist ein blasses
-- Hintergrundbild fuer den Ausdruck, das Marken-Logo dagegen wird in der
-- Kopfzeile jeder Seite scharf angezeigt. Leere Werte = Standarddesign.
ALTER TABLE tenant_settings
ADD COLUMN brand_logo VARCHAR(255) NOT NULL DEFAULT '' AFTER bank_iban,
ADD COLUMN brand_color VARCHAR(7) NOT NULL DEFAULT '' AFTER brand_logo;
@@ -0,0 +1,9 @@
-- Schalter fuer den Hinweis auf die Kaffeeliste (Logo unten rechts).
--
-- Standard ist "an": die Marke ist Teil der Oberflaeche, wer sie nicht
-- moechte, blendet sie aus. Im kostenlosen Tarif bleibt sie unabhaengig von
-- diesem Wert eingeblendet - erzwungen in saas_update_tenant_settings() und
-- app/plan-watermark.php, damit auch ein manipulierter Formular-POST sie
-- dort nicht abschalten kann.
ALTER TABLE tenant_settings
ADD COLUMN watermark_enabled TINYINT(1) NOT NULL DEFAULT 1 AFTER brand_color;
@@ -0,0 +1,8 @@
-- Mailadresse des Zahlers aus der PayPal-Benachrichtigung.
--
-- Die automatische Zuordnung ging bisher nur ueber den Namen; die Adresse
-- ist das deutlich verlaesslichere Merkmal (Mitglieder sind im Mandanten
-- ueber tenant_id + email_norm eindeutig). Leer, wenn die Mail keine
-- brauchbare Adresse enthaelt - dann bleibt es beim Namensvergleich.
ALTER TABLE paypal_payments
ADD COLUMN payer_email VARCHAR(255) NOT NULL DEFAULT '' AFTER payer_name;

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