* * GPL v3 (siehe COPYING). */ /** * \file htdocs/custom/mahnung/ajax/sendmail.php * \ingroup mahnung * \brief Funktionsbibliothek für den Versand der kostenlosen Zahlungserinnerung. * * ACHTUNG — diese Datei ist KEIN eigenständiger Endpoint mehr. Sie * stellt nur noch die gehärteten Bausteine bereit, die card.php * benutzt: Empfängerermittlung, Rechnungsprüfung, Anhang-Suche, * Platzhalter, Statuswächter, atomare Versand-Reservierung und den * eigentlichen CMailFile-Aufruf. * * Warum kein eigener AJAX-Endpoint mehr? * Der Versand läuft jetzt über das Dolibarr-Standard-Mailformular * (FormMail) auf der Mahnungs-Karte: card.php?id=…&action=presend. * Dort sind Empfänger (inkl. der Ansprechpartner des Kunden), Betreff, * Text und Anhang sichtbar UND änderbar. Gäbe es daneben weiter einen * Endpoint, der eigenständig sendet, existierten zwei Sendewege, die * auseinanderlaufen können. Es gibt deshalb bewusst nur EINEN: * mahnungSendeErinnerungsMail(). Ein Direktaufruf dieser Datei per HTTP * verschickt nichts, sondern leitet auf das Formular um. * * Fachliche Sperre (unverändert): versendet werden darf ausschließlich * eine Stufe mit llx_mahnung_stufe.ist_erinnerung = 1. Echte Mahnungen * gehen per Post/Einschreiben raus — eine E-Mail ist nicht beweisbar * zugestellt. * * Angehängt wird die UNVERÄNDERTE Original-Rechnungs-PDF. Es wird kein * Mahn-PDF erzeugt, pdf_path des Mahnvorgangs bleibt unangetastet. * Fehlt die Rechnungs-PDF auf der Platte, wird sie regulär über * Facture::generateDocument() nacherzeugt — dafür ist zusätzlich das * Recht facture.creer nötig. * * Doppelversand-Schutz: der Vorgang wird VOR dem Senden per bedingtem * UPDATE auf VERSENDET reserviert (siehe mahnungReserviereVersand()). * Nur wer die Zeile tatsächlich trifft, darf senden; schlägt der Versand * fehl, wird die Reservierung wieder zurückgenommen. * * Die neue Zahlungsfrist im Mailtext ({frist}) wird zum Versandzeitpunkt * aus neue_frist_tage der Stufe neu berechnet und mitgespeichert — sonst * nennt eine später verschickte Erinnerung eine verstrichene Frist. * * Ebenso wird der offene Betrag ({summe}) zum Versandzeitpunkt frisch * an der Rechnung geprüft: ist sie inzwischen bezahlt, storniert oder * abgeschrieben, wird der Versand abgelehnt; bei einer Teilzahlung * werden betrag_offen/summe_mahnung nachgezogen und mitgespeichert. */ // --------------------------------------------------------------------------- // Direktaufruf per HTTP (Altlast) // --------------------------------------------------------------------------- // Wird die Datei von card.php eingebunden, lief main.inc.php längst und // DOL_DOCUMENT_ROOT ist definiert — dann steht unterhalb nur Funktionsdefinition // und es passiert beim Einbinden nichts. // Ruft dagegen jemand ajax/sendmail.php direkt auf (altes Lesezeichen, eine noch // im Browser stehende Seite von vor dem Umbau), landet er hier: es wird nichts // verschickt, sondern auf das Mailformular der Karte weitergeleitet. if (!defined('DOL_DOCUMENT_ROOT')) { if (!defined('NOREQUIREMENU')) { define('NOREQUIREMENU', '1'); } if (!defined('NOTOKENRENEWAL')) { define('NOTOKENRENEWAL', '1'); } require_once $_SERVER['DOCUMENT_ROOT'].'/main.inc.php'; $langs->loadLangs(array('mahnung@mahnung')); $mahnungIdAlt = GETPOSTINT('mahnung_id'); $zielAlt = DOL_URL_ROOT.'/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung'; if ($mahnungIdAlt > 0) { // mailinit=1, NICHT mode=init: FormMail::get_form() leert bei mode=init selbst // die Anhangsliste und würde die von card.php eingehängte Rechnungs-PDF gleich // wieder entfernen — das Formular käme ohne Anhang hoch. // Token mitgeben: 'presend' ist token-pflichtig, weil dabei die Rechnungs-PDF // bereitgestellt (und notfalls erzeugt) wird. $zielAlt = DOL_URL_ROOT.'/custom/mahnung/card.php?id='.$mahnungIdAlt.'&action=presend&mailinit=1&token='.newToken().'#formmail'; } // Der alte Aufruf war ein jQuery.post() mit dataType "json". Damit eine noch // offene alte Seite keinen kryptischen Parse-Fehler zeigt, wird dort sauber // mit JSON geantwortet statt mit einem Redirect auf HTML. $willJson = false; if (!empty($_SERVER['HTTP_ACCEPT']) && stripos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false) { $willJson = true; } if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') { $willJson = true; } if ($willJson) { header('Content-Type: application/json; charset=utf-8'); echo json_encode(array( 'success' => false, 'redirect' => $zielAlt, 'message' => $langs->transnoentities('MahnungMailEndpointEntfallen'), )); exit; } header('Location: '.$zielAlt); exit; } 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'; // --------------------------------------------------------------------------- // Empfänger // --------------------------------------------------------------------------- /** * Auswahlliste der möglichen Empfänger eines Kunden. * * Liefert exakt das, was Dolibarr im Standard-Mailformular anbietet: die * Haupt-E-Mail der Firma UND die E-Mail-Adressen aller aktiven Ansprechpartner * (llx_socpeople). Genau das braucht Eddy — viele Kunden haben eine * Firmenadresse und zusätzlich einen Kontakt, an den die Rechnung gehen soll. * * Format: array(|'thirdparty' => "Name - Position "). * Der SCHLÜSSEL transportiert die Identität, das Label ist nur Anzeige — so * erwartet es FormMail::getHtmlForTo(). * * @param Societe $societe Geladener Kunde * @return array */ function mahnungEmpfaengerListe($societe) { if (!is_object($societe) || empty($societe->id)) { return array(); } $liste = array(); // thirdparty_and_contact_email_array(1) = aktive Kontakte + Firmenadresse. foreach ($societe->thirdparty_and_contact_email_array(1) as $key => $label) { $liste[$key] = (string) $label; } return $liste; } /** * Zerlegt einen Empfängereintrag "Name " (oder "mail@x") und prüft ihn. * * Der Name wird von Zeichen befreit, die eine Adressliste bzw. einen Mail-Header * zerlegen könnten (Komma, Semikolon, spitze Klammern, Anführungszeichen, * Steuerzeichen). Ohne gültige Mailadresse gibt es null — Kontakte ohne Mail * tragen im Label den übersetzten Text "NoEMail" und fallen hier heraus. * * @param string $eintrag Roher Eintrag * @return array{mail:string,voll:string}|null */ function mahnungAdresseNormieren($eintrag) { $eintrag = mahnungHeaderEinzeilig($eintrag); if ($eintrag === '') { return null; } $reg = array(); if (preg_match('/^(.*)<([^<>]+)>[^<>]*$/', $eintrag, $reg)) { $name = trim(dol_string_nospecial($reg[1], ' ', array(',', ';', '<', '>', '"'))); $mail = trim($reg[2]); } else { $name = ''; $mail = trim($eintrag); } if ($mail === '' || !isValidEmail($mail)) { return null; } return array( // Kleinschreibung dient nur als Dubletten-Schlüssel — verschickt wird 'voll'. 'mail' => strtolower($mail), 'voll' => ($name !== '' ? $name.' <'.$mail.'>' : $mail), ); } /** * Zerlegt ein Freitext-Adressfeld ("a@x, Name ") in geprüfte Adressen. * * Genutzt für die frei eintragbaren Felder des Mailformulars (An, Kopie, * Blindkopie). Getrennt wird an Komma UND Semikolon: Dolibarr nennt zwar nur das * Komma, aus Outlook ist das Semikolon aber so geläufig, dass "a@x; b@y" sonst * als eine einzige — ungültige — Adresse durchliefe. * * Unbrauchbare Teile werden NICHT still geschluckt, sondern in $verworfen * zurückgegeben. Ein Tippfehler in einer Adresse darf nicht dazu führen, dass die * Mail klaglos an weniger Empfänger geht, als im Formular standen. * * Der Rückgabe-Schlüssel ist die kleingeschriebene Adresse, wodurch Dubletten * (auch über mehrere Aufrufe hinweg) von selbst zusammenfallen. * * @param string $eingabe Roher Feldinhalt * @param array $verworfen Rückgabe: nicht verwertbare Teile * @return array Mailadresse (klein) => "Name " */ function mahnungAdressenAusFreitext($eingabe, &$verworfen = array()) { $treffer = array(); $verworfen = array(); foreach (preg_split('/[,;]/', (string) $eingabe) as $teil) { if (trim((string) $teil) === '') { continue; } $norm = mahnungAdresseNormieren($teil); if ($norm === null) { $verworfen[] = trim((string) $teil); continue; } $treffer[$norm['mail']] = $norm['voll']; } return $treffer; } /** * Sendefertige Adresse zu einem Schlüssel aus mahnungEmpfaengerListe(). * * Wichtig für die Sicherheit: der Aufrufer MUSS vorher prüfen, dass der * Schlüssel wirklich aus der Liste dieses Kunden stammt. Societe:: * contact_get_property() fragt llx_socpeople allein über die rowid ab und * validiert die Firmenzugehörigkeit NICHT — ein manipuliertes receiver[] könnte * sonst die Erinnerung an einen beliebigen fremden Kontakt schicken. * * @param Societe $societe Geladener Kunde * @param int|string $key 'thirdparty' oder socpeople.rowid * @return string "Name " oder '' wenn unbrauchbar */ function mahnungEmpfaengerAdresse($societe, $key) { $key = (string) $key; if ($key === 'thirdparty') { $mail = trim((string) $societe->email); if ($mail === '' || !isValidEmail($mail)) { return ''; } // mahnungHeaderEinzeilig() zuerst: dol_string_nospecial() entfernt zwar die // aufgezählten Zeichen, lässt CR/LF/TAB aber stehen — ein Firmenname mit // Zeilenumbruch (aus einem Import) könnte damit einen Mail-Header aufspalten. $name = trim(dol_string_nospecial(mahnungHeaderEinzeilig((string) $societe->name), ' ', array(',', ';', '<', '>', '"'))); return ($name !== '' ? $name.' <'.$mail.'>' : $mail); } if ((int) $key <= 0) { return ''; } $norm = mahnungAdresseNormieren((string) $societe->contact_get_property((int) $key, 'email')); return ($norm === null) ? '' : $norm['voll']; } // --------------------------------------------------------------------------- // Rechnung: ist überhaupt noch etwas zu mahnen? // --------------------------------------------------------------------------- /** * Prüft die Rechnung zum Versandzeitpunkt und zieht den offenen Betrag nach. * * Zwischen der Anlage des Mahnvorgangs (Vorschlagsliste) und dem Versand liegt * bauartbedingt eine manuelle Bestätigung — also beliebig viel Zeit. In dieser * Zeit kann die Rechnung bezahlt, TEILbezahlt, storniert oder als uneinbringlich * abgeschrieben worden sein. Der Statuswächter am Mahnvorgang sieht davon * nichts: der Zahlungs-Trigger erledigt Mahnvorgänge nur bei VOLLzahlung * (istRechnungVollBezahlt()), und auf BILL_CANCEL hört das Modul gar nicht. * Deshalb wird der aktuelle Stand hier am Original nachgeprüft. * * Ändert sich der offene Betrag, werden betrag_offen und summe_mahnung am * übergebenen Objekt nachgezogen (in-memory). Gespeichert wird das erst mit der * Versand-Reservierung, damit Mail und Datensatz denselben Betrag nennen. * * @param Mahnung $mahnung Geladener Mahnvorgang (wird ggf. angepasst) * @param Facture $facture Geladene Rechnung * @param string $fehler Rückgabe: Grund der Ablehnung * @param bool $betragAngepasst Rückgabe: true = Betrag wurde nachgezogen * @return bool true = es ist noch etwas offen */ function mahnungRechnungNochOffen($mahnung, $facture, &$fehler, &$betragAngepasst) { global $langs; $fehler = ''; $betragAngepasst = false; // $facture->status ist der aktuelle Feldname, $facture->statut der alte — // beide werden von Facture::fetch() befüllt, der Fallback hält ältere // Dolibarr-Versionen offen. $statutRechnung = (int) (isset($facture->status) ? $facture->status : $facture->statut); if (!empty($facture->paye) || $statutRechnung !== Facture::STATUS_VALIDATED) { $fehler = $langs->trans('MahnungMailRechnungNichtMehrOffen', $facture->ref); return false; } $betragOffenAktuell = mahnungBetragOffenAktuell($facture); if ($betragOffenAktuell === null) { // Konnte nicht sicher gerechnet werden — lieber nichts verschicken als einen // falschen Betrag zu mahnen. $fehler = $langs->trans('MahnungMailBetragNichtErmittelbar', $facture->ref); return false; } // Schwelle 0,005 EUR: Rundungsreste unterhalb eines halben Cents sind keine // offene Forderung mehr (gleiche Toleranz wie im Zahlungs-Trigger). if ($betragOffenAktuell <= 0.005) { $fehler = $langs->trans('MahnungMailRechnungBereitsBezahlt', $facture->ref); return false; } // Teilzahlung o.Ä.: der Vorgang trägt sonst den Stand vom Anlagezeitpunkt in die // Mail ({summe}). Betrag nachziehen, bevor die Platzhalter gebaut werden. // Bei einer Zahlungserinnerung sind Mahngebühr, §288-Pauschale, Verzugszinsen // und Vorstufenkosten hart 0 (siehe mahnungBaueVorgang()) — rechneSumme() lässt // die Erinnerung damit kostenfrei, summe_mahnung folgt exakt dem offenen Betrag. $betragOffenVorher = (float) $mahnung->betrag_offen; if (abs($betragOffenAktuell - $betragOffenVorher) >= 0.005) { dol_syslog('mahnung/sendmail: offener Betrag zu '.$facture->ref.' hat sich von ' .price2num($betragOffenVorher, 'MT').' auf '.price2num($betragOffenAktuell, 'MT') .' geaendert — Mahnvorgang wird mitgezogen', LOG_INFO); $mahnung->betrag_offen = $betragOffenAktuell; $mahnung->rechneSumme(); $betragAngepasst = true; } return true; } /** * Aktuell offener Betrag einer Rechnung. * * Gerechnet wird wie im Rechnungsmodul selbst (compta/facture/card.php): * total_ttc - Zahlungen - verrechnete Anzahlungen - verrechnete Gutschriften * Die reine Differenz aus total_ttc und llx_paiement_facture würde eine per * Gutschrift oder Anzahlung ausgeglichene Rechnung fälschlich als offen * ausweisen — genau das würde hier eine Zahlungserinnerung auslösen. * * Alle drei Dolibarr-Methoden liefern bei einem SQL-Fehler -1 und setzen * $facture->error. Ein stumm mitverrechnetes -1 würde den offenen Betrag * verfälschen (Vorzeichen: Betrag würde um 1 EUR steigen), deshalb wird der * Fehlerfall sauber als "nicht ermittelbar" gemeldet statt gerechnet. * * @param Facture $facture Geladene Rechnung * @return float|null Offener Betrag in EUR, null = nicht ermittelbar */ function mahnungBetragOffenAktuell($facture) { $summen = array(); foreach (array('getSommePaiement', 'getSumDepositsUsed', 'getSumCreditNotesUsed') as $methode) { $facture->error = ''; $wert = (float) $facture->$methode(); if ($wert < 0 && $facture->error !== '') { dol_syslog('mahnung/sendmail: '.$methode.'() zu Rechnung '.((int) $facture->id).' fehlgeschlagen: '.$facture->error, LOG_ERR); return null; } $summen[] = $wert; } return round(((float) $facture->total_ttc) - array_sum($summen), 2); } // --------------------------------------------------------------------------- // Anhang (Original-Rechnungs-PDF) // --------------------------------------------------------------------------- /** * Liefert die Original-Rechnungs-PDF und erzeugt sie bei Bedarf nach. * * Bewusst die unveränderte Rechnung: sie weist den vollen Rechnungsbetrag aus, * der noch offene Rest steht im Mailtext ({summe}). Die Datei wird nicht * verändert und nicht in pdf_path geschrieben — eine Zahlungserinnerung hat * kein eigenes Mahn-PDF. * * @param Facture $facture Geladene Rechnung * @param User $user Aufrufender Benutzer (Rechteprüfung) * @param string $fehler Rückgabe: Fehlertext ('' = alles gut) * @return string Absoluter Pfad oder '' */ function mahnungHoleRechnungsPdf($facture, $user, &$fehler) { global $langs; $fehler = ''; $pdfDatei = mahnungFindeRechnungsPdf($facture); if ($pdfDatei !== '') { return $pdfDatei; } // Regulär über das Dokumentenmodell des Rechnungsmoduls nacherzeugen. // // generateDocument() schreibt in das Dokumentenverzeichnis des RECHNUNGS- // moduls. Das Recht mahnung.send allein reicht dafür nicht — wer keine // Rechnungen anlegen/ändern darf, darf hier auch keine Rechnungs-PDF // erzeugen lassen. if (!$user->hasRight('facture', 'creer')) { $fehler = $langs->trans('MahnungMailRechnungsPdfKeinRecht', $facture->ref); return ''; } dol_syslog('mahnung/sendmail: Rechnungs-PDF zu '.$facture->ref.' fehlt — wird nacherzeugt', LOG_INFO); if ($facture->generateDocument('', $langs) > 0) { $pdfDatei = mahnungFindeRechnungsPdf($facture); } if ($pdfDatei === '') { $fehler = $langs->trans('MahnungMailRechnungsPdfFehlt', $facture->ref); } return $pdfDatei; } /** * Ermittelt den absoluten Pfad der ORIGINAL-Rechnungs-PDF. * * Reihenfolge: * 1. $facture->last_main_doc (von Dolibarr gepflegter Zeiger, relativ zu * DOL_DATA_ROOT) * 2. Standardpfad //.pdf * * Die Referenz läuft durch dol_sanitizeFileName() (entfernt u.a. "/", "\" und * ".."), zusätzlich wird über realpath() geprüft, dass die gefundene Datei * wirklich unterhalb des Rechnungs-Ausgabeverzeichnisses liegt. Damit ist ein * Ausbrechen aus dem Verzeichnis (Path Traversal) ausgeschlossen. * * @param Facture $facture Geladene Rechnung * @return string Absoluter Pfad oder '' wenn keine PDF vorhanden */ function mahnungFindeRechnungsPdf($facture) { $baseDir = mahnungRechnungsAusgabeVerzeichnis($facture); if ($baseDir === '') { return ''; } $refSan = dol_sanitizeFileName((string) $facture->ref); if ($refSan === '') { return ''; } $kandidaten = array(); // 1) Hauptdokument laut Dolibarr. ACHTUNG: last_main_doc ist relativ zu // DOL_DATA_ROOT (z.B. "facture/FA2601-0001/FA2601-0001.pdf"), NICHT zum // Ausgabeverzeichnis des Rechnungsmoduls — siehe commonGenerateDocument(). if (!empty($facture->last_main_doc)) { $rel = ltrim(str_replace('\\', '/', (string) $facture->last_main_doc), '/'); if ($rel !== '' && strpos($rel, '..') === false && preg_match('/\.pdf$/i', $rel)) { $kandidaten[] = rtrim(DOL_DATA_ROOT, '/').'/'.$rel; } } // 2) Standardpfad des Rechnungsmoduls $kandidaten[] = $baseDir.'/'.$refSan.'/'.$refSan.'.pdf'; // Beide Kandidaten müssen unterhalb des Rechnungs-Ausgabeverzeichnisses // liegen — damit kann auch ein manipulierter last_main_doc nichts anderes // als eine Rechnungs-PDF anhängen. foreach ($kandidaten as $pfad) { if (mahnungIstNutzbarePdf($pfad, $baseDir)) { return $pfad; } } return ''; } /** * Ausgabeverzeichnis des Rechnungsmoduls für die Entity der Rechnung. * $conf->facture ist der Altname, $conf->invoice zeigt auf dasselbe Objekt. * * @param Facture $facture Geladene Rechnung * @return string Verzeichnis ohne Slash am Ende oder '' */ function mahnungRechnungsAusgabeVerzeichnis($facture) { global $conf; $entity = !empty($facture->entity) ? (int) $facture->entity : (int) $conf->entity; if (!empty($conf->facture->multidir_output[$entity])) { return rtrim($conf->facture->multidir_output[$entity], '/'); } if (!empty($conf->invoice->multidir_output[$entity])) { return rtrim($conf->invoice->multidir_output[$entity], '/'); } if (!empty($conf->facture->dir_output)) { return rtrim($conf->facture->dir_output, '/'); } if (!empty($conf->invoice->dir_output)) { return rtrim($conf->invoice->dir_output, '/'); } return ''; } /** * Prüft, ob eine Datei als Mail-Anhang taugt: existiert, lesbar, nicht leer * und nachweislich unterhalb des erlaubten Basisverzeichnisses. * * @param string $pfad Zu prüfender absoluter Pfad * @param string $baseDir Erlaubtes Basisverzeichnis * @return bool */ function mahnungIstNutzbarePdf($pfad, $baseDir) { if (!is_file($pfad) || !is_readable($pfad)) { return false; } if ((int) filesize($pfad) <= 0) { return false; } $real = realpath($pfad); $realBase = realpath($baseDir); if ($real === false || $realBase === false) { return false; } return strpos($real, rtrim($realBase, '/').'/') === 0; } // --------------------------------------------------------------------------- // Betreff / Text // --------------------------------------------------------------------------- /** * Zahlungsfrist zum Versandzeitpunkt. * * date_lim_reglement_neu wurde bei der ANLAGE gesetzt (Anlagedatum + * neue_frist_tage). Zwischen Anlage und Versand können Tage liegen — dann stünde * in der Mail eine bereits verstrichene Frist. Deshalb hier aus neue_frist_tage * der Stufe neu rechnen; gespeichert wird der Wert zusammen mit der * Versand-Reservierung, damit Mail und Datensatz dieselbe Frist nennen. * * @param MahnungStufe $stufeObj Geladene Stufenkonfiguration * @param int $jetzt Versandzeitpunkt (Unix-Zeit) * @return int Unix-Zeit der neuen Frist */ function mahnungBerechneNeueFrist($stufeObj, $jetzt) { return dol_time_plus_duree($jetzt, (int) $stufeObj->neue_frist_tage, 'd'); } /** * Wertetabelle für die Platzhalter des Mailtextes. * * Unterstützt werden ZWEI Schreibweisen für dieselben Werte: * - die modul-eigene mit geschweiften Klammern: {rechnung} * - die Dolibarr-übliche mit Unterstrichen: __REF__ * Wer aus Dolibarr kommt, tippt selbstverständlich __REF__ — steht das Modul nur * auf {…}, bleibt die Marke unersetzt im Betreff stehen. Beide Formen kosten * nichts und ersparen das Nachschlagen. * * Zu __REF__: gemeint ist die RECHNUNGSnummer, nicht die des Mahnvorgangs. In * einer Zahlungserinnerung spricht der Text immer von der Rechnung; die interne * Mahnungs-Referenz sagt dem Kunden nichts. Wer sie doch braucht, nimmt * __MAHNUNGREF__. * * @param Mahnung $mahnung Mahnvorgang * @param ?Facture $facture Zugehörige Rechnung * @param ?Societe $societe Kunde * @param int $neueFrist Neu berechnete Zahlungsfrist (Unix-Zeit) * @param ?MahnungStufe $stufeObj Stufenkonfiguration (für die Anzahl Fristtage) * @return array */ function mahnungPlatzhalterWerte($mahnung, $facture, $societe, $neueFrist, $stufeObj = null) { global $mysoc; $refMahnung = (string) $mahnung->ref; $refRechnung = (string) (is_object($facture) ? $facture->ref : ''); $stufe = (string) ((int) $mahnung->stufe); $summe = price((float) $mahnung->summe_mahnung).' EUR'; $frist = $neueFrist ? dol_print_date($neueFrist, 'day') : ''; $kunde = (string) (is_object($societe) ? $societe->name : ''); $faellig = !empty($mahnung->date_lim_reglement_alt) ? dol_print_date($mahnung->date_lim_reglement_alt, 'day') : ''; $firma = (string) (isset($mysoc->name) ? $mysoc->name : ''); // Rechnungsdatum und -fälligkeit direkt aus der Rechnung; fällt sie weg, greifen // die im Mahnvorgang gespeicherten Werte. $rechnungsDatum = ''; if (is_object($facture) && !empty($facture->date)) { $rechnungsDatum = dol_print_date($facture->date, 'day'); } $faelligRechnung = ''; if (is_object($facture) && !empty($facture->date_lim_reglement)) { $faelligRechnung = dol_print_date($facture->date_lim_reglement, 'day'); } if ($faelligRechnung === '') { $faelligRechnung = $faellig; } // Gesamtbetrag der Rechnung — bewusst NICHT der offene Betrag: __AMOUNT__ meint in // Dolibarr den Rechnungsbetrag ("Rechnung über X"). Der noch offene Rest steht in // {summe} / __SUMME__. $rechnungsBetrag = is_object($facture) ? price((float) $facture->total_ttc).' EUR' : ''; // Anzahl Tage der neuen Zahlungsfrist — direkt aus der Stufen-Konfiguration // (neue_frist_tage). Damit nennt der Text dieselbe Frist, die auch am Vorgang // gespeichert wird; eine im Text fest eingetippte Zahl liefe sonst auseinander, // sobald jemand die Stufe umkonfiguriert. $fristTage = is_object($stufeObj) ? (string) ((int) $stufeObj->neue_frist_tage) : ''; return array( // Modul-eigene Schreibweise '{ref}' => $refMahnung, '{stufe}' => $stufe, '{summe}' => $summe, '{rechnung}' => $refRechnung, '{frist}' => $frist, '{kunde}' => $kunde, '{faellig}' => $faellig, '{firma}' => $firma, '{fristtage}' => $fristTage, '{rechnungsbetrag}' => $rechnungsBetrag, '{rechnungsdatum}' => $rechnungsDatum, // Dolibarr-Schreibweise '__REF__' => $refRechnung, '__RECHNUNG__' => $refRechnung, '__FACREF__' => $refRechnung, '__MAHNUNGREF__' => $refMahnung, '__STUFE__' => $stufe, '__SUMME__' => $summe, '__FRIST__' => $frist, '__FRIST_TAGE__' => $fristTage, '__NACHFRIST__' => $frist, '__NACHFRIST_TAGE__' => $fristTage, '__FAELLIG__' => $faellig, '__KUNDE__' => $kunde, '__THIRDPARTY_NAME__' => $kunde, '__FIRMA__' => $firma, '__MYCOMPANY_NAME__' => $firma, // Rechnungsdaten in Dolibarr-Benennung. __AMOUNT__ ist dort der Betrag DER // RECHNUNG — deshalb hier total_ttc und nicht der offene Rest. // "FORMATED" mit einem T ist Dolibarrs eigene Schreibweise; die naheliegende // Variante mit zwei T wird zusätzlich akzeptiert, damit ein Vertipper nicht // als roher Platzhalter beim Kunden landet. '__DATE_YMD__' => $rechnungsDatum, '__DATE_DUE_YMD__' => $faelligRechnung, '__AMOUNT__' => $rechnungsBetrag, '__AMOUNT_FORMATED__' => $rechnungsBetrag, '__AMOUNT_FORMATTED__' => $rechnungsBetrag, '__TOTAL_TTC__' => $rechnungsBetrag, ); } /** * Betreff und Text aus der Stufen-Konfiguration, Platzhalter bereits ersetzt. * * Der Benutzer soll im Formular den FERTIGEN Text sehen, keine {…}-Marken. * * @param MahnungStufe $stufeObj Geladene Stufenkonfiguration * @param array $werte Platzhalter aus mahnungPlatzhalterWerte() * @return array{subject:string,body:string,ishtml:int} */ function mahnungMailVorlage($stufeObj, $werte) { global $langs; // transnoentities() statt trans(): trans() encodiert Umlaute zu HTML-Entities // ("Grüßen" -> "Grüßen"), was in einer Klartext-Mail sichtbar wäre. $rawSubject = (string) ($stufeObj->email_subject !== null && $stufeObj->email_subject !== '' ? $stufeObj->email_subject : $langs->transnoentities('MahnungErinnerungMailBetreff')); $rawBody = (string) ($stufeObj->email_body !== null && $stufeObj->email_body !== '' ? $stufeObj->email_body : $langs->transnoentities('MahnungErinnerungMailText')); // HTML-Modus aus dem ROH-Template ableiten — NICHT aus dem fertigen Text. // Sonst würde ein Kundenname wie "Muster " den Modus umschalten. $isHtml = dol_textishtml($rawBody) ? 1 : 0; $subject = mahnungHeaderEinzeilig(mahnungErsetzePlatzhalter($rawSubject, $werte, 0)); if ($subject === '') { // Leerer Betreff landet zuverlässig im Spam — auf die Standardvorlage zurückfallen. $subject = mahnungHeaderEinzeilig(mahnungErsetzePlatzhalter($langs->transnoentities('MahnungErinnerungMailBetreff'), $werte, 0)); } return array( 'subject' => $subject, 'body' => mahnungErsetzePlatzhalter($rawBody, $werte, $isHtml), 'ishtml' => $isHtml, ); } /** * Ersetzt die {…}-Platzhalter in Betreff oder Text. * * Im HTML-Modus werden die eingesetzten Werte escaped — {kunde} und {rechnung} * kommen aus der Datenbank und dürfen im HTML-Body keine Tags aufmachen. * * Wird zweimal angewandt: beim Vorbelegen des Formulars und noch einmal auf den * abgeschickten Text. Das ist unschädlich (bereits ersetzte Marken sind weg) und * löst Platzhalter auf, die der Benutzer im Formular selbst ergänzt hat. * * @param string $text Roher Text mit Platzhaltern * @param array $werte Platzhalter => Wert * @param int $isHtml 1 = Werte HTML-escapen * @return string */ function mahnungErsetzePlatzhalter($text, $werte, $isHtml) { return strtr((string) $text, $isHtml ? array_map('dol_escape_htmltag', $werte) : $werte); } /** * Nimmt die Formular-Rundreise wieder aus einem KLARTEXT-Mailtext heraus. * * GETPOST(..., 'restricthtml') jagt jeden NICHT-HTML-Text durch dol_nl2br() * (functions.lib.php, dol_htmlwithnojs) — allein durch das Abschicken des * Formulars wird aus einem Klartext-Mailtext also HTML mit
-Tags. Das schlägt * doppelt durch: im Textfeld stehen sichtbar die Tags, und dol_textishtml() stuft * den Text danach als HTML ein, sodass eine als Klartext gepflegte Erinnerung als * HTML-Mail rausginge. * * Zurückgebaut wird deshalb NUR dieses Artefakt: ein Text, der außer
keine * Tags enthält, und auch nur dann, wenn die Stufen-Vorlage Klartext ist. Sobald * echte Formatierung im Spiel ist — der Text wurde im WYSIWYG-Editor bearbeitet * oder die Vorlage ist von vornherein HTML — bleibt alles unangetastet, sonst * würde diese Funktion Fettschrift, Listen und Links wieder wegwerfen. * * @param string $text Text aus dem Formular * @param int $vorlageIstHtml 1 = die Stufen-Vorlage ist HTML * @return string */ function mahnungBodyEntkleiden($text, $vorlageIstHtml) { $text = (string) $text; if (!empty($vorlageIstHtml) || !dol_textishtml($text)) { return $text; } // Enthält der Text außer
noch andere Tags, ist es echte Formatierung. $ohneBr = preg_replace('//i', '', $text); if (strip_tags($ohneBr) !== $ohneBr) { return $text; } // nl2br() hängt das
VOR den vorhandenen Zeilenumbruch — der folgende // Umbruch wird deshalb mitgeschluckt, sonst verdoppeln sich die Leerzeilen. $text = preg_replace('/\r?\n?/i', "\n", $text); return html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); } /** * Erzwingt einen einzeiligen Mail-Header-Wert. * * Betreffzeilen werden aus DB-Werten (Kundenname, Rechnungsreferenz) zusammen- * gebaut. Ein CR/LF darin könnte in einen zusätzlichen Header umschlagen * (Header-Injection) — deshalb werden alle Steuerzeichen durch ein Leerzeichen * ersetzt und Mehrfach-Leerzeichen zusammengefasst. Die Ersetzung arbeitet * bewusst byteweise ohne /u-Modifier: UTF-8-Folgebytes liegen alle >= 0x80, * Umlaute bleiben also unangetastet, und ungültiges UTF-8 kann die Regex nicht * scheitern lassen. * * @param string $text Roher Headerwert * @return string Einzeiliger, getrimmter Headerwert */ function mahnungHeaderEinzeilig($text) { $text = preg_replace('/[\x00-\x1F\x7F]+/', ' ', (string) $text); $text = preg_replace('/ {2,}/', ' ', (string) $text); return trim((string) $text); } // --------------------------------------------------------------------------- // Absender // --------------------------------------------------------------------------- /** * Absenderadresse der Firma. * * Bewusst NICHT die Adresse des angemeldeten Benutzers: eine Zahlungserinnerung * geht im Namen des Betriebs raus, nicht im Namen dessen, der gerade klickt. * Deshalb wird der Absender im Formular nur angezeigt und nicht zur Auswahl * gestellt; ein trotzdem geposteter Wert wird ignoriert. * * ?: statt ??, damit ein LEERER Firmen-Mailwert auf die globale Absenderadresse * zurückfällt. * * @param string $fehler Rückgabe: Fehlertext ('' = alles gut) * @return array{name:string,mail:string,voll:string} */ function mahnungAbsender(&$fehler) { global $langs, $mysoc; $fehler = ''; // Reihenfolge: eigene Absenderadresse des Moduls (Setup) -> Firmenadresse -> // globale Dolibarr-Absenderadresse. ?: statt ??, damit ein LEERER Wert jeweils // auf die nächste Stufe durchfällt. $fromEmail = trim(getDolGlobalString('MAHNUNG_EMAIL_SENDER')) ?: trim((string) (isset($mysoc->email) ? $mysoc->email : '')) ?: trim(getDolGlobalString('MAIN_MAIL_EMAIL_FROM')); if ($fromEmail === '' || !isValidEmail($fromEmail)) { $fehler = $langs->trans('MahnungMailKeinAbsender'); return array('name' => '', 'mail' => '', 'voll' => ''); } // Anzeigename: eigener Name aus dem Setup, sonst der Firmenname. // mahnungHeaderEinzeilig() zuerst — siehe mahnungEmpfaengerAdresse(): CR/LF im // Namen würde dol_string_nospecial() stehen lassen. $fromNameRoh = trim(getDolGlobalString('MAHNUNG_EMAIL_SENDER_NAME')) ?: (string) (isset($mysoc->name) ? $mysoc->name : ''); $fromName = trim(dol_string_nospecial(mahnungHeaderEinzeilig($fromNameRoh), ' ', array(',', ';', '<', '>', '"'))); return array( 'name' => $fromName, 'mail' => $fromEmail, 'voll' => ($fromName !== '' ? $fromName.' <'.$fromEmail.'>' : $fromEmail), ); } // --------------------------------------------------------------------------- // Versand // --------------------------------------------------------------------------- /** * Statuswächter + fachliche Sperre. * * @param Mahnung $mahnung Geladener Mahnvorgang * @param ?MahnungStufe $stufeObj Geladene Stufenkonfiguration * @param bool $force Erneuter Versand einer bereits versendeten Erinnerung * @param string $fehler Rückgabe: Grund der Ablehnung * @return bool true = Versand erlaubt */ function mahnungVersandErlaubt($mahnung, $stufeObj, $force, &$fehler) { global $langs; $fehler = ''; // Hinweis: $langs->trans() jagt den fertigen Text inkl. der eingesetzten // Parameter durch htmlentities(). Die aus der DB stammenden Werte deshalb // NICHT zusätzlich mit dol_escape_htmltag() behandeln — doppeltes Encoding. $status = (int) $mahnung->status; if ($status === Mahnung::STATUS_STORNIERT) { $fehler = $langs->trans('MahnungMailStatusStorniert', $mahnung->ref); return false; } if ($status === Mahnung::STATUS_ERLEDIGT) { $fehler = $langs->trans('MahnungMailStatusErledigt', $mahnung->ref); return false; } if ($status >= Mahnung::STATUS_VERSENDET && !$force) { $fehler = $langs->trans( 'MahnungMailBereitsVersendet', $mahnung->ref, $mahnung->date_versand ? dol_print_date($mahnung->date_versand, 'dayhour') : '-' ); return false; } // Bewusste fachliche Sperre: echte Mahnungen werden per Post/Einschreiben // versendet, weil eine E-Mail nicht beweisbar zugestellt ist. if (!is_object($stufeObj) || !$stufeObj->istErinnerung()) { $fehler = $langs->trans('MahnungMailNurErinnerung'); return false; } return true; } /** * Verschickt die Zahlungserinnerung — der EINZIGE Weg, auf dem dieses Modul * eine Erinnerungsmail versendet. * * Ablauf: Statuswächter -> atomare Versand-Reservierung -> CMailFile -> * bei Sendefehler Reservierung zurücknehmen. * * @param Mahnung $mahnung Geladener Mahnvorgang (wird bei Erfolg mitgeführt) * @param MahnungStufe $stufeObj Geladene Stufenkonfiguration * @param array $mail to, cc, bcc, from, subject, body, ishtml, * paths, names, mimes, deliveryreceipt, * trackid, force, frist * @param User $user Bearbeitender Benutzer * @param string $fehler Rückgabe: Fehlertext ('' = alles gut) * @return bool true = Mail ist raus */ function mahnungSendeErinnerungsMail($mahnung, $stufeObj, $mail, $user, &$fehler) { global $langs; $fehler = ''; $force = !empty($mail['force']); if (!mahnungVersandErlaubt($mahnung, $stufeObj, $force, $fehler)) { return false; } if (trim((string) $mail['to']) === '') { $fehler = $langs->trans('MahnungMailKeinEmpfaenger'); return false; } if (trim((string) $mail['from']) === '') { $fehler = $langs->trans('MahnungMailKeinAbsender'); return false; } // ----------------------------------------------------------------------- // Versand ATOMAR reservieren — zwingend VOR dem Senden // ----------------------------------------------------------------------- // Zwischen dem Statuswächter und dem Versand liegen mehrere DB- und // Dateizugriffe (Stufe, Kunde, Rechnung, ggf. PDF-Erzeugung). Ohne Sperre // kämen zwei parallele Klicks beide durch und die Erinnerung ginge doppelt // raus. Deshalb wird der Vorgang JETZT per bedingtem UPDATE auf VERSENDET // gesetzt: senden darf nur der Request, dessen UPDATE tatsächlich eine Zeile // getroffen hat. Weil die Statusfortschreibung damit VOR dem Versand liegt, // kann ein erfolgreicher Versand auch nicht mehr durch ein fehlgeschlagenes // UPDATE "unsichtbar" werden. Die umgekehrte Richtung (Status gesetzt, Mail // nicht raus) wird unten sauber zurückgerollt. // // Mitgeschrieben wird derselbe offene Betrag, den die Mail nennt (siehe // mahnungRechnungNochOffen()) — sonst stünde in der Karte weiter der Stand // vom Anlagezeitpunkt. $statusVorVersand = (int) $mahnung->status; $dateVersandVorher = $mahnung->date_versand; $versandwegVorher = $mahnung->versandweg; $fristVorher = $mahnung->date_lim_reglement_neu; $reserviertAm = dol_now(); $neueFrist = !empty($mail['frist']) ? (int) $mail['frist'] : $fristVorher; $sperrFehler = ''; if (!mahnungReserviereVersand($mahnung, $statusVorVersand, $force, $reserviertAm, $neueFrist, (int) $user->id, $sperrFehler)) { if ($sperrFehler !== '') { dol_syslog('mahnung/sendmail: Versand-Reservierung fehlgeschlagen: '.$sperrFehler, LOG_ERR); $fehler = $langs->trans('MahnungMailVersandSperreFehler', $sperrFehler); return false; } // Kein SQL-Fehler, aber keine Zeile getroffen: ein paralleler Request war // schneller oder der Vorgang wurde zwischenzeitlich verändert (Storno, // Zahlungseingang). In beiden Fällen wird bewusst NICHT gesendet. dol_syslog('mahnung/sendmail: Versand von '.$mahnung->ref.' bereits reserviert — kein zweiter Versand', LOG_WARNING); $fehler = $langs->trans('MahnungMailVersandLaeuft', $mahnung->ref); return false; } // Reservierung steht — das geladene Objekt auf denselben Stand bringen. $mahnung->status = Mahnung::STATUS_VERSENDET; $mahnung->date_versand = $reserviertAm; $mahnung->versandweg = 'email'; $mahnung->date_lim_reglement_neu = $neueFrist; // ----------------------------------------------------------------------- // Versand // ----------------------------------------------------------------------- $mailFile = new CMailFile( (string) $mail['subject'], (string) $mail['to'], (string) $mail['from'], (string) $mail['body'], isset($mail['paths']) && is_array($mail['paths']) ? $mail['paths'] : array(), isset($mail['mimes']) && is_array($mail['mimes']) ? $mail['mimes'] : array(), isset($mail['names']) && is_array($mail['names']) ? $mail['names'] : array(), (string) (isset($mail['cc']) ? $mail['cc'] : ''), (string) (isset($mail['bcc']) ? $mail['bcc'] : ''), empty($mail['deliveryreceipt']) ? 0 : 1, (int) $mail['ishtml'], '', '', (string) (isset($mail['trackid']) ? $mail['trackid'] : '') ); // Ab hier ist der Vorgang reserviert: jeder Fehlerausstieg muss die // Reservierung wieder zurücknehmen, sonst gälte die Erinnerung als versendet, // ohne dass sie raus ist. $sendeFehler = ''; if (!empty($mailFile->error) || !empty($mailFile->errors)) { $sendeFehler = !empty($mailFile->error) ? $mailFile->error : implode(', ', (array) $mailFile->errors); } elseif (!$mailFile->sendfile()) { $sendeFehler = !empty($mailFile->error) ? $mailFile->error : implode(', ', (array) $mailFile->errors); if (trim($sendeFehler) === '') { // sendfile() meldet nicht immer einen Text — der häufigste stille Grund // ist die global abgeschaltete Mailfunktion. $sendeFehler = getDolGlobalString('MAIN_DISABLE_ALL_MAILS') ? 'MAIN_DISABLE_ALL_MAILS' : $langs->transnoentities('MahnungMailUnbekannterFehler'); } } if ($sendeFehler !== '') { $fehler = $langs->trans('MahnungEmailFehlgeschlagen', $sendeFehler); $rueckFehler = ''; if (!mahnungVersandZuruecknehmen($mahnung, $statusVorVersand, $dateVersandVorher, $versandwegVorher, $fristVorher, $reserviertAm, $rueckFehler)) { // Status steht jetzt auf VERSENDET, obwohl nichts raus ist — das muss der // Benutzer erfahren, sonst hält er die Erinnerung für zugestellt. dol_syslog('mahnung/sendmail: Ruecknahme der Versand-Reservierung fehlgeschlagen: '.$rueckFehler, LOG_ERR); $fehler .= ' '.$langs->trans('MahnungMailStatusNichtZurueckgesetzt', $rueckFehler); } else { // Objekt wieder auf den Stand vor der Reservierung bringen, damit die // Karte nach dem Fehlschlag nicht "versendet" anzeigt. $mahnung->status = $statusVorVersand; $mahnung->date_versand = $dateVersandVorher; $mahnung->versandweg = $versandwegVorher; $mahnung->date_lim_reglement_neu = $fristVorher; } return false; } // Festhalten, was tatsächlich rausgegangen ist. Bewusst NACH dem erfolgreichen // Versand: protokolliert wird nur, was den Mailserver auch verlassen hat. // Ein Fehler beim Protokollieren darf den Versand nicht nachträglich als // gescheitert erscheinen lassen — er landet im Syslog, mehr nicht. mahnungProtokolliereVersand($mahnung, $mail, $user, $reserviertAm); // Fertig — Status/Versanddaten stehen bereits aus der Reservierung in der DB, // pdf_path bleibt bewusst leer (kein Mahn-PDF). return true; } /** * Schreibt einen versendeten Mailtext ins Protokoll. * * Gespeichert wird der Stand zum Sendezeitpunkt — ändert jemand später die * Vorlage in der Stufen-Konfiguration, bleibt der Nachweis davon unberührt. * * @param Mahnung $mahnung Geladener Mahnvorgang * @param array $mail Dieselben Werte, die an CMailFile gingen * @param User $user Absendender Benutzer * @param int $jetzt Versandzeitpunkt (Unix-Zeit) * @return bool true = protokolliert */ function mahnungProtokolliereVersand($mahnung, $mail, $user, $jetzt) { global $db, $conf; $namen = (isset($mail['names']) && is_array($mail['names'])) ? $mail['names'] : array(); $sql = "INSERT INTO ".MAIN_DB_PREFIX."mahnung_mailprotokoll"; $sql .= " (entity, fk_mahnung, date_versand, mail_from, mail_to, mail_cc, mail_bcc,"; $sql .= " subject, body, ishtml, anhaenge, fk_user, datec) VALUES ("; $sql .= ((int) $conf->entity); $sql .= ", ".((int) $mahnung->id); $sql .= ", '".$db->idate($jetzt)."'"; $sql .= ", '".$db->escape((string) (isset($mail['from']) ? $mail['from'] : ''))."'"; $sql .= ", '".$db->escape((string) (isset($mail['to']) ? $mail['to'] : ''))."'"; $sql .= ", '".$db->escape((string) (isset($mail['cc']) ? $mail['cc'] : ''))."'"; $sql .= ", '".$db->escape((string) (isset($mail['bcc']) ? $mail['bcc'] : ''))."'"; // Spalte ist VARCHAR(255) — ein überlanger Betreff darf den INSERT nicht kippen. $sql .= ", '".$db->escape(dol_trunc((string) (isset($mail['subject']) ? $mail['subject'] : ''), 250, 'right', 'UTF-8', 1))."'"; $sql .= ", '".$db->escape((string) (isset($mail['body']) ? $mail['body'] : ''))."'"; $sql .= ", ".(empty($mail['ishtml']) ? 0 : 1); $sql .= ", '".$db->escape(implode('; ', $namen))."'"; $sql .= ", ".((int) $user->id); $sql .= ", '".$db->idate(dol_now())."'"; $sql .= ")"; if (!$db->query($sql)) { dol_syslog('mahnung/sendmail: Mailprotokoll konnte nicht geschrieben werden: '.$db->lasterror(), LOG_ERR); return false; } return true; } /** * Liest das Versandprotokoll eines Mahnvorgangs, neuester Versand zuerst. * * @param int $mahnungId Mahnvorgang * @return array */ function mahnungHoleMailProtokoll($mahnungId) { global $db; $eintraege = array(); $sql = "SELECT rowid, date_versand, mail_from, mail_to, mail_cc, mail_bcc,"; $sql .= " subject, body, ishtml, anhaenge, fk_user"; $sql .= " FROM ".MAIN_DB_PREFIX."mahnung_mailprotokoll"; $sql .= " WHERE fk_mahnung = ".((int) $mahnungId); $sql .= " AND entity IN (".getEntity('mahnung').")"; $sql .= " ORDER BY date_versand DESC, rowid DESC"; $resql = $db->query($sql); if (!$resql) { // Fehlt die Tabelle (Deploy ohne Migration), ist das kein Grund, die Karte // scheitern zu lassen — der Block bleibt dann einfach leer. dol_syslog('mahnung: Mailprotokoll nicht lesbar: '.$db->lasterror(), LOG_WARNING); return $eintraege; } while ($obj = $db->fetch_object($resql)) { $obj->date_versand = $db->jdate($obj->date_versand); $eintraege[] = $obj; } $db->free($resql); return $eintraege; } /** * Reserviert den Versand atomar. * * Setzt Status, Versanddatum, Versandweg, die neu berechnete Zahlungsfrist sowie * den frisch bestimmten offenen Betrag per bedingtem UPDATE und liefert nur dann * true, wenn genau eine Zeile getroffen wurde. Damit kann von zwei parallelen * Requests nur einer senden. * * betrag_offen/summe_mahnung kommen direkt aus dem übergebenen Objekt — sie * wurden dort bereits nachgezogen (mahnungRechnungNochOffen()), damit Mail und * Datensatz denselben Betrag nennen. * * @param Mahnung $mahnung Geladener Mahnvorgang (liefert die Ausgangswerte) * @param int $status Status, der im Wächter oben gelesen wurde * @param bool $force Erneuter Versand einer bereits versendeten Erinnerung * @param int $jetzt Versandzeitpunkt (Unix-Zeit) * @param int $neueFrist Neu berechnete Zahlungsfrist (Unix-Zeit) * @param int $userId Bearbeitender Benutzer * @param string $fehler Rückgabe: DB-Fehlertext ('' = kein SQL-Fehler) * @return bool true = reserviert, false = nicht senden */ function mahnungReserviereVersand($mahnung, $status, $force, $jetzt, $neueFrist, $userId, &$fehler) { global $db; $fehler = ''; $sql = "UPDATE ".MAIN_DB_PREFIX."mahnung_mahnung SET"; $sql .= " status = ".((int) Mahnung::STATUS_VERSENDET); $sql .= ", date_versand = '".$db->idate($jetzt)."'"; $sql .= ", versandweg = 'email'"; $sql .= ", date_lim_reglement_neu = ".($neueFrist ? "'".$db->idate($neueFrist)."'" : "NULL"); $sql .= ", betrag_offen = ".((float) $mahnung->betrag_offen); $sql .= ", summe_mahnung = ".((float) $mahnung->summe_mahnung); $sql .= ", fk_user_modif = ".((int) $userId); $sql .= " WHERE rowid = ".((int) $mahnung->id); $sql .= " AND entity IN (".getEntity('mahnung').")"; // Exakt der Status, der oben geprüft wurde. Hat ihn zwischenzeitlich jemand // verändert (paralleler Versand, Storno, Zahlungseingang), greift die // Bedingung nicht mehr — und es wird nicht gesendet. $sql .= " AND status = ".((int) $status); if ($force) { // Beim erzwungenen erneuten Versand bleibt der Status gleich (VERSENDET), // die Statusbedingung allein ließe also beide Parallelklicks durch. // Deshalb zusätzlich das bisherige Versanddatum festnageln. $sql .= " AND date_versand ".($mahnung->date_versand ? "= '".$db->idate($mahnung->date_versand)."'" : "IS NULL"); } dol_syslog('mahnung/sendmail: Versand reservieren fuer Mahnung '.((int) $mahnung->id), LOG_DEBUG); $resql = $db->query($sql); if (!$resql) { $fehler = $db->lasterror(); return false; } // Hinweis: MySQL meldet 0 betroffene Zeilen auch dann, wenn die WHERE-Bedingung // passt, sich aber kein einziger Wert ändert. Praktisch nur beim erzwungenen // erneuten Versand innerhalb derselben Sekunde möglich — dann wird der Versand // abgelehnt, was die sichere Richtung ist. return ((int) $db->affected_rows($resql) === 1); } /** * Nimmt eine Versand-Reservierung zurück, wenn die Mail doch nicht rausging. * * Zurückgesetzt wird ausschließlich die EIGENE Reservierung (Status VERSENDET * mit genau unserem Versandzeitpunkt) — ein zwischenzeitlich erfolgreicher * anderer Vorgang darf nicht überschrieben werden. * * betrag_offen/summe_mahnung bleiben bewusst auf dem nachgezogenen Stand: der * Kunde hat tatsächlich (teil)gezahlt, das ist auch dann richtig, wenn die Mail * nicht rausging. * * @param Mahnung $mahnung Geladener Mahnvorgang * @param int $status Status vor der Reservierung * @param int|null $dateVersandAlt Versanddatum vor der Reservierung * @param string|null $versandwegAlt Versandweg vor der Reservierung * @param int|null $fristAlt Zahlungsfrist vor der Reservierung * @param int $reserviertAm Unser Versandzeitpunkt (Unix-Zeit) * @param string $fehler Rückgabe: Fehlertext ('' = alles gut) * @return bool true = zurückgesetzt */ function mahnungVersandZuruecknehmen($mahnung, $status, $dateVersandAlt, $versandwegAlt, $fristAlt, $reserviertAm, &$fehler) { global $db; $fehler = ''; $sql = "UPDATE ".MAIN_DB_PREFIX."mahnung_mahnung SET"; $sql .= " status = ".((int) $status); $sql .= ", date_versand = ".($dateVersandAlt ? "'".$db->idate($dateVersandAlt)."'" : "NULL"); $sql .= ", versandweg = ".($versandwegAlt ? "'".$db->escape($versandwegAlt)."'" : "NULL"); $sql .= ", date_lim_reglement_neu = ".($fristAlt ? "'".$db->idate($fristAlt)."'" : "NULL"); $sql .= " WHERE rowid = ".((int) $mahnung->id); $sql .= " AND entity IN (".getEntity('mahnung').")"; $sql .= " AND status = ".((int) Mahnung::STATUS_VERSENDET); $sql .= " AND date_versand = '".$db->idate($reserviertAm)."'"; dol_syslog('mahnung/sendmail: Versand-Reservierung zuruecknehmen fuer Mahnung '.((int) $mahnung->id), LOG_DEBUG); $resql = $db->query($sql); if (!$resql) { $fehler = $db->lasterror(); return false; } if ((int) $db->affected_rows($resql) !== 1) { $fehler = 'Datensatz wurde zwischenzeitlich veraendert'; return false; } return true; }