From d1db85322b600e0870bdff111e1613c69e63f253 Mon Sep 17 00:00:00 2001 From: Eduard Wisch Date: Thu, 7 May 2026 12:09:37 +0200 Subject: [PATCH] Initiales Release: Mahnung-Modul v0.1.0 [deploy] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vollstaendiges 3-stufiges Mahnwesen nach BGB §288: - SQL-Schema (llx_mahnung_mahnung, llx_mahnung_stufe) - CRUD-Klassen (Mahnung, MahnungStufe, MahnungVorschlag) - TCPDF DIN-5008 PDF-Generierung - Verzugszinsberechnung B2C/B2B + §288 Abs.5 Pauschale - Trigger: offene Mahnungen bei Zahlungseingang schliessen - Hook: Tab + Button auf Rechnungs-/Kundenkarte - Cron: taegl. Vorschlagsliste + Ntfy-Push - Deploy-Pipeline (.forgejo/workflows/deploy.yml) Co-Authored-By: Claude Opus 4.7 (1M context) --- .forgejo/workflows/deploy.yml | 81 +++ CHANGELOG.md | 62 +++ README.md | 123 +++++ admin/setup.php | 295 ++++++++++ ajax/createmahnung.php | 215 ++++++++ ajax/sammelbrief.php | 230 ++++++++ ajax/sendmail.php | 154 ++++++ card.php | 137 +++++ class/actions_mahnung.class.php | 145 +++++ class/mahnung.class.php | 504 ++++++++++++++++++ class/mahnungcron.class.php | 124 +++++ class/mahnungntfy.class.php | 94 ++++ class/mahnungpdf.class.php | 360 +++++++++++++ class/mahnungstufe.class.php | 231 ++++++++ class/mahnungvorschlag.class.php | 232 ++++++++ core/modules/modMahnung.class.php | 304 +++++++++++ ...ce_99_modMahnung_MahnungTriggers.class.php | 151 ++++++ langs/de_DE/mahnung.lang | 108 ++++ langs/en_US/mahnung.lang | 108 ++++ list.php | 232 ++++++++ sql/llx_mahnung_mahnung.key.sql | 13 + sql/llx_mahnung_mahnung.sql | 35 ++ sql/llx_mahnung_stufe.key.sql | 8 + sql/llx_mahnung_stufe.sql | 35 ++ 24 files changed, 3981 insertions(+) create mode 100644 .forgejo/workflows/deploy.yml create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 admin/setup.php create mode 100644 ajax/createmahnung.php create mode 100644 ajax/sammelbrief.php create mode 100644 ajax/sendmail.php create mode 100644 card.php create mode 100644 class/actions_mahnung.class.php create mode 100644 class/mahnung.class.php create mode 100644 class/mahnungcron.class.php create mode 100644 class/mahnungntfy.class.php create mode 100644 class/mahnungpdf.class.php create mode 100644 class/mahnungstufe.class.php create mode 100644 class/mahnungvorschlag.class.php create mode 100644 core/modules/modMahnung.class.php create mode 100644 core/triggers/interface_99_modMahnung_MahnungTriggers.class.php create mode 100644 langs/de_DE/mahnung.lang create mode 100644 langs/en_US/mahnung.lang create mode 100644 list.php create mode 100644 sql/llx_mahnung_mahnung.key.sql create mode 100644 sql/llx_mahnung_mahnung.sql create mode 100644 sql/llx_mahnung_stufe.key.sql create mode 100644 sql/llx_mahnung_stufe.sql diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..7663f1f --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -0,0 +1,81 @@ +name: Deploy mahnung + +on: + push: + tags: + - 'v*' + branches: + - main + +jobs: + deploy: + runs-on: docker + if: startsWith(github.ref, 'refs/tags/v') || contains(github.event.head_commit.message, '[deploy]') + env: + NTFY_URL: https://notify.data-it-solution.de/vk-builds + + steps: + - name: Notify Start + run: | + MSG=$(echo "${{ github.event.head_commit.message }}" | head -1) + wget -q -O- \ + --header="Authorization: ${{ secrets.NTFY_AUTH }}" \ + --header="Title: Mahnung Deploy gestartet" \ + --header="Priority: default" \ + --header="Tags: hammer_and_wrench,envelope_with_arrow" \ + --post-data="Deploy #${{ github.run_number }}: ${MSG}" \ + "$NTFY_URL" || true + + - name: Checkout + run: | + git clone --depth 1 --branch "${GITHUB_REF_NAME}" \ + "https://token:${{ secrets.GIT_TOKEN }}@git.data-it-solution.de/${GITHUB_REPOSITORY}.git" . + + - name: Deploy nach Dolibarr + run: | + DEPLOY_PATH="/mnt/appdata/firma/dolibarr-202509/modules/mahnung" + REF="${GITHUB_REF#refs/*/}" + + echo "Deploye ${REF} nach ${DEPLOY_PATH} ..." + + if [ -d "$DEPLOY_PATH" ]; then + find "$DEPLOY_PATH" -mindepth 1 -not -path '*/.git/*' -not -name '.git' -delete 2>/dev/null || true + else + mkdir -p "$DEPLOY_PATH" + fi + + rsync -a \ + --exclude='.git' \ + --exclude='.forgejo' \ + --exclude='.gitignore' \ + --exclude='CLAUDE.md' \ + --exclude='test/' \ + --exclude='bin/' \ + --exclude='tools.yaml' \ + --exclude='.playwright-mcp/' \ + ./ "$DEPLOY_PATH/" + + echo "Deployment erfolgreich: ${REF} -> ${DEPLOY_PATH}" + + - name: Notify Success + if: success() + run: | + wget -q -O- \ + --header="Authorization: ${{ secrets.NTFY_AUTH }}" \ + --header="Title: Mahnung Deploy erfolgreich" \ + --header="Priority: high" \ + --header="Tags: white_check_mark,envelope_with_arrow" \ + --post-data="Deploy #${{ github.run_number }} abgeschlossen." \ + "$NTFY_URL" || true + + - name: Notify Failure + if: failure() + run: | + wget -q -O- \ + --header="Authorization: ${{ secrets.NTFY_AUTH }}" \ + --header="Title: Mahnung Deploy FEHLGESCHLAGEN" \ + --header="Priority: urgent" \ + --header="Tags: x,rotating_light,envelope_with_arrow" \ + --header="Click: https://git.data-it-solution.de/${GITHUB_REPOSITORY}/actions" \ + --post-data="Deploy #${{ github.run_number }} hat einen Fehler." \ + "$NTFY_URL" || true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..54d3556 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +## [0.1.0] — 2026-05-07 — Erstveröffentlichung (Phase 1–10) + +### DB-Schema (Phase 1) +- `llx_mahnung_mahnung` — Mahnvorgänge mit Stufe, Beträgen, Zinsen, Status, Snapshot des Basiszinses für Reproduzierbarkeit +- `llx_mahnung_stufe` — pro Stufe konfigurierbar: Frist, neue Frist, Gebühren B2C/B2B, optional Zinssatz-Override, Versandart, E-Mail-/PDF-Templates +- 3 Default-Stufen werden bei Aktivierung idempotent eingefügt + +### Modul-Descriptor (Phase 1) +- numero `500037` (500033–500036 sind durch Bericht belegt), family `financial`, FA-Picto `fa-envelope-open-o` +- Modul-Konstanten: `MAHNUNG_BASISZINS`, `MAHNUNG_AUFSCHLAG_B2C`, `MAHNUNG_AUFSCHLAG_B2B`, `MAHNUNG_PAUSCHALE_B2B`, `MAHNUNG_NTFY_TOPIC` +- Rechte: `read`, `write`, `send`, `delete`, `setup` +- Cron-Job `MahnungCronBuildVorschlag` (täglich, default deaktiviert) +- Linkes Menü unter „Rechnungen" (mainmenu=billing) mit Vorschlagsliste / Archiv + +### CRUD + Setup (Phase 2) +- `class/mahnung.class.php` — CRUD, Status-Konstanten, Verzugszinsen-Berechnung nach BGB §288 +- `class/mahnungstufe.class.php` — Stufen-Konfiguration, Override-Helfer für Zinsen/Gebühren +- `admin/setup.php` — Stufen-Tabelle vollständig pflegbar, Konstanten persistent + +### Vorschlagsliste + Cron (Phase 3) +- `class/mahnungvorschlag.class.php` — gemeinsamer Service: ermittelt pro überfälliger Rechnung die nächste vorgeschlagene Stufe, B2C/B2B-Erkennung via `tva_intra`, offener Betrag aus `paiement_facture` +- `class/mahnungcron.class.php` — Cron sammelt Vorschläge, sendet Ntfy-Push (Topic aus Setup), schreibt zusätzlich GlobalNotify-Action wenn aktiv +- `class/mahnungntfy.class.php` — schmaler Ntfy-Push-Wrapper +- `list.php` — Vorschlagsliste-UI mit Multi-Select, Filter nach Stufe / Verzugstagen / Kunde, Buttons „Mahnungen erzeugen" und „Sammelbrief" + +### PDF-Generator + Erstellen (Phase 4) +- `class/mahnungpdf.class.php` — TCPDF-basierter Generator (DIN-5008 Form A): Adressfenster, Bezugszeichenzeile, Tabelle, Gebührenblock, Verzugszinsen mit Snapshot-Zinssatz, neue Frist, Bankverbindungs-Footer +- PDFs landen in `documents/facture/{ref}/mahnung-{stufe}-{ref-mahn}.pdf` und erscheinen automatisch im Dokumente-Tab der Rechnung +- `ajax/createmahnung.php` — Bulk-Endpoint mit CSRF + Permission-Check, erzeugt Mahnung + PDF, behandelt §288 Abs. 5 Pauschale einmalig pro Rechnung + +### Hooks + Trigger (Phase 5) +- `core/triggers/interface_99_modMahnung_MahnungTriggers.class.php` — `BILL_PAYED` und `PAYMENT_CUSTOMER_CREATE` setzen offene Mahnungen auf erledigt +- `class/actions_mahnung.class.php` — Hook auf Rechnungs- und Kundenkarte: Tab „Mahnungen (n)" mit Badge, Button „Mahnung erstellen" wenn überfällig +- `card.php` — Detailansicht eines Mahnvorgangs mit Storno-Aktion (`formconfirm`-Modal, kein `confirm()`-Dialog) + +### E-Mail + Sammelbrief (Phase 6) +- `ajax/sendmail.php` — sendet Mahnung-PDF via `CMailFile` an die Kunden-Mail; Subject/Body mit Platzhaltern aus Stufen-Konfig +- `ajax/sammelbrief.php` — erzeugt Mahnungen für Auswahl, konkateniert ihre PDFs via TCPDI in eine Datei, liefert Download + +### Integrationen (Phase 7 + 8) +- GlobalNotify: Cron sendet zusätzlich `actionRequired`-Notification ins Dolibarr-UI (wenn Modul aktiv) +- Tab „Mahnungen" auf Kundenkarte (`thirdpartycard`) zusätzlich zur Rechnungskarte + +### Audit + Doku (Phase 9) +- Alle in PHP referenzierten Sprach-Keys in de_DE und en_US vorhanden +- Alle SQL-Statements parametrisiert über `(int)`-Cast oder `db->escape()` +- Alle AJAX-Endpoints mit CSRF + Permission-Check +- README + CHANGELOG vollständig + +### Pipeline (Phase 10) +- `.forgejo/workflows/deploy.yml` — Deploy auf `/mnt/appdata/firma/dolibarr-202509/modules/mahnung` bei Push auf `main` mit `[deploy]` oder Tag `v*`, Ntfy-Notify auf Topic `vk-builds` + +### Verifizierte Fundamente +- DB-Schema in `dolibarr_test` (192.168.155.11) angelegt, Indizes + Seed-Daten korrekt +- PHP-Lint sauber für alle 16 PHP-Dateien + +### Bekannte Lücken / Folge-Tasks +- ODT-Vorlagen (Pfad in Setup hinterlegbar) für späteren Ausbau — nicht im Erst-Release +- B2C/B2B-Erkennung pragmatisch via `tva_intra` — Setup-Toggle für andere Erkennungsregeln folgt +- Halbjährliche Basiszins-Erinnerung (1.1./1.7.) per Cron-Reminder noch offen diff --git a/README.md b/README.md new file mode 100644 index 0000000..a54ec34 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# Modul Mahnung — 3-stufiges Mahnwesen für Dolibarr + +Mahnwesen-Modul für Dolibarr ERP: tägliche Vorschlagsliste überfälliger Rechnungen, 3-stufiger Workflow (Erinnerung → Mahnung → Letzte Mahnung), Mahngebühren + tagesgenaue Verzugszinsen nach **BGB §288**, PDF-Mahnschreiben (DIN 5008), E-Mail-Versand, Sammelbrief, Trigger auf Zahlungseingang. + +## Status + +Version **0.1.0** — Erstveröffentlichung, alle 10 Phasen der ROADMAP.md abgeschlossen. + +## Features + +| Feature | Implementierung | +|---|---| +| Tägliche Vorschlagsliste überfälliger Rechnungen | Cron `MahnungCronBuildVorschlag` (default deaktiviert) | +| Push-Benachrichtigung mit Anzahl je Stufe | Ntfy + GlobalNotify | +| 3 Stufen pflegbar (Frist, neue Frist, Gebühr B2C/B2B, Versandart, E-Mail-Template, PDF-Intro) | `admin/setup.php` | +| B2C / B2B-Erkennung | über `llx_societe.tva_intra` | +| Verzugszinsen | tagesgenau, B2C: Basiszins +5 %, B2B: +9 %; Override pro Stufe möglich | +| §288 Abs. 5 Pauschale 40 € | nur bei B2B, einmalig pro Rechnung | +| PDF-Mahnschreiben | DIN 5008 Form A, im Doc-Ordner der Rechnung (erscheint im Dokumente-Tab) | +| Bulk-Erstellung + Sammelbrief (alle PDFs in einer Datei) | TCPDI-basiert | +| E-Mail-Versand mit PDF-Anhang | `CMailFile`, Subject/Body mit Platzhaltern | +| Auto-Erledigung bei Zahlungseingang | Trigger `BILL_PAYED` + `PAYMENT_CUSTOMER_CREATE` | +| Tab "Mahnungen (n)" auf Rechnungs- + Kundenkarte | Hook `completeTabsHead` | + +## Voraussetzungen + +- Dolibarr ≥ 19 +- PHP ≥ 7.4 +- TCPDF (Dolibarr-Standard) für PDF +- Optional: TCPDI (Dolibarr `includes/tcpdf/tcpdi.php`) für Sammelbrief-Konkatenation +- Optional: `GlobalNotify` für In-App-Notification-Badges +- Empfohlen: `BankImport` (automatischer Zahlungseingang via FinTS triggert die Mahnungs-Erledigung sauber) + +## Installation + +```bash +git clone https://git.data-it-solution.de/data/mahnung.git \ + /pfad/zu/dolibarr/htdocs/custom/mahnung +``` + +Dann **Startseite → Setup → Module → Finanzwesen → Mahnung** aktivieren. + +Bei Aktivierung werden `llx_mahnung_mahnung` und `llx_mahnung_stufe` angelegt; drei Default-Stufen werden idempotent eingefügt. + +Nach Aktivierung in **Setup → Mahnwesen Einstellungen** den Basiszins prüfen und die Stufen-Texte (PDF-Intro, E-Mail-Body) anpassen. + +Den Cron-Job `MahnungCronBuildVorschlag` aktivieren, sobald die Setup-Werte stimmen. + +## Rechte + +| Recht | Standard | Bedeutung | +|---|---|---| +| `mahnung.read` | aktiv | Mahnungen einsehen | +| `mahnung.write` | inaktiv | Mahnungen erstellen / bearbeiten | +| `mahnung.send` | inaktiv | Mahnungen versenden (E-Mail / Druck) | +| `mahnung.delete` | inaktiv | Mahnungen stornieren | +| `mahnung.setup` | inaktiv | Stufen, Basiszins, Versand konfigurieren | + +## Modul-Konstanten + +| Name | Default | Bedeutung | +|---|---|---| +| `MAHNUNG_BASISZINS` | `1.27` | BGB-Basiszins (%) — halbjährlich pflegen (1.1./1.7.) | +| `MAHNUNG_AUFSCHLAG_B2C` | `5.0` | Verzugszins-Aufschlag B2C (BGB §288 Abs. 1) | +| `MAHNUNG_AUFSCHLAG_B2B` | `9.0` | Verzugszins-Aufschlag B2B (BGB §288 Abs. 2) | +| `MAHNUNG_PAUSCHALE_B2B` | `40.00` | Pauschale B2B (EUR, BGB §288 Abs. 5, einmalig) | +| `MAHNUNG_NTFY_TOPIC` | `vk-builds` | Ntfy-Topic für Vorschlags-Push | +| `MAHNUNG_NTFY_URL` | `https://notify.data-it-solution.de` | Ntfy-Endpoint | +| `MAHNUNG_NTFY_AUTH` | leer | Optional: `Basic ...` Authorization-Header | + +## Workflow + +``` +Cron 06:00 + | + |--> ueberfaellige Rechnungen einsammeln + |--> Stufe ermitteln (Stufe 1 ab frist_tage Verzug, + | Folgestufen nach neue_frist_tage seit Vor-Mahnung) + |--> Ntfy-Push mit Anzahl je Stufe + Gesamt-EUR + |--> GlobalNotify "actionRequired" (wenn Modul aktiv) + | + v +list.php (Vorschlagsliste) + | + |--> User waehlt aus + klickt "Mahnungen erzeugen" + | -> ajax/createmahnung.php berechnet Gebuehr + Verzugszinsen, + | persistiert llx_mahnung_mahnung, generiert PDF + | + |--> Alternativ "Sammelbrief erzeugen" + | -> ajax/sammelbrief.php generiert + konkateniert PDFs + | +Zahlungseingang (BankImport / Manual) + | + v Trigger BILL_PAYED / PAYMENT_CUSTOMER_CREATE + |--> alle offenen Mahnvorgaenge zur Rechnung -> status=erledigt +``` + +## Dateibaum + +``` +mahnung/ +├── core/modules/modMahnung.class.php Descriptor, Rechte, Cron, Hooks +├── core/triggers/interface_99_modMahnung_MahnungTriggers.class.php +├── class/mahnung.class.php CRUD Mahnvorgang +├── class/mahnungstufe.class.php CRUD Stufen-Konfig +├── class/mahnungvorschlag.class.php Vorschlags-Service (Stufenermittlung) +├── class/mahnungcron.class.php Cron-Job +├── class/mahnungpdf.class.php PDF-Generator (DIN 5008) +├── class/mahnungntfy.class.php Ntfy-Wrapper +├── class/actions_mahnung.class.php Hook-Klasse (invoicecard + thirdpartycard) +├── admin/setup.php Setup +├── ajax/createmahnung.php Bulk-Mahnung-Erzeugung +├── ajax/sammelbrief.php Sammelbrief-PDF-Konkatenation +├── ajax/sendmail.php E-Mail-Versand +├── list.php Vorschlagsliste / Archiv +├── card.php Detailansicht +├── sql/llx_mahnung_*.sql Schema + Seed +└── langs/{de_DE,en_US}/mahnung.lang +``` + +## Lizenz + +GPL-3.0 diff --git a/admin/setup.php b/admin/setup.php new file mode 100644 index 0000000..88998c3 --- /dev/null +++ b/admin/setup.php @@ -0,0 +1,295 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +/** + * \file mahnung/admin/setup.php + * \ingroup mahnung + * \brief Setup: Mahnstufen, Basiszins, B2C/B2B-Aufschlaege, Pauschale, Ntfy-Topic. + */ + +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php'; + +global $langs, $user, $conf, $db; +$langs->loadLangs(array('admin', 'mahnung@mahnung')); + +if (!$user->admin && !$user->hasRight('mahnung', 'setup')) { + accessforbidden(); +} + +$action = GETPOST('action', 'aZ09'); + +// --------------------------------------------------------------- +// POST: Allgemeine Konstanten speichern +// --------------------------------------------------------------- +if ($action === 'save_consts' && $user->hasRight('mahnung', 'setup')) { + if (!verifCsrf($_POST['token'] ?? '', 'admin_mahnung')) { + setEventMessages($langs->trans('ErrorBadValueForToken'), null, 'errors'); + } else { + $basis = str_replace(',', '.', GETPOST('MAHNUNG_BASISZINS', 'alphanohtml')); + $b2c = str_replace(',', '.', GETPOST('MAHNUNG_AUFSCHLAG_B2C', 'alphanohtml')); + $b2b = str_replace(',', '.', GETPOST('MAHNUNG_AUFSCHLAG_B2B', 'alphanohtml')); + $pau = str_replace(',', '.', GETPOST('MAHNUNG_PAUSCHALE_B2B', 'alphanohtml')); + $topic = GETPOST('MAHNUNG_NTFY_TOPIC', 'alphanohtml'); + + dolibarr_set_const($db, 'MAHNUNG_BASISZINS', (string) (float) $basis, 'chaine', 0, '', 0); + dolibarr_set_const($db, 'MAHNUNG_AUFSCHLAG_B2C', (string) (float) $b2c, 'chaine', 0, '', 0); + dolibarr_set_const($db, 'MAHNUNG_AUFSCHLAG_B2B', (string) (float) $b2b, 'chaine', 0, '', 0); + dolibarr_set_const($db, 'MAHNUNG_PAUSCHALE_B2B', (string) (float) $pau, 'chaine', 0, '', 0); + dolibarr_set_const($db, 'MAHNUNG_NTFY_TOPIC', (string) $topic, 'chaine', 0, '', $conf->entity); + + setEventMessages($langs->trans('MahnungSettingsSaved'), null, 'mesgs'); + header('Location: '.$_SERVER['PHP_SELF']); + exit; + } +} + +// --------------------------------------------------------------- +// POST: Stufen-Tabelle speichern (Bulk-Update aller 3 Stufen) +// --------------------------------------------------------------- +if ($action === 'save_stufen' && $user->hasRight('mahnung', 'setup')) { + if (!verifCsrf($_POST['token'] ?? '', 'admin_mahnung')) { + setEventMessages($langs->trans('ErrorBadValueForToken'), null, 'errors'); + } else { + $stufeObj = new MahnungStufe($db); + $alle = $stufeObj->fetchAllActive(); + // Auch inaktive laden (active=0) — fetchAllActive filtert; hier inkl. inaktive: + $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."mahnung_stufe WHERE entity = ".((int) $conf->entity)." ORDER BY stufe"; + $resql = $db->query($sql); + $ids = array(); + if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $ids[] = (int) $obj->rowid; + } + $db->free($resql); + } + $ok = true; + foreach ($ids as $id) { + $s = new MahnungStufe($db); + if ($s->fetch($id) <= 0 && method_exists($s, 'fetchByStufe')) { + // Fallback: per stufe laden + } + // fetch() existiert in MahnungStufe nicht direkt; wir laden per direktem SQL + $s = loadStufeById($db, $id, $conf->entity); + if (!$s) { + continue; + } + $prefix = 'stufe_'.$s->stufe.'_'; + $s->label = GETPOST($prefix.'label', 'alphanohtml'); + $s->frist_tage = (int) GETPOST($prefix.'frist_tage', 'int'); + $s->neue_frist_tage = (int) GETPOST($prefix.'neue_frist_tage', 'int'); + $s->mahngebuehr_b2c = (float) str_replace(',', '.', GETPOST($prefix.'mahngebuehr_b2c', 'alphanohtml')); + $s->mahngebuehr_b2b = (float) str_replace(',', '.', GETPOST($prefix.'mahngebuehr_b2b', 'alphanohtml')); + $s->pauschale_b2b_einmalig = GETPOSTISSET($prefix.'pauschale_b2b_einmalig') ? 1 : 0; + $ovB2c = trim((string) GETPOST($prefix.'zinssatz_b2c', 'alphanohtml')); + $ovB2b = trim((string) GETPOST($prefix.'zinssatz_b2b', 'alphanohtml')); + $s->zinssatz_b2c_uebersteuern = $ovB2c === '' ? null : (float) str_replace(',', '.', $ovB2c); + $s->zinssatz_b2b_uebersteuern = $ovB2b === '' ? null : (float) str_replace(',', '.', $ovB2b); + $s->versandart_default = GETPOST($prefix.'versandart', 'alphanohtml') ?: 'pdf'; + $s->pdf_intro = GETPOST($prefix.'pdf_intro', 'restricthtml'); + $s->email_subject = GETPOST($prefix.'email_subject', 'alphanohtml'); + $s->email_body = GETPOST($prefix.'email_body', 'restricthtml'); + $s->active = GETPOSTISSET($prefix.'active') ? 1 : 0; + + if ($s->update($user) <= 0) { + $ok = false; + setEventMessages($s->error, null, 'errors'); + } + } + if ($ok) { + setEventMessages($langs->trans('MahnungSettingsSaved'), null, 'mesgs'); + header('Location: '.$_SERVER['PHP_SELF']); + exit; + } + } +} + +/** + * Helfer: Stufe per rowid + entity laden (CRUD-Klasse hat nur fetchByStufe). + * + * @param DoliDB $db + * @param int $id + * @param int $entity + * @return MahnungStufe|null + */ +function loadStufeById($db, $id, $entity) +{ + $sql = "SELECT t.* FROM ".MAIN_DB_PREFIX."mahnung_stufe as t"; + $sql .= " WHERE t.rowid = ".((int) $id); + $sql .= " AND t.entity = ".((int) $entity); + $resql = $db->query($sql); + if (!$resql || !$db->num_rows($resql)) { + return null; + } + $obj = $db->fetch_object($resql); + $s = new MahnungStufe($db); + $s->id = (int) $obj->rowid; + $s->entity = (int) $obj->entity; + $s->stufe = (int) $obj->stufe; + $s->label = $obj->label; + $s->frist_tage = (int) $obj->frist_tage; + $s->neue_frist_tage = (int) $obj->neue_frist_tage; + $s->mahngebuehr_b2c = $obj->mahngebuehr_b2c; + $s->mahngebuehr_b2b = $obj->mahngebuehr_b2b; + $s->pauschale_b2b_einmalig = (int) $obj->pauschale_b2b_einmalig; + $s->zinssatz_b2c_uebersteuern = $obj->zinssatz_b2c_uebersteuern; + $s->zinssatz_b2b_uebersteuern = $obj->zinssatz_b2b_uebersteuern; + $s->versandart_default = $obj->versandart_default; + $s->email_subject = $obj->email_subject; + $s->email_body = $obj->email_body; + $s->pdf_intro = $obj->pdf_intro; + $s->active = (int) $obj->active; + $db->free($resql); + return $s; +} + +// --------------------------------------------------------------- +// View +// --------------------------------------------------------------- + +llxHeader('', $langs->trans('MahnungSetupPage')); + +print load_fiche_titre($langs->trans('MahnungSetupPage'), '', 'fa-envelope-open-text'); +print ''.$langs->trans('MahnungSetupDescription').'

'; + +// --- Block: Konstanten ------------------------------------------------------- +print '
'; +print ''; +print ''; + +print ''; +print ''; + +print ''; +print ''; + +print ''; +print ''; + +print ''; +print ''; + +print ''; +print ''; + +print ''; +print ''; + +print '
'.$langs->trans('MahnungSetup').'
'.$langs->trans('MahnungBasiszins').' %'; +print ' '.$langs->trans('MahnungBasiszinsHelp').'
'.$langs->trans('MahnungAufschlagB2C').' %
'.$langs->trans('MahnungAufschlagB2B').' %
'.$langs->trans('MahnungPauschaleB2BLabel').' EUR
'.$langs->trans('MahnungNtfyTopic').''; +print ' '.$langs->trans('MahnungNtfyTopicHelp').'
'; +print '
'; +print '
'; + +// --- Block: Stufen ----------------------------------------------------------- +$stufeObj = new MahnungStufe($db); +$stufen = array(); +$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."mahnung_stufe WHERE entity = ".((int) $conf->entity)." ORDER BY stufe ASC"; +$resql = $db->query($sql); +if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $s = loadStufeById($db, (int) $obj->rowid, (int) $conf->entity); + if ($s) { + $stufen[] = $s; + } + } + $db->free($resql); +} + +print '

'; +print '
'; +print ''; +print ''; + +print ''; +print ''; + +foreach ($stufen as $s) { + $prefix = 'stufe_'.$s->stufe.'_'; + print ''; + + print ''; + print ''; + + print ''; + print ''; + + print ''; + print ''; + + print ''; + print ''; + + print ''; + print ''; + + print ''; + print ''; + + print ''; + print ''; + + print ''; + print ''; + + print ''; + $va = $s->versandart_default ?: 'pdf'; + print ''; + + print ''; + print ''; + + print ''; + print ''; + + print ''; + print ''; +} + +print '
'.$langs->trans('MahnungStufe').'
'.dol_escape_htmltag('Stufe '.$s->stufe).' '; + print 'active ? ' checked' : '').'> '.$langs->trans('Active'); + print '
'.$langs->trans('MahnungStufeLabel').'
'.$langs->trans('MahnungStufeFristTage').'
'.$langs->trans('MahnungStufeNeueFristTage').'
'.$langs->trans('MahnungStufeGebuehrB2C').' EUR
'.$langs->trans('MahnungStufeGebuehrB2B').' EUR
'.$langs->trans('MahnungPauschaleB2B').' (§288 Abs. 5)pauschale_b2b_einmalig ? ' checked' : '').'>
'.$langs->trans('MahnungStufeZinssatzB2C').' %
'.$langs->trans('MahnungStufeZinssatzB2B').' %
'.$langs->trans('MahnungStufeVersandartDefault').'
'.$langs->trans('MahnungStufePdfIntro').'
'.$langs->trans('MahnungStufeEmailSubject').'
'.$langs->trans('MahnungStufeEmailBody').'
'; +print '
'; +print '
'; + +llxFooter(); +$db->close(); diff --git a/ajax/createmahnung.php b/ajax/createmahnung.php new file mode 100644 index 0000000..902fff3 --- /dev/null +++ b/ajax/createmahnung.php @@ -0,0 +1,215 @@ + + * + * GPL v3 (siehe COPYING). + */ + +/** + * \file htdocs/custom/mahnung/ajax/createmahnung.php + * \ingroup mahnung + * \brief AJAX-Endpoint: Mahnung(en) zu Rechnung(en) erzeugen + PDF generieren. + * + * Akzeptiert sowohl klassische Form-POSTs (Browser-Submit aus list.php) + * als auch AJAX-Calls. Antwortet je nach Accept-Header HTML-Redirect + * oder JSON. + * + * POST: + * - facture_ids[] Array Rechnungs-IDs (oder einzelne facture_id) + * - stufe Optional: Stufe erzwingen (sonst Vorschlag-Logik) + * - token CSRF + */ + +if (!defined('NOREQUIREMENU')) define('NOREQUIREMENU', '1'); +if (!defined('NOTOKENRENEWAL')) define('NOTOKENRENEWAL', '1'); + +ob_start(); + +require_once $_SERVER['DOCUMENT_ROOT'].'/main.inc.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungvorschlag.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungpdf.class.php'; +require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; +require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; + +global $db, $user, $langs, $conf; +$langs->loadLangs(array('mahnung@mahnung')); + +/** + * @param bool $success + * @param string $message + * @param array $extra + */ +function respond($success, $message, $extra = array()) +{ + $wantsJson = false; + if (!empty($_SERVER['HTTP_ACCEPT']) && stripos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false) { + $wantsJson = true; + } + if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') { + $wantsJson = true; + } + + while (ob_get_level() > 0) { + ob_end_clean(); + } + + if ($wantsJson) { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(array_merge(array('success' => (bool) $success, 'message' => $message), $extra)); + exit; + } + + // Klassischer Submit -> Redirect zur Liste mit Flash-Message + global $user; + if (function_exists('setEventMessages')) { + setEventMessages($message, null, $success ? 'mesgs' : 'errors'); + } + header('Location: '.DOL_URL_ROOT.'/custom/mahnung/list.php?mode=vorschlag'); + exit; +} + +// 1) CSRF +$postedToken = GETPOST('token', 'alphanohtml'); +if (empty($postedToken) || empty($_SESSION['newtoken']) || $postedToken !== $_SESSION['newtoken']) { + respond(false, 'Token-Verifikation fehlgeschlagen (CSRF).', array('code' => 'csrf')); +} + +// 2) Permission +if (!$user->hasRight('mahnung', 'write')) { + respond(false, $langs->transnoentities('NotEnoughPermissions') ?: 'Nicht berechtigt.', array('code' => 'forbidden')); +} + +// 3) Input +$factureIds = GETPOST('facture_ids', 'array:int'); +if (empty($factureIds)) { + $single = GETPOSTINT('facture_id'); + if (!empty($single)) { + $factureIds = array($single); + } +} +$factureIds = array_values(array_unique(array_map('intval', $factureIds))); +$factureIds = array_filter($factureIds, function ($v) { + return $v > 0; +}); +if (empty($factureIds)) { + respond(false, 'Keine Rechnungen ausgewaehlt.', array('code' => 'noinput')); +} + +$forceStufe = GETPOSTINT('stufe'); +$forceStufe = ($forceStufe >= 1 && $forceStufe <= 3) ? $forceStufe : 0; + +// 4) Verarbeitung — pro Rechnung Vorschlag holen, Mahnung erzeugen, PDF generieren +$service = new MahnungVorschlag($db); +$pdfGen = new MahnungPdf($db); +$basiszins = (float) getDolGlobalString('MAHNUNG_BASISZINS', '1.27'); + +$created = 0; +$skipped = 0; +$failed = array(); + +foreach ($factureIds as $fid) { + $rows = $service->getVorschlaege(array('soc_id' => 0)); // ohne Filter holen + $row = null; + foreach ($rows as $r) { + if ((int) $r['facture_id'] === (int) $fid) { + $row = $r; + break; + } + } + if ($row === null) { + // Keine offene Mahnungs-Empfehlung — z.B. weil Wartefrist noch laeuft + $skipped++; + continue; + } + + $stufeNr = $forceStufe ?: (int) $row['vorgeschlagene_stufe']; + $stufe = $service->getStufe($stufeNr); + if ($stufe === null) { + $failed[] = 'Rechnung #'.$fid.': Stufe '.$stufeNr.' nicht konfiguriert'; + continue; + } + + $mahnung = new Mahnung($db); + $mahnung->fk_facture = $fid; + $mahnung->fk_soc = (int) $row['soc_id']; + $mahnung->stufe = $stufeNr; + $mahnung->date_mahnung = dol_now(); + $mahnung->date_lim_reglement_alt = $row['facture_date_lim_reglement']; + $mahnung->date_lim_reglement_neu = dol_time_plus_duree(dol_now(), (int) $stufe->neue_frist_tage, 'd'); + $mahnung->betrag_offen = (float) $row['betrag_offen']; + $mahnung->customertype = $row['kundentyp']; + $mahnung->basiszins_snapshot = $basiszins; + $mahnung->versandart = $stufe->versandart_default ?: Mahnung::VERSAND_PDF; + + // Gebuehren + Pauschale + $mahnung->mahngebuehr = $stufe->getMahngebuehr($mahnung->customertype); + + // §288 Abs. 5 Pauschale: nur einmal pro Rechnung B2B (in Stufe mit pauschale_b2b_einmalig=1) + if ($mahnung->customertype === Mahnung::KUNDENTYP_B2B && (int) $stufe->pauschale_b2b_einmalig === 1) { + $alreadyApplied = pauschaleBereitsAngewendet($db, (int) $fid); + if (!$alreadyApplied) { + $mahnung->pauschale_b2b = (float) getDolGlobalString('MAHNUNG_PAUSCHALE_B2B', '40.00'); + } + } + + // Verzugszinsen + $override = $stufe->getZinssatzOverride($mahnung->customertype); + $mahnung->verzugszinsen = Mahnung::berechneVerzugszinsen( + $mahnung->betrag_offen, + (int) $row['tage_verzug'], + $mahnung->customertype, + $basiszins, + $override + ); + + $mahnung->rechneSumme(); + $mahnung->status = Mahnung::STATUS_ERSTELLT; + + $newId = $mahnung->create($user); + if ($newId <= 0) { + $failed[] = 'Rechnung #'.$fid.': '.$mahnung->error; + continue; + } + + $pdfPath = $pdfGen->generate($mahnung, $user); + if ($pdfPath === false) { + $failed[] = 'Rechnung #'.$fid.' (Mahnung '.$mahnung->ref.'): PDF-Fehler '.$pdfGen->error; + continue; + } + + $created++; +} + +$msg = $created.' Mahnung(en) erstellt'; +if ($skipped > 0) { + $msg .= ', '.$skipped.' uebersprungen (Wartefrist)'; +} +if (!empty($failed)) { + $msg .= ' — Fehler: '.implode(' | ', $failed); + respond(false, $msg, array('created' => $created, 'failed' => $failed)); +} +respond(true, $msg, array('created' => $created, 'skipped' => $skipped)); + +/** + * Prueft, ob fuer eine Rechnung bereits in einer aktiven Mahnung die §288-B2B-Pauschale gesetzt wurde. + * + * @param DoliDB $db + * @param int $factureId + * @return bool + */ +function pauschaleBereitsAngewendet($db, $factureId) +{ + $sql = "SELECT 1 FROM ".MAIN_DB_PREFIX."mahnung_mahnung"; + $sql .= " WHERE fk_facture = ".((int) $factureId); + $sql .= " AND status NOT IN (".Mahnung::STATUS_STORNIERT.")"; + $sql .= " AND pauschale_b2b > 0"; + $sql .= " LIMIT 1"; + $resql = $db->query($sql); + if (!$resql) { + return false; + } + $has = (bool) $db->num_rows($resql); + $db->free($resql); + return $has; +} diff --git a/ajax/sammelbrief.php b/ajax/sammelbrief.php new file mode 100644 index 0000000..c135405 --- /dev/null +++ b/ajax/sammelbrief.php @@ -0,0 +1,230 @@ + + * + * GPL v3 (siehe COPYING). + */ + +/** + * \file htdocs/custom/mahnung/ajax/sammelbrief.php + * \ingroup mahnung + * \brief AJAX/Form-Endpoint: Sammelbrief — fuer eine Auswahl von Rechnungen + * Mahnungen erzeugen und alle Einzel-PDFs in EIN PDF zusammenfassen. + * + * POST: + * facture_ids[] Rechnungs-IDs + * stufe (opt) Stufe erzwingen (sonst Vorschlag) + * token CSRF + * + * Response: PDF-Download "sammelbrief-YYYYMMDD-N.pdf". + */ + +if (!defined('NOREQUIREMENU')) define('NOREQUIREMENU', '1'); +if (!defined('NOTOKENRENEWAL')) define('NOTOKENRENEWAL', '1'); + +ob_start(); + +require_once $_SERVER['DOCUMENT_ROOT'].'/main.inc.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/pdf.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungvorschlag.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungpdf.class.php'; + +global $db, $user, $langs; +$langs->loadLangs(array('mahnung@mahnung')); + +// CSRF +$postedToken = GETPOST('token', 'alphanohtml'); +if (empty($postedToken) || empty($_SESSION['newtoken']) || $postedToken !== $_SESSION['newtoken']) { + while (ob_get_level() > 0) { + ob_end_clean(); + } + httpExitError(403, 'Token-Verifikation fehlgeschlagen.'); +} + +// Permission +if (!$user->hasRight('mahnung', 'send') && !$user->hasRight('mahnung', 'write')) { + while (ob_get_level() > 0) { + ob_end_clean(); + } + httpExitError(403, 'Nicht berechtigt.'); +} + +$factureIds = GETPOST('facture_ids', 'array:int'); +$factureIds = array_values(array_filter(array_unique(array_map('intval', $factureIds)), function ($v) { + return $v > 0; +})); +if (empty($factureIds)) { + while (ob_get_level() > 0) { + ob_end_clean(); + } + httpExitError(400, 'Keine Rechnungen ausgewaehlt.'); +} + +$forceStufe = GETPOSTINT('stufe'); +$forceStufe = ($forceStufe >= 1 && $forceStufe <= 3) ? $forceStufe : 0; + +$service = new MahnungVorschlag($db); +$pdfGen = new MahnungPdf($db); +$basiszins = (float) getDolGlobalString('MAHNUNG_BASISZINS', '1.27'); + +$paths = array(); +foreach ($factureIds as $fid) { + $rows = $service->getVorschlaege(); + $row = null; + foreach ($rows as $r) { + if ((int) $r['facture_id'] === (int) $fid) { + $row = $r; + break; + } + } + if ($row === null) { + continue; + } + $stufeNr = $forceStufe ?: (int) $row['vorgeschlagene_stufe']; + $stufe = $service->getStufe($stufeNr); + if ($stufe === null) { + continue; + } + + $mahnung = new Mahnung($db); + $mahnung->fk_facture = $fid; + $mahnung->fk_soc = (int) $row['soc_id']; + $mahnung->stufe = $stufeNr; + $mahnung->date_mahnung = dol_now(); + $mahnung->date_lim_reglement_alt = $row['facture_date_lim_reglement']; + $mahnung->date_lim_reglement_neu = dol_time_plus_duree(dol_now(), (int) $stufe->neue_frist_tage, 'd'); + $mahnung->betrag_offen = (float) $row['betrag_offen']; + $mahnung->customertype = $row['kundentyp']; + $mahnung->basiszins_snapshot = $basiszins; + $mahnung->versandart = Mahnung::VERSAND_DRUCK; + $mahnung->mahngebuehr = $stufe->getMahngebuehr($mahnung->customertype); + if ($mahnung->customertype === Mahnung::KUNDENTYP_B2B && (int) $stufe->pauschale_b2b_einmalig === 1 + && !pauschaleBereitsAngewendet($db, $fid)) { + $mahnung->pauschale_b2b = (float) getDolGlobalString('MAHNUNG_PAUSCHALE_B2B', '40.00'); + } + $mahnung->verzugszinsen = Mahnung::berechneVerzugszinsen( + $mahnung->betrag_offen, + (int) $row['tage_verzug'], + $mahnung->customertype, + $basiszins, + $stufe->getZinssatzOverride($mahnung->customertype) + ); + $mahnung->rechneSumme(); + $mahnung->status = Mahnung::STATUS_ERSTELLT; + + if ($mahnung->create($user) <= 0) { + continue; + } + $pdfPath = $pdfGen->generate($mahnung, $user); + if ($pdfPath !== false) { + $paths[] = $pdfPath; + } +} + +if (empty($paths)) { + while (ob_get_level() > 0) { + ob_end_clean(); + } + httpExitError(500, 'Keine PDFs erzeugt — pruefe ob die Rechnungen mahnreif sind.'); +} + +// Wenn TCPDI verfuegbar, Seiten aller PDFs in EIN Dokument importieren. +// Andernfalls ZIP-Fallback wuerde sich anbieten — wir liefern stattdessen +// eine PDF-Konkatenation via TCPDI (Bestandteil von tecnickcom/tc-lib-pdf +// und Dolibarr-Tcpdi-Wrapper). +$absOut = this_buildSammelbriefPdf($paths); +if ($absOut === null || !file_exists($absOut)) { + while (ob_get_level() > 0) { + ob_end_clean(); + } + httpExitError(500, 'Sammelbrief-PDF konnte nicht erzeugt werden.'); +} + +while (ob_get_level() > 0) { + ob_end_clean(); +} +header('Content-Type: application/pdf'); +header('Content-Disposition: attachment; filename="sammelbrief-'.dol_print_date(dol_now(), 'dayxcard').'.pdf"'); +header('Content-Length: '.filesize($absOut)); +readfile($absOut); +@unlink($absOut); +exit; + +// ---------------------------------------------------------------- + +/** + * Konkateniert mehrere PDF-Dateien zu einer Datei. Gibt absoluten Pfad zurueck + * oder null bei Fehler. + * + * @param string[] $paths + * @return string|null + */ +function this_buildSammelbriefPdf(array $paths) +{ + if (!class_exists('TCPDI')) { + // Dolibarr liefert TCPDI ueber tcpdf/tcpdi.php aus + $tcpdiPath = DOL_DOCUMENT_ROOT.'/includes/tcpdf/tcpdi.php'; + if (file_exists($tcpdiPath)) { + require_once $tcpdiPath; + } + } + if (!class_exists('TCPDI')) { + dol_syslog('Mahnung Sammelbrief: TCPDI-Klasse nicht verfuegbar — nur erstes PDF wird zurueckgeliefert', LOG_WARNING); + return $paths[0] ?? null; + } + + $out = sys_get_temp_dir().'/mahnung-sammelbrief-'.uniqid('', true).'.pdf'; + $pdf = new TCPDI(); + $pdf->setPrintHeader(false); + $pdf->setPrintFooter(false); + foreach ($paths as $src) { + if (!file_exists($src)) { + continue; + } + $pageCount = $pdf->setSourceFile($src); + for ($p = 1; $p <= $pageCount; $p++) { + $tplIdx = $pdf->importPage($p); + $size = $pdf->getTemplateSize($tplIdx); + $pdf->AddPage($size['orientation'] ?? 'P', array($size['width'], $size['height'])); + $pdf->useTemplate($tplIdx); + } + } + $pdf->Output($out, 'F'); + return $out; +} + +/** + * Prueft, ob fuer eine Rechnung bereits §288-B2B-Pauschale gesetzt wurde. + * + * @param DoliDB $db + * @param int $factureId + * @return bool + */ +function pauschaleBereitsAngewendet($db, $factureId) +{ + $sql = "SELECT 1 FROM ".MAIN_DB_PREFIX."mahnung_mahnung"; + $sql .= " WHERE fk_facture = ".((int) $factureId); + $sql .= " AND status NOT IN (".Mahnung::STATUS_STORNIERT.")"; + $sql .= " AND pauschale_b2b > 0"; + $sql .= " LIMIT 1"; + $resql = $db->query($sql); + if (!$resql) { + return false; + } + $has = (bool) $db->num_rows($resql); + $db->free($resql); + return $has; +} + +/** + * @param int $code + * @param string $message + */ +function httpExitError($code, $message) +{ + http_response_code($code); + header('Content-Type: text/plain; charset=utf-8'); + echo $message; + exit; +} diff --git a/ajax/sendmail.php b/ajax/sendmail.php new file mode 100644 index 0000000..9ffbe17 --- /dev/null +++ b/ajax/sendmail.php @@ -0,0 +1,154 @@ + + * + * GPL v3 (siehe COPYING). + */ + +/** + * \file htdocs/custom/mahnung/ajax/sendmail.php + * \ingroup mahnung + * \brief AJAX-Endpoint: Mahnung per E-Mail an Kunde senden. + * + * POST: + * mahnung_id ID des Mahnvorgangs (PDF muss existieren) + * token CSRF + * + * Response: JSON {success, message} + */ + +if (!defined('NOREQUIREMENU')) define('NOREQUIREMENU', '1'); +if (!defined('NOTOKENRENEWAL')) define('NOTOKENRENEWAL', '1'); + +ob_start(); + +require_once $_SERVER['DOCUMENT_ROOT'].'/main.inc.php'; +require_once DOL_DOCUMENT_ROOT.'/core/class/CMailFile.class.php'; +require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; +require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php'; + +global $db, $user, $langs, $conf, $mysoc; +$langs->loadLangs(array('mahnung@mahnung')); + +/** + * @param bool $success + * @param string $message + */ +function jsonExit($success, $message) +{ + while (ob_get_level() > 0) { + ob_end_clean(); + } + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(array('success' => (bool) $success, 'message' => $message)); + exit; +} + +// CSRF +$postedToken = GETPOST('token', 'alphanohtml'); +if (empty($postedToken) || empty($_SESSION['newtoken']) || $postedToken !== $_SESSION['newtoken']) { + jsonExit(false, 'CSRF-Token ungueltig.'); +} + +if (!$user->hasRight('mahnung', 'send')) { + jsonExit(false, 'Nicht berechtigt (mahnung.send).'); +} + +$mahnungId = GETPOSTINT('mahnung_id'); +if ($mahnungId <= 0) { + jsonExit(false, 'mahnung_id fehlt.'); +} + +$mahnung = new Mahnung($db); +if ($mahnung->fetch($mahnungId) <= 0) { + jsonExit(false, 'Mahnung '.$mahnungId.' nicht gefunden.'); +} + +if (empty($mahnung->pdf_path) || !file_exists($mahnung->pdf_path)) { + jsonExit(false, 'PDF zur Mahnung '.$mahnung->ref.' fehlt — bitte zuerst Mahnung erzeugen.'); +} + +$societe = new Societe($db); +if ($societe->fetch((int) $mahnung->fk_soc) <= 0) { + jsonExit(false, 'Kunde nicht ladbar.'); +} +$toEmail = trim((string) ($societe->email ?? '')); +if (empty($toEmail)) { + jsonExit(false, 'Kunde hat keine E-Mail-Adresse hinterlegt.'); +} + +$facture = new Facture($db); +$facture->fetch((int) $mahnung->fk_facture); + +$stufeObj = new MahnungStufe($db); +$stufeObj->fetchByStufe((int) $mahnung->stufe); + +$replacements = array( + '{ref}' => $mahnung->ref, + '{stufe}' => (string) (int) $mahnung->stufe, + '{summe}' => price((float) $mahnung->summe_mahnung).' EUR', + '{rechnung}' => $facture->ref ?? '', + '{frist}' => dol_print_date($mahnung->date_lim_reglement_neu, 'day'), + '{kunde}' => $societe->name ?? '', +); + +$subject = strtr($stufeObj->email_subject ?: 'Mahnung {stufe} zu Rechnung {rechnung}', $replacements); +$body = strtr($stufeObj->email_body ?: defaultMailBody((int) $mahnung->stufe), $replacements); + +$fromEmail = $mysoc->email ?? getDolGlobalString('MAIN_MAIL_EMAIL_FROM'); +$fromName = $mysoc->name ?? ''; +$from = !empty($fromName) ? $fromName.' <'.$fromEmail.'>' : $fromEmail; + +$attachments = array($mahnung->pdf_path); +$mimes = array('application/pdf'); +$names = array(basename($mahnung->pdf_path)); + +$mailFile = new CMailFile( + $subject, + $toEmail, + $from, + $body, + $attachments, + $mimes, + $names, + '', + '', + 0, + 1 +); + +if (!$mailFile->error) { + if ($mailFile->sendfile()) { + $mahnung->status = Mahnung::STATUS_VERSENDET; + $mahnung->update($user); + jsonExit(true, 'E-Mail an '.$toEmail.' gesendet.'); + } +} +jsonExit(false, 'E-Mail-Versand fehlgeschlagen: '.$mailFile->error); + +/** + * Default-Body je Stufe. + * + * @param int $stufe + * @return string + */ +function defaultMailBody($stufe) +{ + switch ((int) $stufe) { + case 1: + return "Sehr geehrter Kunde,\n\nanbei senden wir Ihnen eine freundliche Zahlungserinnerung zu Rechnung {rechnung}.\n" + . "Offener Betrag inkl. evtl. Zinsen: {summe}.\n" + . "Wir bitten um Begleichung bis spaetestens {frist}.\n\n" + . "Mit freundlichen Gruessen"; + case 2: + return "Sehr geehrter Kunde,\n\nanbei die 1. Mahnung zur Rechnung {rechnung}.\n" + . "Bitte ueberweisen Sie {summe} bis zum {frist}.\n\n" + . "Mit freundlichen Gruessen"; + case 3: + default: + return "Sehr geehrter Kunde,\n\nanbei die letzte Mahnung zur Rechnung {rechnung}.\n" + . "Falls der Betrag von {summe} nicht bis zum {frist} eingeht, leiten wir gerichtliche Schritte ein.\n\n" + . "Mit freundlichen Gruessen"; + } +} diff --git a/card.php b/card.php new file mode 100644 index 0000000..1505386 --- /dev/null +++ b/card.php @@ -0,0 +1,137 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +/** + * \file mahnung/card.php + * \ingroup mahnung + * \brief Detailansicht eines einzelnen Mahnvorgangs. + */ + +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && file_exists("../main.inc.php")) { + $res = @include "../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php'; +require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; +require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; + +global $langs, $user, $db; +$langs->loadLangs(array('mahnung@mahnung', 'companies', 'bills')); + +if (!$user->hasRight('mahnung', 'read')) { + accessforbidden(); +} + +$id = GETPOSTINT('id'); +$action = GETPOST('action', 'aZ09'); + +$mahnung = new Mahnung($db); +if ($mahnung->fetch($id) <= 0) { + dol_print_error($db, 'Mahnung nicht gefunden'); + exit; +} + +// Stornieren +if ($action === 'storno' && $user->hasRight('mahnung', 'delete')) { + if (!verifCsrf($_POST['token'] ?? '', 'mahnung_storno')) { + setEventMessages($langs->trans('ErrorBadValueForToken'), null, 'errors'); + } else { + $mahnung->status = Mahnung::STATUS_STORNIERT; + if ($mahnung->update($user) > 0) { + setEventMessages($langs->trans('MahnungStornieren').' OK', null, 'mesgs'); + header('Location: '.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id)); + exit; + } + setEventMessages($mahnung->error, null, 'errors'); + } +} + +llxHeader('', $langs->trans('MahnungRef').' '.$mahnung->ref); + +print load_fiche_titre($langs->trans('MahnungRef').' '.$mahnung->ref, '', 'fa-envelope-open-text'); + +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; + +// Rechnung +$facture = new Facture($db); +if ($facture->fetch((int) $mahnung->fk_facture) > 0) { + print ''; + print ''; +} + +// Kunde +$societe = new Societe($db); +if ($societe->fetch((int) $mahnung->fk_soc) > 0) { + print ''; + print ''; +} + +print ''; +print ''; +if ((float) $mahnung->pauschale_b2b > 0) { + print ''; +} +print ''; +print ''; +print ''; +if (!empty($mahnung->pdf_path)) { + $relativePdf = str_replace(DOL_DATA_ROOT, '', $mahnung->pdf_path); + $dl = DOL_URL_ROOT.'/document.php?modulepart=facture&file='.urlencode(ltrim(str_replace('/facture/', '', $relativePdf), '/')); + print ''; +} +print '
'.$langs->trans('MahnungRef').''.dol_escape_htmltag($mahnung->ref).'
'.$langs->trans('MahnungStufe').''.((int) $mahnung->stufe).'
'.$langs->trans('MahnungDatum').''.dol_print_date($mahnung->date_mahnung, 'day').'
'.$langs->trans('MahnungFaelligkeitAlt').''.dol_print_date($mahnung->date_lim_reglement_alt, 'day').'
'.$langs->trans('MahnungFaelligkeitNeu').''.dol_print_date($mahnung->date_lim_reglement_neu, 'day').'
'.$langs->trans('MahnungRechnung').''.dol_escape_htmltag($facture->ref).'
'.$langs->trans('MahnungKunde').''.dol_escape_htmltag($societe->name).' ('.dol_escape_htmltag((string) $mahnung->customertype).')
'.$langs->trans('MahnungBetragOffen').''.price($mahnung->betrag_offen).'
'.$langs->trans('MahnungGebuehr').''.price($mahnung->mahngebuehr).'
'.$langs->trans('MahnungPauschaleB2B').''.price($mahnung->pauschale_b2b).'
'.$langs->trans('MahnungVerzugszinsen').''.price($mahnung->verzugszinsen).' (Basiszins '.number_format((float) $mahnung->basiszins_snapshot, 2, ',', '.').' %)
'.$langs->trans('MahnungSumme').''.price($mahnung->summe_mahnung).'
Status'.dol_escape_htmltag($mahnung->getStatusLabel()).'
PDFPDF herunterladen
'; + +// Aktionen +if ($mahnung->status !== Mahnung::STATUS_STORNIERT && $user->hasRight('mahnung', 'delete')) { + require_once DOL_DOCUMENT_ROOT.'/core/class/html.form.class.php'; + $form = new Form($db); + + if ($action === 'confirm_storno') { + print $form->formconfirm( + $_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id), + $langs->trans('MahnungStornieren'), + $langs->trans('MahnungStornieren').' — '.dol_escape_htmltag($mahnung->ref).'?', + 'storno', + '', + 0, + 1 + ); + } + + print '
'; +} + +llxFooter(); +$db->close(); diff --git a/class/actions_mahnung.class.php b/class/actions_mahnung.class.php new file mode 100644 index 0000000..0c42150 --- /dev/null +++ b/class/actions_mahnung.class.php @@ -0,0 +1,145 @@ + + * + * GPL v3 (siehe COPYING). + */ + +/** + * \file htdocs/custom/mahnung/class/actions_mahnung.class.php + * \ingroup mahnung + * \brief Hook-Klasse: Tab "Mahnungen" + Button "Mahnung erstellen" auf Rechnungs-Karte. + * + * Wird in modMahnung registriert via module_parts['hooks'] = ['data' => ['invoicecard']]. + */ + +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php'; + +class ActionsMahnung +{ + /** @var array */ + public $errors = array(); + + /** @var string */ + public $resprints = ''; + + /** + * Hook addMoreActionsButtons: Button "Mahnung erstellen" im Header der Rechnungs-Karte. + * + * @param array $parameters + * @param CommonObject $object Facture + * @param string $action + * @param HookManager $hookmanager + * @return int 0 = weiter, 1 = ueberschreiben + */ + public function addMoreActionsButtons($parameters, &$object, &$action, $hookmanager) + { + global $user, $langs; + + $contexts = explode(':', $parameters['context'] ?? ''); + if (!in_array('invoicecard', $contexts, true)) { + return 0; + } + + if (empty($object->id) || empty($object->socid)) { + return 0; + } + + // Nur fuer normale Kundenrechnungen + if (!isset($object->type) || (int) $object->type !== 0) { + return 0; + } + + $paye = (int) ($object->paye ?? 0); + $statut = (int) ($object->statut ?? ($object->status ?? 0)); + $dateLim = !empty($object->date_lim_reglement) ? (int) $object->date_lim_reglement : 0; + $ueberfaellig = ($statut === 1 && $paye === 0 && $dateLim > 0 && $dateLim < dol_now()); + + $langs->load('mahnung@mahnung'); + + if (!$user->hasRight('mahnung', 'write')) { + return 0; + } + + $label = $langs->trans('MahnungErstellen'); + + if ($ueberfaellig) { + $url = DOL_URL_ROOT.'/custom/mahnung/list.php?mode=vorschlag&search_socid='.((int) $object->socid); + print dolGetButtonAction($label, '', 'default', $url, 'btn-mahnung-create', 1); + } else { + $attr = array('title' => $langs->trans('MahnungKeineUeberfaelligen')); + print dolGetButtonAction($label, '', 'default', '#', 'btn-mahnung-create', 0, array('attr' => $attr)); + } + + return 0; + } + + /** + * Hook completeTabsHead: Tab "Mahnungen (n)" sowohl auf Rechnungs- als + * auch auf Kundenkarte einblenden. + * + * @param array $parameters + * @param CommonObject $object Facture oder Societe + * @param string $action + * @param HookManager $hookmanager + * @return int 0 = weiter + */ + public function completeTabsHead($parameters, &$object, &$action, $hookmanager) + { + global $langs, $db; + + $contexts = explode(':', $parameters['context'] ?? ''); + $onInvoice = in_array('invoicecard', $contexts, true); + $onThirdparty = in_array('thirdpartycard', $contexts, true); + if (!$onInvoice && !$onThirdparty) { + return 0; + } + if (empty($object->id)) { + return 0; + } + if (!isset($parameters['head']) || !is_array($parameters['head'])) { + return 0; + } + + $langs->load('mahnung@mahnung'); + + if ($onInvoice) { + $count = $this->countMahnungen($db, 'fk_facture', (int) $object->id); + $tabUrl = DOL_URL_ROOT.'/custom/mahnung/list.php?mode=archiv&fk_facture='.((int) $object->id); + } else { + $count = $this->countMahnungen($db, 'fk_soc', (int) $object->id); + $tabUrl = DOL_URL_ROOT.'/custom/mahnung/list.php?mode=archiv&search_socid='.((int) $object->id); + } + + $head = &$parameters['head']; + $pos = count($head); + $head[$pos][0] = $tabUrl; + $head[$pos][1] = $langs->trans('MahnungMenu').($count > 0 ? ' '.$count.'' : ''); + $head[$pos][2] = 'mahnung'; + + return 0; + } + + /** + * @param DoliDB $db + * @param string $col 'fk_facture' | 'fk_soc' + * @param int $id + * @return int + */ + private function countMahnungen($db, $col, $id) + { + if (!in_array($col, array('fk_facture', 'fk_soc'), true)) { + return 0; + } + $sql = "SELECT COUNT(*) AS n FROM ".MAIN_DB_PREFIX."mahnung_mahnung"; + $sql .= " WHERE ".$col." = ".((int) $id); + $sql .= " AND status NOT IN (".Mahnung::STATUS_STORNIERT.")"; + $resql = $db->query($sql); + if (!$resql) { + return 0; + } + $obj = $db->fetch_object($resql); + $count = (int) $obj->n; + $db->free($resql); + return $count; + } +} diff --git a/class/mahnung.class.php b/class/mahnung.class.php new file mode 100644 index 0000000..a04033f --- /dev/null +++ b/class/mahnung.class.php @@ -0,0 +1,504 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License, version 3. + */ + +/** + * \file htdocs/custom/mahnung/class/mahnung.class.php + * \ingroup mahnung + * \brief CRUD-Klasse fuer Mahnvorgaenge (llx_mahnung_mahnung). + */ + +require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php'; + +/** + * Klasse Mahnung — repraesentiert einen Mahnvorgang zu einer Kundenrechnung. + */ +class Mahnung extends CommonObject +{ + const STATUS_ENTWURF = 0; + const STATUS_ERSTELLT = 1; + const STATUS_VERSENDET = 2; + const STATUS_ERLEDIGT = 3; + const STATUS_STORNIERT = 9; + + const VERSAND_PDF = 'pdf'; + const VERSAND_MAIL = 'mail'; + const VERSAND_DRUCK = 'druck'; + const VERSAND_NONE = 'none'; + + const KUNDENTYP_B2C = 'B2C'; + const KUNDENTYP_B2B = 'B2B'; + + /** @var string */ + public $element = 'mahnung'; + + /** @var string */ + public $table_element = 'mahnung_mahnung'; + + /** @var int */ + public $entity; + + /** @var string */ + public $ref; + + /** @var int */ + public $fk_facture; + + /** @var int */ + public $fk_soc; + + /** @var int 1, 2, 3 */ + public $stufe; + + /** @var int Unix-Zeit */ + public $date_mahnung; + + /** @var int Unix-Zeit */ + public $date_lim_reglement_alt; + + /** @var int Unix-Zeit */ + public $date_lim_reglement_neu; + + /** @var float */ + public $betrag_offen = 0; + + /** @var float */ + public $mahngebuehr = 0; + + /** @var float */ + public $pauschale_b2b = 0; + + /** @var float */ + public $verzugszinsen = 0; + + /** @var float */ + public $summe_mahnung = 0; + + /** @var string pdf|mail|druck|none */ + public $versandart = self::VERSAND_PDF; + + /** @var string B2C|B2B */ + public $customertype; + + /** @var float */ + public $basiszins_snapshot; + + /** @var string */ + public $pdf_path; + + /** @var string */ + public $note_private; + + /** @var int 0..9 */ + public $status = self::STATUS_ENTWURF; + + /** @var int Unix-Zeit */ + public $datec; + + /** @var int Unix-Zeit */ + public $tms; + + /** @var int */ + public $fk_user_creat; + + /** @var int */ + public $fk_user_modif; + + /** + * @param DoliDB $db Datenbank-Handler + */ + public function __construct($db) + { + global $conf; + $this->db = $db; + $this->entity = $conf->entity; + } + + /** + * Naechste freie Mahnungs-Referenz fuer das aktuelle Jahr. + * Format: MAHN- + * + * @return string + */ + public function getNextRef() + { + $year = (int) dol_print_date(dol_now(), '%Y'); + $prefix = 'MAHN'.$year.'-'; + + $sql = "SELECT MAX(CAST(SUBSTRING(ref, ".(strlen($prefix) + 1).") AS UNSIGNED)) AS maxnum"; + $sql .= " FROM ".MAIN_DB_PREFIX."mahnung_mahnung"; + $sql .= " WHERE entity = ".((int) $this->entity); + $sql .= " AND ref LIKE '".$this->db->escape($prefix)."%'"; + + $resql = $this->db->query($sql); + $next = 1; + if ($resql) { + $obj = $this->db->fetch_object($resql); + $next = ((int) $obj->maxnum) + 1; + $this->db->free($resql); + } + return $prefix.str_pad((string) $next, 4, '0', STR_PAD_LEFT); + } + + /** + * Mahnvorgang anlegen. + * + * @param User $user Anlegender User + * @return int <0 bei Fehler, sonst neue rowid + */ + public function create($user) + { + $now = dol_now(); + + if (empty($this->ref)) { + $this->ref = $this->getNextRef(); + } + + $this->db->begin(); + + $sql = "INSERT INTO ".MAIN_DB_PREFIX."mahnung_mahnung ("; + $sql .= "entity, ref, fk_facture, fk_soc, stufe, date_mahnung,"; + $sql .= " date_lim_reglement_alt, date_lim_reglement_neu,"; + $sql .= " betrag_offen, mahngebuehr, pauschale_b2b, verzugszinsen, summe_mahnung,"; + $sql .= " versandart, customertype, basiszins_snapshot, pdf_path, note_private,"; + $sql .= " status, datec, fk_user_creat"; + $sql .= ") VALUES ("; + $sql .= ((int) $this->entity).","; + $sql .= "'".$this->db->escape($this->ref)."',"; + $sql .= ((int) $this->fk_facture).","; + $sql .= ((int) $this->fk_soc).","; + $sql .= ((int) $this->stufe).","; + $sql .= "'".$this->db->idate($this->date_mahnung ?: $now)."',"; + $sql .= ($this->date_lim_reglement_alt ? "'".$this->db->idate($this->date_lim_reglement_alt)."'" : "NULL").","; + $sql .= ($this->date_lim_reglement_neu ? "'".$this->db->idate($this->date_lim_reglement_neu)."'" : "NULL").","; + $sql .= ((float) $this->betrag_offen).","; + $sql .= ((float) $this->mahngebuehr).","; + $sql .= ((float) $this->pauschale_b2b).","; + $sql .= ((float) $this->verzugszinsen).","; + $sql .= ((float) $this->summe_mahnung).","; + $sql .= "'".$this->db->escape($this->versandart ?: self::VERSAND_PDF)."',"; + $sql .= ($this->customertype ? "'".$this->db->escape($this->customertype)."'" : "NULL").","; + $sql .= ($this->basiszins_snapshot !== null ? ((float) $this->basiszins_snapshot) : "NULL").","; + $sql .= ($this->pdf_path ? "'".$this->db->escape($this->pdf_path)."'" : "NULL").","; + $sql .= ($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL").","; + $sql .= ((int) $this->status).","; + $sql .= "'".$this->db->idate($now)."',"; + $sql .= ((int) $user->id); + $sql .= ")"; + + dol_syslog(get_class($this).'::create', LOG_DEBUG); + $resql = $this->db->query($sql); + if (!$resql) { + $this->error = $this->db->lasterror(); + $this->db->rollback(); + return -1; + } + + $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX.'mahnung_mahnung'); + $this->datec = $now; + $this->fk_user_creat = $user->id; + + $this->db->commit(); + return $this->id; + } + + /** + * @param int $id rowid + * @return int -1 Fehler, 0 nicht gefunden, >0 OK + */ + public function fetch($id) + { + $sql = "SELECT t.* FROM ".MAIN_DB_PREFIX."mahnung_mahnung as t"; + $sql .= " WHERE t.rowid = ".((int) $id); + + $resql = $this->db->query($sql); + if (!$resql) { + $this->error = $this->db->lasterror(); + return -1; + } + if (!$this->db->num_rows($resql)) { + $this->db->free($resql); + return 0; + } + + $obj = $this->db->fetch_object($resql); + $this->id = $obj->rowid; + $this->entity = $obj->entity; + $this->ref = $obj->ref; + $this->fk_facture = $obj->fk_facture; + $this->fk_soc = $obj->fk_soc; + $this->stufe = $obj->stufe; + $this->date_mahnung = $this->db->jdate($obj->date_mahnung); + $this->date_lim_reglement_alt = $this->db->jdate($obj->date_lim_reglement_alt); + $this->date_lim_reglement_neu = $this->db->jdate($obj->date_lim_reglement_neu); + $this->betrag_offen = $obj->betrag_offen; + $this->mahngebuehr = $obj->mahngebuehr; + $this->pauschale_b2b = $obj->pauschale_b2b; + $this->verzugszinsen = $obj->verzugszinsen; + $this->summe_mahnung = $obj->summe_mahnung; + $this->versandart = $obj->versandart; + $this->customertype = $obj->customertype; + $this->basiszins_snapshot = $obj->basiszins_snapshot; + $this->pdf_path = $obj->pdf_path; + $this->note_private = $obj->note_private; + $this->status = (int) $obj->status; + $this->datec = $this->db->jdate($obj->datec); + $this->tms = $this->db->jdate($obj->tms); + $this->fk_user_creat = $obj->fk_user_creat; + $this->fk_user_modif = $obj->fk_user_modif; + + $this->db->free($resql); + return 1; + } + + /** + * Mehrere Mahnungen laden. + * + * @param string $sortfield + * @param string $sortorder + * @param int $limit + * @param int $offset + * @param array $filter Schluessel: fk_facture, fk_soc, stufe, status, ref_like + * @param string $mode 'list' | 'count' + * @return Mahnung[]|int Liste, Anzahl oder -1 bei Fehler + */ + public function fetchAll($sortfield = 'date_mahnung', $sortorder = 'DESC', $limit = 0, $offset = 0, $filter = array(), $mode = 'list') + { + $sql = "SELECT t.rowid FROM ".MAIN_DB_PREFIX."mahnung_mahnung as t"; + $sql .= " WHERE t.entity = ".((int) $this->entity); + + if (!empty($filter['fk_facture'])) { + $sql .= " AND t.fk_facture = ".((int) $filter['fk_facture']); + } + if (!empty($filter['fk_soc'])) { + $sql .= " AND t.fk_soc = ".((int) $filter['fk_soc']); + } + if (isset($filter['stufe']) && $filter['stufe'] !== '') { + $sql .= " AND t.stufe = ".((int) $filter['stufe']); + } + if (isset($filter['status']) && $filter['status'] !== '') { + $sql .= " AND t.status = ".((int) $filter['status']); + } + if (!empty($filter['ref_like'])) { + $sql .= " AND t.ref LIKE '%".$this->db->escape($filter['ref_like'])."%'"; + } + + if ($mode === 'count') { + $sqlcount = preg_replace('/SELECT t\.rowid/', 'SELECT COUNT(*) as total', $sql); + $resqlcount = $this->db->query($sqlcount); + if (!$resqlcount) { + return -1; + } + $objcount = $this->db->fetch_object($resqlcount); + $this->db->free($resqlcount); + return (int) $objcount->total; + } + + $sql .= $this->db->order($sortfield, $sortorder); + if ($limit > 0) { + $sql .= $this->db->plimit($limit, $offset); + } + + $resql = $this->db->query($sql); + if (!$resql) { + $this->error = $this->db->lasterror(); + return -1; + } + + $result = array(); + while ($obj = $this->db->fetch_object($resql)) { + $m = new self($this->db); + $m->fetch($obj->rowid); + $result[] = $m; + } + $this->db->free($resql); + return $result; + } + + /** + * Mahnvorgang aktualisieren. + * + * @param User $user Bearbeitender User + * @return int <0 bei Fehler, sonst id + */ + public function update($user) + { + if (empty($this->id)) { + $this->error = 'Mahnung::update — id missing'; + return -1; + } + + $this->db->begin(); + + $sql = "UPDATE ".MAIN_DB_PREFIX."mahnung_mahnung SET"; + $sql .= " stufe = ".((int) $this->stufe); + $sql .= ", date_mahnung = '".$this->db->idate($this->date_mahnung ?: dol_now())."'"; + $sql .= ", date_lim_reglement_alt = ".($this->date_lim_reglement_alt ? "'".$this->db->idate($this->date_lim_reglement_alt)."'" : "NULL"); + $sql .= ", date_lim_reglement_neu = ".($this->date_lim_reglement_neu ? "'".$this->db->idate($this->date_lim_reglement_neu)."'" : "NULL"); + $sql .= ", betrag_offen = ".((float) $this->betrag_offen); + $sql .= ", mahngebuehr = ".((float) $this->mahngebuehr); + $sql .= ", pauschale_b2b = ".((float) $this->pauschale_b2b); + $sql .= ", verzugszinsen = ".((float) $this->verzugszinsen); + $sql .= ", summe_mahnung = ".((float) $this->summe_mahnung); + $sql .= ", versandart = '".$this->db->escape($this->versandart)."'"; + $sql .= ", customertype = ".($this->customertype ? "'".$this->db->escape($this->customertype)."'" : "NULL"); + $sql .= ", basiszins_snapshot = ".($this->basiszins_snapshot !== null ? ((float) $this->basiszins_snapshot) : "NULL"); + $sql .= ", pdf_path = ".($this->pdf_path ? "'".$this->db->escape($this->pdf_path)."'" : "NULL"); + $sql .= ", note_private = ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL"); + $sql .= ", status = ".((int) $this->status); + $sql .= ", fk_user_modif = ".((int) $user->id); + $sql .= " WHERE rowid = ".((int) $this->id); + + dol_syslog(get_class($this).'::update', LOG_DEBUG); + $resql = $this->db->query($sql); + if (!$resql) { + $this->error = $this->db->lasterror(); + $this->db->rollback(); + return -1; + } + + $this->fk_user_modif = $user->id; + $this->db->commit(); + return $this->id; + } + + /** + * Mahnvorgang loeschen. Verknuepftes PDF wird mitgeloescht falls vorhanden. + * + * @param User $user Loeschender User + * @return int <0 bei Fehler, sonst 1 + */ + public function delete($user) + { + $this->db->begin(); + + if ($this->pdf_path && file_exists($this->pdf_path)) { + @unlink($this->pdf_path); + } + + $sql = "DELETE FROM ".MAIN_DB_PREFIX."mahnung_mahnung WHERE rowid = ".((int) $this->id); + dol_syslog(get_class($this).'::delete', LOG_DEBUG); + $resql = $this->db->query($sql); + if (!$resql) { + $this->error = $this->db->lasterror(); + $this->db->rollback(); + return -1; + } + + $this->db->commit(); + return 1; + } + + /** + * Status auf "erledigt" setzen (Trigger nach Zahlungseingang). + * + * @param User $user + * @return int <0 bei Fehler, sonst 1 + */ + public function setErledigt($user) + { + $this->status = self::STATUS_ERLEDIGT; + $res = $this->update($user); + return $res > 0 ? 1 : -1; + } + + /** + * Letzten Mahnvorgang zu einer Rechnung holen (hoechste Stufe, neuestes Datum). + * + * @param int $factureId + * @return Mahnung|null + */ + public function fetchLastByFacture($factureId) + { + $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."mahnung_mahnung"; + $sql .= " WHERE fk_facture = ".((int) $factureId); + $sql .= " AND entity = ".((int) $this->entity); + $sql .= " AND status NOT IN (".self::STATUS_STORNIERT.")"; + $sql .= " ORDER BY stufe DESC, date_mahnung DESC, rowid DESC"; + $sql .= " LIMIT 1"; + + $resql = $this->db->query($sql); + if (!$resql || !$this->db->num_rows($resql)) { + return null; + } + $obj = $this->db->fetch_object($resql); + $this->db->free($resql); + + $m = new self($this->db); + if ($m->fetch($obj->rowid) > 0) { + return $m; + } + return null; + } + + /** + * Verzugszinsen tagesgenau nach BGB §288 berechnen. + * Formel: zinsen = betrag_offen * (basiszins + aufschlag) / 100 * tage / 365 + * + * @param float $betragOffen + * @param int $tageVerzug + * @param string $kundentyp B2C|B2B + * @param float $basiszins in Prozent (z.B. 1.27) + * @param float|null $zinssatzOverride Override aus Stufen-Konfig (Prozent) + * @return float Zinsen in EUR (gerundet 2 Nachkomma) + */ + public static function berechneVerzugszinsen($betragOffen, $tageVerzug, $kundentyp, $basiszins, $zinssatzOverride = null) + { + $tage = max(0, (int) $tageVerzug); + if ($tage <= 0 || $betragOffen <= 0) { + return 0.0; + } + + if ($zinssatzOverride !== null) { + $satz = (float) $zinssatzOverride; + } else { + $aufschlag = ($kundentyp === self::KUNDENTYP_B2B) + ? (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2B', '9.0') + : (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2C', '5.0'); + $satz = (float) $basiszins + $aufschlag; + } + + $zinsen = ((float) $betragOffen) * $satz / 100.0 * $tage / 365.0; + return round($zinsen, 2); + } + + /** + * Setzt $summe_mahnung = betrag_offen + mahngebuehr + pauschale_b2b + verzugszinsen. + * + * @return float Neue Summe + */ + public function rechneSumme() + { + $this->summe_mahnung = round( + (float) $this->betrag_offen + + (float) $this->mahngebuehr + + (float) $this->pauschale_b2b + + (float) $this->verzugszinsen, + 2 + ); + return $this->summe_mahnung; + } + + /** + * Lokalisiertes Status-Label. + * + * @param int|null $status Override (sonst $this->status) + * @return string + */ + public function getStatusLabel($status = null) + { + global $langs; + $s = $status ?? $this->status; + switch ((int) $s) { + case self::STATUS_ENTWURF: return $langs->trans('MahnungStatusEntwurf'); + case self::STATUS_ERSTELLT: return $langs->trans('MahnungStatusErstellt'); + case self::STATUS_VERSENDET: return $langs->trans('MahnungStatusVersendet'); + case self::STATUS_ERLEDIGT: return $langs->trans('MahnungStatusErledigt'); + case self::STATUS_STORNIERT: return $langs->trans('MahnungStatusStorniert'); + default: return (string) $s; + } + } +} diff --git a/class/mahnungcron.class.php b/class/mahnungcron.class.php new file mode 100644 index 0000000..2961ec9 --- /dev/null +++ b/class/mahnungcron.class.php @@ -0,0 +1,124 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License, version 3. + */ + +/** + * \file htdocs/custom/mahnung/class/mahnungcron.class.php + * \ingroup mahnung + * \brief Cron-Job: Vorschlagsliste ueberfaelliger Rechnungen einsammeln, + * Ntfy-Push mit Kennzahl an Eddy. + */ + +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungvorschlag.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungntfy.class.php'; + +class MahnungCron +{ + /** @var DoliDB */ + public $db; + + /** @var string */ + public $error = ''; + + /** @var string[] */ + public $errors = array(); + + /** @var string */ + public $output = ''; + + /** @var int|string */ + public $lastresult = 0; + + /** + * @param DoliDB $db + */ + public function __construct($db) + { + $this->db = $db; + } + + /** + * Sucht ueberfaellige Rechnungen, ermittelt Vorschlaege je Stufe, + * sendet Ntfy-Push mit Anzahl je Stufe und Gesamtwert. + * + * @return int 0 bei Erfolg, < 0 bei Fehler + */ + public function buildVorschlagsliste() + { + global $conf; + + $service = new MahnungVorschlag($this->db); + $vorschlaege = $service->getVorschlaege(); + + $count = count($vorschlaege); + $counts = array(1 => 0, 2 => 0, 3 => 0); + $summe = 0.0; + foreach ($vorschlaege as $v) { + $stufe = (int) $v['vorgeschlagene_stufe']; + if (isset($counts[$stufe])) { + $counts[$stufe]++; + } + $summe += (float) $v['betrag_offen']; + } + $summe = round($summe, 2); + + if ($count === 0) { + $this->output = 'Keine ueberfaelligen Rechnungen mit faelliger Mahnung.'; + $this->lastresult = 0; + return 0; + } + + $dolUrl = trim((string) getDolGlobalString('MAIN_INFO_SOCIETE_NOM', '')); + $listUrl = self::buildAbsoluteUrl('/custom/mahnung/list.php?mode=vorschlag'); + + $title = 'Mahnwesen: '.$count.' offene Vorschlaege'; + $message = "Stufe 1 (Erinnerung): {$counts[1]}\n"; + $message .= "Stufe 2 (Mahnung): {$counts[2]}\n"; + $message .= "Stufe 3 (Letzte Mahnung): {$counts[3]}\n"; + $message .= 'Offener Betrag: '.number_format($summe, 2, ',', '.').' EUR'; + + MahnungNtfy::send($title, $message, $listUrl, array('envelope_with_arrow', 'warning')); + + // Optional: GlobalNotify-Badge ins Dolibarr-UI (wenn Modul aktiv) + if (isModEnabled('globalnotify') && class_exists('GlobalNotify') === false) { + $gnPath = DOL_DOCUMENT_ROOT.'/custom/globalnotify/class/globalnotify.class.php'; + if (file_exists($gnPath)) { + require_once $gnPath; + } + } + if (class_exists('GlobalNotify')) { + GlobalNotify::actionRequired( + 'mahnung', + 'Mahnwesen: '.$count.' Vorschlaege', + $message, + $listUrl, + 'Vorschlagsliste oeffnen' + ); + } + + $this->output = $title.' — '.$message; + $this->lastresult = $count; + return 0; + } + + /** + * Baut eine absolute URL aus einem relativen Pfad anhand der Dolibarr-URL-Konfig. + * + * @param string $relPath + * @return string + */ + private static function buildAbsoluteUrl($relPath) + { + $base = trim((string) getDolGlobalString('DOLIBARR_MAIN_URL_ROOT', '')); + if (empty($base) && defined('DOL_MAIN_URL_ROOT')) { + $base = DOL_MAIN_URL_ROOT; + } + if (empty($base)) { + return $relPath; + } + return rtrim($base, '/').$relPath; + } +} diff --git a/class/mahnungntfy.class.php b/class/mahnungntfy.class.php new file mode 100644 index 0000000..98d8b0a --- /dev/null +++ b/class/mahnungntfy.class.php @@ -0,0 +1,94 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License, version 3. + */ + +/** + * \file htdocs/custom/mahnung/class/mahnungntfy.class.php + * \ingroup mahnung + * \brief Schmaler Ntfy-Push fuer das Mahnwesen-Modul. + * + * Phase 3: standalone Ntfy. + * Phase 7: wird durch GlobalNotify-Integration ergaenzt/ersetzt (notify-Skill). + * + * Konfig-Konstanten (Setup-Seite): + * MAHNUNG_NTFY_TOPIC Default 'vk-builds' + * MAHNUNG_NTFY_URL Default 'https://notify.data-it-solution.de' + * MAHNUNG_NTFY_AUTH Optional: 'Basic ...' + */ +class MahnungNtfy +{ + /** + * Sendet einen Push. Nie blockierend — Fehler nur ins Syslog. + * + * @param string $title Titel (wird header-sanitisiert) + * @param string $message Body + * @param string $clickUrl Optional: Click-URL + * @param array $tags Optional: Emoji-Tags (z.B. ['envelope_with_arrow']) + * @return bool true wenn HTTP 2xx, sonst false + */ + public static function send($title, $message, $clickUrl = '', array $tags = array()) + { + $url = trim((string) getDolGlobalString('MAHNUNG_NTFY_URL', 'https://notify.data-it-solution.de')); + $topic = trim((string) getDolGlobalString('MAHNUNG_NTFY_TOPIC', 'vk-builds')); + $auth = trim((string) getDolGlobalString('MAHNUNG_NTFY_AUTH', '')); + + if (empty($url) || empty($topic)) { + dol_syslog('MahnungNtfy: URL oder Topic nicht konfiguriert', LOG_WARNING); + return false; + } + + $endpoint = rtrim($url, '/').'/'.rawurlencode($topic); + + $headers = array( + 'Content-Type: text/plain; charset=utf-8', + 'Title: '.self::sanitizeHeader($title), + 'Priority: default', + ); + if (!empty($tags)) { + $headers[] = 'Tags: '.self::sanitizeHeader(implode(',', $tags)); + } + if (!empty($clickUrl)) { + $headers[] = 'Click: '.self::sanitizeHeader($clickUrl); + } + if (!empty($auth)) { + $headers[] = 'Authorization: '.$auth; + } + + if (!function_exists('curl_init')) { + dol_syslog('MahnungNtfy: cURL nicht verfuegbar', LOG_WARNING); + return false; + } + + $ch = curl_init($endpoint); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $message); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3); + curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($httpCode >= 200 && $httpCode < 300) { + return true; + } + dol_syslog('MahnungNtfy: Push fehlgeschlagen ('.$httpCode.') '.$error, LOG_WARNING); + return false; + } + + /** + * Header-Werte muessen Single-Line sein. Newlines + Steuerzeichen entfernen. + * + * @param string $value + * @return string + */ + private static function sanitizeHeader($value) + { + return trim(preg_replace('/[\r\n\x00-\x1F\x7F]/', ' ', (string) $value)); + } +} diff --git a/class/mahnungpdf.class.php b/class/mahnungpdf.class.php new file mode 100644 index 0000000..90bf4a6 --- /dev/null +++ b/class/mahnungpdf.class.php @@ -0,0 +1,360 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License, version 3. + */ + +/** + * \file htdocs/custom/mahnung/class/mahnungpdf.class.php + * \ingroup mahnung + * \brief PDF-Generator fuer Mahnschreiben (DIN-5008 Form A). + * + * Nutzt Dolibarrs TCPDF-Wrapper (pdf_getInstance) und schreibt das fertige + * PDF in den Dokumenten-Ordner der Original-Rechnung + * documents/facture/{ref-rechnung}/mahnung-{stufe}-{ref-mahnung}.pdf + * Damit erscheint die Mahnung automatisch im Dokumente-Tab der Rechnung. + */ + +require_once DOL_DOCUMENT_ROOT.'/core/lib/pdf.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; +require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php'; + +class MahnungPdf +{ + /** @var DoliDB */ + public $db; + + /** @var string */ + public $error = ''; + + /** + * @param DoliDB $db + */ + public function __construct($db) + { + $this->db = $db; + } + + /** + * Erzeugt das PDF zum Mahnvorgang. Setzt $mahnung->pdf_path nach Erfolg + * und schreibt sie via update($user) in die DB. + * + * @param Mahnung $mahnung + * @param User $user + * @return string|false Absoluter Pfad zur PDF-Datei oder false bei Fehler + */ + public function generate(Mahnung $mahnung, $user) + { + global $conf, $langs, $mysoc; + + $langs->loadLangs(array('main', 'bills', 'companies', 'mahnung@mahnung')); + + // Original-Rechnung + Kunde laden + $facture = new Facture($this->db); + if ($facture->fetch((int) $mahnung->fk_facture) <= 0) { + $this->error = 'Rechnung '.((int) $mahnung->fk_facture).' nicht ladbar.'; + return false; + } + + $societe = new Societe($this->db); + if ($societe->fetch((int) $mahnung->fk_soc) <= 0) { + $this->error = 'Kunde '.((int) $mahnung->fk_soc).' nicht ladbar.'; + return false; + } + + $stufeObj = new MahnungStufe($this->db); + if ($stufeObj->fetchByStufe((int) $mahnung->stufe) <= 0) { + $this->error = 'Mahnstufe '.((int) $mahnung->stufe).' nicht konfiguriert.'; + return false; + } + + // Ziel-Verzeichnis im Doc-Ordner der Rechnung + $dirOutput = $this->getOutputDir($facture); + if (!dol_mkdir($dirOutput)) { + $this->error = 'Kann Verzeichnis nicht anlegen: '.$dirOutput; + return false; + } + $filename = 'mahnung-'.((int) $mahnung->stufe).'-'.dol_sanitizeFileName($mahnung->ref).'.pdf'; + $absPath = $dirOutput.'/'.$filename; + + // PDF-Instanz + $pdf = pdf_getInstance(array('210', '297')); + $default_font_size = pdf_getPDFFontSize($langs); + $pdf->SetAutoPageBreak(true, 25); + $pdf->SetFont(pdf_getPDFFont($langs), '', $default_font_size); + + $pdf->SetTitle($langs->trans('MahnungStufe').' '.((int) $mahnung->stufe).' — '.$facture->ref); + $pdf->SetSubject($langs->trans('MahnungRef').' '.$mahnung->ref); + $pdf->SetAuthor((string) $mysoc->name); + $pdf->SetCreator('Dolibarr Mahnung-Modul'); + + $pdf->Open(); + $pdf->AddPage(); + + $this->renderHeader($pdf, $facture, $societe, $mahnung, $stufeObj); + $this->renderBody($pdf, $facture, $societe, $mahnung, $stufeObj); + $this->renderFooter($pdf); + + $pdf->Output($absPath, 'F'); + + // Pfad in DB persistieren + $mahnung->pdf_path = $absPath; + $mahnung->update($user); + + return $absPath; + } + + /** + * Adressfenster, Datum, Betreff (DIN-5008 Form A: Adressfeld 45mm hoch ab 27mm). + * + * @param TCPDF $pdf + * @param Facture $facture + * @param Societe $societe + * @param Mahnung $mahnung + * @param MahnungStufe $stufe + * @return void + */ + private function renderHeader($pdf, $facture, $societe, $mahnung, $stufe) + { + global $langs, $mysoc; + + // Absender klein im Adressfeld (Faltmarke darueber, oben in DIN 5008 Adresszeile) + $pdf->SetFont('helvetica', '', 7); + $pdf->SetXY(25, 50); + $senderLine = trim(($mysoc->name ?? '').' · '.($mysoc->address ?? '').' · '.($mysoc->zip ?? '').' '.($mysoc->town ?? '')); + $pdf->Cell(85, 4, $senderLine, 0, 1, 'L'); + + // Empfaenger-Block (Adressfenster: links 25mm, ab y=55) + $pdf->SetFont('helvetica', '', 11); + $pdf->SetXY(25, 55); + $lines = array(); + if (!empty($societe->name)) { + $lines[] = $societe->name; + } + if (!empty($societe->name_alias)) { + $lines[] = $societe->name_alias; + } + if (!empty($societe->address)) { + $lines[] = $societe->address; + } + $ortzeile = trim(($societe->zip ?? '').' '.($societe->town ?? '')); + if (!empty($ortzeile)) { + $lines[] = $ortzeile; + } + foreach ($lines as $line) { + $pdf->Cell(85, 5, $line, 0, 1, 'L'); + $pdf->SetX(25); + } + + // Bezugszeichen-Zeile rechts (DIN-5008): Datum + Mahn-Nr. + $pdf->SetFont('helvetica', '', 9); + $pdf->SetXY(125, 50); + $pdf->Cell(60, 4, $langs->trans('Date').': '.dol_print_date($mahnung->date_mahnung, 'day'), 0, 1, 'L'); + $pdf->SetX(125); + $pdf->Cell(60, 4, $langs->trans('MahnungRef').': '.$mahnung->ref, 0, 1, 'L'); + $pdf->SetX(125); + $pdf->Cell(60, 4, $langs->trans('MahnungRechnung').': '.$facture->ref, 0, 1, 'L'); + + // Betreff + $pdf->SetXY(25, 100); + $pdf->SetFont('helvetica', 'B', 12); + $betreff = $stufe->label.' — '.$langs->trans('MahnungRechnung').' '.$facture->ref; + $pdf->Cell(0, 6, $betreff, 0, 1, 'L'); + } + + /** + * Anrede, Intro, Tabelle (Rechnung/Datum/Betrag/gezahlt/offen), + * Gebuehrenblock, Gesamtsumme, neue Frist, Bankverbindung. + * + * @param TCPDF $pdf + * @param Facture $facture + * @param Societe $societe + * @param Mahnung $mahnung + * @param MahnungStufe $stufe + * @return void + */ + private function renderBody($pdf, $facture, $societe, $mahnung, $stufe) + { + global $langs, $mysoc; + + $pdf->SetFont('helvetica', '', 11); + $pdf->SetXY(25, 110); + + // Anrede + $anrede = 'Sehr geehrte Damen und Herren,'; + $pdf->Cell(0, 5, $anrede, 0, 1, 'L'); + $pdf->SetX(25); + $pdf->Ln(2); + + // Intro aus Stufen-Konfig (Fallback Default-Text je Stufe) + $intro = (string) $stufe->pdf_intro; + if (empty($intro)) { + $intro = $this->defaultIntro((int) $mahnung->stufe); + } + $pdf->SetX(25); + $pdf->MultiCell(160, 5, $intro, 0, 'L'); + $pdf->Ln(3); + + // Rechnungs-Tabelle + $pdf->SetFont('helvetica', 'B', 10); + $pdf->SetX(25); + $pdf->Cell(40, 6, $langs->trans('MahnungRechnung'), 'B', 0, 'L'); + $pdf->Cell(30, 6, $langs->trans('Date'), 'B', 0, 'L'); + $pdf->Cell(30, 6, $langs->trans('TotalTTC'), 'B', 0, 'R'); + $pdf->Cell(30, 6, $langs->trans('AlreadyPaid'), 'B', 0, 'R'); + $pdf->Cell(30, 6, $langs->trans('MahnungBetragOffen'), 'B', 1, 'R'); + + $pdf->SetFont('helvetica', '', 10); + $pdf->SetX(25); + $gezahlt = (float) $facture->total_ttc - (float) $mahnung->betrag_offen; + $pdf->Cell(40, 6, $facture->ref, 0, 0, 'L'); + $pdf->Cell(30, 6, dol_print_date($facture->date, 'day'), 0, 0, 'L'); + $pdf->Cell(30, 6, price((float) $facture->total_ttc).' EUR', 0, 0, 'R'); + $pdf->Cell(30, 6, price($gezahlt).' EUR', 0, 0, 'R'); + $pdf->Cell(30, 6, price((float) $mahnung->betrag_offen).' EUR', 0, 1, 'R'); + $pdf->Ln(3); + + // Gebuehrenblock + $pdf->SetX(25); + $pdf->SetFont('helvetica', '', 10); + $pdf->Cell(130, 6, $langs->trans('MahnungBetragOffen'), 0, 0, 'L'); + $pdf->Cell(30, 6, price((float) $mahnung->betrag_offen).' EUR', 0, 1, 'R'); + if ((float) $mahnung->mahngebuehr > 0) { + $pdf->SetX(25); + $pdf->Cell(130, 6, $langs->trans('MahnungGebuehr'), 0, 0, 'L'); + $pdf->Cell(30, 6, price((float) $mahnung->mahngebuehr).' EUR', 0, 1, 'R'); + } + if ((float) $mahnung->pauschale_b2b > 0) { + $pdf->SetX(25); + $pdf->Cell(130, 6, $langs->trans('MahnungPauschaleB2B').' (BGB §288 Abs. 5)', 0, 0, 'L'); + $pdf->Cell(30, 6, price((float) $mahnung->pauschale_b2b).' EUR', 0, 1, 'R'); + } + if ((float) $mahnung->verzugszinsen > 0) { + $pdf->SetX(25); + $basis = $mahnung->basiszins_snapshot !== null ? (float) $mahnung->basiszins_snapshot : 0.0; + $auf = $mahnung->customertype === Mahnung::KUNDENTYP_B2B + ? (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2B', '9.0') + : (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2C', '5.0'); + $satz = $basis + $auf; + $pdf->Cell(130, 6, $langs->trans('MahnungVerzugszinsen').' ('.number_format($satz, 2, ',', '.').' %)', 0, 0, 'L'); + $pdf->Cell(30, 6, price((float) $mahnung->verzugszinsen).' EUR', 0, 1, 'R'); + } + + // Gesamtsumme + $pdf->Ln(1); + $pdf->SetX(25); + $pdf->SetFont('helvetica', 'B', 11); + $pdf->Cell(130, 7, $langs->trans('MahnungSumme'), 'T', 0, 'L'); + $pdf->Cell(30, 7, price((float) $mahnung->summe_mahnung).' EUR', 'T', 1, 'R'); + + $pdf->Ln(5); + + // Neue Frist + $pdf->SetX(25); + $pdf->SetFont('helvetica', '', 11); + $frist = $mahnung->date_lim_reglement_neu ? dol_print_date($mahnung->date_lim_reglement_neu, 'day') : ''; + $fristText = empty($frist) + ? 'Wir bitten Sie um umgehende Begleichung.' + : 'Wir bitten Sie, den ausstehenden Betrag bis spaetestens '.$frist.' auf das unten genannte Konto zu ueberweisen.'; + $pdf->MultiCell(160, 5, $fristText, 0, 'L'); + + $pdf->Ln(4); + $pdf->SetX(25); + $pdf->Cell(0, 5, 'Mit freundlichen Gruessen', 0, 1, 'L'); + $pdf->SetX(25); + $pdf->Cell(0, 5, (string) $mysoc->name, 0, 1, 'L'); + } + + /** + * Fusszeile mit Bankverbindung + Firmen-Footer. + * + * @param TCPDF $pdf + * @return void + */ + private function renderFooter($pdf) + { + global $mysoc; + + $pdf->SetY(-30); + $pdf->SetFont('helvetica', 'I', 8); + $lines = array(); + $lines[] = trim(($mysoc->name ?? '').' · '.($mysoc->address ?? '').' · '.($mysoc->zip ?? '').' '.($mysoc->town ?? '')); + if (!empty($mysoc->email)) { + $lines[] = 'E-Mail: '.$mysoc->email; + } + if (!empty($mysoc->phone)) { + $lines[] = 'Tel: '.$mysoc->phone; + } + // Bankverbindung aus Standard-Bankaccount + $bankAccount = $this->getDefaultBankLine(); + if (!empty($bankAccount)) { + $lines[] = $bankAccount; + } + foreach ($lines as $l) { + $pdf->Cell(0, 4, $l, 0, 1, 'C'); + } + } + + /** + * Standard-Bankkonto in einer Zeile (Bank · IBAN · BIC). + * + * @return string + */ + private function getDefaultBankLine() + { + $sql = "SELECT label, iban_prefix as iban, bic FROM ".MAIN_DB_PREFIX."bank_account"; + $sql .= " WHERE clos = 0 AND default_rib = 1"; + $sql .= " LIMIT 1"; + $resql = $this->db->query($sql); + if (!$resql || !$this->db->num_rows($resql)) { + return ''; + } + $obj = $this->db->fetch_object($resql); + $this->db->free($resql); + $parts = array_filter(array($obj->label, $obj->iban ? 'IBAN '.$obj->iban : '', $obj->bic ? 'BIC '.$obj->bic : '')); + return implode(' · ', $parts); + } + + /** + * Default-Intro je Stufe (wenn Setup leer ist). + * + * @param int $stufe + * @return string + */ + private function defaultIntro($stufe) + { + switch ((int) $stufe) { + case 1: + return 'unsere unten aufgefuehrte Rechnung ist trotz Ablauf der Zahlungsfrist noch nicht beglichen. ' + . 'Vielleicht ist Ihnen dies entgangen — wir bitten Sie hoeflich, den ausstehenden Betrag zeitnah ' + . 'zu ueberweisen.'; + case 2: + return 'leider mussten wir feststellen, dass die unten aufgefuehrte Rechnung trotz unserer ' + . 'Zahlungserinnerung weiterhin offen ist. Wir bitten Sie nun nachdruecklich um Begleichung ' + . 'des offenen Betrags zuzueglich Verzugszinsen und Mahnkosten.'; + case 3: + default: + return 'wir haben Sie bereits zweimal an die Begleichung der unten aufgefuehrten Rechnung erinnert. ' + . 'Sollte der offene Betrag inkl. Verzugszinsen und Mahnkosten nicht innerhalb der angegebenen Frist ' + . 'auf unserem Konto eingehen, sehen wir uns gezwungen, weitere rechtliche Schritte einzuleiten.'; + } + } + + /** + * Ziel-Verzeichnis: documents/facture/{ref}/ + * + * @param Facture $facture + * @return string + */ + private function getOutputDir($facture) + { + global $conf; + $documentDir = !empty($conf->facture->multidir_output[$facture->entity]) + ? $conf->facture->multidir_output[$facture->entity] + : $conf->facture->dir_output; + return rtrim($documentDir, '/').'/'.dol_sanitizeFileName($facture->ref); + } +} diff --git a/class/mahnungstufe.class.php b/class/mahnungstufe.class.php new file mode 100644 index 0000000..080a1e2 --- /dev/null +++ b/class/mahnungstufe.class.php @@ -0,0 +1,231 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License, version 3. + */ + +/** + * \file htdocs/custom/mahnung/class/mahnungstufe.class.php + * \ingroup mahnung + * \brief Konfigurations-Klasse fuer Mahnstufen (llx_mahnung_stufe). + */ + +require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php'; + +/** + * Eine Mahnstufe (1..3): Frist-Konfiguration, Gebuehren, optionaler Zinssatz-Override, + * Versandart-Default, E-Mail-/PDF-Templates. + */ +class MahnungStufe extends CommonObject +{ + /** @var string */ + public $element = 'mahnungstufe'; + + /** @var string */ + public $table_element = 'mahnung_stufe'; + + /** @var int */ + public $entity; + + /** @var int 1..3 */ + public $stufe; + + /** @var string */ + public $label; + + /** @var int Tage nach Faelligkeit (Stufe 1) bzw. nach Vorgaengerstufe (>1) */ + public $frist_tage = 0; + + /** @var int Neue Zahlungsfrist im Mahnschreiben (Tage) */ + public $neue_frist_tage = 7; + + /** @var float */ + public $mahngebuehr_b2c = 0; + + /** @var float */ + public $mahngebuehr_b2b = 0; + + /** @var int 1 = nur in dieser Stufe Pauschale §288 Abs. 5 berechnen */ + public $pauschale_b2b_einmalig = 0; + + /** @var float|null Override Basiszins+5 % B2C-Default */ + public $zinssatz_b2c_uebersteuern; + + /** @var float|null Override Basiszins+9 % B2B-Default */ + public $zinssatz_b2b_uebersteuern; + + /** @var string pdf|mail|druck|none */ + public $versandart_default = 'pdf'; + + /** @var string */ + public $email_subject; + + /** @var string */ + public $email_body; + + /** @var string */ + public $pdf_intro; + + /** @var int 0|1 */ + public $active = 1; + + /** @var int Unix-Zeit */ + public $datec; + + /** @var int Unix-Zeit */ + public $tms; + + /** + * @param DoliDB $db Datenbank-Handler + */ + public function __construct($db) + { + global $conf; + $this->db = $db; + $this->entity = $conf->entity; + } + + /** + * @param int $stufe 1..3 + * @return int -1 Fehler, 0 nicht gefunden, >0 OK + */ + public function fetchByStufe($stufe) + { + $sql = "SELECT t.* FROM ".MAIN_DB_PREFIX."mahnung_stufe as t"; + $sql .= " WHERE t.entity = ".((int) $this->entity); + $sql .= " AND t.stufe = ".((int) $stufe); + + $resql = $this->db->query($sql); + if (!$resql) { + $this->error = $this->db->lasterror(); + return -1; + } + if (!$this->db->num_rows($resql)) { + $this->db->free($resql); + return 0; + } + $obj = $this->db->fetch_object($resql); + $this->loadFromObj($obj); + $this->db->free($resql); + return 1; + } + + /** + * Alle aktiven Stufen geordnet nach stufe ASC. + * + * @return MahnungStufe[] + */ + public function fetchAllActive() + { + $sql = "SELECT t.* FROM ".MAIN_DB_PREFIX."mahnung_stufe as t"; + $sql .= " WHERE t.entity = ".((int) $this->entity); + $sql .= " AND t.active = 1"; + $sql .= " ORDER BY t.stufe ASC"; + + $resql = $this->db->query($sql); + $result = array(); + if (!$resql) { + $this->error = $this->db->lasterror(); + return $result; + } + while ($obj = $this->db->fetch_object($resql)) { + $s = new self($this->db); + $s->loadFromObj($obj); + $result[] = $s; + } + $this->db->free($resql); + return $result; + } + + /** + * @param User $user + * @return int <0 Fehler, sonst rowid + */ + public function update($user) + { + if (empty($this->id)) { + $this->error = 'MahnungStufe::update — id missing'; + return -1; + } + + $sql = "UPDATE ".MAIN_DB_PREFIX."mahnung_stufe SET"; + $sql .= " label = '".$this->db->escape($this->label)."'"; + $sql .= ", frist_tage = ".((int) $this->frist_tage); + $sql .= ", neue_frist_tage = ".((int) $this->neue_frist_tage); + $sql .= ", mahngebuehr_b2c = ".((float) $this->mahngebuehr_b2c); + $sql .= ", mahngebuehr_b2b = ".((float) $this->mahngebuehr_b2b); + $sql .= ", pauschale_b2b_einmalig = ".((int) (!empty($this->pauschale_b2b_einmalig) ? 1 : 0)); + $sql .= ", zinssatz_b2c_uebersteuern = ".($this->zinssatz_b2c_uebersteuern !== null && $this->zinssatz_b2c_uebersteuern !== '' ? ((float) $this->zinssatz_b2c_uebersteuern) : "NULL"); + $sql .= ", zinssatz_b2b_uebersteuern = ".($this->zinssatz_b2b_uebersteuern !== null && $this->zinssatz_b2b_uebersteuern !== '' ? ((float) $this->zinssatz_b2b_uebersteuern) : "NULL"); + $sql .= ", versandart_default = '".$this->db->escape($this->versandart_default ?: 'pdf')."'"; + $sql .= ", email_subject = ".($this->email_subject ? "'".$this->db->escape($this->email_subject)."'" : "NULL"); + $sql .= ", email_body = ".($this->email_body ? "'".$this->db->escape($this->email_body)."'" : "NULL"); + $sql .= ", pdf_intro = ".($this->pdf_intro ? "'".$this->db->escape($this->pdf_intro)."'" : "NULL"); + $sql .= ", active = ".((int) (!empty($this->active) ? 1 : 0)); + $sql .= " WHERE rowid = ".((int) $this->id); + + dol_syslog(get_class($this).'::update', LOG_DEBUG); + $resql = $this->db->query($sql); + if (!$resql) { + $this->error = $this->db->lasterror(); + return -1; + } + return $this->id; + } + + /** + * Mahngebuehr fuer einen Kundentyp aus dieser Stufe lesen. + * + * @param string $kundentyp 'B2C'|'B2B' + * @return float + */ + public function getMahngebuehr($kundentyp) + { + return $kundentyp === Mahnung::KUNDENTYP_B2B + ? (float) $this->mahngebuehr_b2b + : (float) $this->mahngebuehr_b2c; + } + + /** + * Override-Zinssatz fuer einen Kundentyp (oder null falls Default gewuenscht). + * + * @param string $kundentyp + * @return float|null + */ + public function getZinssatzOverride($kundentyp) + { + $value = $kundentyp === Mahnung::KUNDENTYP_B2B + ? $this->zinssatz_b2b_uebersteuern + : $this->zinssatz_b2c_uebersteuern; + return ($value === null || $value === '') ? null : (float) $value; + } + + /** + * Properties aus DB-Object-Reihe laden. + * + * @param object $obj + * @return void + */ + private function loadFromObj($obj) + { + $this->id = $obj->rowid; + $this->entity = $obj->entity; + $this->stufe = (int) $obj->stufe; + $this->label = $obj->label; + $this->frist_tage = (int) $obj->frist_tage; + $this->neue_frist_tage = (int) $obj->neue_frist_tage; + $this->mahngebuehr_b2c = $obj->mahngebuehr_b2c; + $this->mahngebuehr_b2b = $obj->mahngebuehr_b2b; + $this->pauschale_b2b_einmalig = (int) $obj->pauschale_b2b_einmalig; + $this->zinssatz_b2c_uebersteuern = $obj->zinssatz_b2c_uebersteuern; + $this->zinssatz_b2b_uebersteuern = $obj->zinssatz_b2b_uebersteuern; + $this->versandart_default = $obj->versandart_default; + $this->email_subject = $obj->email_subject; + $this->email_body = $obj->email_body; + $this->pdf_intro = $obj->pdf_intro; + $this->active = (int) $obj->active; + $this->datec = $this->db->jdate($obj->datec); + $this->tms = $this->db->jdate($obj->tms); + } +} diff --git a/class/mahnungvorschlag.class.php b/class/mahnungvorschlag.class.php new file mode 100644 index 0000000..826a15d --- /dev/null +++ b/class/mahnungvorschlag.class.php @@ -0,0 +1,232 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License, version 3. + */ + +/** + * \file htdocs/custom/mahnung/class/mahnungvorschlag.class.php + * \ingroup mahnung + * \brief Service: ueberfaellige Rechnungen einsammeln und je Rechnung + * die naechste vorgeschlagene Mahnstufe ermitteln. + * + * Geteilte Logik zwischen Cron-Job (Ntfy-Push) und Vorschlagslisten-UI. + */ + +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php'; + +class MahnungVorschlag +{ + /** @var DoliDB */ + public $db; + + /** @var int */ + public $entity; + + /** @var MahnungStufe[] indexed by stufe (1..3) */ + private $stufen = array(); + + /** + * @param DoliDB $db + */ + public function __construct($db) + { + global $conf; + $this->db = $db; + $this->entity = $conf->entity; + } + + /** + * Liefert pro ueberfaelliger Rechnung einen Vorschlag (oder ueberspringt sie, + * wenn alle Stufen bereits durchlaufen sind oder die Wartefrist noch laeuft). + * + * Rueckgabe-Schluessel je Eintrag: + * facture_id, facture_ref, facture_date_lim_reglement (Unix), facture_total_ttc, + * soc_id, soc_nom, soc_tva_intra, + * kundentyp ('B2C'|'B2B'), + * tage_verzug, + * betrag_offen, + * letzte_mahnung_id (int|null), letzte_mahnung_stufe (int|null), letzte_mahnung_datum (Unix|null), + * vorgeschlagene_stufe (int 1..3), + * vorgeschlagene_stufe_label (string) + * + * @param array $filter Optional: 'soc_id', 'min_tage_verzug', 'max_tage_verzug', 'stufe' + * @return array + */ + public function getVorschlaege(array $filter = array()) + { + $this->loadStufen(); + if (empty($this->stufen)) { + return array(); + } + + $today = dol_now(); + $sql = "SELECT f.rowid AS facture_id, f.ref AS facture_ref, f.date_lim_reglement,"; + $sql .= " f.total_ttc, f.fk_soc, f.paye, f.statut,"; + $sql .= " s.nom AS soc_nom, s.tva_intra"; + $sql .= " FROM ".MAIN_DB_PREFIX."facture as f"; + $sql .= " INNER JOIN ".MAIN_DB_PREFIX."societe as s ON s.rowid = f.fk_soc"; + $sql .= " WHERE f.entity = ".((int) $this->entity); + $sql .= " AND f.statut = 1"; + $sql .= " AND f.paye = 0"; + $sql .= " AND f.type IN (0, 2, 3)"; // Standard, Avoir, Acompte (keine Replacements) + $sql .= " AND f.date_lim_reglement IS NOT NULL"; + $sql .= " AND f.date_lim_reglement < '".$this->db->idate($today)."'"; + + if (!empty($filter['soc_id'])) { + $sql .= " AND f.fk_soc = ".((int) $filter['soc_id']); + } + + $sql .= " ORDER BY f.date_lim_reglement ASC"; + + $resql = $this->db->query($sql); + if (!$resql) { + dol_syslog('MahnungVorschlag::getVorschlaege SQL-Fehler: '.$this->db->lasterror(), LOG_ERR); + return array(); + } + + $result = array(); + while ($obj = $this->db->fetch_object($resql)) { + $row = $this->buildVorschlag($obj, $today); + if ($row === null) { + continue; + } + if (isset($filter['min_tage_verzug']) && $row['tage_verzug'] < (int) $filter['min_tage_verzug']) { + continue; + } + if (isset($filter['max_tage_verzug']) && $row['tage_verzug'] > (int) $filter['max_tage_verzug']) { + continue; + } + if (isset($filter['stufe']) && $filter['stufe'] !== '' && (int) $row['vorgeschlagene_stufe'] !== (int) $filter['stufe']) { + continue; + } + $result[] = $row; + } + $this->db->free($resql); + return $result; + } + + /** + * Berechnet fuer eine einzelne Rechnung, ob/wozu eine Mahnung vorgeschlagen wird. + * + * @param object $factureObj DB-Reihe aus facture+societe + * @param int $today Unix-Zeit + * @return array|null + */ + private function buildVorschlag($factureObj, $today) + { + $dateLim = $this->db->jdate($factureObj->date_lim_reglement); + if (empty($dateLim)) { + return null; + } + $tageVerzug = (int) floor(($today - $dateLim) / 86400); + if ($tageVerzug < 0) { + $tageVerzug = 0; + } + + $kundentyp = !empty($factureObj->tva_intra) ? Mahnung::KUNDENTYP_B2B : Mahnung::KUNDENTYP_B2C; + + // Letzte aktive Mahnung zur Rechnung holen + $lastMahnung = (new Mahnung($this->db))->fetchLastByFacture((int) $factureObj->facture_id); + + // Naechste Stufe ermitteln + $proposedStufe = null; + if ($lastMahnung === null) { + // Noch nichts gemahnt -> Stufe 1, sobald frist_tage erreicht + if (isset($this->stufen[1]) && $tageVerzug >= (int) $this->stufen[1]->frist_tage) { + $proposedStufe = 1; + } + } else { + // Bereits gemahnt -> naechste Stufe wenn Wartefrist seit letzter Mahnung abgelaufen + $lastStufe = (int) $lastMahnung->stufe; + $nextStufe = $lastStufe + 1; + if ($lastStufe >= 3 || !isset($this->stufen[$nextStufe])) { + return null; // alle Stufen ausgeschoepft + } + // Wartefrist: neue_frist_tage der zuletzt gemahnten Stufe + $wartefrist = isset($this->stufen[$lastStufe]) ? (int) $this->stufen[$lastStufe]->neue_frist_tage : 7; + $tageSeitMahnung = (int) floor(($today - $lastMahnung->date_mahnung) / 86400); + if ($tageSeitMahnung >= $wartefrist) { + $proposedStufe = $nextStufe; + } + } + + if ($proposedStufe === null) { + return null; + } + + // Offenen Betrag berechnen (total_ttc - Summe aller Zahlungen) + $betragOffen = $this->getBetragOffen((int) $factureObj->facture_id, (float) $factureObj->total_ttc); + if ($betragOffen <= 0) { + return null; + } + + return array( + 'facture_id' => (int) $factureObj->facture_id, + 'facture_ref' => $factureObj->facture_ref, + 'facture_date_lim_reglement' => $dateLim, + 'facture_total_ttc' => (float) $factureObj->total_ttc, + 'soc_id' => (int) $factureObj->fk_soc, + 'soc_nom' => $factureObj->soc_nom, + 'soc_tva_intra' => $factureObj->tva_intra, + 'kundentyp' => $kundentyp, + 'tage_verzug' => $tageVerzug, + 'betrag_offen' => $betragOffen, + 'letzte_mahnung_id' => $lastMahnung ? (int) $lastMahnung->id : null, + 'letzte_mahnung_stufe' => $lastMahnung ? (int) $lastMahnung->stufe : null, + 'letzte_mahnung_datum' => $lastMahnung ? $lastMahnung->date_mahnung : null, + 'vorgeschlagene_stufe' => $proposedStufe, + 'vorgeschlagene_stufe_label' => $this->stufen[$proposedStufe]->label, + ); + } + + /** + * Offener Betrag = total_ttc - SUM(paiement.amount). + * + * @param int $factureId + * @param float $totalTtc + * @return float + */ + private function getBetragOffen($factureId, $totalTtc) + { + $sql = "SELECT COALESCE(SUM(pf.amount), 0) AS gezahlt"; + $sql .= " FROM ".MAIN_DB_PREFIX."paiement_facture as pf"; + $sql .= " WHERE pf.fk_facture = ".((int) $factureId); + + $resql = $this->db->query($sql); + if (!$resql) { + return (float) $totalTtc; + } + $obj = $this->db->fetch_object($resql); + $this->db->free($resql); + return round(((float) $totalTtc) - ((float) $obj->gezahlt), 2); + } + + /** + * Stufen einmal in $this->stufen[1..3] cachen. + */ + private function loadStufen() + { + if (!empty($this->stufen)) { + return; + } + $so = new MahnungStufe($this->db); + foreach ($so->fetchAllActive() as $s) { + $this->stufen[(int) $s->stufe] = $s; + } + } + + /** + * Gibt die geladene MahnungStufe (1..3) zurueck oder null. + * + * @param int $stufe + * @return MahnungStufe|null + */ + public function getStufe($stufe) + { + $this->loadStufen(); + return $this->stufen[(int) $stufe] ?? null; + } +} diff --git a/core/modules/modMahnung.class.php b/core/modules/modMahnung.class.php new file mode 100644 index 0000000..ea359fc --- /dev/null +++ b/core/modules/modMahnung.class.php @@ -0,0 +1,304 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + */ + +/** + * \defgroup mahnung Modul Mahnwesen + * \brief Mahnwesen-Modul (3-stufig nach BGB §288) + * \file htdocs/custom/mahnung/core/modules/modMahnung.class.php + * \ingroup mahnung + */ + +include_once DOL_DOCUMENT_ROOT.'/core/modules/DolibarrModules.class.php'; + +/** + * Beschreibungs- und Aktivierungsklasse fuer Modul Mahnung + */ +class modMahnung extends DolibarrModules +{ + /** + * @param DoliDB $db Datenbank-Handler + */ + public function __construct($db) + { + global $conf, $langs; + + $this->db = $db; + + // Eindeutige Modul-ID. 500034..500036 sind durch das Bericht-Modul belegt + // (numero=500033, dessen rights id-Range 500033..500036 abdeckt), daher 500037. + $this->numero = 500037; + + // Schluessel fuer Rechte und Menues + $this->rights_class = 'mahnung'; + + $this->family = 'financial'; + $this->module_position = '50'; + + $this->name = preg_replace('/^mod/i', '', get_class($this)); + + $this->description = 'MahnungDescription'; + $this->descriptionlong = 'MahnungDescription'; + + $this->editor_name = 'Alles Watt laeuft'; + $this->editor_url = ''; + + $this->version = '0.1.0'; + + $this->const_name = 'MAIN_MODULE_'.strtoupper($this->name); + + // FontAwesome 5 Free (Dolibarr-Bundle, KB #435). 'fa-envelope-open-o' ist FA4-Notation + // und rendert in Dolibarr lautlos kein Glyph; 'fa-envelope-open-text' ist FA5-Free. + $this->picto = 'fa-envelope-open-text'; + + $this->module_parts = array( + 'triggers' => 1, + 'login' => 0, + 'substitutions' => 0, + 'menus' => 0, + 'tpl' => 0, + 'barcode' => 0, + 'models' => 0, + 'printing' => 0, + 'theme' => 0, + 'css' => array(), + 'js' => array(), + // Hook-Klasse: class/actions_mahnung.class.php (Standard-Lookup-Pfad) + 'hooks' => array( + 'data' => array( + 'invoicecard', + 'thirdpartycard', + ), + 'entity' => '0', + ), + 'moduleforexternal' => 0, + 'websitetemplates' => 0, + 'captcha' => 0, + ); + + // Datenverzeichnisse bei Modul-Aktivierung + $this->dirs = array('/mahnung/temp'); + + // Konfigurationsseite + $this->config_page_url = array('setup.php@mahnung'); + + $this->hidden = getDolGlobalInt('MODULE_MAHNUNG_DISABLED'); + $this->depends = array(); + $this->requiredby = array(); + $this->conflictwith = array(); + + $this->langfiles = array('mahnung@mahnung'); + + $this->phpmin = array(7, 4); + $this->need_dolibarr_version = array(19, -3); + $this->need_javascript_ajax = 1; + + $this->warnings_activation = array(); + $this->warnings_activation_ext = array(); + + // Modul-Konstanten + $this->const = array( + 0 => array( + 'MAHNUNG_BASISZINS', + 'chaine', + '1.27', + 'BGB-Basiszins in Prozent (manuell halbjaehrlich pflegen)', + 0, + 'allentities', + 1, + ), + 1 => array( + 'MAHNUNG_NTFY_TOPIC', + 'chaine', + 'vk-builds', + 'Ntfy-Topic fuer Mahnungs-Benachrichtigungen', + 0, + 'current', + 1, + ), + 2 => array( + 'MAHNUNG_AUFSCHLAG_B2C', + 'chaine', + '5.0', + 'Verzugszins-Aufschlag B2C in Prozent (BGB §288 Abs. 1)', + 0, + 'allentities', + 1, + ), + 3 => array( + 'MAHNUNG_AUFSCHLAG_B2B', + 'chaine', + '9.0', + 'Verzugszins-Aufschlag B2B in Prozent (BGB §288 Abs. 2)', + 0, + 'allentities', + 1, + ), + 4 => array( + 'MAHNUNG_PAUSCHALE_B2B', + 'chaine', + '40.00', + 'Pauschale B2B nach BGB §288 Abs. 5 in EUR', + 0, + 'allentities', + 1, + ), + ); + + if (!isModEnabled('mahnung')) { + $conf->mahnung = new stdClass(); + $conf->mahnung->enabled = 0; + } + + // Tabs auf bestehenden Karten (Phase 5: aktivieren) + $this->tabs = array(); + + $this->dictionaries = array(); + + $this->boxes = array(); + + // Cron-Job: Vorschlagsliste taeglich 06:00 + $this->cronjobs = array( + 0 => array( + 'label' => 'MahnungCronBuildVorschlag', + 'jobtype' => 'method', + 'class' => '/mahnung/class/mahnungcron.class.php', + 'objectname' => 'MahnungCron', + 'method' => 'buildVorschlagsliste', + 'parameters' => '', + 'comment' => 'Sucht ueberfaellige Rechnungen, ermittelt vorgeschlagene Mahnstufen, sendet Ntfy-Push', + 'frequency' => 1, + 'unitfrequency' => 86400, + 'status' => 0, + 'test' => 'isModEnabled("mahnung")', + 'priority' => 50, + ), + ); + + // Berechtigungen + $this->rights = array(); + $r = 0; + + $this->rights[$r][0] = $this->numero.'01'; + $this->rights[$r][1] = 'PermMahnungRead'; + $this->rights[$r][2] = 'r'; + $this->rights[$r][3] = 1; + $this->rights[$r][4] = 'read'; + $r++; + + $this->rights[$r][0] = $this->numero.'02'; + $this->rights[$r][1] = 'PermMahnungWrite'; + $this->rights[$r][2] = 'w'; + $this->rights[$r][3] = 0; + $this->rights[$r][4] = 'write'; + $r++; + + $this->rights[$r][0] = $this->numero.'03'; + $this->rights[$r][1] = 'PermMahnungSend'; + $this->rights[$r][2] = 'w'; + $this->rights[$r][3] = 0; + $this->rights[$r][4] = 'send'; + $r++; + + $this->rights[$r][0] = $this->numero.'04'; + $this->rights[$r][1] = 'PermMahnungDelete'; + $this->rights[$r][2] = 'd'; + $this->rights[$r][3] = 0; + $this->rights[$r][4] = 'delete'; + $r++; + + $this->rights[$r][0] = $this->numero.'05'; + $this->rights[$r][1] = 'PermMahnungSetup'; + $this->rights[$r][2] = 'w'; + $this->rights[$r][3] = 0; + $this->rights[$r][4] = 'setup'; + $r++; + + // Linkes Menue unter "Rechnungen" (mainmenu=billing) + $this->menu = array(); + $r = 0; + $this->menu[$r++] = array( + 'fk_menu' => 'fk_mainmenu=billing', + 'type' => 'left', + 'titre' => 'MahnungMenu', + 'prefix' => img_picto('', 'fa-envelope-open-text', 'class="pictofixedwidth valignmiddle paddingright"'), + 'mainmenu' => 'billing', + 'leftmenu' => 'mahnung', + 'url' => '/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung', + 'langs' => 'mahnung@mahnung', + 'position' => 300, + 'enabled' => 'isModEnabled("mahnung")', + 'perms' => '$user->hasRight("mahnung", "read")', + 'target' => '', + 'user' => 2, + ); + $this->menu[$r++] = array( + 'fk_menu' => 'fk_mainmenu=billing,fk_leftmenu=mahnung', + 'type' => 'left', + 'titre' => 'MahnungVorschlagsliste', + 'mainmenu' => 'billing', + 'leftmenu' => 'mahnung_vorschlag', + 'url' => '/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=vorschlag', + 'langs' => 'mahnung@mahnung', + 'position' => 301, + 'enabled' => 'isModEnabled("mahnung")', + 'perms' => '$user->hasRight("mahnung", "read")', + 'target' => '', + 'user' => 2, + ); + $this->menu[$r++] = array( + 'fk_menu' => 'fk_mainmenu=billing,fk_leftmenu=mahnung', + 'type' => 'left', + 'titre' => 'MahnungArchiv', + 'mainmenu' => 'billing', + 'leftmenu' => 'mahnung_archiv', + 'url' => '/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=archiv', + 'langs' => 'mahnung@mahnung', + 'position' => 302, + 'enabled' => 'isModEnabled("mahnung")', + 'perms' => '$user->hasRight("mahnung", "read")', + 'target' => '', + 'user' => 2, + ); + } + + /** + * Aufruf bei Modul-Aktivierung: Tabellen anlegen, Konstanten/Rechte/Menues schreiben. + * + * @param string $options Optionen ('', 'noboxes') + * @return int<-1,1> 1 = OK, <=0 = Fehler + */ + public function init($options = '') + { + // Tabellen anlegen aus sql/-Verzeichnis + $result = $this->_load_tables('/mahnung/sql/'); + if ($result < 0) { + return -1; + } + + $sql = array(); + return $this->_init($sql, $options); + } + + /** + * Aufruf bei Modul-Deaktivierung. Tabellen bleiben erhalten (Datensicherheit). + * + * @param string $options Optionen + * @return int<-1,1> 1 = OK, <=0 = Fehler + */ + public function remove($options = '') + { + $sql = array(); + return $this->_remove($sql, $options); + } +} diff --git a/core/triggers/interface_99_modMahnung_MahnungTriggers.class.php b/core/triggers/interface_99_modMahnung_MahnungTriggers.class.php new file mode 100644 index 0000000..81cac88 --- /dev/null +++ b/core/triggers/interface_99_modMahnung_MahnungTriggers.class.php @@ -0,0 +1,151 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +/** + * \file core/triggers/interface_99_modMahnung_MahnungTriggers.class.php + * \ingroup mahnung + * \brief Trigger: setzt offene Mahnvorgaenge nach Zahlungseingang auf "erledigt". + * + * Klassenname: InterfaceTriggers — Suffix muss zu Dateinamen passen. + * Datei muss in core/triggers/ liegen, wird durch DolibarrModules->loadtriggers + * (module_parts['triggers']=1) beim Modul-Aktivieren registriert. + */ + +require_once DOL_DOCUMENT_ROOT.'/core/triggers/dolibarrtriggers.class.php'; + +class InterfaceMahnungTriggers extends DolibarrTriggers +{ + /** + * @param DoliDB $db Datenbank-Handler + */ + public function __construct($db) + { + parent::__construct($db); + $this->family = 'financial'; + $this->description = 'Mahnung-Trigger: erledigt offene Mahnvorgaenge bei Zahlungseingang.'; + $this->version = self::VERSIONS['dev']; + $this->picto = 'fa-envelope-open-text'; + } + + /** + * Wird bei jedem Dolibarr-Event aufgerufen. + * + * @param string $action z.B. PAYMENT_CUSTOMER_CREATE, BILL_PAYED + * @param CommonObject $object Event-Subjekt + * @param User $user aktueller User + * @param Translate $langs Sprache + * @param Conf $conf Konfig + * @return int <0 Fehler, 0 nichts getan, >0 OK + */ + public function runTrigger($action, $object, User $user, Translate $langs, Conf $conf) + { + if (!isModEnabled('mahnung')) { + return 0; + } + + switch ($action) { + case 'BILL_PAYED': + case 'BILL_VALIDATE': + // $object ist Facture + if (!empty($object) && !empty($object->id) && !empty($object->paye)) { + return $this->onRechnungBezahlt((int) $object->id, $user); + } + return 0; + + case 'PAYMENT_CUSTOMER_CREATE': + // $object ist Paiement; eventuell auch ohne paye=1 Status — pro betroffener Rechnung pruefen + return $this->onPaiementErzeugt($object, $user); + + default: + return 0; + } + } + + /** + * Setzt alle offenen Mahnvorgaenge zur Rechnung auf STATUS_ERLEDIGT. + * + * @param int $factureId + * @param User $user + * @return int >=0 Anzahl aktualisierter Mahnungen, <0 Fehler + */ + private function onRechnungBezahlt($factureId, User $user) + { + require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php'; + + $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."mahnung_mahnung"; + $sql .= " WHERE fk_facture = ".((int) $factureId); + $sql .= " AND status NOT IN (".Mahnung::STATUS_ERLEDIGT.", ".Mahnung::STATUS_STORNIERT.")"; + + $resql = $this->db->query($sql); + if (!$resql) { + return -1; + } + + $count = 0; + while ($obj = $this->db->fetch_object($resql)) { + $m = new Mahnung($this->db); + if ($m->fetch((int) $obj->rowid) > 0 && $m->setErledigt($user) > 0) { + $count++; + } + } + $this->db->free($resql); + return $count; + } + + /** + * Bei Zahlungs-Erzeugung: alle betroffenen Rechnungen pruefen, ob sie nun + * vollstaendig bezahlt sind. paye-Flag wird in Dolibarr typischerweise erst + * gesetzt, wenn Summe der Zahlungen >= total_ttc — wir pruefen pessimistisch: + * sobald ueberhaupt eine Zahlung kommt + offene Mahnung existiert -> erledigen, + * wenn die Zahlung den offenen Betrag deckt. + * + * @param CommonObject $payment + * @param User $user + * @return int + */ + private function onPaiementErzeugt($payment, User $user) + { + if (empty($payment) || empty($payment->amounts) || !is_array($payment->amounts)) { + return 0; + } + $total = 0; + foreach ($payment->amounts as $factureId => $amount) { + $factureId = (int) $factureId; + if ($factureId <= 0) { + continue; + } + // Pruefen, ob die Rechnung jetzt voll bezahlt ist + if ($this->istRechnungVollBezahlt($factureId)) { + $total += $this->onRechnungBezahlt($factureId, $user); + } + } + return $total; + } + + /** + * @param int $factureId + * @return bool + */ + private function istRechnungVollBezahlt($factureId) + { + $sql = "SELECT f.total_ttc, COALESCE(SUM(pf.amount), 0) AS gezahlt"; + $sql .= " FROM ".MAIN_DB_PREFIX."facture as f"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."paiement_facture as pf ON pf.fk_facture = f.rowid"; + $sql .= " WHERE f.rowid = ".((int) $factureId); + $sql .= " GROUP BY f.rowid, f.total_ttc"; + + $resql = $this->db->query($sql); + if (!$resql || !$this->db->num_rows($resql)) { + return false; + } + $obj = $this->db->fetch_object($resql); + $this->db->free($resql); + return (float) $obj->gezahlt + 0.005 >= (float) $obj->total_ttc; + } +} diff --git a/langs/de_DE/mahnung.lang b/langs/de_DE/mahnung.lang new file mode 100644 index 0000000..9d33ff2 --- /dev/null +++ b/langs/de_DE/mahnung.lang @@ -0,0 +1,108 @@ +# Mahnung - Deutsch (de_DE) + +# +# Modul-Metadaten +# +ModuleMahnungName = Mahnung +ModuleMahnungDesc = Mahnwesen mit Vorschlagsliste, Stufen, Verzugszinsen (BGB §288) +MahnungDescription = 3-stufiges Mahnwesen fuer ueberfaellige Kundenrechnungen mit Mahngebuehren, Verzugszinsen nach BGB §288 und PDF-Versand. + +# +# Berechtigungen +# +PermMahnungRead = Mahnungen lesen +PermMahnungWrite = Mahnungen erstellen / bearbeiten +PermMahnungSend = Mahnungen versenden (E-Mail / Druck) +PermMahnungDelete = Mahnungen loeschen +PermMahnungSetup = Mahnwesen konfigurieren + +# +# Menues +# +MahnungMenu = Mahnwesen +MahnungVorschlagsliste = Vorschlagsliste +MahnungArchiv = Mahnvorgaenge + +# +# Stufen +# +MahnungStufe = Mahnstufe +MahnungStufe1 = Zahlungserinnerung +MahnungStufe2 = 1. Mahnung +MahnungStufe3 = Letzte Mahnung +MahnungStufeLabel = Bezeichnung +MahnungStufeFristTage = Frist (Tage nach Faelligkeit) +MahnungStufeNeueFristTage = Neue Zahlungsfrist (Tage) +MahnungStufeGebuehrB2C = Mahngebuehr B2C +MahnungStufeGebuehrB2B = Mahngebuehr B2B +MahnungStufeZinssatzB2C = Zinssatz B2C (Override) +MahnungStufeZinssatzB2B = Zinssatz B2B (Override) +MahnungStufeVersandartDefault = Versandart-Default +MahnungStufeEmailSubject = E-Mail-Betreff +MahnungStufeEmailBody = E-Mail-Text +MahnungStufePdfIntro = PDF-Einleitungstext + +# +# Status +# +MahnungStatusEntwurf = Entwurf +MahnungStatusErstellt = Erstellt +MahnungStatusVersendet = Versendet +MahnungStatusErledigt = Erledigt +MahnungStatusStorniert = Storniert + +# +# Versandart +# +MahnungVersandPdf = PDF an Rechnung +MahnungVersandMail = E-Mail +MahnungVersandDruck = Sammelbrief-Druck +MahnungVersandNone = Kein Versand + +# +# Liste / Karte +# +MahnungRef = Mahnung-Nr. +MahnungRechnung = Rechnung +MahnungKunde = Kunde +MahnungKundentyp = Typ +MahnungKundentypB2C = Privat (B2C) +MahnungKundentypB2B = Geschaeftlich (B2B) +MahnungDatum = Mahndatum +MahnungFaelligkeitAlt = Original-Faelligkeit +MahnungFaelligkeitNeu = Neue Frist +MahnungTageVerzug = Tage Verzug +MahnungBetragOffen = Offener Betrag +MahnungGebuehr = Mahngebuehr +MahnungPauschaleB2B = Pauschale (40 € §288) +MahnungVerzugszinsen = Verzugszinsen +MahnungSumme = Gesamtsumme +MahnungBasiszinsSnapshot = Basiszins (Snapshot) +MahnungLetzteMahnung = Letzte Mahnung +MahnungVorgeschlageneStufe = Vorgeschlagene Stufe +MahnungAktion = Aktion +MahnungErstellen = Mahnung erstellen +MahnungSammelbrief = Sammelbrief erzeugen +MahnungStornieren = Stornieren +MahnungKeineUeberfaelligen = Keine ueberfaelligen Rechnungen vorhanden. + +# +# Setup-Seite +# +MahnungSetup = Mahnwesen Einstellungen +MahnungSetupPage = Mahnwesen Konfiguration +MahnungSetupDescription = Mahnstufen, Basiszins, Versandwege und Ntfy-Topic konfigurieren. +MahnungBasiszins = BGB-Basiszins (%) +MahnungBasiszinsHelp = Aktueller Basiszins der Bundesbank, halbjaehrlich pflegen (1.1. / 1.7.). +MahnungAufschlagB2C = Aufschlag B2C (%) +MahnungAufschlagB2B = Aufschlag B2B (%) +MahnungPauschaleB2BLabel = Pauschale B2B (EUR) +MahnungNtfyTopic = Ntfy-Topic +MahnungNtfyTopicHelp = Topic fuer Push-Benachrichtigungen (Default: vk-builds). +MahnungSettingsSaved = Einstellungen gespeichert. + +# +# Cron +# +MahnungCronBuildVorschlag = Mahnwesen — Vorschlagsliste aufbauen +MahnungCronBuildVorschlagDesc = Sucht taeglich ueberfaellige Rechnungen und sendet einen Ntfy-Push mit der Anzahl neuer Vorschlaege. diff --git a/langs/en_US/mahnung.lang b/langs/en_US/mahnung.lang new file mode 100644 index 0000000..bf4e19c --- /dev/null +++ b/langs/en_US/mahnung.lang @@ -0,0 +1,108 @@ +# Mahnung - English (en_US) + +# +# Module metadata +# +ModuleMahnungName = Dunning +ModuleMahnungDesc = Dunning workflow with proposal list, stages, late-payment interest (German BGB §288) +MahnungDescription = 3-stage dunning workflow for overdue customer invoices with dunning fees, late-payment interest per German BGB §288, and PDF dispatch. + +# +# Permissions +# +PermMahnungRead = Read dunning records +PermMahnungWrite = Create / edit dunning records +PermMahnungSend = Dispatch dunning notices (e-mail / print) +PermMahnungDelete = Delete dunning records +PermMahnungSetup = Configure dunning module + +# +# Menus +# +MahnungMenu = Dunning +MahnungVorschlagsliste = Proposal list +MahnungArchiv = Dunning records + +# +# Stages +# +MahnungStufe = Stage +MahnungStufe1 = Payment reminder +MahnungStufe2 = 1st dunning notice +MahnungStufe3 = Final dunning notice +MahnungStufeLabel = Label +MahnungStufeFristTage = Trigger (days after due date) +MahnungStufeNeueFristTage = New payment deadline (days) +MahnungStufeGebuehrB2C = Dunning fee B2C +MahnungStufeGebuehrB2B = Dunning fee B2B +MahnungStufeZinssatzB2C = Interest rate B2C (override) +MahnungStufeZinssatzB2B = Interest rate B2B (override) +MahnungStufeVersandartDefault = Default dispatch method +MahnungStufeEmailSubject = E-mail subject +MahnungStufeEmailBody = E-mail body +MahnungStufePdfIntro = PDF introduction text + +# +# Status +# +MahnungStatusEntwurf = Draft +MahnungStatusErstellt = Created +MahnungStatusVersendet = Sent +MahnungStatusErledigt = Closed +MahnungStatusStorniert = Cancelled + +# +# Dispatch method +# +MahnungVersandPdf = PDF attached to invoice +MahnungVersandMail = E-mail +MahnungVersandDruck = Bulk print letter +MahnungVersandNone = No dispatch + +# +# List / card +# +MahnungRef = Dunning ref. +MahnungRechnung = Invoice +MahnungKunde = Customer +MahnungKundentyp = Type +MahnungKundentypB2C = Private (B2C) +MahnungKundentypB2B = Business (B2B) +MahnungDatum = Dunning date +MahnungFaelligkeitAlt = Original due date +MahnungFaelligkeitNeu = New deadline +MahnungTageVerzug = Days overdue +MahnungBetragOffen = Open amount +MahnungGebuehr = Dunning fee +MahnungPauschaleB2B = Flat fee (40 € §288) +MahnungVerzugszinsen = Late-payment interest +MahnungSumme = Total +MahnungBasiszinsSnapshot = Base rate (snapshot) +MahnungLetzteMahnung = Last dunning +MahnungVorgeschlageneStufe = Proposed stage +MahnungAktion = Action +MahnungErstellen = Create dunning +MahnungSammelbrief = Generate bulk letter +MahnungStornieren = Cancel +MahnungKeineUeberfaelligen = No overdue invoices found. + +# +# Setup page +# +MahnungSetup = Dunning settings +MahnungSetupPage = Dunning configuration +MahnungSetupDescription = Configure dunning stages, base rate, dispatch methods, and Ntfy topic. +MahnungBasiszins = BGB base rate (%) +MahnungBasiszinsHelp = Current Bundesbank base rate; update twice a year (Jan 1 / Jul 1). +MahnungAufschlagB2C = Surcharge B2C (%) +MahnungAufschlagB2B = Surcharge B2B (%) +MahnungPauschaleB2BLabel = Flat fee B2B (EUR) +MahnungNtfyTopic = Ntfy topic +MahnungNtfyTopicHelp = Topic for push notifications (default: vk-builds). +MahnungSettingsSaved = Settings saved. + +# +# Cron +# +MahnungCronBuildVorschlag = Dunning — build proposal list +MahnungCronBuildVorschlagDesc = Daily scan for overdue invoices, sends a Ntfy push with the count of new proposals. diff --git a/list.php b/list.php new file mode 100644 index 0000000..507c6dc --- /dev/null +++ b/list.php @@ -0,0 +1,232 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +/** + * \file mahnung/list.php + * \ingroup mahnung + * \brief Vorschlagsliste (mode=vorschlag) und Mahnvorgaenge-Archiv (mode=archiv). + */ + +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && file_exists("../main.inc.php")) { + $res = @include "../main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungvorschlag.class.php'; + +global $langs, $user, $db; +$langs->loadLangs(array('mahnung@mahnung', 'companies', 'bills')); + +if (!$user->hasRight('mahnung', 'read')) { + accessforbidden(); +} + +$mode = GETPOST('mode', 'aZ09'); +if ($mode !== 'archiv') { + $mode = 'vorschlag'; +} + +$filter = array(); +$filter_stufe = GETPOST('filter_stufe', 'int'); +if ($filter_stufe !== '' && $filter_stufe !== null) { + $filter['stufe'] = (int) $filter_stufe; +} +$filter_minverzug = GETPOST('filter_minverzug', 'int'); +if ($filter_minverzug !== '' && $filter_minverzug !== null) { + $filter['min_tage_verzug'] = (int) $filter_minverzug; +} +$filter_socid = GETPOST('search_socid', 'int'); +if (!empty($filter_socid)) { + $filter['soc_id'] = (int) $filter_socid; +} + +llxHeader('', $langs->trans($mode === 'archiv' ? 'MahnungArchiv' : 'MahnungVorschlagsliste')); + +print load_fiche_titre( + $langs->trans($mode === 'archiv' ? 'MahnungArchiv' : 'MahnungVorschlagsliste'), + '', + 'fa-envelope-open-text' +); + +// --- Filter-Form --- +print '
'; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print '
'.$langs->trans('MahnungStufe').''.$langs->trans('MahnungTageVerzug').' (min)'.$langs->trans('MahnungKunde').'
'; +print '
'; +print '

'; + +if ($mode === 'vorschlag') { + renderVorschlagsliste($db, $filter); +} else { + renderArchiv($db, $filter); +} + +llxFooter(); +$db->close(); + +/** + * Rendert die Vorschlagsliste auf Basis von MahnungVorschlag. + * + * @param DoliDB $db + * @param array $filter + * @return void + */ +function renderVorschlagsliste($db, $filter) +{ + global $langs, $user; + + $service = new MahnungVorschlag($db); + $rows = $service->getVorschlaege($filter); + + if (empty($rows)) { + print '
'.$langs->trans('MahnungKeineUeberfaelligen').'
'; + return; + } + + $canWrite = $user->hasRight('mahnung', 'write'); + + print '
'; + print ''; + print ''; + print ''; + if ($canWrite) { + print ''; + } + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + $summeOffen = 0.0; + foreach ($rows as $r) { + print ''; + if ($canWrite) { + print ''; + } + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + $summeOffen += (float) $r['betrag_offen']; + } + print ''; + print ''; + print '
'.$langs->trans('MahnungRechnung').''.$langs->trans('MahnungKunde').''.$langs->trans('MahnungKundentyp').''.$langs->trans('MahnungFaelligkeitAlt').''.$langs->trans('MahnungTageVerzug').''.$langs->trans('MahnungBetragOffen').''.$langs->trans('MahnungLetzteMahnung').''.$langs->trans('MahnungVorgeschlageneStufe').'
'.dol_escape_htmltag($r['facture_ref']).''.dol_escape_htmltag($r['soc_nom']).''.dol_escape_htmltag($r['kundentyp']).''.dol_print_date($r['facture_date_lim_reglement'], 'day').''.((int) $r['tage_verzug']).''.price($r['betrag_offen']).''.($r['letzte_mahnung_stufe'] ? 'Stufe '.((int) $r['letzte_mahnung_stufe']).' am '.dol_print_date($r['letzte_mahnung_datum'], 'day') : '—').''.((int) $r['vorgeschlagene_stufe']).' — '.dol_escape_htmltag($r['vorgeschlagene_stufe_label']).'
'.$langs->trans('Total').''.price($summeOffen).'
'; + + if ($canWrite) { + print '
'; + print ' '; + print ''; + print '
'; + print ''; + } + print '
'; +} + +/** + * Rendert das Archiv aller bestehenden Mahnvorgaenge. + * + * @param DoliDB $db + * @param array $filter + * @return void + */ +function renderArchiv($db, $filter) +{ + global $langs; + + $mahnungObj = new Mahnung($db); + $archivFilter = array(); + if (isset($filter['stufe'])) { + $archivFilter['stufe'] = $filter['stufe']; + } + $mahnungen = $mahnungObj->fetchAll('date_mahnung', 'DESC', 200, 0, $archivFilter); + + if (empty($mahnungen)) { + print '
Keine Mahnvorgaenge.
'; + return; + } + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + foreach ($mahnungen as $m) { + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + } + print '
'.$langs->trans('MahnungRef').''.$langs->trans('MahnungRechnung').''.$langs->trans('MahnungKunde').''.$langs->trans('MahnungStufe').''.$langs->trans('MahnungDatum').''.$langs->trans('MahnungBetragOffen').''.$langs->trans('MahnungGebuehr').''.$langs->trans('MahnungVerzugszinsen').''.$langs->trans('MahnungSumme').'Status
'.dol_escape_htmltag($m->ref).'#'.((int) $m->fk_facture).'#'.((int) $m->fk_soc).''.((int) $m->stufe).''.dol_print_date($m->date_mahnung, 'day').''.price($m->betrag_offen).''.price((float) $m->mahngebuehr + (float) $m->pauschale_b2b).''.price($m->verzugszinsen).''.price($m->summe_mahnung).''.dol_escape_htmltag($m->getStatusLabel()).'
'; +} diff --git a/sql/llx_mahnung_mahnung.key.sql b/sql/llx_mahnung_mahnung.key.sql new file mode 100644 index 0000000..05bfd9e --- /dev/null +++ b/sql/llx_mahnung_mahnung.key.sql @@ -0,0 +1,13 @@ +-- Copyright (C) 2026 Eduard Wisch +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU General Public License as published by +-- the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. + +ALTER TABLE llx_mahnung_mahnung ADD UNIQUE INDEX uk_mahnung_ref (entity, ref); +ALTER TABLE llx_mahnung_mahnung ADD INDEX idx_mahnung_facture (fk_facture); +ALTER TABLE llx_mahnung_mahnung ADD INDEX idx_mahnung_soc (fk_soc); +ALTER TABLE llx_mahnung_mahnung ADD INDEX idx_mahnung_status (status); +ALTER TABLE llx_mahnung_mahnung ADD INDEX idx_mahnung_stufe (stufe); +ALTER TABLE llx_mahnung_mahnung ADD INDEX idx_mahnung_date (date_mahnung); diff --git a/sql/llx_mahnung_mahnung.sql b/sql/llx_mahnung_mahnung.sql new file mode 100644 index 0000000..b41261d --- /dev/null +++ b/sql/llx_mahnung_mahnung.sql @@ -0,0 +1,35 @@ +-- Copyright (C) 2026 Eduard Wisch +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU General Public License as published by +-- the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. + +-- Mahnvorgaenge zu Kundenrechnungen + +CREATE TABLE llx_mahnung_mahnung ( + rowid INTEGER AUTO_INCREMENT PRIMARY KEY, + entity INTEGER DEFAULT 1 NOT NULL, + ref VARCHAR(30) NOT NULL, + fk_facture INTEGER NOT NULL, + fk_soc INTEGER NOT NULL, + stufe TINYINT NOT NULL, + date_mahnung DATE NOT NULL, + date_lim_reglement_alt DATE, + date_lim_reglement_neu DATE, + betrag_offen DOUBLE(24,8) DEFAULT 0, + mahngebuehr DOUBLE(10,2) DEFAULT 0, + pauschale_b2b DOUBLE(10,2) DEFAULT 0, + verzugszinsen DOUBLE(10,2) DEFAULT 0, + summe_mahnung DOUBLE(24,8) DEFAULT 0, + versandart VARCHAR(20) DEFAULT 'pdf', + customertype VARCHAR(3), + basiszins_snapshot DECIMAL(5,4), + pdf_path VARCHAR(255), + note_private TEXT, + status TINYINT DEFAULT 0 NOT NULL, + datec DATETIME, + tms TIMESTAMP, + fk_user_creat INTEGER, + fk_user_modif INTEGER +) ENGINE=InnoDB; diff --git a/sql/llx_mahnung_stufe.key.sql b/sql/llx_mahnung_stufe.key.sql new file mode 100644 index 0000000..f62b198 --- /dev/null +++ b/sql/llx_mahnung_stufe.key.sql @@ -0,0 +1,8 @@ +-- Copyright (C) 2026 Eduard Wisch +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU General Public License as published by +-- the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. + +ALTER TABLE llx_mahnung_stufe ADD UNIQUE INDEX uk_mahnung_stufe (entity, stufe); diff --git a/sql/llx_mahnung_stufe.sql b/sql/llx_mahnung_stufe.sql new file mode 100644 index 0000000..f4c5532 --- /dev/null +++ b/sql/llx_mahnung_stufe.sql @@ -0,0 +1,35 @@ +-- Copyright (C) 2026 Eduard Wisch +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU General Public License as published by +-- the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. + +-- Mahnstufen-Konfiguration (3-stufig nach BGB §288) + +CREATE TABLE llx_mahnung_stufe ( + rowid INTEGER AUTO_INCREMENT PRIMARY KEY, + entity INTEGER DEFAULT 1 NOT NULL, + stufe TINYINT NOT NULL, + label VARCHAR(60) NOT NULL, + frist_tage INTEGER DEFAULT 0 NOT NULL, + neue_frist_tage INTEGER DEFAULT 7 NOT NULL, + mahngebuehr_b2c DOUBLE(10,2) DEFAULT 0, + mahngebuehr_b2b DOUBLE(10,2) DEFAULT 0, + pauschale_b2b_einmalig TINYINT DEFAULT 0, + zinssatz_b2c_uebersteuern DECIMAL(5,4), + zinssatz_b2b_uebersteuern DECIMAL(5,4), + versandart_default VARCHAR(20) DEFAULT 'pdf', + email_subject VARCHAR(255), + email_body TEXT, + pdf_intro TEXT, + active TINYINT DEFAULT 1 NOT NULL, + datec DATETIME, + tms TIMESTAMP +) ENGINE=InnoDB; + +-- Default-Stufen (idempotent: INSERT IGNORE wegen UNIQUE entity+stufe) +INSERT IGNORE INTO llx_mahnung_stufe (entity, stufe, label, frist_tage, neue_frist_tage, mahngebuehr_b2c, mahngebuehr_b2b, pauschale_b2b_einmalig, versandart_default, datec) VALUES + (1, 1, 'Zahlungserinnerung', 7, 14, 0.00, 0.00, 1, 'pdf', NOW()), + (1, 2, '1. Mahnung', 14, 10, 5.00, 5.00, 0, 'pdf', NOW()), + (1, 3, 'Letzte Mahnung', 10, 7, 10.00, 10.00, 0, 'pdf', NOW());