diff --git a/app/Http/Controllers/ExpenseController.php b/app/Http/Controllers/ExpenseController.php index 0553b5dd3aec..44444990de0e 100644 --- a/app/Http/Controllers/ExpenseController.php +++ b/app/Http/Controllers/ExpenseController.php @@ -497,7 +497,7 @@ class ExpenseController extends BaseController $expenses = Expense::withTrashed()->find($request->ids); - if($request->action == 'bulk_categorize' && $user->can('edit', $expenses->first())) { + if ($request->action == 'bulk_categorize' && $user->can('edit', $expenses->first())) { $this->expense_repo->categorize($expenses, $request->category_id); $expenses = collect([]); } @@ -573,7 +573,7 @@ class ExpenseController extends BaseController */ public function upload(UploadExpenseRequest $request, Expense $expense) { - if (! $this->checkFeature(Account::FEATURE_DOCUMENTS)) { + if (!$this->checkFeature(Account::FEATURE_DOCUMENTS)) { return $this->featureFailure(); } @@ -587,9 +587,8 @@ class ExpenseController extends BaseController public function edocument(EDocumentRequest $request): string { if ($request->hasFile("documents")) { - return (new ImportEDocument($request->file("documents")[0]->get(), $request->file("documents")[0]->getClientOriginalName()))->handle(); - } - else { + return (new ImportEDocument($request->file("documents")[0]->get(), $request->file("documents")[0]->getClientOriginalName(), $request->file("documents")[0]->getMimeType()))->handle(); + } else { return "No file found"; } diff --git a/app/Jobs/EDocument/ImportEDocument.php b/app/Jobs/EDocument/ImportEDocument.php index 59d8e7b99596..1d5e379db683 100644 --- a/app/Jobs/EDocument/ImportEDocument.php +++ b/app/Jobs/EDocument/ImportEDocument.php @@ -13,6 +13,7 @@ namespace App\Jobs\EDocument; use App\Models\Expense; use App\Services\EDocument\Imports\ParseEDocument; +use App\Utils\TempFile; use Exception; use Illuminate\Bus\Queueable; use Illuminate\Queue\SerializesModels; @@ -29,7 +30,7 @@ class ImportEDocument implements ShouldQueue public $deleteWhenMissingModels = true; - public function __construct(private readonly string $file_content, private string $file_name) + public function __construct(private readonly string $file_content, private string $file_name, private string $file_mime_type) { } @@ -42,6 +43,10 @@ class ImportEDocument implements ShouldQueue */ public function handle(): Expense { - return (new ParseEDocument($this->file_content, $this->file_name))->run(); + + $file = TempFile::UploadedFileFromRaw($this->file_content, $this->file_name, $this->file_mime_type); + + return (new ParseEDocument($file))->run(); + } } diff --git a/app/Services/EDocument/Imports/MindeeEDocument.php b/app/Services/EDocument/Imports/MindeeEDocument.php new file mode 100644 index 000000000000..456e393cf499 --- /dev/null +++ b/app/Services/EDocument/Imports/MindeeEDocument.php @@ -0,0 +1,148 @@ +user(); + + $api_key = config('services.mindee.api_key'); + + if (!$api_key) + throw new Exception('Mindee API key not configured'); + + // check global contingent + // TODO: add contingent for each company + + + $mindeeClient = new Client($api_key); + + + // Load a file from disk + $inputSource = $mindeeClient->sourceFromFile($this->file); + + // Parse the file + $apiResponse = $mindeeClient->parse(InvoiceV4::class, $inputSource); + + $expense = Expense::where('amount', $grandTotalAmount)->where("transaction_reference", $documentno)->whereDate("date", $documentdate)->first(); + if (empty($expense)) { + // The document does not exist as an expense + // Handle accordingly + $visualizer = new ZugferdVisualizer($this->document); + $visualizer->setDefaultTemplate(); + $visualizer->setRenderer(app(ZugferdVisualizerLaravelRenderer::class)); + $visualizer->setPdfFontDefault("arial"); + $visualizer->setPdfPaperSize('A4-P'); + $visualizer->setTemplate('edocument.xinvoice'); + + $expense = ExpenseFactory::create($user->company()->id, $user->id); + $expense->date = $documentdate; + $expense->user_id = $user->id; + $expense->company_id = $user->company->id; + $expense->public_notes = $documentno; + $expense->currency_id = Currency::whereCode($invoiceCurrency)->first()->id; + $expense->save(); + + $documents = []; + array_push($documents, $this->file); + if ($this->file->getExtension() == "xml") + array_push($documents, TempFile::UploadedFileFromRaw($visualizer->renderPdf(), $documentno . "_visualiser.pdf", "application/pdf")); + $this->saveDocuments($documents, $expense); + $expense->saveQuietly(); + + if ($taxCurrency && $taxCurrency != $invoiceCurrency) { + $expense->private_notes = ctrans("texts.tax_currency_mismatch"); + } + $expense->uses_inclusive_taxes = True; + $expense->amount = $grandTotalAmount; + $counter = 1; + if ($this->document->firstDocumentTax()) { + do { + $this->document->getDocumentTax($categoryCode, $typeCode, $basisAmount, $calculatedAmount, $rateApplicablePercent, $exemptionReason, $exemptionReasonCode, $lineTotalBasisAmount, $allowanceChargeBasisAmount, $taxPointDate, $dueDateTypeCode); + $expense->{"tax_amount$counter"} = $calculatedAmount; + $expense->{"tax_rate$counter"} = $rateApplicablePercent; + $counter++; + } while ($this->document->nextDocumentTax()); + } + $this->document->getDocumentSeller($name, $buyer_id, $buyer_description); + $this->document->getDocumentSellerContact($person_name, $person_department, $contact_phone, $contact_fax, $contact_email); + $this->document->getDocumentSellerAddress($address_1, $address_2, $address_3, $postcode, $city, $country, $subdivision); + $this->document->getDocumentSellerTaxRegistration($taxtype); + $taxid = null; + if (array_key_exists("VA", $taxtype)) { + $taxid = $taxtype["VA"]; + } + $vendor = Vendor::where('vat_number', $taxid)->first(); + + if (!empty($vendor)) { + // Vendor found + $expense->vendor_id = $vendor->id; + } else { + $vendor = VendorFactory::create($user->company()->id, $user->id); + $vendor->name = $name; + if ($taxid != null) { + $vendor->vat_number = $taxid; + } + $vendor->currency_id = Currency::whereCode($invoiceCurrency)->first()->id; + $vendor->phone = $contact_phone; + $vendor->address1 = $address_1; + $vendor->address2 = $address_2; + $vendor->city = $city; + $vendor->postal_code = $postcode; + $vendor->country_id = Country::where('iso_3166_2', $country)->first()->id; + + $vendor->save(); + $expense->vendor_id = $vendor->id; + } + $expense->transaction_reference = $documentno; + } else { + // The document exists as an expense + // Handle accordingly + nlog("Document already exists"); + $expense->private_notes = $expense->private_notes . ctrans("texts.edocument_import_already_exists", ["date" => time()]); + } + $expense->save(); + return $expense; + } +} + diff --git a/app/Services/EDocument/Imports/ParseEDocument.php b/app/Services/EDocument/Imports/ParseEDocument.php index 89ad20986eb7..bcda0ece3c6f 100644 --- a/app/Services/EDocument/Imports/ParseEDocument.php +++ b/app/Services/EDocument/Imports/ParseEDocument.php @@ -14,6 +14,7 @@ namespace App\Services\EDocument\Imports; use App\Models\Expense; use App\Services\AbstractService; use Exception; +use Illuminate\Http\UploadedFile; class ParseEDocument extends AbstractService { @@ -21,32 +22,50 @@ class ParseEDocument extends AbstractService /** * @throws Exception */ - public function __construct(private string $file_content, private string $file_name) + public function __construct(private UploadedFile $file) { } /** * Execute the service. + * the service will parse the file with all available libraries of the system and will return an expense, when possible * * @return Expense * @throws \Exception */ public function run(): Expense { - if (str_contains($this->file_name, ".xml")) { - switch (true) { - case stristr($this->file_content, "urn:cen.eu:en16931:2017"): - case stristr($this->file_content, "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0"): - case stristr($this->file_content, "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_2.1"): - case stristr($this->file_content, "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_2.0"): - return (new ZugferdEDocument($this->file_content, $this->file_name))->run(); - default: - throw new Exception("E-Invoice standard not supported"); - } - } else { - throw new Exception("File type not supported"); + + $expense = null; + + // try to parse via Zugferd lib + $zugferd_exception = null; + try { + $expense = (new ZugferdEDocument($this->file))->run(); + } catch (Exception $e) { + $zugferd_exception = $e; } + + // try to parse via mindee lib + $mindee_exception = null; + try { + $expense = (new MindeeEDocument($this->file))->run(); + } catch (Exception $e) { + // ignore not available exceptions + $mindee_exception = $e; + } + + // return expense, when available and supress any errors occured before + if ($expense) + return $expense; + + // log exceptions and throw error + if ($zugferd_exception) + nlog("Zugferd Exception: " . $zugferd_exception->getMessage()); + if ($mindee_exception) + nlog("Mindee Exception: " . $zugferd_exception->getMessage()); + throw new Exception("File type not supported or issue while parsing"); } } diff --git a/app/Services/EDocument/Imports/ZugferdEDocument.php b/app/Services/EDocument/Imports/ZugferdEDocument.php index 0794a2a8a0b4..21a6388ff3ce 100644 --- a/app/Services/EDocument/Imports/ZugferdEDocument.php +++ b/app/Services/EDocument/Imports/ZugferdEDocument.php @@ -25,6 +25,7 @@ use Exception; use horstoeko\zugferd\ZugferdDocumentReader; use horstoeko\zugferdvisualizer\renderer\ZugferdVisualizerLaravelRenderer; use horstoeko\zugferdvisualizer\ZugferdVisualizer; +use Illuminate\Http\UploadedFile; class ZugferdEDocument extends AbstractService { @@ -34,7 +35,7 @@ class ZugferdEDocument extends AbstractService /** * @throws Exception */ - public function __construct(public string $tempdocument, public string $documentname) + public function __construct(public UploadedFile $file) { # curl -X POST http://localhost:8000/api/v1/edocument/upload -H "Content-Type: multipart/form-data" -H "X-API-TOKEN: 7tdDdkz987H3AYIWhNGXy8jTjJIoDhkAclCDLE26cTCj1KYX7EBHC66VEitJwWhn" -H "X-Requested-With: XMLHttpRequest" -F _method=PUT -F documents[]=@einvoice.xml } @@ -45,7 +46,7 @@ class ZugferdEDocument extends AbstractService public function run(): Expense { $user = auth()->user(); - $this->document = ZugferdDocumentReader::readAndGuessFromContent($this->tempdocument); + $this->document = ZugferdDocumentReader::readAndGuessFromContent($this->file->get()); $this->document->getDocumentInformation($documentno, $documenttypecode, $documentdate, $invoiceCurrency, $taxCurrency, $documentname, $documentlanguage, $effectiveSpecifiedPeriod); $this->document->getDocumentSummation($grandTotalAmount, $duePayableAmount, $lineTotalAmount, $chargeTotalAmount, $allowanceTotalAmount, $taxBasisTotalAmount, $taxTotalAmount, $roundingAmount, $totalPrepaidAmount); @@ -68,10 +69,12 @@ class ZugferdEDocument extends AbstractService $expense->currency_id = Currency::whereCode($invoiceCurrency)->first()->id; $expense->save(); - $origin_file = TempFile::UploadedFileFromRaw($this->tempdocument, $this->documentname, "application/xml"); - $uploaded_file = TempFile::UploadedFileFromRaw($visualizer->renderPdf(), $documentno . "_visualiser.pdf", "application/pdf"); - $this->saveDocuments([$origin_file, $uploaded_file], $expense); - $expense->save(); + $documents = []; + array_push($documents, $this->file); + if ($this->file->getExtension() == "xml") + array_push($documents, TempFile::UploadedFileFromRaw($visualizer->renderPdf(), $documentno . "_visualiser.pdf", "application/pdf")); + $this->saveDocuments($documents, $expense); + $expense->saveQuietly(); if ($taxCurrency && $taxCurrency != $invoiceCurrency) { $expense->private_notes = ctrans("texts.tax_currency_mismatch"); diff --git a/app/Services/InboundMail/InboundMailEngine.php b/app/Services/InboundMail/InboundMailEngine.php index f2973556811d..8c5acf764443 100644 --- a/app/Services/InboundMail/InboundMailEngine.php +++ b/app/Services/InboundMail/InboundMailEngine.php @@ -181,13 +181,12 @@ class InboundMailEngine // check if document can be parsed to an expense try { - $expense = (new ParseEDocument($document->get(), $document->getFilename()))->run(); + $expense = (new ParseEDocument($document))->run(); // check if expense was already matched within this job and skip if true - if (array_search($expense->id, $parsed_expense_ids)) { - $this->saveDocument($document, $expense); + if (array_search($expense->id, $parsed_expense_ids)) continue; - } + array_push($parsed_expenses, $expense->id); } catch (\Exception $err) { @@ -201,6 +200,8 @@ class InboundMailEngine } } + $is_imported_by_parser = array_search($expense->id, $parsed_expense_ids); + // populate missing data with data from email if (!$expense) $expense = ExpenseFactory::create($company->id, $company->owner()->id); @@ -217,18 +218,22 @@ class InboundMailEngine if (!$expense->vendor_id && $expense_vendor) $expense->vendor_id = $expense_vendor->id; - // handle documents + // save document only, when not imported by parser $documents = []; - array_push($documents, $document); + if ($is_imported_by_parser) + array_push($documents, $document); - // handle email document + // email document if ($email->body_document !== null) array_push($documents, $email->body_document); - $expense->saveQuietly(); - $this->saveDocuments($documents, $expense); + if ($is_imported_by_parser) + $expense->saveQuietly(); + else + $expense->save(); + } } diff --git a/config/services.php b/config/services.php index b0d9851df34e..48e8b75f4769 100644 --- a/config/services.php +++ b/config/services.php @@ -51,6 +51,13 @@ return [ 'redirect' => env('MICROSOFT_REDIRECT_URI'), ], + 'mindee' => [ + 'api_key' => env('MINDEE_API_KEY'), + 'global_contingent_month' => env('MINDEE_GLOBAL_CONTINGENT_MONTH', 1000), + 'company_contingent_month' => env('MINDEE_COMPANY_CONTINGENT_MONTH', 500), + 'company_contingent_month_enterprise' => env('MINDEE_COMPANY_CONTINGENT_MONTH', 500), + ], + 'apple' => [ 'client_id' => env('APPLE_CLIENT_ID'), 'client_secret' => env('APPLE_CLIENT_SECRET'),