diff --git a/app/Http/Controllers/InvoiceController.php b/app/Http/Controllers/InvoiceController.php index 531e33f..9313f7b 100644 --- a/app/Http/Controllers/InvoiceController.php +++ b/app/Http/Controllers/InvoiceController.php @@ -46,7 +46,7 @@ public function index() }); } - public function single($id) + public static function single($id) { $invoice = Invoice::with([ 'customer.contacts' => function ($query) { @@ -61,30 +61,18 @@ public function single($id) $invoiceArray = $invoice->toArray(); $invoiceArray['customer']['payment_terms'] = $invoice->customer->paymentTerms->toArray(); unset($invoiceArray['customer']['payment_terms_id']); + return ApiDataTransformer::snakeToCamel($invoiceArray); } public function preview($id) { - $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']); - - $giroCode = $this->generateGiroCode($invoice->totalAmount, $invoice->nr, $invoice->title); + $invoice = self::single($id); + $giroCode = $this->generateGiroCode($invoice['totalAmount'], $invoice['nr'], $invoice['title']); return view('invoice', [ - 'invoice' => ApiDataTransformer::snakeToCamel($invoiceArray), + 'invoice' => $invoice, 'isPDF' => false, 'fontPath' => '/storage/fonts', 'giroCode' => $giroCode, @@ -94,24 +82,11 @@ public function preview($id) // https://www.itsolutionstuff.com/post/laravel-dompdf-table-with-page-break-exampleexample.html public function exportPdf($id) { - $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']); - - $giroCode = $this->generateGiroCode($invoice->totalAmount, $invoice->nr, $invoice->title); + $invoice = self::single($id); + $giroCode = $this->generateGiroCode($invoice['totalAmount'], $invoice['nr'], $invoice['title']); $pdf = Pdf::loadView('invoice', [ - 'invoice' => ApiDataTransformer::snakeToCamel($invoiceArray), + 'invoice' => $invoice, 'isPDF' => true, 'fontPath' => public_path('storage/fonts/'), 'giroCode' => $giroCode, @@ -119,25 +94,14 @@ public function exportPdf($id) $pdf->setOption(['isRemoteEnabled' => true]); $pdf->setOption(['fontDir' => public_path('storage/fonts/')]); $pdf->setOption(['fontCache' => public_path('storage/fonts/')]); - return $pdf->stream($invoice->nr . '.pdf'); - return $pdf->download($this->getFilename($invoice) . '.pdf'); + + return $pdf->stream($invoice['nr'] . '.pdf'); + // return $pdf->download($this->getFilename($invoice) . '.pdf'); } public function exportXml($id) { - $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']); + $invoice = self::single($id); // Standard // https://easyfirma.net/e-rechnung/zugferd/bt-felder @@ -166,17 +130,24 @@ public function exportXml($id) // 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( - $invoice->nr, + $invoice['nr'], ZugferdDocumentType::COMMERCIAL_INVOICE, - new DateTime($invoice->invoice_date), + new DateTime($invoice['invoiceDate']), 'EUR', - $invoice->title, + $invoice['title'], 'de', - new DateTime($invoice->due_date), + new DateTime($invoice['dueDate']), ); - // TODO: start_service_date end_service_date - $document->setDocumentBillingPeriod(DateTime::createFromFormat('Ymd', '20250101'), DateTime::createFromFormat('Ymd', '20250131'), '01.01.2025 - 31.01.2025'); + + // 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'] + ); // Add a payment term @@ -186,11 +157,11 @@ public function exportXml($id) // 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( - $invoice->paymentTerms->name == 'prepaid' ? - 'Vorkasse' : ($invoice->paymentTerms->name == 'on_receipt' ? + $invoice['billingData']['paymentTerms']['name'] == 'prepaid' ? + 'Vorkasse' : ($invoice['billingData']['paymentTerms']['name'] == 'on_receipt' ? 'Bei Rechnungserhalt' : - $invoice->paymentTerms->days . ' Tage nach Rechnungserhalt'), - new DateTime($invoice->due_date) + $invoice['billingData']['paymentTerms']['days'] . ' Tage nach Rechnungserhalt'), + new DateTime($invoice['dueDate']) ); @@ -243,9 +214,7 @@ public function exportXml($id) // 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 - if ($invoice->customer->company_name && $invoice->customer->customer_nr) { - $document->setDocumentBuyer($invoice->customer->company_name, $invoice->customer->customer_nr); - } + $document->setDocumentBuyer($invoice['billingData']['companyName'], $invoice['customer']['customerNr']); // Set contact of the buyer party // @@ -255,12 +224,11 @@ public function exportXml($id) // 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( - // TODO: use invoice trade contact field - $invoice->customer->contacts[0]->first_name . ' ' . $invoice->customer->contacts[0]->last_name, + $invoice['billingData']['contactFirstName'] . ' ' . $invoice['billingData']['contactLastName'], null, - $invoice->customer->contacts[0]->phone, null, - $invoice->customer->contacts[0]->email + null, + null ); // Leitweg-ID (nur bei Behörden) @@ -275,21 +243,21 @@ public function exportXml($id) // 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( - $mappedBillingAddress['line_one'] ?? '', - $mappedBillingAddress['line_two'] ?? '', - "", - $mappedBillingAddress['postal_code'] ?? '', - $mappedBillingAddress['city'] ?? '', - $mappedBillingAddress['country_code'] ?? '' + $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 ); - foreach ($invoice->items as $item) { - $document->addNewPosition($item->position); + foreach ($invoice['items'] as $item) { + $document->addNewPosition($item['position']); $document->setDocumentPositionProductDetails( - $item->title, - $item->description + $item['title'], + $item['description'] ); - $document->setDocumentPositionNetPrice($item->price); + $document->setDocumentPositionNetPrice($item['price']); // 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 @@ -297,9 +265,17 @@ public function exportXml($id) // 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 + + // 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 + ]; $document->setDocumentPositionQuantity( - $item->quantity, - ZugferdUnitCodes::REC20_PIECE + $item['quantity'], + $units[$item['unit']] ); $document->addDocumentPositionTax( @@ -308,7 +284,7 @@ public function exportXml($id) 19 ); - $document->setDocumentPositionLineSummation($item->quantity * $item->price); + $document->setDocumentPositionLineSummation($item['quantity'] * $item['price']); } // Get the XML content as a string @@ -628,10 +604,10 @@ protected function transliterateString($string) * Generate a filename for the invoice export * Format: YYYY-MM-DD {invoice.nr} {invoice.title} */ - protected function getFilename(Invoice $invoice) + protected function getFilename($invoice) { - $date = \DateTime::createFromFormat("Y-m-d", $invoice->invoice_date); + $date = \DateTime::createFromFormat("Y-m-d", $invoice['invoiceDate']); $formattedDate = $date ? $date->format('Y-m-d') : (new DateTime())->format('Y-m-d'); - return "{$formattedDate} {$invoice->nr} {$invoice->title}"; + return "{$formattedDate} {$invoice['nr']} {$invoice['title']}"; } } diff --git a/app/Mail/Reminder.php b/app/Mail/Reminder.php new file mode 100644 index 0000000..44e01f5 --- /dev/null +++ b/app/Mail/Reminder.php @@ -0,0 +1,57 @@ + + */ + public function attachments(): array + { + return []; + } +} diff --git a/config/mail.php b/config/mail.php index 756305b..c2fe609 100644 --- a/config/mail.php +++ b/config/mail.php @@ -109,8 +109,8 @@ */ 'from' => [ - 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), - 'name' => env('MAIL_FROM_NAME', 'Example'), + 'address' => env('MAIL_FROM_ADDRESS', 'buchhaltung@tooloop.de'), + 'name' => env('MAIL_FROM_NAME', 'Tooloop Multimedia'), ], ]; diff --git a/resources/js/components/documents/InvoiceDialog.vue b/resources/js/components/documents/InvoiceDialog.vue index ee25266..26ddcff 100644 --- a/resources/js/components/documents/InvoiceDialog.vue +++ b/resources/js/components/documents/InvoiceDialog.vue @@ -237,6 +237,15 @@ const deleteInvoice = function () { isOpen.value = false } +const remind = function () { + // await axios call + // make button spin + // success -> set new status and save + // error -> toast + if (!invoice.value) return; + window?.open('/api/invoices/' + invoice.value.id + '/remind', '_blank')?.focus(); +} + const updateTotalAmount = () => { if (!invoice.value) return; let total = 0; @@ -381,10 +390,10 @@ const updateTotalAmount = () => {
-
+
+ class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" /> + class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" /> + class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" /> + class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" /> + class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" />
+ class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 w-20 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" /> + class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" />
+ class="mt-6 bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" />
@@ -475,7 +484,7 @@ const updateTotalAmount = () => { + :size="'sm'" :variant="'destructive'" @click="remind">Mahnen @@ -580,7 +589,7 @@ const updateTotalAmount = () => { + }} diff --git a/resources/views/mail/reminder.blade.php b/resources/views/mail/reminder.blade.php new file mode 100644 index 0000000..770ba75 --- /dev/null +++ b/resources/views/mail/reminder.blade.php @@ -0,0 +1,316 @@ + + + + + + + + + Zahlungserinnerung + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + +
 
+ + + + + +
+ Tooloop +
+ +
 
+ + + + + + + + + + + + + + + + + +
 
+ + + + + + + + + + + + + + + + + +
+

Hallo {{ $invoice['billingData']['contactSalutation'] ?? '' }} {{ $invoice['billingData']['contactLastName'] }},

+

uns ist aufgefallen, dass die hier aufgeführten Rechnungsbeträge noch nicht beglichen wurden.

+
 
+ @php + $fmt = new \IntlDateFormatter('de_DE', NULL, NULL); + $fmt->setPattern('d. MMMM yyyy'); + @endphp + + + + + + + + + + + + + + + + + + + + + +
Rechnungs-Nr.{{ $invoice['nr'] }}
Beteff{{ $invoice['title'] }}
Datum{{ $fmt->format(strtotime($invoice['invoiceDate'])) }}
Zahlbar bis{{ $fmt->format(strtotime($invoice['dueDate'])) }}
Offene Forderung@toCurrency(round($invoice['totalAmount'] * 1.19, 2))
+
 
+

Wir gehen davon aus, dass es sich um ein Versehen handelt, und möchten Sie bitten, den offenen Betrag von @toCurrency(round($invoice['totalAmount'] * 1.19, 2)) bis zum {{ \Carbon\Carbon::parse($invoice['dueDate'])->addDays(14)->format('d. F Y') }} zu überweisen.

+
+ +
 
+ + + + + + + + + + + +
 
+ + + + + + + + + + + + +
 
+ + + + + +
+

+ +49 821 + 65079983, + www.tooloop.de, + info@tooloop.de +

+

Tooloop + Multimedia Daniel + Stock, Rehmstraße 4, 86161 Augsburg

+

+ Threema + Matrix + Github + LinkedIn + Xing + Xing + Xing +

+
+ +
 
+ +
+
+ + + \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index e902308..e4ae8c2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,8 +4,11 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\CustomerController; use App\Http\Controllers\InvoiceController; +use App\Mail\Reminder; +use App\Models\Invoice; use App\Http\Controllers\PaymentTermsController; use App\Http\Controllers\SettingController; +use App\Support\ApiDataTransformer; Route::get('customers', [CustomerController::class, 'index']); @@ -16,11 +19,16 @@ Route::get('/invoices', [InvoiceController::class, 'index']); Route::post('/invoices', [InvoiceController::class, 'store']); +Route::get('/invoices/{id}', [InvoiceController::class, 'single']); Route::put('/invoices/{id}', [InvoiceController::class, 'update']); Route::delete('/invoices/{id}', [InvoiceController::class, 'delete']); -Route::get('/invoices/{id}', [InvoiceController::class, 'single']); + +Route::get('/invoices/{id}/remind', function ($id) { + $invoice = InvoiceController::single($id); + return new Reminder($invoice); +}); Route::get('/paymentterms', [PaymentTermsController::class, 'index']); Route::get('/settings', [SettingController::class, 'index']); -Route::post('/settings', [SettingController::class, 'update']); \ No newline at end of file +Route::post('/settings', [SettingController::class, 'update']);