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);
+ }
+}