2025-10-20 08:57:51 +02:00
< ? php
// TODO: finish XML creation
// TODO: adapt models, views and company settings accordingly
// TODO: define routes export <-> download, format = xml | pdf
namespace App\Http\Controllers ;
2025-11-18 10:27:49 +01:00
use Inertia\Inertia ;
2025-10-20 08:57:51 +02:00
use App\Models\Invoice ;
2025-12-03 14:23:03 +01:00
use App\Models\LineItem ;
use App\Models\Setting ;
use App\Mail\Reminder ;
use App\Support\ApiDataTransformer ;
use Illuminate\Http\Request ;
use Illuminate\Support\Facades\DB ;
use Illuminate\Support\Facades\Mail ;
2025-10-20 08:57:51 +02:00
use Barryvdh\DomPDF\Facade\Pdf ;
2025-12-03 14:23:03 +01:00
use tbQuar\Facades\Quar ;
2025-10-20 08:57:51 +02:00
use horstoeko\zugferdlaravel\Facades\ZugferdLaravel ;
2025-11-18 10:27:49 +01:00
// use horstoeko\zugferd\codelists\ZugferdInvoiceType;
2025-10-20 08:57:51 +02:00
use horstoeko\zugferd\codelists\ZugferdUnitCodes ;
use horstoeko\zugferd\codelists\ZugferdVatCategoryCodes ;
use horstoeko\zugferd\codelists\ZugferdVatTypeCodes ;
use horstoeko\zugferd\codelists\ZugferdDocumentType ;
use DateTime ;
2025-11-19 14:30:24 +01:00
2025-10-20 08:57:51 +02:00
class InvoiceController extends Controller
{
2025-11-18 10:27:49 +01:00
public function show ()
{
2025-11-19 14:30:24 +01:00
return Inertia :: render (
'Invoices' ,
[ 'invoicesData' => $this -> summaryThisYear ()]
2025-11-18 10:27:49 +01:00
);
}
public function summaryAll ()
{
$invoices = Invoice :: select ()
-> orderBy ( 'invoice_date' , 'asc' )
-> orderByRaw ( " CAST(SUBSTRING(nr, 4) AS UNSIGNED) ASC " )
-> get ();
return $invoices -> map ( function ( $invoice ) {
return ApiDataTransformer :: snakeToCamel ( $invoice -> toArray ());
});
}
public function summaryThisYear ()
{
2025-12-03 14:23:03 +01:00
$dt = new DateTime ( 'first day of january this year' );
2025-11-18 10:27:49 +01:00
$invoices = Invoice :: select ()
2025-11-28 09:28:07 +01:00
-> where ( 'invoice_date' , '>=' , $dt -> format ( 'Y-m-d' ))
2025-11-18 10:27:49 +01:00
-> orderBy ( 'invoice_date' , 'asc' )
-> orderByRaw ( " CAST(SUBSTRING(nr, 4) AS UNSIGNED) ASC " )
-> get ();
return $invoices -> map ( function ( $invoice ) {
return ApiDataTransformer :: snakeToCamel ( $invoice -> toArray ());
});
}
public function summaryBeforeThisYear ()
{
2025-12-03 14:23:03 +01:00
$dt = new DateTime ( 'first day of january this year' );
2025-11-18 10:27:49 +01:00
$invoices = Invoice :: select ()
2025-11-28 09:28:07 +01:00
-> where ( 'invoice_date' , '<' , $dt -> format ( 'Y-m-d' ))
2025-11-18 10:27:49 +01:00
-> orderBy ( 'invoice_date' , 'asc' )
-> orderByRaw ( " CAST(SUBSTRING(nr, 4) AS UNSIGNED) ASC " )
-> get ();
return $invoices -> map ( function ( $invoice ) {
return ApiDataTransformer :: snakeToCamel ( $invoice -> toArray ());
});
}
2025-10-20 08:57:51 +02:00
public function index ()
{
$invoices = Invoice :: with ([
'customer.contacts' => function ( $query ) {
$query -> orderBy ( 'is_primary' , 'desc' );
},
'items' => function ( $query ) {
$query -> orderBy ( 'position' , 'asc' );
},
'customer.paymentTerms'
])
-> orderBy ( 'invoice_date' , 'asc' )
-> orderByRaw ( " CAST(SUBSTRING(nr, 4) AS UNSIGNED) ASC " )
-> get ();
return $invoices -> map ( function ( $invoice ) {
$invoiceArray = $invoice -> toArray ();
$invoiceArray [ 'customer' ][ 'payment_terms' ] = $invoice -> customer -> paymentTerms -> toArray ();
unset ( $invoiceArray [ 'customer' ][ 'payment_terms_id' ]);
return ApiDataTransformer :: snakeToCamel ( $invoiceArray );
});
}
2025-10-22 11:58:18 +02:00
public static function single ( $id )
2025-10-20 08:57:51 +02:00
{
$invoice = Invoice :: with ([
'customer.contacts' => function ( $query ) {
$query -> orderBy ( 'is_primary' , 'desc' );
},
'items' => function ( $query ) {
$query -> orderBy ( 'position' , 'asc' );
},
'customer.paymentTerms'
]) -> findOrFail ( $id );
$invoiceArray = $invoice -> toArray ();
$invoiceArray [ 'customer' ][ 'payment_terms' ] = $invoice -> customer -> paymentTerms -> toArray ();
unset ( $invoiceArray [ 'customer' ][ 'payment_terms_id' ]);
2025-10-22 11:58:18 +02:00
2025-10-20 08:57:51 +02:00
return ApiDataTransformer :: snakeToCamel ( $invoiceArray );
}
2025-11-28 09:06:34 +01:00
/**
* Get yearly statistics for invoices
*/
public function salesStatistics ()
{
$currentYear = date ( 'Y' );
// Basis-Abfrage für das aktuelle Jahr
$invoicesQuery = Invoice :: whereYear ( 'invoice_date' , $currentYear );
// Gesamtumsatz (alle außer cancelled)
$totalRevenue = $invoicesQuery -> clone ()
-> where ( 'payment_status' , '!=' , 'cancelled' )
-> sum ( 'total_amount' );
// Bezahlt (paid)
$paid = $invoicesQuery -> clone ()
-> where ( 'payment_status' , 'paid' )
-> sum ( 'total_amount' );
// Noch nicht gestellt (draft)
$draft = $invoicesQuery -> clone ()
-> where ( 'payment_status' , 'draft' )
-> sum ( 'total_amount' );
// Offene Rechnungen (issued)
$issued = $invoicesQuery -> clone ()
-> where ( 'payment_status' , 'issued' )
-> sum ( 'total_amount' );
// Fällige Rechnungen (due)
$due = $invoicesQuery -> clone ()
-> where ( 'payment_status' , 'due' )
-> sum ( 'total_amount' );
// Gemahnte Rechnungen (reminded)
$reminded = $invoicesQuery -> clone ()
-> where ( 'payment_status' , 'reminded' )
-> sum ( 'total_amount' );
// Statistikdaten zusammenstellen
$statistics = [
'year' => $currentYear ,
'totalRevenue' => $totalRevenue ,
'paid' => $paid ,
'paidPercent' => round (( $paid / $totalRevenue ) * 100 , 2 ),
'draft' => $draft ,
'draftPercent' => round (( $draft / $totalRevenue ) * 100 , 2 ),
'issued' => $issued ,
'issuedPercent' => round (( $issued / $totalRevenue ) * 100 , 2 ),
'due' => $due ,
'duePercent' => round (( $due / $totalRevenue ) * 100 , 2 ),
'reminded' => $reminded ,
'remindedPercent' => round (( $reminded / $totalRevenue ) * 100 , 2 ),
];
// Daten in camelCase umwandeln
return ApiDataTransformer :: snakeToCamel ( $statistics );
}
2025-10-20 08:57:51 +02:00
public function preview ( $id )
{
2025-10-22 11:58:18 +02:00
$invoice = self :: single ( $id );
2025-10-30 15:40:23 +01:00
$giroCode = $this -> generateGiroCode (
round ( $invoice [ 'totalAmount' ] * 1.19 , 2 ),
$invoice [ 'nr' ],
$invoice [ 'title' ]
);
2025-10-20 08:57:51 +02:00
return view ( 'invoice' , [
2025-10-22 11:58:18 +02:00
'invoice' => $invoice ,
2025-10-20 08:57:51 +02:00
'isPDF' => false ,
'fontPath' => '/storage/fonts' ,
'giroCode' => $giroCode ,
2025-10-28 10:21:40 +01:00
'totalPages' => 1
2025-10-20 08:57:51 +02:00
]);
}
2025-10-28 10:21:40 +01:00
2025-10-20 08:57:51 +02:00
// https://www.itsolutionstuff.com/post/laravel-dompdf-table-with-page-break-exampleexample.html
public function exportPdf ( $id )
{
2025-10-22 11:58:18 +02:00
$invoice = self :: single ( $id );
2025-10-30 15:40:23 +01:00
$giroCode = $this -> generateGiroCode (
round ( $invoice [ 'totalAmount' ] * 1.19 , 2 ),
$invoice [ 'nr' ],
$invoice [ 'title' ]
);
2025-10-20 08:57:51 +02:00
2025-10-28 10:21:40 +01:00
$viewData = [
2025-10-22 11:58:18 +02:00
'invoice' => $invoice ,
2025-10-20 08:57:51 +02:00
'isPDF' => true ,
'fontPath' => public_path ( 'storage/fonts/' ),
'giroCode' => $giroCode ,
2025-10-28 10:21:40 +01:00
'totalPages' => 1
];
$options = [
'isRemoteEnabled' => true ,
'fontDir' => public_path ( 'storage/fonts/' ),
'fontCache' => public_path ( 'storage/fonts/' )
];
$pdf = Pdf :: loadView ( 'invoice' , $viewData );
$pdf -> setOptions ( $options , true );
$pdf -> render ();
// if there’ s more than one page, inject page number an render again
// https://github.com/dompdf/dompdf/issues/1636
$viewData [ 'totalPages' ] = $pdf -> getCanvas () -> get_page_count ();
if ( $viewData [ 'totalPages' ] > 1 ) {
$pdf = Pdf :: loadView ( 'invoice' , $viewData );
$pdf -> setOptions ( $options , true );
}
2025-10-22 11:58:18 +02:00
return $pdf -> stream ( $invoice [ 'nr' ] . '.pdf' );
// return $pdf->download($this->getFilename($invoice) . '.pdf');
2025-10-20 08:57:51 +02:00
}
public function exportXml ( $id )
{
2025-10-22 11:58:18 +02:00
$invoice = self :: single ( $id );
2025-10-20 08:57:51 +02:00
// Standard
// https://easyfirma.net/e-rechnung/zugferd/bt-felder
// ZugferdDocumentBuilder.php
// https://github.com/horstoeko/zugferd/blob/aaa3e8d0d775cd1621a65aac81ceab621f0ac115/src/ZugferdDocumentBuilder.php
// EN16931-Beispiel
// https://github.com/horstoeko/zugferd/blob/a192d3e0eb4ffd23e38eae419097d179c4ffb0cf/examples/01_ZugferdDocumentBuilder_EN16931.php
$document = ZugferdLaravel :: createDocumentInEN16931Profile ();
// ---------------------------------------------------------------------
// Document information
// ---------------------------------------------------------------------
// Set main information about this document.
//
// string $documentNo __BT-1, From MINIMUM__ The document no issued by the seller
// string $documentTypeCode __BT-3, From MINIMUM__ The type of the document, See \horstoeko\codelists\ZugferdInvoiceType for details
// DateTimeInterface $documentDate __BT-2, From MINIMUM__ Date of invoice. The date when the document was issued by the seller
// string $invoiceCurrency __BT-5, From MINIMUM__ Code for the invoice currency
// string|null $documentName __BT-X-2, From EXTENDED__ Document Type. The documenttype (free text)
// string|null $documentLanguage __BT-X-4, From EXTENDED__ Language indicator. The language code in which the document was written
// DateTimeInterface|null $effectiveSpecifiedPeriod __BT-X-6-000, From EXTENDED__ The contractual due date of the invoice
$document -> setDocumentInformation (
2025-10-22 11:58:18 +02:00
$invoice [ 'nr' ],
2025-10-20 08:57:51 +02:00
ZugferdDocumentType :: COMMERCIAL_INVOICE ,
2025-10-22 11:58:18 +02:00
new DateTime ( $invoice [ 'invoiceDate' ]),
2025-10-20 08:57:51 +02:00
'EUR' ,
2025-10-22 11:58:18 +02:00
$invoice [ 'title' ],
2025-10-20 08:57:51 +02:00
'de' ,
2025-10-22 11:58:18 +02:00
new DateTime ( $invoice [ 'dueDate' ]),
2025-10-20 08:57:51 +02:00
);
2025-10-22 11:58:18 +02:00
// DateTimeInterface|null $startDate __BT-73, From BASIC WL__ Start of the billing period
// DateTimeInterface|null $endDate __BT-74, From BASIC WL__ End of the billing period
// string|null $description __BT-X-264, From EXTENDED__ Further information of the billing period (Obsolete)
$document -> setDocumentBillingPeriod (
DateTime :: createFromFormat ( 'Y-m-d' , $invoice [ 'serviceStartDate' ]),
DateTime :: createFromFormat ( 'Y-m-d' , $invoice [ 'serviceEndDate' ]),
$invoice [ 'serviceStartDate' ] . ' – ' . $invoice [ 'serviceEndDate' ]
);
2025-10-20 08:57:51 +02:00
// Add a payment term
//
// string|null $description __BT-20, From _BASIC WL__ A text description of the payment terms that apply to the payment amount due (including a description of possible penalties). Note: This element can contain multiple lines and multiple conditions.
// DateTimeInterface|null $dueDate __BT-9, From BASIC WL__ The date by which payment is due Note: The payment due date reflects the net payment due date. In the case of partial payments, this indicates the first due date of a net payment. The corresponding description of more complex payment terms can be given in BT-20.
// string|null $directDebitMandateID __BT-89, From BASIC WL__ Unique identifier assigned by the payee to reference the direct debit authorization.
// float|null $partialPaymentAmount __BT-X-275, From EXTENDED__ Amount of the partial payment
$document -> addDocumentPaymentTerm (
2025-10-22 11:58:18 +02:00
$invoice [ 'billingData' ][ 'paymentTerms' ][ 'name' ] == 'prepaid' ?
'Vorkasse' : ( $invoice [ 'billingData' ][ 'paymentTerms' ][ 'name' ] == 'on_receipt' ?
2025-10-20 08:57:51 +02:00
'Bei Rechnungserhalt' :
2025-10-22 11:58:18 +02:00
$invoice [ 'billingData' ][ 'paymentTerms' ][ 'days' ] . ' Tage nach Rechnungserhalt' ),
new DateTime ( $invoice [ 'dueDate' ])
2025-10-20 08:57:51 +02:00
);
// ---------------------------------------------------------------------
// Seller information
// ---------------------------------------------------------------------
// string $name __BT-27, From MINIMUM__ The full formal name under which the seller is registered in the National Register of Legal Entities, Taxable Person or otherwise acting as person(s)
// string|null $id __BT-29, From BASIC WL__ An identifier of the seller. In many systems, seller identification is key information. Multiple seller IDs can be assigned or specified. They can be differentiated by using different identification schemes. If no scheme is given, it should be known to the buyer and seller, e.g. a previously exchanged, buyer-assigned identifier of the seller
// string|null $description __BT-33, From EN 16931__ Further legal information that is relevant for the seller
$document -> setDocumentSeller ( " Tooloop Multimedia Daniel Stock " , " 549910 " );
// string|null $globalID __BT-29/BT-29-0, From BASIC WL__ The seller's identifier identification scheme is an identifier uniquely assigned to a seller by a global registration organization.
// string|null $globalIDType __BT-29-1, From BASIC WL__ If the identifier is used for the identification scheme, it must be selected from the entries in the list published by the ISO / IEC 6523 Maintenance Agency.
$document -> addDocumentSellerGlobalId ( " 4000001123452 " , " 0088 " );
// string|null $taxRegType __BT-31-0/BT-32-0, From MINIMUM/EN 16931__ Type of tax number of the seller (FC = Tax number, VA = Sales tax identification number)
// string|null $taxRegId __BT-31/32, From MINIMUM/EN 16931__ Tax number of the seller or sales tax identification number of the seller
$document -> addDocumentSellerTaxRegistration ( " FC " , " 201/113/40209 " );
$document -> addDocumentSellerTaxRegistration ( " VA " , " DE123456789 " );
// string|null $lineOne __BT-35, From BASIC WL__ The main line in the sellers address. This is usually the street name and house number or the post office box
// string|null $lineTwo __BT-36, From BASIC WL__ Line 2 of the seller's address. This is an additional address line in an address that can be used to provide additional details in addition to the main line used to provide additional details in addition to the main line
// string|null $lineThree __BT-162, From BASIC WL__ Line 3 of the seller's address. This is an additional address line in an address that can be used to provide additional details in addition to the main line
// string|null $postCode __BT-38, From BASIC WL__ Identifier for a group of properties, such as a zip code
// string|null $city __BT-37, From BASIC WL__ Usual name of the city or municipality in which the seller's address is located
// string|null $country __BT-40, From MINIMUM__ Code used to identify the country. If no tax agent is specified, this is the country in which the sales tax is due. The lists of approved countries are maintained by the EN ISO 3166-1 Maintenance Agency “Codes for the representation of names of countries and their subdivisions”
// string|null $subDivision __BT-39, From BASIC WL__ The sellers state
$document -> setDocumentSellerAddress (
" Rehmstraße 4 " ,
" " ,
" " ,
" 86161 " ,
" Augsburg " ,
" DE "
);
$document -> setDocumentSellerContact (
" Daniel Stock " ,
" Geschäftsführer " ,
" +49-111-2222222 " ,
" +49-111-3333333 " ,
" info@tooloop.de "
);
// ---------------------------------------------------------------------
// Buyer information
// ---------------------------------------------------------------------
// string $name __BT-44, From MINIMUM__ The full name of the buyer
// string|null $id __BT-46, From BASIC WL__ An identifier of the buyer. In many systems, buyer identification is key information. Multiple buyer IDs can be assigned or specified. They can be differentiated by using different identification schemes. If no scheme is given, it should be known to the buyer and buyer, e.g. a previously exchanged, seller-assigned identifier of the buyer
// string|null $description __BT-X-334, From EXTENDED__ Further legal information about the buyer
2025-10-22 11:58:18 +02:00
$document -> setDocumentBuyer ( $invoice [ 'billingData' ][ 'companyName' ], $invoice [ 'customer' ][ 'customerNr' ]);
2025-10-20 08:57:51 +02:00
// Set contact of the buyer party
//
// string|null $contactPersonName __BT-56, From EN 16931__ Contact point for a legal entity, such as a personal name of the contact person
// string|null $contactDepartmentName __BT-56-0, From EN 16931__ Contact point for a legal entity, such as a name of the department or office
// string|null $contactPhoneNo __BT-57, From EN 16931__ A telephone number for the contact point
// string|null $contactFaxNo __BT-X-115, From EXTENDED__ A fax number of the contact point
// string|null $contactEmailAddress __BT-58, From EN 16931__ An e-mail address of the contact point
$document -> setDocumentBuyerContact (
2025-10-22 11:58:18 +02:00
$invoice [ 'billingData' ][ 'contactFirstName' ] . ' ' . $invoice [ 'billingData' ][ 'contactLastName' ],
2025-10-20 08:57:51 +02:00
null ,
null ,
2025-10-22 11:58:18 +02:00
null ,
null
2025-10-20 08:57:51 +02:00
);
// Leitweg-ID (nur bei Behörden)
// string $buyerReference __BT-10, From MINIMUM__ An identifier assigned by the buyer and used for internal routing
$document -> setDocumentBuyerReference ( " 34676-342323 " );
// string|null $lineOne __BT-50, From BASIC WL__ The main line in the buyers address. This is usually the street name and house number or the post office box
// string|null $lineTwo __BT-51, From BASIC WL__ Line 2 of the buyers address. This is an additional address line in an address that can be used to provide additional details in addition to the main line
// string|null $lineThree __BT-163, From BASIC WL__ Line 3 of the buyers address. This is an additional address line in an address that can be used to provide additional details in addition to the main line
// string|null $postCode __BT-53, From BASIC WL__ Identifier for a group of properties, such as a zip code
// string|null $city __BT-52, From BASIC WL__ Usual name of the city or municipality in which the buyers address is located
// string|null $country __BT-55, From BASIC WL__ Code used to identify the country. If no tax agent is specified, this is the country in which the sales tax is due. The lists of approved countries are maintained by the EN ISO 3166-1 Maintenance Agency “Codes for the representation of names of countries and their subdivisions”
// string|null $subDivision __BT-54, From BASIC WL__ The buyers state
$document -> setDocumentBuyerAddress (
2025-10-22 11:58:18 +02:00
$invoice [ 'billingData' ][ 'billingAddress' ][ 'lineOne' ] ? ? null ,
$invoice [ 'billingData' ][ 'billingAddress' ][ 'lineTwo' ] ? ? null ,
null ,
$invoice [ 'billingData' ][ 'billingAddress' ][ 'postalCode' ] ? ? null ,
$invoice [ 'billingData' ][ 'billingAddress' ][ 'city' ] ? ? null ,
$invoice [ 'billingData' ][ 'billingAddress' ][ 'countryCode' ] ? ? null
2025-10-20 08:57:51 +02:00
);
2025-10-22 11:58:18 +02:00
foreach ( $invoice [ 'items' ] as $item ) {
$document -> addNewPosition ( $item [ 'position' ]);
2025-10-20 08:57:51 +02:00
$document -> setDocumentPositionProductDetails (
2025-10-22 11:58:18 +02:00
$item [ 'title' ],
$item [ 'description' ]
2025-10-20 08:57:51 +02:00
);
2025-10-22 11:58:18 +02:00
$document -> setDocumentPositionNetPrice ( $item [ 'price' ]);
2025-10-20 08:57:51 +02:00
// float $billedQuantity __BT-129, From BASIC__ The quantity of individual items (goods or services) billed in the relevant line
// string $billedQuantityUnitCode __BT-130, From BASIC__ The unit of measure applicable to the amount billed
// float|null $chargeFreeQuantity __BT-X-46, From EXTENDED__ Quantity, free of charge
// string|null $chargeFreeQuantityUnitCpde __BT-X-46-0, From EXTENDED__ Unit of measure code for the quantity free of charge
// float|null $packageQuantity __BT-X-47, From EXTENDED__ Number of packages
// string|null $packageQuantityUnitCode __BT-X-47-0, From EXTENDED__ Unit of measure code for number of packages
2025-10-28 10:21:40 +01:00
2025-10-22 11:58:18 +02:00
// TODO: this needs to be properly mapped. It should probably become a database item, the user can manage
$units = [
'Stück' => ZugferdUnitCodes :: REC20_PIECE ,
'Stunden' => ZugferdUnitCodes :: REC20_HOUR ,
'Tage' => ZugferdUnitCodes :: REC20_WORKING_DAY ,
'pauschal' => ZugferdUnitCodes :: REC20_LUMP_SUM
2025-10-28 10:21:40 +01:00
];
2025-10-20 08:57:51 +02:00
$document -> setDocumentPositionQuantity (
2025-10-22 11:58:18 +02:00
$item [ 'quantity' ],
2025-10-22 12:36:02 +02:00
$units [ $item [ 'unit' ]] ? ? ZugferdUnitCodes :: REC20_PIECE
2025-10-20 08:57:51 +02:00
);
$document -> addDocumentPositionTax (
ZugferdVatCategoryCodes :: STAN_RATE ,
ZugferdVatTypeCodes :: VALUE_ADDED_TAX ,
19
);
2025-10-22 11:58:18 +02:00
$document -> setDocumentPositionLineSummation ( $item [ 'quantity' ] * $item [ 'price' ]);
2025-10-20 08:57:51 +02:00
}
// Get the XML content as a string
$xmlContent = $document -> getContent ();
// Create a response with the XML content
$response = response ( $xmlContent , 200 , [
'Content-Type' => 'application/xml' ,
// 'Content-Disposition' => 'attachment; filename="'.$this->getFilename($invoice) . '.xml"',
]);
return $response ;
}
/**
* Stores api input to the database
* Expects all date in camelCase but will convert and store in snake_case
*/
public function store ( Request $request )
{
// Validate input data (expecting camelCase)
$validatedData = $request -> validate ([
'nr' => 'nullable|string' ,
'invoiceDate' => 'required|date' ,
'dueDate' => 'required|date' ,
'serviceStartDate' => 'nullable|date' ,
'serviceEndDate' => 'nullable|date' ,
'isRecurring' => 'boolean' ,
'isPartialService' => 'boolean' ,
'customerId' => 'nullable|integer' ,
'billingData' => 'nullable|array' ,
'billingData.companyName' => 'required_with:billingData|string' ,
'billingData.vatId' => 'nullable|string' ,
'billingData.billingAddress' => 'nullable|array' ,
'billingData.billingAddress.lineOne' => 'nullable|string' ,
'billingData.billingAddress.lineTwo' => 'nullable|string' ,
'billingData.billingAddress.city' => 'nullable|string' ,
'billingData.billingAddress.postalCode' => 'nullable|string' ,
'billingData.billingAddress.countryCode' => 'nullable|string' ,
2025-10-29 13:53:08 +01:00
'billingData.contactSalutation' => 'nullable|string' ,
2025-10-20 08:57:51 +02:00
'billingData.contactFirstName' => 'nullable|string' ,
'billingData.contactLastName' => 'nullable|string' ,
'billingData.paymentTerms' => 'nullable|array' ,
'paymentStatus' => 'required|string' ,
'totalAmount' => 'required|numeric' ,
'title' => 'nullable|string' ,
'text' => 'nullable|string' ,
'items' => 'nullable|array' ,
2025-11-19 10:12:45 +01:00
'items.*.isSection' => 'nullable|boolean' ,
'items.*.position' => 'required|numeric' ,
2025-10-20 08:57:51 +02:00
'items.*.title' => 'nullable|string' ,
'items.*.description' => 'nullable|string' ,
'items.*.quantity' => 'nullable|numeric' ,
'items.*.unit' => 'nullable|string' ,
'items.*.price' => 'nullable|numeric' ,
]);
// Convert camelCase to snake_case
$snakeCaseData = ApiDataTransformer :: camelToSnake ( $validatedData );
// Generate invoice number only if paymentStatus is not 'draft'
if ( $snakeCaseData [ 'payment_status' ] !== 'draft' && empty ( $snakeCaseData [ 'nr' ])) {
$snakeCaseData [ 'nr' ] = $this -> generateInvoiceNumber ();
}
DB :: beginTransaction ();
try {
// Prepare data for invoice creation (excluding items)
$invoiceData = $snakeCaseData ;
unset ( $invoiceData [ 'items' ]);
// Create invoice with snake_case data
$invoice = Invoice :: create ( $invoiceData );
// Create line items if present
if ( ! empty ( $snakeCaseData [ 'items' ])) {
foreach ( $snakeCaseData [ 'items' ] as $item ) {
LineItem :: create ([
'invoice_id' => $invoice -> id ,
... $item
]);
}
}
DB :: commit ();
// Return created invoice with items in camelCase
$invoice = Invoice :: with ([ 'items' , 'customer.contacts' ]) -> find ( $invoice -> id );
return response () -> json (
ApiDataTransformer :: snakeToCamel ( $invoice -> toArray ()),
201
);
} catch ( \Exception $e ) {
DB :: rollBack ();
return response () -> json ([
'message' => 'Fehler beim Speichern der Rechnung' ,
'error' => $e -> getMessage ()
], 500 );
}
}
public function update ( Request $request , $id )
{
// Validate input data (expecting camelCase)
$validatedData = $request -> validate ([
'nr' => 'nullable|string' ,
'invoiceDate' => 'required|date' ,
'dueDate' => 'required|date' ,
'serviceStartDate' => 'nullable|date' ,
'serviceEndDate' => 'nullable|date' ,
'isRecurring' => 'boolean' ,
'isPartialService' => 'boolean' ,
'customerId' => 'nullable|integer' ,
'billingData' => 'nullable|array' ,
'billingData.companyName' => 'required_with:billingData|string' ,
'billingData.vatId' => 'nullable|string' ,
'billingData.billingAddress' => 'nullable|array' ,
'billingData.billingAddress.lineOne' => 'nullable|string' ,
'billingData.billingAddress.lineTwo' => 'nullable|string' ,
'billingData.billingAddress.city' => 'nullable|string' ,
'billingData.billingAddress.postalCode' => 'nullable|string' ,
'billingData.billingAddress.countryCode' => 'nullable|string' ,
2025-10-29 13:53:08 +01:00
'billingData.contactSalutation' => 'nullable|string' ,
2025-10-20 08:57:51 +02:00
'billingData.contactFirstName' => 'nullable|string' ,
'billingData.contactLastName' => 'nullable|string' ,
'billingData.paymentTerms' => 'nullable|array' ,
'paymentStatus' => 'required|string' ,
'totalAmount' => 'required|numeric' ,
'title' => 'nullable|string' ,
'text' => 'nullable|string' ,
'items' => 'nullable|array' ,
'items.*.id' => 'nullable|integer' ,
2025-11-19 10:12:45 +01:00
'items.*.isSection' => 'nullable|boolean' ,
'items.*.position' => 'required|numeric' ,
2025-10-20 08:57:51 +02:00
'items.*.title' => 'nullable|string' ,
'items.*.description' => 'nullable|string' ,
'items.*.quantity' => 'nullable|numeric' ,
'items.*.unit' => 'nullable|string' ,
'items.*.price' => 'nullable|numeric' ,
]);
// Convert camelCase to snake_case
$snakeCaseData = ApiDataTransformer :: camelToSnake ( $validatedData );
// Generate invoice number if paymentStatus is not 'draft' and no invoice number exists
if ( $snakeCaseData [ 'payment_status' ] !== 'draft' && empty ( $snakeCaseData [ 'nr' ])) {
$snakeCaseData [ 'nr' ] = $this -> generateInvoiceNumber ();
}
DB :: beginTransaction ();
try {
// Find invoice
$invoice = Invoice :: findOrFail ( $id );
// Prepare data for invoice update (excluding items)
$invoiceData = $snakeCaseData ;
unset ( $invoiceData [ 'items' ]);
// Update invoice with snake_case data
$invoice -> update ( $invoiceData );
// Handle items separately
$existingItemIds = [];
if ( ! empty ( $snakeCaseData [ 'items' ])) {
foreach ( $snakeCaseData [ 'items' ] as $item ) {
// Remove unit from item data if it's empty to let the database use the default value
if ( empty ( $item [ 'unit' ])) {
unset ( $item [ 'unit' ]);
}
if ( isset ( $item [ 'id' ]) && $item [ 'id' ] > 0 ) {
// Update existing item
$lineItem = LineItem :: findOrFail ( $item [ 'id' ]);
$lineItem -> update ( $item );
$existingItemIds [] = $item [ 'id' ];
} else {
// Create new item
$lineItem = LineItem :: create ([
'invoice_id' => $invoice -> id ,
... $item
]);
$existingItemIds [] = $lineItem -> id ;
}
}
}
// Delete items that are no longer in the data
LineItem :: where ( 'invoice_id' , $invoice -> id )
-> whereNotIn ( 'id' , $existingItemIds )
-> delete ();
DB :: commit ();
// Return updated invoice with items in camelCase
$invoice = Invoice :: with ([ 'items' , 'customer.contacts' ]) -> find ( $invoice -> id );
return response () -> json (
ApiDataTransformer :: snakeToCamel ( $invoice -> toArray ()),
200
);
} catch ( \Exception $e ) {
DB :: rollBack ();
return response () -> json ([
'message' => 'Rechnung konnte nicht aktualisiert werden' ,
'error' => $e -> getMessage ()
], 500 );
}
}
2025-11-19 14:30:24 +01:00
public function delete ( Request $request , int $id )
2025-10-20 08:57:51 +02:00
{
DB :: beginTransaction ();
try {
$invoice = Invoice :: findOrFail ( $id );
LineItem :: where ( 'invoice_id' , $invoice -> id ) -> delete ();
$invoice -> delete ();
DB :: commit ();
return response () -> json ([
'message' => 'Rechnung gelöscht'
], 200 );
} catch ( \Exception $e ) {
DB :: rollBack ();
return response () -> json ([
'message' => 'Rechnung konnte nicht gelöscht werden' ,
'error' => $e -> getMessage ()
], 500 );
}
}
2025-11-19 14:30:24 +01:00
public function remind ( Request $request , int $id )
{
$invoice = InvoiceController :: single ( $id );
$to = $request -> query ( 'to' );
$cc = $request -> query ( 'cc' );
// TODO: get from settings
$bcc = 'buchhaltung@tooloop.de' ;
if ( empty ( $to ) || ! filter_var ( $to , FILTER_VALIDATE_EMAIL )) {
return response () -> json ([
'error' => 'Keine gültige E_Mail-Adresse ' . $to
], 400 );
}
if ( ! empty ( $cc ) && ! filter_var ( $cc , FILTER_VALIDATE_EMAIL )) {
return response () -> json ([
'error' => 'Keine gültige E_Mail-Adresse ' . $cc
], 400 );
}
Mail :: to ( $to )
-> cc ( $cc )
-> bcc ( $cc )
-> send ( new Reminder ( $invoice ));
// return new Reminder($invoice);
}
2025-10-20 08:57:51 +02:00
/**
* Generate the next available invoice number
*/
protected function generateInvoiceNumber ()
{
2025-12-03 14:23:03 +01:00
// Key/value Einstellungen
$format = Setting :: where ( 'key' , 'invoice.number_format' ) -> value ( 'value' ) ? ? 'RE-{number}' ;
$start = ( int ) ( Setting :: where ( 'key' , 'invoice.number_start' ) -> value ( 'value' ) ? ? 1 );
// prefix / suffix aus dem Format ableiten (erwartet genau ein {number})
$parts = explode ( '{number}' , $format );
$prefix = $parts [ 0 ] ? ? '' ;
$suffix = $parts [ 1 ] ? ? '' ;
// Query: nur Rechnungen mit Prefix (falls vorhanden) - reduziert Datensatz
$query = \App\Models\Invoice :: query ();
if ( $prefix !== '' ) {
$query -> where ( 'nr' , 'like' , $prefix . '%' );
}
// optional: further restrict by suffix if known
if ( $suffix !== '' ) {
$query -> where ( 'nr' , 'like' , '%' . $suffix );
2025-10-20 08:57:51 +02:00
}
2025-12-03 14:23:03 +01:00
// Hole die Nummern (nur Spalte 'nr') — bei großen Datenmengen ggf. limit/stream verwenden
$numbers = $query -> pluck ( 'nr' ) -> toArray ();
$max = 0 ;
foreach ( $numbers as $nr ) {
// Entferne Prefix/Suffix falls vorhanden
if ( $prefix !== '' && str_starts_with ( $nr , $prefix )) {
$nrCore = substr ( $nr , strlen ( $prefix ));
} else {
$nrCore = $nr ;
}
if ( $suffix !== '' && str_ends_with ( $nrCore , $suffix )) {
$nrCore = substr ( $nrCore , 0 , - strlen ( $suffix ));
}
// Extrahiere erste Ziffernfolge (handhabt auch führende Nullen)
if ( preg_match ( '/\d+/' , $nrCore , $m )) {
$val = intval ( $m [ 0 ]);
if ( $val > $max ) $max = $val ;
}
}
$newNumber = $max > 0 ? $max + 1 : $start ;
// Setze neue Nummer ins Format (bei Bedarf Padding hier ergänzen)
return str_replace ( '{number}' , ( string ) $newNumber , $format );
2025-10-20 08:57:51 +02:00
}
2025-12-03 14:23:03 +01:00
2025-10-20 08:57:51 +02:00
/**
* Generate a GiroCode for the invoice
* https://www.girocode.eu/en/home/
* @return string Base64 encoded SVG image
*/
protected function generateGiroCode ( $amount , $invoiceNumber , $invoiceTitle )
{
$content = " BCD
002
2
SCT
AUGSDE77XXX
Daniel Stock
DE40720500000251512513
EUR $amount
$invoiceNumber $invoiceTitle " ;
// Convert the content to ISO-8859-1
$content = $this -> transliterateString ( $content );
// Generate the QR code
$giroCode = Quar :: format ( 'svg' ) -> generate ( $content );
return base64_encode ( $giroCode );
}
protected function transliterateString ( $string )
{
// Define replacements for special characters
$replacements = [
'ä' => 'ae' ,
'ä' => 'ae' ,
'ö' => 'oe' ,
'ö' => 'oe' ,
'ü' => 'ue' ,
'ü' => 'ue' ,
'Ä' => 'Ae' ,
'Ä' => 'Ae' ,
'Ö' => 'Oe' ,
'Ö' => 'Oe' ,
'Ü' => 'Ue' ,
'Ü' => 'Ue' ,
'ß' => 'ss' ,
'„' => '"' ,
'“' => '"' ,
'‚ ' => " ' " ,
'‘ ' => " ' " ,
'’ ' => " ' " ,
'€' => 'EUR' ,
// Add more replacements as needed
];
// Replace special characters
foreach ( $replacements as $search => $replace ) {
$string = str_replace ( $search , $replace , $string );
}
// Transliterate remaining characters
$string = iconv ( 'UTF-8' , 'ISO-8859-1//IGNORE' , $string );
return $string ;
}
/**
* Generate a filename for the invoice export
* Format: YYYY-MM-DD {invoice.nr} {invoice.title}
*/
2025-10-22 11:58:18 +02:00
protected function getFilename ( $invoice )
2025-10-20 08:57:51 +02:00
{
2025-10-22 11:58:18 +02:00
$date = \DateTime :: createFromFormat ( " Y-m-d " , $invoice [ 'invoiceDate' ]);
2025-10-20 08:57:51 +02:00
$formattedDate = $date ? $date -> format ( 'Y-m-d' ) : ( new DateTime ()) -> format ( 'Y-m-d' );
2025-10-22 11:58:18 +02:00
return " { $formattedDate } { $invoice [ 'nr' ] } { $invoice [ 'title' ] } " ;
2025-10-20 08:57:51 +02:00
}
}