Initiales Release: Mahnung-Modul v0.1.0 [deploy]
All checks were successful
Deploy mahnung / deploy (push) Successful in 14s

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) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-05-07 12:09:37 +02:00
commit d1db85322b
24 changed files with 3981 additions and 0 deletions

View file

@ -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

62
CHANGELOG.md Normal file
View file

@ -0,0 +1,62 @@
# Changelog
## [0.1.0] — 2026-05-07 — Erstveröffentlichung (Phase 110)
### 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` (500033500036 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

123
README.md Normal file
View file

@ -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

295
admin/setup.php Normal file
View file

@ -0,0 +1,295 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
/**
* \file 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 '<span class="opacitymedium">'.$langs->trans('MahnungSetupDescription').'</span><br><br>';
// --- Block: Konstanten -------------------------------------------------------
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'">';
print '<input type="hidden" name="token" value="'.newToken('admin_mahnung').'">';
print '<input type="hidden" name="action" value="save_consts">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre"><th colspan="2">'.$langs->trans('MahnungSetup').'</th></tr>';
print '<tr class="oddeven"><td>'.$langs->trans('MahnungBasiszins').'</td>';
print '<td><input type="text" name="MAHNUNG_BASISZINS" size="8" value="'.dol_escape_htmltag(getDolGlobalString('MAHNUNG_BASISZINS', '1.27')).'"> %';
print ' <span class="opacitymedium">'.$langs->trans('MahnungBasiszinsHelp').'</span></td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans('MahnungAufschlagB2C').'</td>';
print '<td><input type="text" name="MAHNUNG_AUFSCHLAG_B2C" size="8" value="'.dol_escape_htmltag(getDolGlobalString('MAHNUNG_AUFSCHLAG_B2C', '5.0')).'"> %</td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans('MahnungAufschlagB2B').'</td>';
print '<td><input type="text" name="MAHNUNG_AUFSCHLAG_B2B" size="8" value="'.dol_escape_htmltag(getDolGlobalString('MAHNUNG_AUFSCHLAG_B2B', '9.0')).'"> %</td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans('MahnungPauschaleB2BLabel').'</td>';
print '<td><input type="text" name="MAHNUNG_PAUSCHALE_B2B" size="8" value="'.dol_escape_htmltag(getDolGlobalString('MAHNUNG_PAUSCHALE_B2B', '40.00')).'"> EUR</td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans('MahnungNtfyTopic').'</td>';
print '<td><input type="text" name="MAHNUNG_NTFY_TOPIC" size="40" value="'.dol_escape_htmltag(getDolGlobalString('MAHNUNG_NTFY_TOPIC', 'vk-builds')).'">';
print ' <span class="opacitymedium">'.$langs->trans('MahnungNtfyTopicHelp').'</span></td></tr>';
print '</table>';
print '<br><div class="center"><input type="submit" class="button" value="'.$langs->trans('Save').'"></div>';
print '</form>';
// --- 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 '<br><br>';
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'">';
print '<input type="hidden" name="token" value="'.newToken('admin_mahnung').'">';
print '<input type="hidden" name="action" value="save_stufen">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre"><th colspan="2">'.$langs->trans('MahnungStufe').'</th></tr>';
foreach ($stufen as $s) {
$prefix = 'stufe_'.$s->stufe.'_';
print '<tr class="liste_titre_filter"><th colspan="2">'.dol_escape_htmltag('Stufe '.$s->stufe).' ';
print '<input type="checkbox" name="'.$prefix.'active" value="1"'.($s->active ? ' checked' : '').'> '.$langs->trans('Active');
print '</th></tr>';
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufeLabel').'</td>';
print '<td><input type="text" name="'.$prefix.'label" size="40" value="'.dol_escape_htmltag($s->label).'"></td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufeFristTage').'</td>';
print '<td><input type="number" name="'.$prefix.'frist_tage" size="6" value="'.((int) $s->frist_tage).'"></td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufeNeueFristTage').'</td>';
print '<td><input type="number" name="'.$prefix.'neue_frist_tage" size="6" value="'.((int) $s->neue_frist_tage).'"></td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufeGebuehrB2C').'</td>';
print '<td><input type="text" name="'.$prefix.'mahngebuehr_b2c" size="8" value="'.((float) $s->mahngebuehr_b2c).'"> EUR</td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufeGebuehrB2B').'</td>';
print '<td><input type="text" name="'.$prefix.'mahngebuehr_b2b" size="8" value="'.((float) $s->mahngebuehr_b2b).'"> EUR</td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans('MahnungPauschaleB2B').' (§288 Abs. 5)</td>';
print '<td><input type="checkbox" name="'.$prefix.'pauschale_b2b_einmalig" value="1"'.($s->pauschale_b2b_einmalig ? ' checked' : '').'></td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufeZinssatzB2C').'</td>';
print '<td><input type="text" name="'.$prefix.'zinssatz_b2c" size="8" value="'.($s->zinssatz_b2c_uebersteuern !== null ? (float) $s->zinssatz_b2c_uebersteuern : '').'"> %</td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufeZinssatzB2B').'</td>';
print '<td><input type="text" name="'.$prefix.'zinssatz_b2b" size="8" value="'.($s->zinssatz_b2b_uebersteuern !== null ? (float) $s->zinssatz_b2b_uebersteuern : '').'"> %</td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufeVersandartDefault').'</td>';
$va = $s->versandart_default ?: 'pdf';
print '<td><select name="'.$prefix.'versandart">';
foreach (array('pdf' => 'MahnungVersandPdf', 'mail' => 'MahnungVersandMail', 'druck' => 'MahnungVersandDruck', 'none' => 'MahnungVersandNone') as $v => $tx) {
print '<option value="'.$v.'"'.($va === $v ? ' selected' : '').'>'.$langs->trans($tx).'</option>';
}
print '</select></td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufePdfIntro').'</td>';
print '<td><textarea name="'.$prefix.'pdf_intro" cols="80" rows="3">'.dol_escape_htmltag($s->pdf_intro).'</textarea></td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufeEmailSubject').'</td>';
print '<td><input type="text" name="'.$prefix.'email_subject" size="80" value="'.dol_escape_htmltag($s->email_subject).'"></td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufeEmailBody').'</td>';
print '<td><textarea name="'.$prefix.'email_body" cols="80" rows="5">'.dol_escape_htmltag($s->email_body).'</textarea></td></tr>';
}
print '</table>';
print '<br><div class="center"><input type="submit" class="button" value="'.$langs->trans('Save').'"></div>';
print '</form>';
llxFooter();
$db->close();

215
ajax/createmahnung.php Normal file
View file

@ -0,0 +1,215 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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;
}

230
ajax/sammelbrief.php Normal file
View file

@ -0,0 +1,230 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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;
}

154
ajax/sendmail.php Normal file
View file

@ -0,0 +1,154 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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";
}
}

137
card.php Normal file
View file

@ -0,0 +1,137 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
/**
* \file 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 '<table class="border centpercent">';
print '<tr><td class="titlefield">'.$langs->trans('MahnungRef').'</td><td>'.dol_escape_htmltag($mahnung->ref).'</td></tr>';
print '<tr><td>'.$langs->trans('MahnungStufe').'</td><td>'.((int) $mahnung->stufe).'</td></tr>';
print '<tr><td>'.$langs->trans('MahnungDatum').'</td><td>'.dol_print_date($mahnung->date_mahnung, 'day').'</td></tr>';
print '<tr><td>'.$langs->trans('MahnungFaelligkeitAlt').'</td><td>'.dol_print_date($mahnung->date_lim_reglement_alt, 'day').'</td></tr>';
print '<tr><td>'.$langs->trans('MahnungFaelligkeitNeu').'</td><td>'.dol_print_date($mahnung->date_lim_reglement_neu, 'day').'</td></tr>';
// Rechnung
$facture = new Facture($db);
if ($facture->fetch((int) $mahnung->fk_facture) > 0) {
print '<tr><td>'.$langs->trans('MahnungRechnung').'</td>';
print '<td><a href="'.DOL_URL_ROOT.'/compta/facture/card.php?id='.((int) $facture->id).'">'.dol_escape_htmltag($facture->ref).'</a></td></tr>';
}
// Kunde
$societe = new Societe($db);
if ($societe->fetch((int) $mahnung->fk_soc) > 0) {
print '<tr><td>'.$langs->trans('MahnungKunde').'</td>';
print '<td><a href="'.DOL_URL_ROOT.'/societe/card.php?socid='.((int) $societe->id).'">'.dol_escape_htmltag($societe->name).'</a> ('.dol_escape_htmltag((string) $mahnung->customertype).')</td></tr>';
}
print '<tr><td>'.$langs->trans('MahnungBetragOffen').'</td><td>'.price($mahnung->betrag_offen).'</td></tr>';
print '<tr><td>'.$langs->trans('MahnungGebuehr').'</td><td>'.price($mahnung->mahngebuehr).'</td></tr>';
if ((float) $mahnung->pauschale_b2b > 0) {
print '<tr><td>'.$langs->trans('MahnungPauschaleB2B').'</td><td>'.price($mahnung->pauschale_b2b).'</td></tr>';
}
print '<tr><td>'.$langs->trans('MahnungVerzugszinsen').'</td><td>'.price($mahnung->verzugszinsen).' (Basiszins '.number_format((float) $mahnung->basiszins_snapshot, 2, ',', '.').' %)</td></tr>';
print '<tr><td>'.$langs->trans('MahnungSumme').'</td><td><strong>'.price($mahnung->summe_mahnung).'</strong></td></tr>';
print '<tr><td>Status</td><td>'.dol_escape_htmltag($mahnung->getStatusLabel()).'</td></tr>';
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 '<tr><td>PDF</td><td><a href="'.$dl.'">PDF herunterladen</a></td></tr>';
}
print '</table>';
// 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 '<br><div class="tabsAction">';
print '<a class="butActionDelete" href="'.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id).'&action=confirm_storno">';
print $langs->trans('MahnungStornieren');
print '</a></div>';
}
llxFooter();
$db->close();

View file

@ -0,0 +1,145 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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 ? ' <span class="badge">'.$count.'</span>' : '');
$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;
}
}

504
class/mahnung.class.php Normal file
View file

@ -0,0 +1,504 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License, 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<YYYY>-<NNNN>
*
* @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;
}
}
}

124
class/mahnungcron.class.php Normal file
View file

@ -0,0 +1,124 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License, 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;
}
}

View file

@ -0,0 +1,94 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License, 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));
}
}

360
class/mahnungpdf.class.php Normal file
View file

@ -0,0 +1,360 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License, 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);
}
}

View file

@ -0,0 +1,231 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License, 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);
}
}

View file

@ -0,0 +1,232 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License, 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;
}
}

View file

@ -0,0 +1,304 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*/
/**
* \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);
}
}

View file

@ -0,0 +1,151 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
/**
* \file core/triggers/interface_99_modMahnung_MahnungTriggers.class.php
* \ingroup mahnung
* \brief Trigger: setzt offene Mahnvorgaenge nach Zahlungseingang auf "erledigt".
*
* Klassenname: Interface<Suffix>Triggers 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;
}
}

108
langs/de_DE/mahnung.lang Normal file
View file

@ -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.

108
langs/en_US/mahnung.lang Normal file
View file

@ -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.

232
list.php Normal file
View file

@ -0,0 +1,232 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
/**
* \file 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 '<form method="GET" action="'.$_SERVER['PHP_SELF'].'">';
print '<input type="hidden" name="mode" value="'.dol_escape_htmltag($mode).'">';
print '<table class="noborder centpercent"><tr class="liste_titre">';
print '<th>'.$langs->trans('MahnungStufe').'</th>';
print '<th>'.$langs->trans('MahnungTageVerzug').' (min)</th>';
print '<th>'.$langs->trans('MahnungKunde').'</th>';
print '<th></th></tr>';
print '<tr><td><select name="filter_stufe">';
print '<option value="">— '.$langs->trans('All').' —</option>';
foreach (array(1, 2, 3) as $st) {
print '<option value="'.$st.'"'.((string) $filter_stufe === (string) $st ? ' selected' : '').'>'.$st.'</option>';
}
print '</select></td>';
print '<td><input type="number" name="filter_minverzug" value="'.dol_escape_htmltag((string) $filter_minverzug).'" size="4"></td>';
print '<td><input type="number" name="search_socid" value="'.dol_escape_htmltag((string) $filter_socid).'" size="6" placeholder="rowid">';
print '</td>';
print '<td><input type="submit" class="button" value="'.$langs->trans('Search').'"></td>';
print '</tr></table>';
print '</form><br>';
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 '<div class="info">'.$langs->trans('MahnungKeineUeberfaelligen').'</div>';
return;
}
$canWrite = $user->hasRight('mahnung', 'write');
print '<form method="POST" action="'.DOL_URL_ROOT.'/custom/mahnung/ajax/createmahnung.php" id="formMahnung">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
if ($canWrite) {
print '<th><input type="checkbox" id="chkAll"></th>';
}
print '<th>'.$langs->trans('MahnungRechnung').'</th>';
print '<th>'.$langs->trans('MahnungKunde').'</th>';
print '<th>'.$langs->trans('MahnungKundentyp').'</th>';
print '<th>'.$langs->trans('MahnungFaelligkeitAlt').'</th>';
print '<th class="right">'.$langs->trans('MahnungTageVerzug').'</th>';
print '<th class="right">'.$langs->trans('MahnungBetragOffen').'</th>';
print '<th>'.$langs->trans('MahnungLetzteMahnung').'</th>';
print '<th>'.$langs->trans('MahnungVorgeschlageneStufe').'</th>';
print '</tr>';
$summeOffen = 0.0;
foreach ($rows as $r) {
print '<tr class="oddeven">';
if ($canWrite) {
print '<td><input type="checkbox" name="facture_ids[]" value="'.((int) $r['facture_id']).'" data-stufe="'.((int) $r['vorgeschlagene_stufe']).'"></td>';
}
print '<td><a href="'.DOL_URL_ROOT.'/compta/facture/card.php?id='.((int) $r['facture_id']).'">'.dol_escape_htmltag($r['facture_ref']).'</a></td>';
print '<td><a href="'.DOL_URL_ROOT.'/societe/card.php?socid='.((int) $r['soc_id']).'">'.dol_escape_htmltag($r['soc_nom']).'</a></td>';
print '<td>'.dol_escape_htmltag($r['kundentyp']).'</td>';
print '<td>'.dol_print_date($r['facture_date_lim_reglement'], 'day').'</td>';
print '<td class="right">'.((int) $r['tage_verzug']).'</td>';
print '<td class="right">'.price($r['betrag_offen']).'</td>';
print '<td>'.($r['letzte_mahnung_stufe'] ? 'Stufe '.((int) $r['letzte_mahnung_stufe']).' am '.dol_print_date($r['letzte_mahnung_datum'], 'day') : '—').'</td>';
print '<td><strong>'.((int) $r['vorgeschlagene_stufe']).'</strong> — '.dol_escape_htmltag($r['vorgeschlagene_stufe_label']).'</td>';
print '</tr>';
$summeOffen += (float) $r['betrag_offen'];
}
print '<tr class="liste_total"><td colspan="'.($canWrite ? 6 : 5).'" class="right">'.$langs->trans('Total').'</td>';
print '<td class="right">'.price($summeOffen).'</td><td colspan="2"></td></tr>';
print '</table>';
if ($canWrite) {
print '<br><div class="center">';
print '<button type="submit" class="button" name="action" value="bulk_create">'.$langs->trans('MahnungErstellen').'</button> ';
print '<button type="submit" class="button" name="action" value="bulk_sammelbrief" formaction="'.DOL_URL_ROOT.'/custom/mahnung/ajax/sammelbrief.php">'.$langs->trans('MahnungSammelbrief').'</button>';
print '</div>';
print '<script>document.getElementById("chkAll")?.addEventListener("change", function(e){';
print 'document.querySelectorAll("input[name=\'facture_ids[]\']").forEach(c => c.checked = e.target.checked);';
print '});</script>';
}
print '</form>';
}
/**
* 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 '<div class="info">Keine Mahnvorgaenge.</div>';
return;
}
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans('MahnungRef').'</th>';
print '<th>'.$langs->trans('MahnungRechnung').'</th>';
print '<th>'.$langs->trans('MahnungKunde').'</th>';
print '<th>'.$langs->trans('MahnungStufe').'</th>';
print '<th>'.$langs->trans('MahnungDatum').'</th>';
print '<th class="right">'.$langs->trans('MahnungBetragOffen').'</th>';
print '<th class="right">'.$langs->trans('MahnungGebuehr').'</th>';
print '<th class="right">'.$langs->trans('MahnungVerzugszinsen').'</th>';
print '<th class="right">'.$langs->trans('MahnungSumme').'</th>';
print '<th>Status</th>';
print '</tr>';
foreach ($mahnungen as $m) {
print '<tr class="oddeven">';
print '<td>'.dol_escape_htmltag($m->ref).'</td>';
print '<td><a href="'.DOL_URL_ROOT.'/compta/facture/card.php?id='.((int) $m->fk_facture).'">#'.((int) $m->fk_facture).'</a></td>';
print '<td><a href="'.DOL_URL_ROOT.'/societe/card.php?socid='.((int) $m->fk_soc).'">#'.((int) $m->fk_soc).'</a></td>';
print '<td>'.((int) $m->stufe).'</td>';
print '<td>'.dol_print_date($m->date_mahnung, 'day').'</td>';
print '<td class="right">'.price($m->betrag_offen).'</td>';
print '<td class="right">'.price((float) $m->mahngebuehr + (float) $m->pauschale_b2b).'</td>';
print '<td class="right">'.price($m->verzugszinsen).'</td>';
print '<td class="right"><strong>'.price($m->summe_mahnung).'</strong></td>';
print '<td>'.dol_escape_htmltag($m->getStatusLabel()).'</td>';
print '</tr>';
}
print '</table>';
}

View file

@ -0,0 +1,13 @@
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU General Public License as published by
-- the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
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);

View file

@ -0,0 +1,35 @@
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU General Public License as published by
-- the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
-- 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;

View file

@ -0,0 +1,8 @@
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU General Public License as published by
-- the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
ALTER TABLE llx_mahnung_stufe ADD UNIQUE INDEX uk_mahnung_stufe (entity, stufe);

35
sql/llx_mahnung_stufe.sql Normal file
View file

@ -0,0 +1,35 @@
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU General Public License as published by
-- the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
-- 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());