Files
thetool/application/ManualInvoice/ManualInvoiceController.php

894 lines
38 KiB
PHP

<?php
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
class ManualInvoiceController extends TTCrud
{
protected string $headerTitle = 'Manuelle Rechnungen';
protected bool $createText = false;
protected array $additionalJS = ["js/pages/ManualInvoice/ManualInvoice.js"];
protected array $additionalHead = ["<link rel='stylesheet' href='/js/pages/ManualInvoice/ManualInvoice.css'>"];
private array $tempPositions = [];
protected array $columns = [
['key' => 'id', 'text' => 'ID', 'table' => ['visible' => false], 'modal' => false],
['key' => 'invoice_number', 'text' => 'Rechnungsnr.', 'table' => ['sortable' => true, 'filter' => 'search']],
['key' => 'invoice_date', 'text' => 'Datum', 'type' => 'timestamp', 'table' => ['sortable' => true, 'filter' => 'date', 'formatter' => 'formatDate']],
['key' => 'company', 'text' => 'Firma', 'table' => ['sortable' => true, 'filter' => 'search']],
['key' => 'firstname', 'text' => 'Vorname', 'table' => ['visible' => false], 'modal' => false],
['key' => 'lastname', 'text' => 'Nachname', 'table' => ['visible' => false], 'modal' => false],
['key' => 'customer_number', 'text' => 'Kundennr.', 'table' => ['sortable' => true, 'filter' => 'search']],
['key' => 'total', 'text' => 'Netto', 'table' => ['sortable' => true, 'formatter' => 'formatPrice']],
['key' => 'total_gross', 'text' => 'Brutto', 'table' => ['sortable' => true, 'formatter' => 'formatPrice']],
['key' => 'status', 'text' => 'Status', 'table' => ['filter' => 'select', 'filterOptions' => [
['value' => 'erstellt', 'text' => 'Erstellt'],
['value' => 'gesendet', 'text' => 'Gesendet'],
['value' => 'exportiert', 'text' => 'Exportiert'],
]]],
['key' => 'billing_type', 'text' => 'Zahlungsart', 'table' => ['filter' => 'select', 'filterOptions' => [
['value' => 'invoice', 'text' => 'Rechnung'],
['value' => 'sepa', 'text' => 'SEPA'],
]]],
['key' => 'actions', 'text' => 'Aktionen', 'modal' => false, 'table' => ['filter' => false, 'sortable' => false]],
];
protected array $additionalActions = [
['key' => 'createGutschrift', 'title' => 'Gutschrift erstellen', 'class' => 'fas fa-file-invoice text-warning'],
['key' => 'pdfPreview', 'title' => 'PDF Vorschau', 'class' => 'fas fa-file-pdf text-danger'],
['key' => 'sendInvoice', 'title' => 'Rechnung aussenden', 'class' => 'fas fa-paper-plane text-success']
];
protected function createPDFAction($returnFilename = false) {
$post = json_decode(file_get_contents('php://input'), true);
$invoice = (object)[];
$positions = [];
if (isset($post['preview']) && $post['preview'] === true) {
$invoice = (object) array_merge([
'id' => 0, 'invoice_number' => null, 'invoice_date' => time(),
'customer_number' => 0, 'fibu_account_number' => 0,
'company' => '', 'firstname' => '', 'lastname' => '',
'street' => '', 'zip' => '', 'city' => '', 'country' => 'Österreich',
'email' => '', 'uid' => '', 'tax_text' => '', 'billing_type' => 'invoice',
'leistungszeitraum' => '', 'einleitender_text' => '', 'externe_referenz' => '', 'gesamtrabatt' => 0,
'total' => 0, 'total_gross' => 0
], $post);
// Convert invoice_date from string to timestamp if needed
if (isset($invoice->invoice_date) && is_string($invoice->invoice_date)) {
$invoice->invoice_date = strtotime($invoice->invoice_date);
}
$positions = array_map(function($p) {
$obj = (object)$p;
// Map _group to position_group for preview
if (isset($p['_group'])) {
$obj->position_group = $p['_group'];
}
return $obj;
}, $post['positions'] ?? []);
} else {
$id = $this->request->id ?? $post['id'] ?? null;
if (!$id || !($invoice = ManualInvoiceModel::get($id))) {
http_response_code(500);
self::returnJson(['success' => false, 'message' => 'Rechnung wurde nicht gefunden']);
return;
}
$positions = $invoice->getProperty('positions');
}
$vat = [];
foreach ($positions as $p) {
$pObj = (object)$p;
$vat[$pObj->vatrate] = ($vat[$pObj->vatrate] ?? 0) + ($pObj->price_gross ?? 0) - ($pObj->price_total ?? 0);
}
$invoice->positions = $positions;
$pdf_vars = [
"invoice" => $invoice,
"vat" => $vat,
"bank_iban" => TT_INVOICE_BANK_IBAN,
"bank_bic" => TT_INVOICE_BANK_BIC,
"bank_bank" => TT_INVOICE_BANK_BANK,
"bank_owner" => TT_INVOICE_BANK_OWNER
];
$replacements = [
"{{ basedir }}" => BASEDIR,
"{{ addressLine_1 }}" => $invoice->company ?: "",
"{{ addressLine_2 }}" => trim($invoice->firstname . " " . $invoice->lastname),
"{{ addressLine_3 }}" => $invoice->street ?? '',
"{{ addressLine_4 }}" => ($invoice->zip ?? '') . " " . ($invoice->city ?? ''),
"{{ addressLine_5 }}" => ($invoice->country ?? '') != "Österreich" ? ($invoice->country ?? '') : "",
"{{ customerNumber }}" => $invoice->customer_number ?? '',
"{{ billingAccount }}" => $invoice->fibu_account_number ?? '',
"{{ invoiceNumber }}" => $invoice->invoice_number ?? "VORSCHAU",
"{{ invoiceDate }}" => date("d.m.Y", $invoice->invoice_date ?? time()),
"{{ leistungszeitraumHtml }}" => ($invoice->leistungszeitraum ?? '') ? "<tr><td>Leistungszeitraum:</td><td>" . htmlspecialchars($invoice->leistungszeitraum) . "</td></tr>" : "",
"{{ externeReferenzHtml }}" => ($invoice->externe_referenz ?? '') ? "<tr><td>Externe Referenz:</td><td>" . htmlspecialchars($invoice->externe_referenz) . "</td></tr>" : "",
"{{ vatHtml }}" => ($invoice->uid ?? '') ? "<tr><td>Ihre UID:</td><td>" . $invoice->uid . "</td></tr>" : "",
"{{ qrCodeSrc }}" => $this->generateSepaQRCode($invoice->invoice_number ?? "VORSCHAU", round($invoice->total_gross ?? 0, 2))
];
$headerHtml = str_replace(array_keys($replacements), array_values($replacements), file_get_contents(BASEDIR . "/Layout/default/ManualInvoice/PDF_HEADER.html"));
$headerFile = BASEDIR . "/var/temp/manualinvoice_header-" . date("U") . "-" . rand(1000, 9999) . ".html";
file_put_contents($headerFile, $headerHtml);
$footerReplacements = [
"{{ bank_iban }}" => TT_INVOICE_BANK_IBAN_FORMATTED,
"{{ bank_bic }}" => TT_INVOICE_BANK_BIC,
"{{ bank_bank }}" => TT_INVOICE_BANK_BANK,
"{{ bank_owner }}" => TT_INVOICE_BANK_OWNER
];
$footerHtml = str_replace(array_keys($footerReplacements), array_values($footerReplacements), file_get_contents(BASEDIR . "/Layout/default/ManualInvoice/PDF_FOOTER.html"));
$footerFile = BASEDIR . "/var/temp/manualinvoice_footer-" . date("U") . "-" . rand(1000, 9999) . ".html";
file_put_contents($footerFile, $footerHtml);
$pdf = new PdfForm("ManualInvoice/PDF_MAIN", $pdf_vars);
$filename = $pdf->render("--header-html $headerFile --footer-html $footerFile");
if ($returnFilename === true) return $filename;
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="' . ($invoice->invoice_number ?? 'preview') . '.pdf"');
readfile($filename);
die();
}
protected function downloadInvoicePdfAction() {
$id = $this->request->id;
if (!is_numeric($id) || !$id || !($invoice = ManualInvoiceModel::get($id))) {
$this->layout()->setFlash("Rechnung nicht gefunden", "error");
$this->redirect("ManualInvoice");
}
if(!($pdf_filename = $this->createPDFAction(true)) || !file_exists($pdf_filename)) {
$this->layout()->setFlash("PDF-Datei konnte nicht erstellt werden");
$this->redirect("ManualInvoice");
}
header('Content-Type: application/pdf');
header('Content-disposition: attachment; filename="'.$invoice->invoice_number.'.pdf"');
header('Content-Length: ' . filesize($pdf_filename));
readfile($pdf_filename);
exit;
}
protected function pdfPreviewAction() {
$post = json_decode(file_get_contents('php://input'), true);
$id = $post['id'] ?? null;
if (!$id || !($invoice = ManualInvoiceModel::get($id))) {
self::returnJson(['success' => false, 'message' => 'Rechnung nicht gefunden']);
return;
}
// Log PDF preview in journal
$me = new User();
$me->loadMe();
ManualInvoiceJournalModel::create([
'manualinvoiceId' => $id,
'text' => 'PDF Vorschau geöffnet',
'createBy' => $me->id,
'create' => time()
]);
// Return URL to open in new tab
$url = "?action=ManualInvoice_createPDF&id=" . $id;
self::returnJson(['success' => true, 'url' => $url]);
}
protected function getInvoiceEmailAction() {
$post = json_decode(file_get_contents('php://input'), true);
$id = $post['id'] ?? null;
if (!$id || !($invoice = ManualInvoiceModel::get($id))) {
self::returnJson(['success' => false, 'message' => 'Rechnung nicht gefunden']);
return;
}
self::returnJson([
'success' => true,
'invoice' => [
'id' => $invoice->id,
'invoice_number' => $invoice->invoice_number,
'email' => $invoice->email,
'customerName' => trim(($invoice->company ?: '') . ' ' . $invoice->firstname . ' ' . $invoice->lastname)
]
]);
}
protected function sendInvoiceEmailAction() {
// Enable error reporting for debugging
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
$post = json_decode(file_get_contents('php://input'), true);
$id = $post['id'] ?? null;
$recipientEmail = $post['email'] ?? null;
if (!$id || !$recipientEmail) {
self::returnJson(['success' => false, 'message' => 'ID oder E-Mail-Adresse fehlt']);
return;
}
$invoice = ManualInvoiceModel::get($id);
if (!$invoice) {
self::returnJson(['success' => false, 'message' => 'Rechnung nicht gefunden']);
return;
}
// Format invoice date for display
$invoiceDateFormatted = date('d.m.Y', $invoice->invoice_date);
// Set default subject and body with invoice number and date
$defaultSubject = "Ihre Rechnung {$invoice->invoice_number} vom {$invoiceDateFormatted}";
$defaultBody = "Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung Nr. {$invoice->invoice_number} vom {$invoiceDateFormatted}.\n\nMit freundlichen Grüßen\nIhr XINON Team";
$subject = $post['subject'] ?? $defaultSubject;
$bodyText = $post['body'] ?? $defaultBody;
// Convert literal \n strings to actual newlines (in case frontend sends escaped strings)
$bodyText = str_replace('\n', "\n", $bodyText);
// Generate PDF
$pdf_filename = $this->createPDFAction(true);
if (!$pdf_filename || !file_exists($pdf_filename)) {
self::returnJson(['success' => false, 'message' => 'PDF konnte nicht erstellt werden']);
return;
}
$pdfContent = file_get_contents($pdf_filename);
// --- HTML Email Generation ---
$logoXinonPath = BASEDIR . '/public/assets/images/xinon-full.png';
$logoXinonExists = file_exists($logoXinonPath);
// Construct HTML Body with Outlook compatibility
$html = '<!DOCTYPE html>';
$html .= '<html lang="de" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">';
$html .= '<head>';
$html .= '<meta charset="UTF-8">';
$html .= '<meta http-equiv="X-UA-Compatible" content="IE=edge">';
$html .= '<meta name="viewport" content="width=device-width, initial-scale=1.0">';
$html .= '<title>Rechnung</title>';
$html .= '<!--[if mso]><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]-->';
$html .= '<style>body { font-family: Arial, sans-serif; color: #333; margin: 0; padding: 0; }</style>';
$html .= '</head>';
$html .= '<body style="margin:0;padding:20px;background-color:#f3f4f6;">';
// Outlook-safe container table
$html .= '<!--[if mso]><table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" align="center"><tr><td><![endif]-->';
$html .= '<div style="background-color:#fff;padding:20px;border-radius:8px;max-width:600px;margin:0 auto;">';
// Logo with Outlook-safe sizing
$html .= '<div style="text-align:center;margin-bottom:20px;border-bottom:1px solid #e5e7eb;padding-bottom:15px;">';
if ($logoXinonExists) {
$html .= '<!--[if mso]><table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"><tr><td align="center"><![endif]-->';
$html .= '<img src="cid:logo_xinon" alt="XINON GmbH" width="150" height="50" style="display:block;width:150px;height:50px;max-width:150px;margin:0 auto;">';
$html .= '<!--[if mso]></td></tr></table><![endif]-->';
}
$html .= '</div>';
$html .= '<h2 style="color:#00558c;text-align:center;font-size:20px;margin-bottom:20px;">' . htmlspecialchars($subject) . '</h2>';
$html .= '<div style="font-size:14px;line-height:1.6;color:#333;">';
$html .= nl2br(htmlspecialchars($bodyText));
$html .= '</div>';
$html .= '<br><div style="border-top:1px solid #eee;padding-top:20px;font-size:12px;color:#999;text-align:center;">';
$html .= 'XINON GmbH | <a href="https://www.xinon.at" style="color:#00558c;text-decoration:none;">www.xinon.at</a>';
$html .= '</div></div>';
$html .= '<!--[if mso]></td></tr></table><![endif]-->';
$html .= '</body></html>';
$mail = new PHPMailer(true);
try {
// Server settings
$mail->isSMTP();
$mail->Host = TT_PIPEWORK_SMTP_HOST;
$mail->SMTPAuth = true;
$mail->Username = TT_PIPEWORK_SMTP_USER;
$mail->Password = TT_PIPEWORK_SMTP_PASS;
$mail->CharSet = PHPMailer::CHARSET_UTF8;
$mail->Encoding = 'base64';
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = 587;
// Logo embedding
if ($logoXinonExists) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon');
$mail->addReplyTo('backoffice@xinon.at', 'XINON Backoffice');
$mail->setFrom('thetool@xinon.at', 'XINON GmbH - Rechnungswesen');
$customerName = trim(($invoice->company ?: '') . ' ' . $invoice->firstname . ' ' . $invoice->lastname);
$mail->addAddress($recipientEmail, $customerName);
$mail->Subject = $subject;
$mail->isHTML(true);
$mail->Body = $html;
$mail->AltBody = strip_tags($bodyText);
// Attachment filename: YYYY-MM-DD_InvoiceNumber_Rechnung.pdf
$invoiceDateFile = date('Y-m-d', $invoice->invoice_date);
$attachmentFilename = "{$invoiceDateFile}_{$invoice->invoice_number}_Rechnung.pdf";
$mail->addStringAttachment($pdfContent, $attachmentFilename, 'base64', 'application/pdf');
$mail->send();
// Update invoice status
$invoice->status = 'gesendet';
$invoice->save();
// Add Journal Entry
$me = new User();
$me->loadMe();
ManualInvoiceJournalModel::create([
'manualinvoiceId' => $id,
'text' => "Rechnung per E-Mail an $recipientEmail gesendet.",
'statusChange' => 'gesendet',
'createBy' => $me->id,
'create' => time()
]);
self::returnJson(['success' => true, 'message' => 'E-Mail erfolgreich versendet an ' . $recipientEmail]);
} catch (Exception $e) {
self::returnJson(['success' => false, 'message' => 'E-Mail konnte nicht gesendet werden. Fehler: ' . $mail->ErrorInfo]);
}
}
protected function downloadInvoiceAction() {
$post = json_decode(file_get_contents('php://input'), true);
$id = $post['id'] ?? null;
if (!$id || !($invoice = ManualInvoiceModel::get($id))) {
self::returnJson(['success' => false, 'message' => 'Rechnung nicht gefunden']);
return;
}
$me = new User();
$me->loadMe();
// Log download in journal
ManualInvoiceJournalModel::create([
'manualinvoiceId' => $id,
'text' => 'Rechnung heruntergeladen',
'createBy' => $me->id,
'create' => time()
]);
$downloadUrl = "?action=ManualInvoice_downloadInvoicePdf&id=" . $id;
self::returnJson([
'success' => true,
'url' => $downloadUrl
]);
}
protected function beforeCreate(&$data): bool {
if (isset($data['positions']) && is_array($data['positions'])) {
$this->tempPositions = $data['positions'];
unset($data['positions']);
}
$me = new User();
$me->loadMe();
// Convert invoice_date from string to timestamp if needed
if (isset($data['invoice_date']) && is_string($data['invoice_date'])) {
$data['invoice_date'] = strtotime($data['invoice_date']);
}
// Always generate invoice number (override any null from frontend)
$data['invoice_number'] = ManualInvoiceModel::getNextInvoiceNumber();
$data['invoice_date'] = $data['invoice_date'] ?? time();
$data['status'] = 'erstellt';
$data['fibu_payment_skonto'] = $data['fibu_payment_skonto'] ?? 0;
$data['fibu_payment_skonto_rate'] = $data['fibu_payment_skonto_rate'] ?? 0;
$data['gesamtrabatt'] = $data['gesamtrabatt'] ?? 0;
$data['total'] = $data['total'] ?? 0;
$data['total_gross'] = $data['total_gross'] ?? 0;
$data['lock'] = 0;
$data['exported'] = 0;
$data['create_by'] = $me->id;
$data['edit_by'] = $me->id;
$data['create'] = time();
$data['edit'] = time();
return true;
}
protected function afterCreate($data) {
$this->savePositions($data['id']);
$this->recalculateTotals($data['id']);
// Log creation in journal
$me = new User();
$me->loadMe();
ManualInvoiceJournalModel::create([
'manualinvoiceId' => $data['id'],
'text' => 'Rechnung erstellt',
'statusChange' => 'erstellt',
'createBy' => $me->id,
'create' => time()
]);
}
protected function beforeUpdate(&$data): bool {
if (isset($data['positions']) && is_array($data['positions'])) {
$this->tempPositions = $data['positions'];
unset($data['positions']);
}
if (isset($data['id']) && ($invoice = ManualInvoiceModel::get($data['id']))) {
if ($invoice->lock == 1) {
$this->infoMessages['update'] = 'Rechnung ist gesperrt und kann nicht bearbeitet werden';
return false;
}
if ($invoice->status === 'exportiert') {
$this->infoMessages['update'] = 'Rechnung wurde bereits exportiert und kann nicht mehr bearbeitet werden';
return false;
}
}
// Convert invoice_date from string to timestamp if needed
if (isset($data['invoice_date']) && is_string($data['invoice_date'])) {
$data['invoice_date'] = strtotime($data['invoice_date']);
}
$me = new User();
$me->loadMe();
$data['edit_by'] = $me->id;
$data['edit'] = time();
return true;
}
protected function afterUpdate($data) {
$existingPositions = ManualInvoicepositionModel::search(['manualinvoice_id' => $data['id']]);
foreach ($existingPositions as $pos) ManualInvoicepositionModel::delete($pos->id);
$this->savePositions($data['id']);
$this->recalculateTotals($data['id']);
// Log update in journal
$me = new User();
$me->loadMe();
ManualInvoiceJournalModel::create([
'manualinvoiceId' => $data['id'],
'text' => 'Rechnung aktualisiert',
'createBy' => $me->id,
'create' => time()
]);
}
private function savePositions($invoiceId) {
if (empty($this->tempPositions)) return;
$me = new User();
$me->loadMe();
foreach ($this->tempPositions as $position) {
// Skip empty positions
if (empty($position['product_name']) || ($position['amount'] ?? 0) == 0) continue;
// Map _group to position_group
$groupName = $position['_group'] ?? null;
unset($position['_group']);
ManualInvoicepositionModel::create(array_merge([
'manualinvoice_id' => $invoiceId,
'position_group' => $groupName,
'unit' => 'Stk.',
'discount' => 0,
'create_by' => $me->id,
'edit_by' => $me->id,
'create' => time(),
'edit' => time()
], $position));
}
$this->tempPositions = [];
}
protected function recalculateTotals($invoiceId) {
if (!($invoice = ManualInvoiceModel::get($invoiceId))) return;
$positions = ManualInvoicepositionModel::search(['manualinvoice_id' => $invoiceId]);
$subtotal = array_sum(array_column($positions, 'price_total'));
// Apply gesamtrabatt (total discount) if exists
$gesamtrabatt = $invoice->gesamtrabatt ?? 0;
$discountAmount = $subtotal * ($gesamtrabatt / 100);
$netTotal = $subtotal - $discountAmount;
// Calculate gross total with VAT applied after discount
$grossTotal = 0;
foreach ($positions as $pos) {
$positionNet = $pos->price_total;
$positionAfterDiscount = $positionNet * (1 - $gesamtrabatt / 100);
$grossTotal += $positionAfterDiscount * (1 + $pos->vatrate / 100);
}
$invoice->total = $netTotal;
$invoice->total_gross = $grossTotal;
$invoice->save();
}
protected function getAction() {
$filter = $this->postData['filters'] ?? [];
$order = $this->postData['order']['key'] ? $this->postData['order'] : ($this->defaultOrder ?? ['key' => null, 'order' => 'ASC']);
$page = $this->postData['pagination']['page'] ?? 1;
$perPage = $this->postData['pagination']['per_page'] ?? 10;
$rows = ManualInvoiceModel::getAll($filter, $perPage, ($page - 1) * $perPage, $order);
$filteredAvailable = ManualInvoiceModel::count($filter);
$totalRows = ManualInvoiceModel::count();
foreach ($rows as &$row) {
$row->customerName = trim(($row->company ?: '') . ' ' . $row->firstname . ' ' . $row->lastname);
$row->positions = array_map([$this, 'formatPosition'], $row->getProperty('positions'));
}
self::returnJson([
"rows" => $rows,
"autoCompleteData" => [],
"pagination" => [
"page" => $page,
"total_pages" => ceil($filteredAvailable / $perPage),
"per_page" => $perPage,
"filtered_available" => intval($filteredAvailable),
"total_rows" => intval($totalRows)
]
]);
}
protected function getByIdAction() {
$id = $_GET['id'] ?? null;
if (!$id || !is_numeric($id)) {
http_response_code(500);
self::returnJson(['success' => false, 'message' => 'No ID provided.']);
die();
}
if (!($invoice = ManualInvoiceModel::get($id))) {
http_response_code(404);
self::returnJson(['success' => false, 'message' => 'Invoice not found.']);
die();
}
$data = (array) $invoice;
$data['positions'] = array_map([$this, 'formatPosition'], $invoice->getProperty('positions'));
self::returnJson($data);
}
private function formatPosition($pos) {
return [
'id' => $pos->id,
'manualinvoice_id' => $pos->manualinvoice_id,
'_group' => $pos->position_group ?? '',
'billing_id' => $pos->billing_id,
'contract_id' => $pos->contract_id,
'matchcode' => $pos->matchcode,
'product_id' => $pos->product_id,
'product_name' => $pos->product_name,
'product_info' => $pos->product_info,
'amount' => $pos->amount,
'unit' => $pos->unit ?? 'Stk.',
'price' => $pos->price,
'discount' => $pos->discount ?? 0,
'price_total' => $pos->price_total,
'price_gross' => $pos->price_gross,
'vatrate' => $pos->vatrate,
'fibu_cost_account' => $pos->fibu_cost_account,
'fibu_cost_account_legacy' => $pos->fibu_cost_account_legacy,
'fibu_taxcode' => $pos->fibu_taxcode,
'options' => $pos->options
];
}
protected function customRowsHandler($rows) {
foreach ($rows as &$row) {
$row->customerName = trim(($row->company ?: '') . ' ' . $row->firstname . ' ' . $row->lastname);
}
return $rows;
}
protected function generateSepaQRCode($paymentReference, $amount) {
$xinonIBAN = TT_INVOICE_BANK_IBAN;
$xinonBIC = TT_INVOICE_BANK_BIC;
$xinonOwner = TT_INVOICE_BANK_OWNER;
$epc = "BCD\n001\n1\nSCT\n$xinonBIC\n$xinonOwner\n$xinonIBAN\nEUR$amount\nXINO\n$paymentReference\n\nXINON GmbH";
return (new \chillerlan\QRCode\QRCode)->render($epc);
}
protected function getInvoiceForGutschriftAction() {
if (!($id = $_GET['id'] ?? null) || !($invoice = ManualInvoiceModel::get($id))) {
self::returnJson(['success' => false, 'message' => 'Invoice not found']);
}
if ($invoice->total < 0) {
self::returnJson(['success' => false, 'message' => 'Kann keine Gutschrift für eine Gutschrift erstellen']);
}
$positions = $invoice->getProperty('positions');
$existingCredits = ManualInvoiceModel::getAll(['credit_for_invoice_id' => $id]);
$creditedAmounts = [];
foreach ($existingCredits as $credit) {
foreach ($credit->getProperty('positions') as $creditPos) {
$key = $creditPos->product_id . '_' . $creditPos->matchcode;
$creditedAmounts[$key] = ($creditedAmounts[$key] ?? 0) + abs($creditPos->amount);
}
}
$availablePositions = [];
foreach ($positions as $pos) {
$key = $pos->product_id . '_' . $pos->matchcode;
$availableAmount = $pos->amount - ($creditedAmounts[$key] ?? 0);
if ($availableAmount > 0) {
$availablePositions[] = [
'id' => $pos->id,
'product_name' => $pos->product_name,
'product_info' => $pos->product_info,
'original_amount' => $pos->amount,
'credited_amount' => $creditedAmounts[$key] ?? 0,
'available_amount' => $availableAmount,
'unit' => $pos->unit ?? 'Stk.',
'price' => $pos->price,
'vatrate' => $pos->vatrate,
'product_id' => $pos->product_id,
'matchcode' => $pos->matchcode,
'fibu_cost_account' => $pos->fibu_cost_account,
'fibu_taxcode' => $pos->fibu_taxcode
];
}
}
self::returnJson([
'success' => true,
'invoice' => [
'id' => $invoice->id,
'invoice_number' => $invoice->invoice_number,
'customer_name' => trim(($invoice->company ?? '') . ' ' . $invoice->firstname . ' ' . $invoice->lastname),
'positions' => $availablePositions
]
]);
}
protected function createGutschriftAction() {
$post = json_decode(file_get_contents('php://input'), true);
$originalInvoiceId = $post['original_invoice_id'] ?? null;
$positions = $post['positions'] ?? [];
if (!$originalInvoiceId || empty($positions) || !($originalInvoice = ManualInvoiceModel::get($originalInvoiceId))) {
self::returnJson(['success' => false, 'message' => 'Ungültige Anfrage']);
return;
}
if ($originalInvoice->lock == 1) {
self::returnJson(['success' => false, 'message' => 'Originalrechnung ist gesperrt und kann nicht gutgeschrieben werden']);
return;
}
$me = new User();
$me->loadMe();
$invoiceData = [
'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(),
'invoice_date' => time(),
'leistungszeitraum' => $originalInvoice->leistungszeitraum ?? null,
'einleitender_text' => 'Gutschrift zur Rechnung ' . $originalInvoice->invoice_number,
'externe_referenz' => $originalInvoice->externe_referenz ?? null,
'gesamtrabatt' => 0,
'owner_id' => $originalInvoice->owner_id,
'billingaddress_id' => $originalInvoice->billingaddress_id,
'customer_number' => $originalInvoice->customer_number,
'fibu_account_number' => $originalInvoice->fibu_account_number,
'fibu_payment_due' => $originalInvoice->fibu_payment_due,
'fibu_payment_skonto' => $originalInvoice->fibu_payment_skonto,
'fibu_payment_skonto_rate' => $originalInvoice->fibu_payment_skonto_rate,
'sepa_date' => $originalInvoice->sepa_date,
'sepa_id' => $originalInvoice->sepa_id,
'sepa_last_date' => $originalInvoice->sepa_last_date,
'fibu_cost_area' => $originalInvoice->fibu_cost_area,
'fibu_cost_account' => $originalInvoice->fibu_cost_account,
'fibu_cost_account_legacy' => $originalInvoice->fibu_cost_account_legacy,
'fibu_taxcode' => $originalInvoice->fibu_taxcode,
'tax_text' => $originalInvoice->tax_text,
'company' => $originalInvoice->company,
'firstname' => $originalInvoice->firstname,
'lastname' => $originalInvoice->lastname,
'street' => $originalInvoice->street,
'zip' => $originalInvoice->zip,
'city' => $originalInvoice->city,
'country' => $originalInvoice->country,
'email' => $originalInvoice->email,
'uid' => $originalInvoice->uid,
'billing_type' => $originalInvoice->billing_type,
'billing_delivery' => $originalInvoice->billing_delivery,
'bank_account_bank' => $originalInvoice->bank_account_bank,
'bank_account_owner' => $originalInvoice->bank_account_owner,
'bank_account_iban' => $originalInvoice->bank_account_iban,
'bank_account_bic' => $originalInvoice->bank_account_bic,
'total' => 0,
'total_gross' => 0,
'vatgroup_id' => $originalInvoice->vatgroup_id,
'credit_for_invoice_id' => $originalInvoiceId,
'status' => 'erstellt',
'lock' => 0,
'exported' => 0,
'create' => time(),
'edit' => time(),
'create_by' => $me->id,
'edit_by' => $me->id
];
if (!($creditInvoiceId = ManualInvoiceModel::create($invoiceData))) {
self::returnJson(['success' => false, 'message' => 'Fehler beim Erstellen der Gutschrift']);
return;
}
foreach ($positions as $pos) {
$priceTotal = (-abs($pos['amount'])) * $pos['price'];
ManualInvoicepositionModel::create([
'manualinvoice_id' => $creditInvoiceId,
'position_group' => null,
'product_id' => $pos['product_id'],
'product_name' => $pos['product_name'],
'product_info' => $pos['product_info'] ?? '',
'amount' => -abs($pos['amount']),
'unit' => $pos['unit'] ?? 'Stk.',
'price' => $pos['price'],
'discount' => 0,
'vatrate' => $pos['vatrate'],
'price_total' => $priceTotal,
'price_gross' => $priceTotal * (1 + $pos['vatrate'] / 100),
'matchcode' => $pos['matchcode'] ?? null,
'fibu_cost_account' => $pos['fibu_cost_account'] ?? null,
'fibu_taxcode' => $pos['fibu_taxcode'] ?? null,
'contract_id' => 0,
'billing_id' => null,
'create_by' => $me->id,
'edit_by' => $me->id,
'create' => time(),
'edit' => time()
]);
}
$this->recalculateTotals($creditInvoiceId);
self::returnJson(['success' => true, 'message' => 'Gutschrift erfolgreich erstellt', 'credit_invoice_id' => $creditInvoiceId]);
}
protected function beforeDelete(): bool {
if ($id = $this->request->id) {
$invoice = ManualInvoiceModel::get($id);
if ($invoice && $invoice->lock == 1) {
$this->infoMessages['delete'] = 'Rechnung ist gesperrt und kann nicht gelöscht werden';
return false;
}
if ($invoice && ($invoice->status === 'exported' || $invoice->status === 'exportiert')) {
$this->infoMessages['delete'] = 'Rechnung wurde bereits exportiert und kann nicht gelöscht werden';
return false;
}
if (ManualInvoiceModel::count(['credit_for_invoice_id' => $id]) > 0) {
$this->infoMessages['delete'] = 'Rechnung kann nicht gelöscht werden, da bereits Gutschriften existieren';
return false;
}
foreach (ManualInvoicepositionModel::search(['manualinvoice_id' => $id]) as $pos) {
ManualInvoicepositionModel::delete($pos->id);
}
}
return true;
}
protected function getArticleVatInfoAction() {
$articleId = $_GET['article_id'] ?? null;
$vatarea = $_GET['vatarea'] ?? 'domestic';
if (!$articleId) {
self::returnJson(['success' => false, 'message' => 'Article ID required']);
return;
}
$article = WarehouseArticleModel::get($articleId);
if (!$article) {
self::returnJson(['success' => false, 'message' => 'Article not found']);
return;
}
// Map revenueAccount to vatgroup_id
// revenueAccount 0 = Dienstleistungen = vatgroup_id 2
// revenueAccount 1 = Handelswaren = vatgroup_id 3
$vatgroupId = $article->revenueAccount == 0 ? 2 : 3;
// Get vatrate for this vatgroup and area
$vatrate = VatrateModel::getFirst(['vatgroup_id' => $vatgroupId, 'area' => $vatarea]);
if (!$vatrate) {
self::returnJson(['success' => false, 'message' => 'Vatrate not found for vatgroup ' . $vatgroupId . ' and area ' . $vatarea]);
return;
}
self::returnJson([
'success' => true,
'article' => [
'id' => $article->id,
'title' => $article->title,
'articleNumber' => $article->articleNumber,
'description' => $article->description,
'revenueAccount' => $article->revenueAccount
],
'vatgroup_id' => $vatgroupId,
'fibu_cost_account' => $vatrate->account,
'fibu_cost_account_legacy' => $vatrate->legacy_account,
'fibu_taxcode' => $vatrate->taxcode,
'vatrate' => $vatrate->rate
]);
}
protected function getCustomerBillingInfoAction() {
$addressId = $_GET['address_id'] ?? null;
$vatgroupId = $_GET['vatgroup_id'] ?? 2;
if (!$addressId) {
self::returnJson(['success' => false, 'message' => 'Address ID required']);
return;
}
$address = new Address($addressId);
if (!$address->id) {
self::returnJson(['success' => false, 'message' => 'Address not found']);
return;
}
$vatarea = 'domestic';
if ($address->country_id) {
$country = new Country($address->country_id);
if ($country->id && $country->isocode != TT_HOMECOUNTRY_ISOCODE) {
$vatarea = $country->is_eu ? 'eu' : 'other';
}
}
if ($address->uid && substr(strtolower(preg_replace('/[^a-z0-9]/i', '', $address->uid)), 0, 3) == 'atu') {
$vatarea = 'domestic';
}
$vatrate = VatrateModel::getFirst(['vatgroup_id' => $vatgroupId, 'area' => $vatarea]);
$taxText = $vatrate ? $vatrate->invoice_text : '';
$db = $this->db();
$sepaLimit = null;
$res = $db->query("SELECT manual_invoice_sepa_limit FROM Address WHERE id = " . intval($addressId));
if ($res && $row = $res->fetch_assoc()) {
$sepaLimit = $row['manual_invoice_sepa_limit'] ? floatval($row['manual_invoice_sepa_limit']) : null;
}
self::returnJson([
'success' => true,
'billing_type' => $address->billing_type ?: 'invoice',
'manual_invoice_sepa_limit' => $sepaLimit,
'vatarea' => $vatarea,
'tax_text' => $taxText,
'bank_account_bank' => $address->bank_account_bank,
'bank_account_owner' => $address->bank_account_owner,
'bank_account_iban' => $address->bank_account_iban,
'bank_account_bic' => $address->bank_account_bic,
'sepa_date' => $address->sepa_date,
'sepa_id' => $address->sepa_id
]);
}
protected function getTaxTextAction() {
$vatgroupId = $_GET['vatgroup_id'] ?? 2;
$vatarea = $_GET['vatarea'] ?? 'domestic';
$vatrate = VatrateModel::getFirst(['vatgroup_id' => $vatgroupId, 'area' => $vatarea]);
self::returnJson([
'success' => true,
'tax_text' => $vatrate ? $vatrate->invoice_text : '',
'vatrate' => $vatrate ? $vatrate->rate : 20
]);
}
}