diff --git a/.gitignore b/.gitignore index 8c1625524ae2..dff90a49360a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ public/test.pdf public/storage/test.pdf /Modules _ide_helper_models.php -_ide_helper.php \ No newline at end of file +_ide_helper.php +/composer.phar diff --git a/app/Console/Commands/SendRemindersCron.php b/app/Console/Commands/SendRemindersCron.php index 7319ec30f727..7921633c8d9d 100644 --- a/app/Console/Commands/SendRemindersCron.php +++ b/app/Console/Commands/SendRemindersCron.php @@ -175,6 +175,9 @@ class SendRemindersCron extends Command $invoice->calc()->getInvoice()->save(); $invoice->fresh(); $invoice->service()->deletePdf()->save(); + if ($invoice->company->enable_e_invoice){ + $invoice->service()->deleteEInvoice()->save(); + } /* Refresh the client here to ensure the balance is fresh */ $client = $invoice->client; diff --git a/app/Helpers/Invoice/InvoiceSum.php b/app/Helpers/Invoice/InvoiceSum.php index 8c0cbfd64550..09d181fe198a 100644 --- a/app/Helpers/Invoice/InvoiceSum.php +++ b/app/Helpers/Invoice/InvoiceSum.php @@ -295,6 +295,11 @@ class InvoiceSum return $this->total; } + public function getTotalSurcharges() + { + return $this->total_custom_values; + } + public function setTaxMap() { if ($this->invoice->is_amount_discount == true) { diff --git a/app/Helpers/Invoice/InvoiceSumInclusive.php b/app/Helpers/Invoice/InvoiceSumInclusive.php index 66480f96964b..d0d9c1e9c6e3 100644 --- a/app/Helpers/Invoice/InvoiceSumInclusive.php +++ b/app/Helpers/Invoice/InvoiceSumInclusive.php @@ -173,6 +173,11 @@ class InvoiceSumInclusive return $this; } + public function getTotalSurcharges() + { + return $this->total_custom_values; + } + public function getRecurringInvoice() { $this->invoice->amount = $this->formatValue($this->getTotal(), $this->precision); diff --git a/app/Http/Controllers/InvoiceController.php b/app/Http/Controllers/InvoiceController.php index fb7cde006ec1..1d7ddc21d76e 100644 --- a/app/Http/Controllers/InvoiceController.php +++ b/app/Http/Controllers/InvoiceController.php @@ -535,7 +535,7 @@ class InvoiceController extends BaseController if (Ninja::isHosted() && (stripos($action, 'email') !== false) && !auth()->user()->company()->account->account_sms_verified) { return response(['message' => 'Please verify your account to send emails.'], 400); } - + $invoices = Invoice::withTrashed()->whereIn('id', $this->transformKeys($ids))->company()->get(); if (! $invoices) { @@ -678,7 +678,7 @@ class InvoiceController extends BaseController case 'clone_to_invoice': $invoice = CloneInvoiceFactory::create($invoice, auth()->user()->id); return $this->itemResponse($invoice); - + case 'clone_to_quote': $quote = CloneInvoiceToQuoteFactory::create($invoice, auth()->user()->id); @@ -860,6 +860,73 @@ class InvoiceController extends BaseController }, basename($file), $headers); } + /** + * @OA\Get( + * path="/api/v1/invoice/{invitation_key}/download_xinvoice", + * operationId="downloadXInvoice", + * tags={"invoices"}, + * summary="Download a specific x-invoice by invitation key", + * description="Downloads a specific x-invoice", + * @OA\Parameter(ref="#/components/parameters/X-API-TOKEN"), + * @OA\Parameter(ref="#/components/parameters/X-Requested-With"), + * @OA\Parameter(ref="#/components/parameters/include"), + * @OA\Parameter( + * name="invitation_key", + * in="path", + * description="The Invoice Invitation Key", + * example="D2J234DFA", + * required=true, + * @OA\Schema( + * type="string", + * format="string", + * ), + * ), + * @OA\Response( + * response=200, + * description="Returns the x-invoice pdf", + * @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"), + * @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"), + * @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"), + * ), + * @OA\Response( + * response=422, + * description="Validation error", + * @OA\JsonContent(ref="#/components/schemas/ValidationError"), + * + * ), + * @OA\Response( + * response="default", + * description="Unexpected Error", + * @OA\JsonContent(ref="#/components/schemas/Error"), + * ), + * ) + * @param $invitation_key + * @return \Symfony\Component\HttpFoundation\BinaryFileResponse + */ + public function downloadEInvoice($invitation_key) + { + $invitation = $this->invoice_repo->getInvitationByKey($invitation_key); + + if (! $invitation) { + return response()->json(['message' => 'no record found'], 400); + } + + $contact = $invitation->contact; + $invoice = $invitation->invoice; + + $file = $invoice->service()->getEInvoice($contact); + + $headers = ['Content-Type' => 'application/xml']; + + if (request()->input('inline') == 'true') { + $headers = array_merge($headers, ['Content-Disposition' => 'inline']); + } + + return response()->streamDownload(function () use ($file) { + echo Storage::get($file); + }, basename($file), $headers); + } + /** * @OA\Get( * path="/api/v1/invoices/{id}/delivery_note", diff --git a/app/Jobs/Entity/CreateEntityPdf.php b/app/Jobs/Entity/CreateEntityPdf.php index 9bdb48f53767..631bf2d35ef5 100644 --- a/app/Jobs/Entity/CreateEntityPdf.php +++ b/app/Jobs/Entity/CreateEntityPdf.php @@ -12,6 +12,7 @@ namespace App\Jobs\Entity; use App\Exceptions\FilePermissionsFailure; +use App\Jobs\Invoice\CreateXInvoice; use App\Libraries\MultiDB; use App\Models\Credit; use App\Models\CreditInvitation; @@ -211,7 +212,9 @@ class CreateEntityPdf implements ShouldQueue throw new FilePermissionsFailure($e->getMessage()); } } - + if ($this->entity_string == "invoice" && $this->company->enable_e_invoice){ + (new CreateXInvoice($this->entity, true))->handle(); + } $this->invitation = null; $this->entity = null; $this->company = null; @@ -219,7 +222,8 @@ class CreateEntityPdf implements ShouldQueue $this->contact = null; $maker = null; $state = null; - + + return $file_path; } diff --git a/app/Jobs/Invoice/CreateXInvoice.php b/app/Jobs/Invoice/CreateXInvoice.php new file mode 100644 index 000000000000..833e85c4fa93 --- /dev/null +++ b/app/Jobs/Invoice/CreateXInvoice.php @@ -0,0 +1,240 @@ +invoice = $invoice; + $this->alterpdf = $alterPDF; + $this->custompdfpath = $custompdfpath; + } + + /** + * Execute the job. + * + * + * @return string + */ + public function handle(): string + { + $invoice = $this->invoice; + $company = $invoice->company; + $client = $invoice->client; + $profile = ""; + switch ($company->e_invoice_type) { + case "EN16931": + $profile = ZugferdProfiles::PROFILE_EN16931; + break; + case "XInvoice_2_2": + $profile = ZugferdProfiles::PROFILE_XRECHNUNG_2_2; + break; + case "XInvoice_2_1": + $profile = ZugferdProfiles::PROFILE_XRECHNUNG_2_1; + break; + case "XInvoice_2_0": + $profile = ZugferdProfiles::PROFILE_XRECHNUNG_2; + break; + case "XInvoice_1_0": + $profile = ZugferdProfiles::PROFILE_XRECHNUNG; + break; + case "XInvoice-Extended": + $profile = ZugferdProfiles::PROFILE_EXTENDED; + break; + case "XInvoice-BasicWL": + $profile = ZugferdProfiles::PROFILE_BASICWL; + break; + case "XInvoice-Basic": + $profile = ZugferdProfiles::PROFILE_BASIC; + break; + } + $xrechnung = ZugferdDocumentBuilder::CreateNew($profile); + + $xrechnung + ->setDocumentInformation($invoice->number, "380", date_create($invoice->date), $invoice->client->getCurrencyCode()) + ->setDocumentSupplyChainEvent(date_create($invoice->date)) + ->setDocumentSeller($company->getSetting('name')) + ->setDocumentSellerAddress($company->getSetting("address1"), $company->getSetting("address2"), "", $company->getSetting("postal_code"), $company->getSetting("city"), $company->country()->iso_3166_2, $company->getSetting("state")) + ->setDocumentSellerContact($invoice->user->first_name." ".$invoice->user->last_name, "", $invoice->user->phone, "", $invoice->user->email) + ->setDocumentBuyer($client->name, $client->number) + ->setDocumentBuyerAddress($client->address1, "", "", $client->postal_code, $client->city, $client->country->iso_3166_2) + ->setDocumentBuyerReference($client->routing_id) + ->setDocumentBuyerContact($client->primary_contact()->first()->first_name . " " . $client->primary_contact()->first()->last_name, "", $client->primary_contact()->first()->phone, "", $client->primary_contact()->first()->email) + ->setDocumentShipToAddress($client->shipping_address1, $client->shipping_address2, "", $client->shipping_postal_code, $client->shipping_city, $client->shipping_country->iso_3166_2, $client->shipping_state) + ->addDocumentPaymentTerm(ctrans("texts.xinvoice_payable", ['payeddue' => date_create($invoice->date)->diff(date_create($invoice->due_date))->format("%d"), 'paydate' => $invoice->due_date])); + if (!empty($invoice->public_notes)) { + $xrechnung->addDocumentNote($invoice->public_notes); + } + if (!empty($invoice->po_number)) { + $xrechnung->setDocumentBuyerOrderReferencedDocument($invoice->po_number); + } + if (empty($client->routing_id)){ + $xrechnung->setDocumentBuyerReference(ctrans("texts.xinvoice_no_buyers_reference")); + } + $xrechnung->addDocumentPaymentMean(68, ctrans("texts.xinvoice_online_payment")); + + if (str_contains($company->getSetting('vat_number'), "/")) { + $xrechnung->addDocumentSellerTaxRegistration("FC", $company->getSetting('vat_number')); + } else { + $xrechnung->addDocumentSellerTaxRegistration("VA", $company->getSetting('vat_number')); + } + + $invoicingdata = $invoice->calc(); + $globaltax = null; + + //Create line items and calculate taxes + foreach ($invoice->line_items as $index => $item) { + $xrechnung->addNewPosition($index) + ->setDocumentPositionProductDetails($item->notes) + ->setDocumentPositionGrossPrice($item->gross_line_total) + ->setDocumentPositionNetPrice($item->line_total); + if (isset($item->task_id)) { + $xrechnung->setDocumentPositionQuantity($item->quantity, "HUR"); + } else { + $xrechnung->setDocumentPositionQuantity($item->quantity, "H87"); + } + $linenetamount = $item->line_total; + if ($item->discount > 0){ + if ($invoice->is_amount_discount){ + $linenetamount -= $item->discount; + } + else { + $linenetamount -= $linenetamount * ($item->discount / 100); + } + } + $xrechnung->setDocumentPositionLineSummation($linenetamount); + // According to european law, each line item can only have one tax rate + if (!(empty($item->tax_name1) && empty($item->tax_name2) && empty($item->tax_name3))){ + if (!empty($item->tax_name1)) { + $xrechnung->addDocumentPositionTax($this->getTaxType($item->tax_id, $invoice), 'VAT', $item->tax_rate1); + } elseif (!empty($item->tax_name2)) { + $xrechnung->addDocumentPositionTax($this->getTaxType($item->tax_id, $invoice), 'VAT', $item->tax_rate2); + } elseif (!empty($item->tax_name3)) { + $xrechnung->addDocumentPositionTax($this->getTaxType($item->tax_id, $invoice), 'VAT', $item->tax_rate3); + } else { + nlog("Can't add correct tax position"); + } + } else { + if (!empty($invoice->tax_name1)) { + $globaltax = 0; + $xrechnung->addDocumentPositionTax($this->getTaxType($invoice->tax_name1, $invoice), 'VAT', $invoice->tax_rate1); + } elseif (!empty($invoice->tax_name2)) { + $globaltax = 1; + $xrechnung->addDocumentPositionTax($this->getTaxType($invoice->tax_name2, $invoice), 'VAT', $invoice->tax_rate2); + } elseif (!empty($invoice->tax_name3)) { + $globaltax = 2; + $xrechnung->addDocumentPositionTax($this->getTaxType($invoice->tax_name3, $invoice), 'VAT', $item->tax_rate3); + } else { + nlog("Can't add correct tax position"); + } + } + } + + + if ($invoice->isPartial()) { + $xrechnung->setDocumentSummation($invoice->amount, $invoice->balance, $invoicingdata->getSubTotal(), $invoicingdata->getTotalSurcharges(), $invoicingdata->getTotalDiscount(), $invoicingdata->getSubTotal(), $invoicingdata->getItemTotalTaxes(), null, $invoice->partial); + } else { + $xrechnung->setDocumentSummation($invoice->amount, $invoice->balance, $invoicingdata->getSubTotal(), $invoicingdata->getTotalSurcharges(), $invoicingdata->getTotalDiscount(), $invoicingdata->getSubTotal(), $invoicingdata->getItemTotalTaxes(), null, 0.0); + } + + foreach ($invoicingdata->getTaxMap() as $item) { + $tax = explode(" ", $item["name"]); + $xrechnung->addDocumentTax($this->getTaxType("", $invoice), "VAT", $item["total"] / (explode("%", end($tax))[0] / 100), $item["total"], explode("%", end($tax))[0]); + // TODO: Add correct tax type within getTaxType + } + if (!empty($globaltax)){ + $tax = explode(" ", $invoicingdata->getTotalTaxMap()[$globaltax]["name"]); + $xrechnung->addDocumentTax($this->getTaxType("", $invoice), "VAT", $invoicingdata->getTotalTaxMap()[$globaltax]["total"] / (explode("%", end($tax))[0] / 100), $invoicingdata->getTotalTaxMap()[$globaltax]["total"], explode("%", end($tax))[0]); + // TODO: Add correct tax type within getTaxType + } + + $disk = config('filesystems.default'); + if (!Storage::exists($client->e_invoice_filepath($invoice->invitations->first()))) { + Storage::makeDirectory($client->e_invoice_filepath($invoice->invitations->first())); + } + $xrechnung->writeFile(Storage::disk($disk)->path($client->e_invoice_filepath($invoice->invitations->first()) . $invoice->getFileName("xml"))); + // The validity can be checked using https://portal3.gefeg.com/invoice/validation + + if ($this->alterpdf) { + if ($this->custompdfpath != "") { + $pdfBuilder = new ZugferdDocumentPdfBuilder($xrechnung, $this->custompdfpath); + $pdfBuilder->generateDocument(); + $pdfBuilder->saveDocument($this->custompdfpath); + } else { + $filepath_pdf = $client->invoice_filepath($invoice->invitations->first()) . $invoice->getFileName(); + $file = Storage::disk($disk)->exists($filepath_pdf); + if ($file) { + $pdfBuilder = new ZugferdDocumentPdfBuilder($xrechnung, Storage::disk($disk)->path($filepath_pdf)); + $pdfBuilder->generateDocument(); + $pdfBuilder->saveDocument(Storage::disk($disk)->path($filepath_pdf)); + } + } + } + + return $client->e_invoice_filepath($invoice->invitations->first()) . $invoice->getFileName("xml"); + } + + private function getTaxType($name, Invoice $invoice): string + { + $taxtype = null; + switch ($name) { + case Product::PRODUCT_TYPE_SERVICE: + case Product::PRODUCT_TYPE_DIGITAL: + case Product::PRODUCT_TYPE_PHYSICAL: + case Product::PRODUCT_TYPE_SHIPPING: + case Product::PRODUCT_TYPE_REDUCED_TAX: + $taxtype = ZugferdDutyTaxFeeCategories::STANDARD_RATE; + break; + case Product::PRODUCT_TYPE_EXEMPT: + $taxtype = ZugferdDutyTaxFeeCategories::EXEMPT_FROM_TAX; + break; + case Product::PRODUCT_TYPE_ZERO_RATED: + $taxtype = ZugferdDutyTaxFeeCategories::ZERO_RATED_GOODS; + break; + case Product::PRODUCT_TYPE_REVERSE_TAX: + $taxtype = ZugferdDutyTaxFeeCategories::VAT_REVERSE_CHARGE; + break; + } + $eu_states = ["AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "EL", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", "IS", "LI", "NO", "CH"]; + if (empty($taxtype)){ + if (in_array($invoice->company->country()->iso_3166_2, $eu_states) && in_array($invoice->client->country->iso_3166_2, $eu_states)){ + $taxtype = ZugferdDutyTaxFeeCategories::VAT_EXEMPT_FOR_EEA_INTRACOMMUNITY_SUPPLY_OF_GOODS_AND_SERVICES; + } + elseif (!in_array($invoice->client->country->iso_3166_2, $eu_states)){ + $taxtype = ZugferdDutyTaxFeeCategories::SERVICE_OUTSIDE_SCOPE_OF_TAX; + } + elseif ($invoice->client->country->iso_3166_2 == "ES-CN"){ + $taxtype = ZugferdDutyTaxFeeCategories::CANARY_ISLANDS_GENERAL_INDIRECT_TAX; + } + elseif (in_array($invoice->client->country->iso_3166_2, ["ES-CE", "ES-ML"])){ + $taxtype = ZugferdDutyTaxFeeCategories::TAX_FOR_PRODUCTION_SERVICES_AND_IMPORTATION_IN_CEUTA_AND_MELILLA; + } + else { + nlog("Unkown tax case for xinvoice"); + $taxtype = ZugferdDutyTaxFeeCategories::STANDARD_RATE; + } + } + return $taxtype; + } +} diff --git a/app/Jobs/Invoice/ZipInvoices.php b/app/Jobs/Invoice/ZipInvoices.php index 9a0d4752c3a8..3eb9bd21823e 100644 --- a/app/Jobs/Invoice/ZipInvoices.php +++ b/app/Jobs/Invoice/ZipInvoices.php @@ -78,13 +78,19 @@ class ZipInvoices implements ShouldQueue $this->invoices->each(function ($invoice) { (new CreateEntityPdf($invoice->invitations()->first()))->handle(); + if ($this->company->use_xinvoice){ + (new CreateXInvoice($invoice, false))->handle(); + } }); try { foreach ($this->invoices as $invoice) { $file = $invoice->service()->getInvoicePdf(); + $xinvoice = $invoice->service()->getXInvoice(); $zip_file_name = basename($file); - $zipFile->addFromString($zip_file_name, Storage::get($file)); + $xinvoice_zip_file_name = basename($xinvoice); + $zipFile->addFromString($zip_file_name, Storage::get($file)) + ->addDir($xinvoice_zip_file_name, Storage::get($xinvoice)); //$download_file = file_get_contents($invoice->pdf_file_path($invitation, 'url', true)); //$zipFile->addFromString(basename($invoice->pdf_file_path($invitation)), $download_file); diff --git a/app/Mail/TemplateEmail.php b/app/Mail/TemplateEmail.php index 70334976837b..11f83ba3f990 100644 --- a/app/Mail/TemplateEmail.php +++ b/app/Mail/TemplateEmail.php @@ -18,6 +18,7 @@ use App\Services\PdfMaker\Designs\Utilities\DesignHelpers; use App\Utils\HtmlEngine; use App\Utils\Ninja; use Illuminate\Mail\Mailable; +use Illuminate\Support\Facades\Storage; class TemplateEmail extends Mailable { @@ -152,6 +153,11 @@ class TemplateEmail extends Mailable $this->attachData($ubl_string, $this->invitation->invoice->getFileName('xml')); } } + if ($this->invitation && $this->invitation->invoice && $company->use_xinvoice && $this->company->account->hasFeature(Account::FEATURE_PDF_ATTACHMENT)) { + $this->invitation->invoice->service()->getXInvoice($this->invitation->contact); + $disk = config('filesystems.default'); + $this->attach(Storage::disk($disk)->path($this->invitation->invoice->client->xinvoice_filepath($this->invitation->invoice->invitations->first()) . $this->invitation->invoice->getFileName("xml"))); + } return $this; } diff --git a/app/Models/Client.php b/app/Models/Client.php index a4cee0c0edaf..a00119ac20c9 100644 --- a/app/Models/Client.php +++ b/app/Models/Client.php @@ -41,6 +41,7 @@ use Laracasts\Presenter\PresentableTrait; * @property string|null $client_hash * @property string|null $logo * @property string|null $phone + * @property string|null routing_id * @property string $balance * @property string $paid_to_date * @property string $credit_balance @@ -400,6 +401,7 @@ class Client extends BaseModel implements HasLocalePreference 'public_notes', 'phone', 'number', + 'routing_id', ]; protected $with = [ @@ -449,6 +451,7 @@ class Client extends BaseModel implements HasLocalePreference 'id_number', 'public_notes', 'phone', + 'routing_id', ]; // public function scopeExclude($query) @@ -910,6 +913,12 @@ class Client extends BaseModel implements HasLocalePreference return $this->company->company_key.'/'.$this->client_hash.'/'.$contact_key.'/invoices/'; } + public function e_invoice_filepath($invitation) + { + $contact_key = $invitation->contact->contact_key; + + return $this->company->company_key.'/'.$this->client_hash.'/'.$contact_key.'/e_invoice/'; + } public function quote_filepath($invitation) { diff --git a/app/Models/Company.php b/app/Models/Company.php index afdbbecc03ab..d50594d26aee 100644 --- a/app/Models/Company.php +++ b/app/Models/Company.php @@ -97,6 +97,8 @@ use Laracasts\Presenter\PresentableTrait; * @property int $stock_notification * @property string|null $matomo_url * @property int|null $matomo_id + * @property bool $enable_e_invoice + * @property string $e_invoice_type * @property int $enabled_expense_tax_rates * @property int $invoice_task_project * @property int $report_include_deleted @@ -837,6 +839,8 @@ class Company extends BaseModel 'google_analytics_key', 'matomo_url', 'matomo_id', + 'enable_e_invoice', + 'e_invoice_type', 'client_can_register', 'enable_shop_api', 'invoice_task_timelog', @@ -914,7 +918,7 @@ class Company extends BaseModel public function refreshTaxData() { - + } public function documents() diff --git a/app/Models/Product.php b/app/Models/Product.php index 4f6a3f5a6e5f..cc251944a749 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -123,6 +123,8 @@ class Product extends BaseModel public const PRODUCT_TYPE_EXEMPT = 5; public const PRODUCT_TYPE_REDUCED_TAX = 6; public const PRODUCT_TYPE_OVERRIDE_TAX = 7; + public const PRODUCT_TYPE_ZERO_RATED = 8; + public const PRODUCT_TYPE_REVERSE_TAX = 9; protected $fillable = [ 'custom_value1', diff --git a/app/Services/Email/EmailDefaults.php b/app/Services/Email/EmailDefaults.php index 1b08585f64c6..13b8294bb146 100644 --- a/app/Services/Email/EmailDefaults.php +++ b/app/Services/Email/EmailDefaults.php @@ -11,6 +11,8 @@ namespace App\Services\Email; +use App\Jobs\Invoice\CreateXInvoice; +use App\Services\Invoice\GetInvoiceXInvoice; use App\DataMapper\EmailTemplateDefaults; use App\Jobs\Entity\CreateRawPdf; use App\Jobs\Invoice\CreateUbl; @@ -55,7 +57,7 @@ class EmailDefaults public function __construct(protected Email $email) { } - + /** * Entry point for generating * the defaults for the email object @@ -78,7 +80,6 @@ class EmailDefaults ->setAttachments() ->setVariables() ->setHeaders(); - return $this->email->email_object; } @@ -183,7 +184,6 @@ class EmailDefaults // Default template to be used $this->email->email_object->body = EmailTemplateDefaults::getDefaultTemplate($this->email->email_object->email_template_body, $this->locale); } - return $this; } @@ -224,7 +224,7 @@ class EmailDefaults public function setVariables(): self { $this->email->email_object->body = strtr($this->email->email_object->body, $this->email->email_object->variables); - + $this->email->email_object->subject = strtr($this->email->email_object->subject, $this->email->email_object->variables); if ($this->template != 'custom') { @@ -253,7 +253,7 @@ class EmailDefaults foreach ($bccs as $bcc) { $bcc_array[] = new Address($bcc); } - + $this->email->email_object->bcc = array_merge($this->email->email_object->bcc, $bcc_array); return $this; @@ -267,7 +267,7 @@ class EmailDefaults return $this; // return $this->email->email_object->cc; // return [ - + // ]; } @@ -298,7 +298,16 @@ class EmailDefaults $this->email->email_object->entity instanceof Quote || $this->email->email_object->entity instanceof Credit)) { $pdf = ((new CreateRawPdf($this->email->email_object->invitation, $this->email->company->db))->handle()); - + if ($this->email->email_object->company->enable_e_invoice && $this->email->email_object->entity instanceof Invoice) { + $tempfile = tmpfile(); + file_put_contents(stream_get_meta_data($tempfile)['uri'], $pdf); + $xinvoice_path = (new CreateXInvoice($this->email->email_object->entity, true, stream_get_meta_data($tempfile)['uri']))->handle(); + $this->email->email_object->attachments = array_merge($this->email->email_object->attachments, [['file' => base64_encode(file_get_contents(stream_get_meta_data($tempfile)['uri'])), 'name' => $this->email->email_object->entity->numberFormatter().'.pdf']]); + $this->email->email_object->attachments = array_merge($this->email->email_object->attachments, [['file' => base64_encode(file_get_contents($xinvoice_path)), 'name' => explode(".", $this->email->email_object->entity->getFileName('xml'))[0]."-xinvoice.xml"]]); + } + else { + $this->email->email_object->attachments = array_merge($this->email->email_object->attachments, [['file' => base64_encode($pdf), 'name' => $this->email->email_object->entity->numberFormatter().'.pdf']]); + } $this->email->email_object->attachments = array_merge($this->email->email_object->attachments, [['file' => base64_encode($pdf), 'name' => $this->email->email_object->entity->numberFormatter().'.pdf']]); } @@ -310,6 +319,11 @@ class EmailDefaults $this->email->email_object->attachments = array_merge($this->email->email_object->attachments, [['file' => base64_encode($ubl_string), 'name' => $this->email->email_object->entity->getFileName('xml')]]); } } + /** E-Invoice xml file */ + if ($this->email->email_object->company->enable_e_invoice && $this->email->email_object->entity instanceof Invoice) { + $xinvoice_path = (new GetInvoiceXInvoice($this->email->email_object->entity))->run(); + $this->email->email_object->attachments = array_merge($this->email->email_object->attachments, [['file' => base64_encode(file_get_contents($xinvoice_path)), 'name' => explode(".", $this->email->email_object->entity->getFileName('xml'))[0]."-e_invoice.xml"]]); + } if (!$this->email->email_object->settings->document_email_attachment || !$this->email->company->account->hasFeature(Account::FEATURE_DOCUMENTS)) { return $this; @@ -332,7 +346,7 @@ class EmailDefaults if ($this->email->email_object->entity instanceof Invoice) { $expense_ids = []; $task_ids = []; - + foreach ($this->email->email_object->entity->line_items as $item) { if (property_exists($item, 'expense_id')) { $expense_ids[] = $item->expense_id; diff --git a/app/Services/Invoice/GetInvoiceXInvoice.php b/app/Services/Invoice/GetInvoiceXInvoice.php new file mode 100644 index 000000000000..ebe07aca1530 --- /dev/null +++ b/app/Services/Invoice/GetInvoiceXInvoice.php @@ -0,0 +1,54 @@ +invoice = $invoice; + + $this->contact = $contact; + } + + public function run() + { + if (! $this->contact) { + $this->contact = $this->invoice->client->primary_contact()->first() ?: $this->invoice->client->contacts()->first(); + } + + $invitation = $this->invoice->invitations->where('client_contact_id', $this->contact->id)->first(); + + if (! $invitation) { + $invitation = $this->invoice->invitations->first(); + } + + $file_path = $this->invoice->client->e_invoice_filepath($this->invoice->invitations->first()). $this->invoice->getFileName("xml"); + + // $disk = 'public'; + $disk = config('filesystems.default'); + + $file = Storage::disk($disk)->exists($file_path); + + if (! $file) { + $file_path = (new CreateXInvoice($this->invoice, false))->handle(); + } + + return $file_path; + } +} diff --git a/app/Services/Invoice/InvoiceService.php b/app/Services/Invoice/InvoiceService.php index 6c4fe4882003..e7bf0d28375d 100644 --- a/app/Services/Invoice/InvoiceService.php +++ b/app/Services/Invoice/InvoiceService.php @@ -14,10 +14,12 @@ namespace App\Services\Invoice; use App\Events\Invoice\InvoiceWasArchived; use App\Jobs\Entity\CreateEntityPdf; use App\Jobs\Inventory\AdjustProductInventory; +use App\Jobs\Invoice\CreateXInvoice; use App\Libraries\Currency\Conversion\CurrencyApi; use App\Models\CompanyGateway; use App\Models\Expense; use App\Models\Invoice; +use App\Models\InvoiceInvitation; use App\Models\Payment; use App\Models\Task; use App\Utils\Ninja; @@ -184,6 +186,11 @@ class InvoiceService return (new GenerateDeliveryNote($invoice, $contact))->run(); } + public function getEInvoice($contact = null) + { + return (new GetInvoiceXInvoice($this->invoice, $contact))->run(); + } + public function sendEmail($contact = null) { $send_email = new SendEmail($this->invoice, null, $contact); @@ -293,7 +300,7 @@ class InvoiceService } elseif ($this->invoice->balance < 0 || $this->invoice->balance > 0) { $this->invoice->status_id = Invoice::STATUS_SENT; } - + return $this; } @@ -351,6 +358,27 @@ class InvoiceService return $this; } + public function deleteEInvoice() + { + $this->invoice->load('invitations'); + + $this->invoice->invitations->each(function ($invitation) { + try { + if (Storage::disk(config('filesystems.default'))->exists($this->invoice->client->e_invoice_filepath($invitation).$this->invoice->getFileName("xml"))) { + Storage::disk(config('filesystems.default'))->delete($this->invoice->client->e_invoice_filepath($invitation).$this->invoice->getFileName("xml")); + } + + if (Ninja::isHosted() && Storage::disk('public')->exists($this->invoice->client->e_invoice_filepath($invitation).$this->invoice->getFileName("xml"))) { + Storage::disk('public')->delete($this->invoice->client->e_invoice_filepath($invitation).$this->invoice->getFileName("xml")); + } + } catch (\Exception $e) { + nlog($e->getMessage()); + } + }); + + return $this; + } + public function removeUnpaidGatewayFees() { $balance = $this->invoice->balance; @@ -421,6 +449,10 @@ class InvoiceService if ($force) { $this->invoice->invitations->each(function ($invitation) { (new CreateEntityPdf($invitation))->handle(); + if ($invitation instanceof InvoiceInvitation) + { + (new CreateXInvoice($invitation->invoice, true))->handle(); + } }); return $this; @@ -428,6 +460,10 @@ class InvoiceService $this->invoice->invitations->each(function ($invitation) { CreateEntityPdf::dispatch($invitation); + if ($invitation instanceof InvoiceInvitation) + { + CreateXInvoice::dispatch($invitation->invoice, true); + } }); } catch (\Exception $e) { nlog('failed creating invoices in Touch PDF'); diff --git a/composer.json b/composer.json index 05c1167dc222..55098e1af312 100644 --- a/composer.json +++ b/composer.json @@ -91,6 +91,7 @@ "turbo124/predis": "1.1.11", "twilio/sdk": "^6.40", "webpatser/laravel-countries": "dev-master#75992ad", + "horstoeko/zugferd":"^1", "wepay/php-sdk": "^0.3" }, "require-dev": { diff --git a/database/migrations/2023_03_13_156872_add_e_invoice_type_to_clients_table.php b/database/migrations/2023_03_13_156872_add_e_invoice_type_to_clients_table.php new file mode 100644 index 000000000000..ad5129219784 --- /dev/null +++ b/database/migrations/2023_03_13_156872_add_e_invoice_type_to_clients_table.php @@ -0,0 +1,35 @@ +string('routing_id')->default(null)->nullable(); + }); + Schema::table('companies', function (Blueprint $table) { + $table->boolean('enable_e_invoice')->default(false); + $table->string('e_invoice_type')->default("EN16931"); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + + } +}; diff --git a/lang/en/texts.php b/lang/en/texts.php index 7904d66e5738..d9f1808be8d8 100644 --- a/lang/en/texts.php +++ b/lang/en/texts.php @@ -4979,6 +4979,9 @@ $LANG = array( 'white_label_body' => 'Thank you for purchasing a white label license.

Your license key is:

:license_key', 'payment_type_Klarna' => 'Klarna', 'payment_type_Interac E Transfer' => 'Interac E Transfer', + 'xinvoice_payable' => 'Payable within :payeddue days net until :paydate', + 'xinvoice_no_buyers_reference' => "No buyer's reference given", + 'xinvoice_online_payment' => 'The invoice needs to be payed online via the provided link', 'pre_payment' => 'Pre Payment', 'number_of_payments' => 'Number of payments', 'number_of_payments_helper' => 'The number of times this payment will be made', diff --git a/routes/api.php b/routes/api.php index 93a127673d6d..26be8a89aedc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -205,6 +205,7 @@ Route::group(['middleware' => ['throttle:api', 'api_db', 'token_auth', 'locale'] Route::get('invoices/{invoice}/{action}', [InvoiceController::class, 'action'])->name('invoices.action'); Route::put('invoices/{invoice}/upload', [InvoiceController::class, 'upload'])->name('invoices.upload'); Route::get('invoice/{invitation_key}/download', [InvoiceController::class, 'downloadPdf'])->name('invoices.downloadPdf'); + Route::get('invoice/{invitation_key}/download_e_invoice', [InvoiceController::class, 'downloadEInvoice'])->name('invoices.downloadEInvoice'); Route::post('invoices/bulk', [InvoiceController::class, 'bulk'])->name('invoices.bulk'); Route::post('invoices/update_reminders', [InvoiceController::class, 'update_reminders'])->name('invoices.update_reminders'); @@ -316,7 +317,7 @@ Route::group(['middleware' => ['throttle:api', 'api_db', 'token_auth', 'locale'] Route::post('verify', [TwilioController::class, 'generate'])->name('verify.generate')->middleware('throttle:100,1'); Route::post('verify/confirm', [TwilioController::class, 'confirm'])->name('verify.confirm'); - + Route::resource('vendors', VendorController::class); // name = (vendors. index / create / show / update / destroy / edit Route::post('vendors/bulk', [VendorController::class, 'bulk'])->name('vendors.bulk'); Route::put('vendors/{vendor}/upload', [VendorController::class, 'upload']); diff --git a/routes/client.php b/routes/client.php index 80f9b60549a2..d8fd795cc9b7 100644 --- a/routes/client.php +++ b/routes/client.php @@ -102,7 +102,7 @@ Route::group(['middleware' => ['auth:contact', 'locale', 'domain_db','check_clie Route::resource('documents', App\Http\Controllers\ClientPortal\DocumentController::class)->only(['index', 'show']); Route::get('subscriptions/{recurring_invoice}/plan_switch/{target}', [App\Http\Controllers\ClientPortal\SubscriptionPlanSwitchController::class, 'index'])->name('subscription.plan_switch'); - + Route::get('subscriptions/{recurring_invoice}', [SubscriptionController::class, 'show'])->middleware('portal_enabled')->name('subscriptions.show'); Route::get('subscriptions', [SubscriptionController::class, 'index'])->middleware('portal_enabled')->name('subscriptions.index'); @@ -130,6 +130,7 @@ Route::group(['middleware' => ['invite_db'], 'prefix' => 'client', 'as' => 'clie Route::get('credit/{invitation_key}', [App\Http\Controllers\ClientPortal\InvitationController::class, 'creditRouter']); Route::get('recurring_invoice/{invitation_key}/download_pdf', [RecurringInvoiceController::class, 'downloadPdf'])->name('recurring_invoice.download_invitation_key'); Route::get('invoice/{invitation_key}/download_pdf', [InvoiceController::class, 'downloadPdf'])->name('invoice.download_invitation_key'); + Route::get('invoice/{invitation_key}/download_e_invoice', [InvoiceController::class, 'downloadEInvoice'])->name('invoice.download_e_invoice'); Route::get('quote/{invitation_key}/download_pdf', [QuoteController::class, 'downloadPdf'])->name('quote.download_invitation_key'); Route::get('credit/{invitation_key}/download_pdf', [CreditController::class, 'downloadPdf'])->name('credit.download_invitation_key'); Route::get('{entity}/{invitation_key}/download', [App\Http\Controllers\ClientPortal\InvitationController::class, 'routerForDownload']); diff --git a/tests/Unit/EInvoiceTest.php b/tests/Unit/EInvoiceTest.php new file mode 100644 index 000000000000..a67661977efb --- /dev/null +++ b/tests/Unit/EInvoiceTest.php @@ -0,0 +1,68 @@ +withoutMiddleware( + ThrottleRequests::class + ); + $this->makeTestData(); + } + + public function testEInvoiceGenerates() + { + $xinvoice = (new CreateXInvoice($this->invoice, false))->handle(); + $this->assertNotNull($xinvoice); + $this->assertFileExists($xinvoice); + } + + /** + * @throws Exception + */ + public function testValidityofXMLFile() + { + $xinvoice = (new CreateXInvoice($this->invoice, false))->handle(); + $document = ZugferdDocumentReader::readAndGuessFromFile($xinvoice); + $document ->getDocumentInformation($documentno); + $this->assertEquals($this->invoice->number, $documentno); + } + + /** + * @throws Exception + */ + public function checkEmbededPDFFile() + { + $pdf = (new CreateEntityPdf($this->invoice->invitations()->first())); + (new CreateXInvoice($this->invoice, true, $pdf))->handle(); + $document = ZugferdDocumentReader::readAndGuessFromFile($pdf); + $document ->getDocumentInformation($documentno); + $this->assertEquals($this->invoice->number, $documentno); + } +}