Merge pull request #8368 from LarsK1/v5-develop

Support for XRechnung / ZUGFeRD / e-Factur
This commit is contained in:
David Bomba 2023-04-17 17:50:31 +10:00 committed by GitHub
commit d742e7b42f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 583 additions and 18 deletions

3
.gitignore vendored
View File

@ -37,4 +37,5 @@ public/test.pdf
public/storage/test.pdf public/storage/test.pdf
/Modules /Modules
_ide_helper_models.php _ide_helper_models.php
_ide_helper.php _ide_helper.php
/composer.phar

View File

@ -175,6 +175,9 @@ class SendRemindersCron extends Command
$invoice->calc()->getInvoice()->save(); $invoice->calc()->getInvoice()->save();
$invoice->fresh(); $invoice->fresh();
$invoice->service()->deletePdf()->save(); $invoice->service()->deletePdf()->save();
if ($invoice->company->enable_e_invoice){
$invoice->service()->deleteEInvoice()->save();
}
/* Refresh the client here to ensure the balance is fresh */ /* Refresh the client here to ensure the balance is fresh */
$client = $invoice->client; $client = $invoice->client;

View File

@ -295,6 +295,11 @@ class InvoiceSum
return $this->total; return $this->total;
} }
public function getTotalSurcharges()
{
return $this->total_custom_values;
}
public function setTaxMap() public function setTaxMap()
{ {
if ($this->invoice->is_amount_discount == true) { if ($this->invoice->is_amount_discount == true) {

View File

@ -173,6 +173,11 @@ class InvoiceSumInclusive
return $this; return $this;
} }
public function getTotalSurcharges()
{
return $this->total_custom_values;
}
public function getRecurringInvoice() public function getRecurringInvoice()
{ {
$this->invoice->amount = $this->formatValue($this->getTotal(), $this->precision); $this->invoice->amount = $this->formatValue($this->getTotal(), $this->precision);

View File

@ -535,7 +535,7 @@ class InvoiceController extends BaseController
if (Ninja::isHosted() && (stripos($action, 'email') !== false) && !auth()->user()->company()->account->account_sms_verified) { 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); return response(['message' => 'Please verify your account to send emails.'], 400);
} }
$invoices = Invoice::withTrashed()->whereIn('id', $this->transformKeys($ids))->company()->get(); $invoices = Invoice::withTrashed()->whereIn('id', $this->transformKeys($ids))->company()->get();
if (! $invoices) { if (! $invoices) {
@ -678,7 +678,7 @@ class InvoiceController extends BaseController
case 'clone_to_invoice': case 'clone_to_invoice':
$invoice = CloneInvoiceFactory::create($invoice, auth()->user()->id); $invoice = CloneInvoiceFactory::create($invoice, auth()->user()->id);
return $this->itemResponse($invoice); return $this->itemResponse($invoice);
case 'clone_to_quote': case 'clone_to_quote':
$quote = CloneInvoiceToQuoteFactory::create($invoice, auth()->user()->id); $quote = CloneInvoiceToQuoteFactory::create($invoice, auth()->user()->id);
@ -860,6 +860,73 @@ class InvoiceController extends BaseController
}, basename($file), $headers); }, 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( * @OA\Get(
* path="/api/v1/invoices/{id}/delivery_note", * path="/api/v1/invoices/{id}/delivery_note",

View File

@ -12,6 +12,7 @@
namespace App\Jobs\Entity; namespace App\Jobs\Entity;
use App\Exceptions\FilePermissionsFailure; use App\Exceptions\FilePermissionsFailure;
use App\Jobs\Invoice\CreateXInvoice;
use App\Libraries\MultiDB; use App\Libraries\MultiDB;
use App\Models\Credit; use App\Models\Credit;
use App\Models\CreditInvitation; use App\Models\CreditInvitation;
@ -211,7 +212,9 @@ class CreateEntityPdf implements ShouldQueue
throw new FilePermissionsFailure($e->getMessage()); 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->invitation = null;
$this->entity = null; $this->entity = null;
$this->company = null; $this->company = null;
@ -219,7 +222,8 @@ class CreateEntityPdf implements ShouldQueue
$this->contact = null; $this->contact = null;
$maker = null; $maker = null;
$state = null; $state = null;
return $file_path; return $file_path;
} }

View File

@ -0,0 +1,240 @@
<?php
namespace App\Jobs\Invoice;
use App\Models\Invoice;
use App\Models\Product;
use horstoeko\zugferd\codelists\ZugferdDutyTaxFeeCategories;
use horstoeko\zugferd\ZugferdDocumentBuilder;
use horstoeko\zugferd\ZugferdDocumentPdfBuilder;
use horstoeko\zugferd\ZugferdProfiles;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
class CreateXInvoice implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private Invoice $invoice;
private bool $alterpdf;
private string $custompdfpath;
public function __construct(Invoice $invoice, bool $alterPDF, string $custompdfpath = "")
{
$this->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;
}
}

View File

@ -78,13 +78,19 @@ class ZipInvoices implements ShouldQueue
$this->invoices->each(function ($invoice) { $this->invoices->each(function ($invoice) {
(new CreateEntityPdf($invoice->invitations()->first()))->handle(); (new CreateEntityPdf($invoice->invitations()->first()))->handle();
if ($this->company->use_xinvoice){
(new CreateXInvoice($invoice, false))->handle();
}
}); });
try { try {
foreach ($this->invoices as $invoice) { foreach ($this->invoices as $invoice) {
$file = $invoice->service()->getInvoicePdf(); $file = $invoice->service()->getInvoicePdf();
$xinvoice = $invoice->service()->getXInvoice();
$zip_file_name = basename($file); $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)); //$download_file = file_get_contents($invoice->pdf_file_path($invitation, 'url', true));
//$zipFile->addFromString(basename($invoice->pdf_file_path($invitation)), $download_file); //$zipFile->addFromString(basename($invoice->pdf_file_path($invitation)), $download_file);

View File

@ -18,6 +18,7 @@ use App\Services\PdfMaker\Designs\Utilities\DesignHelpers;
use App\Utils\HtmlEngine; use App\Utils\HtmlEngine;
use App\Utils\Ninja; use App\Utils\Ninja;
use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailable;
use Illuminate\Support\Facades\Storage;
class TemplateEmail extends Mailable class TemplateEmail extends Mailable
{ {
@ -152,6 +153,11 @@ class TemplateEmail extends Mailable
$this->attachData($ubl_string, $this->invitation->invoice->getFileName('xml')); $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; return $this;
} }

View File

@ -41,6 +41,7 @@ use Laracasts\Presenter\PresentableTrait;
* @property string|null $client_hash * @property string|null $client_hash
* @property string|null $logo * @property string|null $logo
* @property string|null $phone * @property string|null $phone
* @property string|null routing_id
* @property string $balance * @property string $balance
* @property string $paid_to_date * @property string $paid_to_date
* @property string $credit_balance * @property string $credit_balance
@ -400,6 +401,7 @@ class Client extends BaseModel implements HasLocalePreference
'public_notes', 'public_notes',
'phone', 'phone',
'number', 'number',
'routing_id',
]; ];
protected $with = [ protected $with = [
@ -449,6 +451,7 @@ class Client extends BaseModel implements HasLocalePreference
'id_number', 'id_number',
'public_notes', 'public_notes',
'phone', 'phone',
'routing_id',
]; ];
// public function scopeExclude($query) // 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/'; 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) public function quote_filepath($invitation)
{ {

View File

@ -97,6 +97,8 @@ use Laracasts\Presenter\PresentableTrait;
* @property int $stock_notification * @property int $stock_notification
* @property string|null $matomo_url * @property string|null $matomo_url
* @property int|null $matomo_id * @property int|null $matomo_id
* @property bool $enable_e_invoice
* @property string $e_invoice_type
* @property int $enabled_expense_tax_rates * @property int $enabled_expense_tax_rates
* @property int $invoice_task_project * @property int $invoice_task_project
* @property int $report_include_deleted * @property int $report_include_deleted
@ -837,6 +839,8 @@ class Company extends BaseModel
'google_analytics_key', 'google_analytics_key',
'matomo_url', 'matomo_url',
'matomo_id', 'matomo_id',
'enable_e_invoice',
'e_invoice_type',
'client_can_register', 'client_can_register',
'enable_shop_api', 'enable_shop_api',
'invoice_task_timelog', 'invoice_task_timelog',
@ -914,7 +918,7 @@ class Company extends BaseModel
public function refreshTaxData() public function refreshTaxData()
{ {
} }
public function documents() public function documents()

View File

@ -123,6 +123,8 @@ class Product extends BaseModel
public const PRODUCT_TYPE_EXEMPT = 5; public const PRODUCT_TYPE_EXEMPT = 5;
public const PRODUCT_TYPE_REDUCED_TAX = 6; public const PRODUCT_TYPE_REDUCED_TAX = 6;
public const PRODUCT_TYPE_OVERRIDE_TAX = 7; public const PRODUCT_TYPE_OVERRIDE_TAX = 7;
public const PRODUCT_TYPE_ZERO_RATED = 8;
public const PRODUCT_TYPE_REVERSE_TAX = 9;
protected $fillable = [ protected $fillable = [
'custom_value1', 'custom_value1',

View File

@ -11,6 +11,8 @@
namespace App\Services\Email; namespace App\Services\Email;
use App\Jobs\Invoice\CreateXInvoice;
use App\Services\Invoice\GetInvoiceXInvoice;
use App\DataMapper\EmailTemplateDefaults; use App\DataMapper\EmailTemplateDefaults;
use App\Jobs\Entity\CreateRawPdf; use App\Jobs\Entity\CreateRawPdf;
use App\Jobs\Invoice\CreateUbl; use App\Jobs\Invoice\CreateUbl;
@ -55,7 +57,7 @@ class EmailDefaults
public function __construct(protected Email $email) public function __construct(protected Email $email)
{ {
} }
/** /**
* Entry point for generating * Entry point for generating
* the defaults for the email object * the defaults for the email object
@ -78,7 +80,6 @@ class EmailDefaults
->setAttachments() ->setAttachments()
->setVariables() ->setVariables()
->setHeaders(); ->setHeaders();
return $this->email->email_object; return $this->email->email_object;
} }
@ -183,7 +184,6 @@ class EmailDefaults
// Default template to be used // Default template to be used
$this->email->email_object->body = EmailTemplateDefaults::getDefaultTemplate($this->email->email_object->email_template_body, $this->locale); $this->email->email_object->body = EmailTemplateDefaults::getDefaultTemplate($this->email->email_object->email_template_body, $this->locale);
} }
return $this; return $this;
} }
@ -224,7 +224,7 @@ class EmailDefaults
public function setVariables(): self public function setVariables(): self
{ {
$this->email->email_object->body = strtr($this->email->email_object->body, $this->email->email_object->variables); $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); $this->email->email_object->subject = strtr($this->email->email_object->subject, $this->email->email_object->variables);
if ($this->template != 'custom') { if ($this->template != 'custom') {
@ -253,7 +253,7 @@ class EmailDefaults
foreach ($bccs as $bcc) { foreach ($bccs as $bcc) {
$bcc_array[] = new Address($bcc); $bcc_array[] = new Address($bcc);
} }
$this->email->email_object->bcc = array_merge($this->email->email_object->bcc, $bcc_array); $this->email->email_object->bcc = array_merge($this->email->email_object->bcc, $bcc_array);
return $this; return $this;
@ -267,7 +267,7 @@ class EmailDefaults
return $this; return $this;
// return $this->email->email_object->cc; // return $this->email->email_object->cc;
// return [ // return [
// ]; // ];
} }
@ -298,7 +298,16 @@ class EmailDefaults
$this->email->email_object->entity instanceof Quote || $this->email->email_object->entity instanceof Quote ||
$this->email->email_object->entity instanceof Credit)) { $this->email->email_object->entity instanceof Credit)) {
$pdf = ((new CreateRawPdf($this->email->email_object->invitation, $this->email->company->db))->handle()); $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']]); $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')]]); $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)) { if (!$this->email->email_object->settings->document_email_attachment || !$this->email->company->account->hasFeature(Account::FEATURE_DOCUMENTS)) {
return $this; return $this;
@ -332,7 +346,7 @@ class EmailDefaults
if ($this->email->email_object->entity instanceof Invoice) { if ($this->email->email_object->entity instanceof Invoice) {
$expense_ids = []; $expense_ids = [];
$task_ids = []; $task_ids = [];
foreach ($this->email->email_object->entity->line_items as $item) { foreach ($this->email->email_object->entity->line_items as $item) {
if (property_exists($item, 'expense_id')) { if (property_exists($item, 'expense_id')) {
$expense_ids[] = $item->expense_id; $expense_ids[] = $item->expense_id;

View File

@ -0,0 +1,54 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Invoice;
use App\Jobs\Invoice\CreateXInvoice;
use App\Models\ClientContact;
use App\Models\Invoice;
use App\Services\AbstractService;
use Illuminate\Support\Facades\Storage;
class GetInvoiceXInvoice extends AbstractService
{
public function __construct(Invoice $invoice, ClientContact $contact = null)
{
$this->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;
}
}

View File

@ -14,10 +14,12 @@ namespace App\Services\Invoice;
use App\Events\Invoice\InvoiceWasArchived; use App\Events\Invoice\InvoiceWasArchived;
use App\Jobs\Entity\CreateEntityPdf; use App\Jobs\Entity\CreateEntityPdf;
use App\Jobs\Inventory\AdjustProductInventory; use App\Jobs\Inventory\AdjustProductInventory;
use App\Jobs\Invoice\CreateXInvoice;
use App\Libraries\Currency\Conversion\CurrencyApi; use App\Libraries\Currency\Conversion\CurrencyApi;
use App\Models\CompanyGateway; use App\Models\CompanyGateway;
use App\Models\Expense; use App\Models\Expense;
use App\Models\Invoice; use App\Models\Invoice;
use App\Models\InvoiceInvitation;
use App\Models\Payment; use App\Models\Payment;
use App\Models\Task; use App\Models\Task;
use App\Utils\Ninja; use App\Utils\Ninja;
@ -184,6 +186,11 @@ class InvoiceService
return (new GenerateDeliveryNote($invoice, $contact))->run(); return (new GenerateDeliveryNote($invoice, $contact))->run();
} }
public function getEInvoice($contact = null)
{
return (new GetInvoiceXInvoice($this->invoice, $contact))->run();
}
public function sendEmail($contact = null) public function sendEmail($contact = null)
{ {
$send_email = new SendEmail($this->invoice, null, $contact); $send_email = new SendEmail($this->invoice, null, $contact);
@ -293,7 +300,7 @@ class InvoiceService
} elseif ($this->invoice->balance < 0 || $this->invoice->balance > 0) { } elseif ($this->invoice->balance < 0 || $this->invoice->balance > 0) {
$this->invoice->status_id = Invoice::STATUS_SENT; $this->invoice->status_id = Invoice::STATUS_SENT;
} }
return $this; return $this;
} }
@ -351,6 +358,27 @@ class InvoiceService
return $this; 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() public function removeUnpaidGatewayFees()
{ {
$balance = $this->invoice->balance; $balance = $this->invoice->balance;
@ -421,6 +449,10 @@ class InvoiceService
if ($force) { if ($force) {
$this->invoice->invitations->each(function ($invitation) { $this->invoice->invitations->each(function ($invitation) {
(new CreateEntityPdf($invitation))->handle(); (new CreateEntityPdf($invitation))->handle();
if ($invitation instanceof InvoiceInvitation)
{
(new CreateXInvoice($invitation->invoice, true))->handle();
}
}); });
return $this; return $this;
@ -428,6 +460,10 @@ class InvoiceService
$this->invoice->invitations->each(function ($invitation) { $this->invoice->invitations->each(function ($invitation) {
CreateEntityPdf::dispatch($invitation); CreateEntityPdf::dispatch($invitation);
if ($invitation instanceof InvoiceInvitation)
{
CreateXInvoice::dispatch($invitation->invoice, true);
}
}); });
} catch (\Exception $e) { } catch (\Exception $e) {
nlog('failed creating invoices in Touch PDF'); nlog('failed creating invoices in Touch PDF');

View File

@ -91,6 +91,7 @@
"turbo124/predis": "1.1.11", "turbo124/predis": "1.1.11",
"twilio/sdk": "^6.40", "twilio/sdk": "^6.40",
"webpatser/laravel-countries": "dev-master#75992ad", "webpatser/laravel-countries": "dev-master#75992ad",
"horstoeko/zugferd":"^1",
"wepay/php-sdk": "^0.3" "wepay/php-sdk": "^0.3"
}, },
"require-dev": { "require-dev": {

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('clients', function (Blueprint $table) {
$table->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()
{
}
};

View File

@ -4979,6 +4979,9 @@ $LANG = array(
'white_label_body' => 'Thank you for purchasing a white label license. <br><br> Your license key is: <br><br> :license_key', 'white_label_body' => 'Thank you for purchasing a white label license. <br><br> Your license key is: <br><br> :license_key',
'payment_type_Klarna' => 'Klarna', 'payment_type_Klarna' => 'Klarna',
'payment_type_Interac E Transfer' => 'Interac E Transfer', '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', 'pre_payment' => 'Pre Payment',
'number_of_payments' => 'Number of payments', 'number_of_payments' => 'Number of payments',
'number_of_payments_helper' => 'The number of times this payment will be made', 'number_of_payments_helper' => 'The number of times this payment will be made',

View File

@ -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::get('invoices/{invoice}/{action}', [InvoiceController::class, 'action'])->name('invoices.action');
Route::put('invoices/{invoice}/upload', [InvoiceController::class, 'upload'])->name('invoices.upload'); 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', [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/bulk', [InvoiceController::class, 'bulk'])->name('invoices.bulk');
Route::post('invoices/update_reminders', [InvoiceController::class, 'update_reminders'])->name('invoices.update_reminders'); 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', [TwilioController::class, 'generate'])->name('verify.generate')->middleware('throttle:100,1');
Route::post('verify/confirm', [TwilioController::class, 'confirm'])->name('verify.confirm'); Route::post('verify/confirm', [TwilioController::class, 'confirm'])->name('verify.confirm');
Route::resource('vendors', VendorController::class); // name = (vendors. index / create / show / update / destroy / edit Route::resource('vendors', VendorController::class); // name = (vendors. index / create / show / update / destroy / edit
Route::post('vendors/bulk', [VendorController::class, 'bulk'])->name('vendors.bulk'); Route::post('vendors/bulk', [VendorController::class, 'bulk'])->name('vendors.bulk');
Route::put('vendors/{vendor}/upload', [VendorController::class, 'upload']); Route::put('vendors/{vendor}/upload', [VendorController::class, 'upload']);

View File

@ -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::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}/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/{recurring_invoice}', [SubscriptionController::class, 'show'])->middleware('portal_enabled')->name('subscriptions.show');
Route::get('subscriptions', [SubscriptionController::class, 'index'])->middleware('portal_enabled')->name('subscriptions.index'); 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('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('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_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('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('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']); Route::get('{entity}/{invitation_key}/download', [App\Http\Controllers\ClientPortal\InvitationController::class, 'routerForDownload']);

View File

@ -0,0 +1,68 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
use App\Jobs\Entity\CreateEntityPdf;
use App\Jobs\Invoice\CreateXInvoice;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Tests\MockAccountData;
use Tests\TestCase;
use horstoeko\zugferd\ZugferdDocumentReader;
/**
* @test
* @covers App\Jobs\Invoice\CreateXInvoice
*/
class EInvoiceTest extends TestCase
{
use MockAccountData;
use DatabaseTransactions;
protected function setUp() :void
{
parent::setUp();
$this->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);
}
}