Merge pull request #11368 from turbo124/v5-develop

Fixes for percentage discount imports
This commit is contained in:
David Bomba
2025-10-21 07:51:16 +11:00
committed by GitHub
189 changed files with 21915 additions and 776 deletions

View File

@@ -0,0 +1,66 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www/elastic.co/licensing/elastic-license
*/
namespace App\Casts;
use App\DataMapper\InvoiceBackup;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class InvoiceBackupCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes)
{
if (is_null($value)) {
return new InvoiceBackup();
}
$data = json_decode($value, true) ?? [];
return InvoiceBackup::fromArray($data);
}
public function set($model, string $key, $value, array $attributes)
{
if (is_null($value)) {
return [$key => null];
}
// Ensure we're dealing with our object type
if (! $value instanceof InvoiceBackup) {
// Attempt to create the instance from legacy data before throwing
try {
if (is_object($value)) {
$value = InvoiceBackup::fromArray((array) $value);
}
} catch (\Exception $e) {
throw new \InvalidArgumentException('Value must be an InvoiceBackup instance. Legacy data conversion failed: ' . $e->getMessage());
}
}
return [
$key => json_encode([
'guid' => $value->guid,
'cancellation' => $value->cancellation ? [
'adjustment' => $value->cancellation->adjustment,
'status_id' => $value->cancellation->status_id,
] : [],
'parent_invoice_id' => $value->parent_invoice_id,
'parent_invoice_number' => $value->parent_invoice_number,
'document_type' => $value->document_type,
'child_invoice_ids' => $value->child_invoice_ids->toArray(),
'redirect' => $value->redirect,
'adjustable_amount' => $value->adjustable_amount,
'notes' => $value->notes,
])
];
}
}

View File

@@ -0,0 +1,87 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\DataMapper\Analytics;
use Turbo124\Beacon\ExampleMetric\GenericMixedMetric;
class FeedbackCreated extends GenericMixedMetric
{
/**
* The type of Sample.
*
* Monotonically incrementing counter
*
* - counter
*
* @var string
*/
public $type = 'mixed_metric';
/**
* The name of the counter.
* @var string
*/
public $name = 'app.feedback';
/**
* The datetime of the counter measurement.
*
* date("Y-m-d H:i:s")
*
*/
public $datetime;
/**
* The Class failure name
* set to 0.
*
* @var string
*/
public $string_metric5 = '';
/**
* The exception string
* set to 0.
*
* @var string
*/
public $string_metric6 = '';
/**
* The counter
* set to 1.
*
*/
public $int_metric1 = 1;
/**
* Company Key
* @var string
*/
public $string_metric7 = '';
/**
* Subject
* @var string
*/
public $string_metric8 = '';
public function __construct($int_metric1, $string_metric5, $string_metric6, $string_metric7, $string_metric8)
{
$this->int_metric1 = $int_metric1;
$this->string_metric5 = $string_metric5;
$this->string_metric6 = $string_metric6;
$this->string_metric7 = $string_metric7;
$this->string_metric8 = $string_metric8;
}
}

View File

@@ -0,0 +1,32 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www/elastic.co/licensing/elastic-license
*/
namespace App\DataMapper;
/**
* Cancellation value object for invoice backup data.
*/
class Cancellation
{
public function __construct(
public float $adjustment = 0, // The cancellation adjustment amount
public int $status_id = 0 //The status id of the invoice when it was cancelled
) {}
public static function fromArray(array $data): self
{
return new self(
adjustment: $data['adjustment'] ?? 0,
status_id: $data['status_id'] ?? 0
);
}
}

View File

@@ -479,7 +479,7 @@ class CompanySettings extends BaseSettings
public $sync_invoice_quote_columns = true;
public $e_invoice_type = 'EN16931';
public $e_invoice_type = 'EN16931'; //verifactu
public $e_quote_type = 'OrderX_Comfort';

View File

@@ -0,0 +1,106 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www/elastic.co/licensing/elastic-license
*/
namespace App\DataMapper;
use App\Casts\InvoiceBackupCast;
use App\DataMapper\Cancellation;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Support\Collection;
/**
* InvoiceBackup.
*/
class InvoiceBackup implements Castable
{
/**
* @param string $guid - The E-INVOICE SENT GUID reference - or enum to advise the document has been successfully sent.
* @param Cancellation $cancellation The cancellation data for the invoice.
* @param string $parent_invoice_id The id of the invoice that was cancelled
* @param string $parent_invoice_number The number of the invoice that was cancelled
* @param string $document_type The type of document the invoice is - F1, R2, R1
* @param Collection $child_invoice_ids The collection of child invoice IDs
* @param string $redirect The redirect url for the invoice
* @param float $adjustable_amount The adjustable amount for the invoice
* @param string $notes The notes field - can be multi purpose, but general usage for Verifactu cancellation reason
* @return void
*/
public function __construct(
public string $guid = '',
public Cancellation $cancellation = new Cancellation(0,0),
public ?string $parent_invoice_id = null,
public ?string $parent_invoice_number = null,
public ?string $document_type = null,
public Collection $child_invoice_ids = new Collection(),
public ?string $redirect = null,
public float $adjustable_amount = 0,
public ?string $notes = null,
) {}
/**
* Get the name of the caster class to use when casting from / to this cast target.
*
* @param array<string, mixed> $arguments
*/
public static function castUsing(array $arguments): string
{
return InvoiceBackupCast::class;
}
public static function fromArray(array $data): self
{
return new self(
guid: $data['guid'] ?? '',
cancellation: Cancellation::fromArray($data['cancellation'] ?? []),
parent_invoice_id: $data['parent_invoice_id'] ?? null,
parent_invoice_number: $data['parent_invoice_number'] ?? null,
document_type: $data['document_type'] ?? null,
child_invoice_ids: isset($data['child_invoice_ids']) ? collect($data['child_invoice_ids']) : new Collection(),
redirect: $data['redirect'] ?? null,
adjustable_amount: $data['adjustable_amount'] ?? 0,
notes: $data['notes'] ?? null,
);
}
/**
* Add a child invoice ID to the collection
*/
public function addChildInvoiceId(string $invoiceId): void
{
$this->child_invoice_ids->push($invoiceId);
}
/**
* Remove a child invoice ID from the collection
*/
public function removeChildInvoiceId(string $invoiceId): void
{
$this->child_invoice_ids = $this->child_invoice_ids->reject($invoiceId);
}
/**
* Check if a child invoice ID exists
*/
public function hasChildInvoiceId(string $invoiceId): bool
{
return $this->child_invoice_ids->contains($invoiceId);
}
/**
* Get all child invoice IDs as an array
*/
public function getChildInvoiceIds(): array
{
return $this->child_invoice_ids->toArray();
}
}

View File

@@ -34,4 +34,5 @@ class EmailRecord
* @var string
*/
public string $entity_id = '';
}

View File

@@ -1291,10 +1291,12 @@ $products = str_getcsv($this->input['product_key'], ',', "'");
$this->end_date = 'All available data';
return $query;
case 'last7':
case 'last_7_days':
$this->start_date = now()->subDays(7)->format('Y-m-d');
$this->end_date = now()->format('Y-m-d');
return $query->whereBetween($this->date_key, [now()->subDays(7), now()])->orderBy($this->date_key, 'ASC');
case 'last30':
case 'last_30_days':
$this->start_date = now()->subDays(30)->format('Y-m-d');
$this->end_date = now()->format('Y-m-d');
return $query->whereBetween($this->date_key, [now()->subDays(30), now()])->orderBy($this->date_key, 'ASC');

View File

@@ -225,9 +225,9 @@ class ExpenseExport extends BaseExport
if (in_array('expense.vendor_id', $this->input['report_keys'])) {
// $entity['expense.vendor'] = $expense->vendor ? $expense->vendor->name : '';
$entity['expense.vendor_id'] = $expense->vendor ? $expense->vendor->name : '';
$entity['expense.vendor_id'] = $expense->vendor ? $expense->vendor->id : '';
// $entity['expense.vendor_id'] = $expense->vendor ? $expense->vendor->id : '';
}
if (in_array('expense.payment_type_id', $this->input['report_keys'])) {

View File

@@ -150,7 +150,8 @@ class CreditFilters extends QueryFilters
$dir = ($sort_col[1] == 'asc') ? 'asc' : 'desc';
if ($sort_col[0] == 'client_id') {
return $this->builder->orderBy(\App\Models\Client::select('name')
return $this->builder->orderByRaw('ISNULL(client_id), client_id '. $dir)
->orderBy(\App\Models\Client::select('name')
->whereColumn('clients.id', 'credits.client_id'), $dir);
}

View File

@@ -287,11 +287,20 @@ class InvoiceFilters extends QueryFilters
if ($sort_col[0] == 'client_id') {
return $this->builder->orderBy(\App\Models\Client::select('name')
return $this->builder->orderByRaw('ISNULL(client_id), client_id '. $dir)
->orderBy(\App\Models\Client::select('name')
->whereColumn('clients.id', 'invoices.client_id'), $dir);
}
if ($sort_col[0] == 'project_id') {
return $this->builder->orderByRaw('ISNULL(project_id), project_id '. $dir)
->orderBy(\App\Models\Project::select('name')
->whereColumn('projects.id', 'invoices.project_id'), $dir);
}
if ($sort_col[0] == 'number') {
return $this->builder->orderByRaw("REGEXP_REPLACE(invoices.number,'[^0-9]+','')+0 " . $dir);
}

View File

@@ -172,7 +172,8 @@ class PaymentFilters extends QueryFilters
$dir = ($sort_col[1] == 'asc') ? 'asc' : 'desc';
if ($sort_col[0] == 'client_id') {
return $this->builder->orderBy(\App\Models\Client::select('name')
return $this->builder->orderByRaw('ISNULL(client_id), client_id '. $dir)
->orderBy(\App\Models\Client::select('name')
->whereColumn('clients.id', 'payments.client_id'), $dir);
}

View File

@@ -68,7 +68,8 @@ class ProjectFilters extends QueryFilters
$dir = ($sort_col[1] == 'asc') ? 'asc' : 'desc';
if ($sort_col[0] == 'client_id') {
return $this->builder->orderBy(\App\Models\Client::select('name')
return $this->builder->orderByRaw('ISNULL(client_id), client_id '. $dir)
->orderBy(\App\Models\Client::select('name')
->whereColumn('clients.id', 'projects.client_id'), $dir);
}

View File

@@ -173,7 +173,8 @@ class QuoteFilters extends QueryFilters
}
if ($sort_col[0] == 'client_id') {
return $this->builder->orderBy(\App\Models\Client::select('name')
return $this->builder->orderByRaw('ISNULL(client_id), client_id '. $dir)
->orderBy(\App\Models\Client::select('name')
->whereColumn('clients.id', 'quotes.client_id'), $dir);
}

View File

@@ -141,7 +141,8 @@ class RecurringInvoiceFilters extends QueryFilters
$dir = ($sort_col[1] == 'asc') ? 'asc' : 'desc';
if ($sort_col[0] == 'client_id') {
return $this->builder->orderBy(\App\Models\Client::select('name')
return $this->builder->orderByRaw('ISNULL(client_id), client_id '. $dir)
->orderBy(\App\Models\Client::select('name')
->whereColumn('clients.id', 'recurring_invoices.client_id'), $dir);
}

View File

@@ -138,12 +138,14 @@ class TaskFilters extends QueryFilters
$dir = ($sort_col[1] == 'asc') ? 'asc' : 'desc';
if ($sort_col[0] == 'client_id') {
return $this->builder->orderBy(\App\Models\Client::select('name')
return $this->builder->orderByRaw('ISNULL(client_id), client_id '. $dir)
->orderBy(\App\Models\Client::select('name')
->whereColumn('clients.id', 'tasks.client_id'), $dir);
}
if ($sort_col[0] == 'user_id') {
return $this->builder->orderBy(\App\Models\User::select('first_name')
return $this->builder->orderByRaw('ISNULL(user_id), user_id '. $dir)
->orderBy(\App\Models\User::select('first_name')
->whereColumn('users.id', 'tasks.user_id'), $dir);
}
@@ -164,6 +166,33 @@ class TaskFilters extends QueryFilters
}
public function client_ids(string $client_ids = ''): Builder
{
if (strlen($client_ids) == 0) {
return $this->builder;
}
return $this->builder->whereIn('client_id', $this->transformKeys(explode(',', $client_ids)));
}
public function project_ids(string $project_ids = ''): Builder
{
if (strlen($project_ids) == 0) {
return $this->builder;
}
return $this->builder->whereIn('project_id', $this->transformKeys(explode(',', $project_ids)));
}
public function assigned_user_ids(string $assigned_user_ids = ''): Builder
{
if (strlen($assigned_user_ids) == 0) {
return $this->builder;
}
return $this->builder->whereIn('assigned_user_id', $this->transformKeys(explode(',', $assigned_user_ids)));
}
public function assigned_user(string $user = ''): Builder
{
if (strlen($user) == 0) {
@@ -194,6 +223,31 @@ class TaskFilters extends QueryFilters
return $this->builder;
}
/**
* Filter by date range
*
* @param string $date_range
* @return Builder
*/
public function date_range(string $date_range = ''): Builder
{
$parts = explode(",", $date_range);
if (count($parts) != 2 || !in_array('calculated_start_date', \Illuminate\Support\Facades\Schema::getColumnListing($this->builder->getModel()->getTable()))) {
return $this->builder;
}
try {
$start_date = \Carbon\Carbon::parse($parts[0]);
$end_date = \Carbon\Carbon::parse($parts[1]);
return $this->builder->whereBetween('calculated_start_date', [$start_date, $end_date]);
} catch (\Exception $e) {
return $this->builder;
}
}
/**
* Filters the query by the users company ID.

View File

@@ -222,6 +222,7 @@ class InvoiceItemSum
private function push(): self
{
$this->sub_total += round($this->getLineTotal(), $this->currency->precision);
$this->gross_sub_total += $this->getGrossLineTotal();
@@ -295,6 +296,7 @@ class InvoiceItemSum
*/
private function calcTaxes()
{
if ($this->calc_tax) {
$this->calcTaxesAutomatically();
}
@@ -368,24 +370,28 @@ class InvoiceItemSum
$tax_component = 0;
$amount = 0;
if ($this->invoice->custom_surcharge1) {
$tax_component += round($this->invoice->custom_surcharge1 * ($tax['percentage'] / 100), 2);
$amount += $this->invoice->custom_surcharge1;
}
if ($this->invoice->custom_surcharge2) {
$tax_component += round($this->invoice->custom_surcharge2 * ($tax['percentage'] / 100), 2);
$amount += $this->invoice->custom_surcharge2;
}
if ($this->invoice->custom_surcharge3) {
$tax_component += round($this->invoice->custom_surcharge3 * ($tax['percentage'] / 100), 2);
$amount += $this->invoice->custom_surcharge3;
}
if ($this->invoice->custom_surcharge4) {
$tax_component += round($this->invoice->custom_surcharge4 * ($tax['percentage'] / 100), 2);
$amount += $this->invoice->custom_surcharge4;
}
$amount = $this->invoice->custom_surcharge4 + $this->invoice->custom_surcharge3 + $this->invoice->custom_surcharge2 + $this->invoice->custom_surcharge1;
if ($tax_component > 0) {
$this->groupTax($tax['name'], $tax['percentage'], $tax_component, $amount, $tax['tax_id']);
}

View File

@@ -81,6 +81,7 @@ class YodleeController extends BaseController
$bi->disabled_upstream = false;
$bi->balance = $account['current_balance'];
$bi->currency = $account['account_currency'];
$bi->integration_type = BankIntegration::INTEGRATION_TYPE_YODLEE;
$bi->save();
} else {
$bank_integration = new BankIntegration();

View File

@@ -42,10 +42,10 @@ class EInvoiceController extends BaseController
*/
public function validateEntity(ValidateEInvoiceRequest $request)
{
$el = new EntityLevel();
$el = $request->getValidatorClass();
$data = [];
match ($request->entity) {
'invoices' => $data = $el->checkInvoice($request->getEntity()),
'clients' => $data = $el->checkClient($request->getEntity()),

View File

@@ -12,17 +12,18 @@
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use App\Http\Requests\EInvoice\Peppol\StoreEntityRequest;
use Illuminate\Http\JsonResponse;
use App\Services\EDocument\Jobs\SendEDocument;
use App\Http\Requests\EInvoice\Peppol\RetrySendRequest;
use App\Services\EDocument\Gateway\Storecove\Storecove;
use App\Http\Requests\EInvoice\Peppol\DisconnectRequest;
use App\Http\Requests\EInvoice\Peppol\ShowEntityRequest;
use App\Http\Requests\EInvoice\Peppol\StoreEntityRequest;
use App\Http\Requests\EInvoice\Peppol\UpdateEntityRequest;
use App\Services\EDocument\Standards\Verifactu\SendToAeat;
use App\Http\Requests\EInvoice\Peppol\AddTaxIdentifierRequest;
use App\Http\Requests\EInvoice\Peppol\RemoveTaxIdentifierRequest;
use App\Http\Requests\EInvoice\Peppol\RetrySendRequest;
use App\Http\Requests\EInvoice\Peppol\ShowEntityRequest;
use App\Http\Requests\EInvoice\Peppol\UpdateEntityRequest;
use App\Services\EDocument\Jobs\SendEDocument;
class EInvoicePeppolController extends BaseController
{
@@ -266,8 +267,12 @@ class EInvoicePeppolController extends BaseController
public function retrySend(RetrySendRequest $request)
{
SendEDocument::dispatch($request->entity, $request->entity_id, auth()->user()->company()->db);
if(auth()->user()->company()->verifactuEnabled()) {
SendToAeat::dispatch($request->entity_id, auth()->user()->company(), 'create');
}
else {
SendEDocument::dispatch($request->entity, $request->entity_id, auth()->user()->company()->db);
}
return response()->json(['message' => 'trying....'], 200);
}

View File

@@ -72,6 +72,13 @@ class EmailController extends BaseController
$user = auth()->user();
$company = $entity_obj->company;
/** Force AEAT Submission */
if($company->verifactuEnabled() && ($entity_obj instanceof Invoice) && $entity_obj->backup->guid == "") {
$entity_obj->invitations()->update(['email_error' => 'primed']); // Flag the invitations as primed for AEAT submission
$entity_obj->service()->markSent()->sendVerifactu();
return $this->itemResponse($entity_obj->fresh());
}
if ($request->cc_email && (Ninja::isSelfHost() || $user->account->isPremium())) {
foreach ($request->cc_email as $email) {
@@ -80,19 +87,27 @@ class EmailController extends BaseController
}
$entity_obj->invitations->each(function ($invitation) use ($entity_obj, $mo, $template) {
if (! $invitation->contact->trashed() && $invitation->contact->email && !$invitation->contact->is_locked) {
$entity_obj->invitations()
->whereHas('contact', function($query) {
$query->where(function ($sq){
$sq->whereNotNull('email')
->orWhere('email', '!=', '');
})->where('is_locked', false)
->withoutTrashed();
})
->each(function ($invitation) use ($entity_obj, $mo, $template) {
$entity_obj->service()->markSent()->save();
$mo->invitation_id = $invitation->id;
$mo->client_id = $invitation->contact->client_id ?? null;
$mo->vendor_id = $invitation->contact->vendor_id ?? null;
$mo->invitation_id = $invitation->id;
$mo->client_id = $invitation->contact->client_id ?? null;
$mo->vendor_id = $invitation->contact->vendor_id ?? null;
Email::dispatch($mo, $invitation->company);
$entity_obj->entityEmailEvent($invitation, $template, $template);
Email::dispatch($mo, $invitation->company);
$entity_obj->entityEmailEvent($invitation, $template, $template);
}
});
});
$entity_obj = $entity_obj->fresh();
$entity_obj->last_sent_date = now();

View File

@@ -0,0 +1,37 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Controllers;
use App\Utils\Ninja;
use Illuminate\Http\Request;
use Turbo124\Beacon\Facades\LightLogs;
use App\DataMapper\Analytics\FeedbackCreated;
class FeedbackController extends Controller
{
public function __invoke(Request $request)
{
if(Ninja::isHosted()){
$user = auth()->user();
$company = $user->company();
$rating = $request->input('rating', 0);
$notes = $request->input('notes', '');
LightLogs::create(new FeedbackCreated($rating, $notes, $company->company_key, $company->account->key, $user->present()->name()))->batch();
}
return response()->noContent();
}
}

View File

@@ -604,12 +604,10 @@ class InvoiceController extends BaseController
if (in_array($action, ['email','send_email'])) {
$invoice = $invoices->first();
$invoices->filter(function ($invoice) use ($user) {
return $user->can('edit', $invoice);
})->each(function ($invoice) use ($user, $request) {
$invoice->service()->sendEmail(email_type: $request->input('email_type', $invoice->calculateTemplate('invoice')));
$invoice->service()->markSent()->sendEmail(email_type: $request->input('email_type', $invoice->calculateTemplate('invoice')));
});
return $this->listResponse(Invoice::withTrashed()->whereIn('id', $this->transformKeys($ids))->company());
@@ -779,7 +777,7 @@ class InvoiceController extends BaseController
}
break;
case 'cancel':
$invoice = $invoice->service()->handleCancellation()->save();
$invoice = $invoice->service()->handleCancellation(request()->input('reason'))->save();
if (! $bulk) {
$this->itemResponse($invoice);
}

View File

@@ -43,6 +43,7 @@ class StoreClientRequest extends Request
/** @var \App\Models\User $user */
$user = auth()->user();
$rules['name'] = 'bail|sometimes|nullable|string';
$rules['file'] = 'bail|sometimes|array';
$rules['file.*'] = $this->fileValidation();
$rules['documents'] = 'bail|sometimes|array';
@@ -99,6 +100,20 @@ class StoreClientRequest extends Request
return $rules;
}
public function withValidator($validator)
{
$validator->after(function ($validator) {
$user = auth()->user();
$company = $user->company();
if(isset($this->settings['lock_invoices']) && $company->verifactuEnabled() && $this->settings['lock_invoices'] != 'when_sent'){
$validator->errors()->add('settings.lock_invoices', 'Locked Invoices Cannot Be Disabled');
}
});
}
public function prepareForValidation()
{
$input = $this->all();
@@ -185,7 +200,7 @@ class StoreClientRequest extends Request
}
// prevent xss injection
if (array_key_exists('name', $input)) {
if (array_key_exists('name', $input) && is_string($input['name'])) {
$input['name'] = strip_tags($input['name']);
}

View File

@@ -101,6 +101,20 @@ class UpdateClientRequest extends Request
return $rules;
}
public function withValidator($validator)
{
$validator->after(function ($validator) {
$user = auth()->user();
$company = $user->company();
if(isset($this->settings['lock_invoices']) && $company->verifactuEnabled() && $this->settings['lock_invoices'] != 'when_sent'){
$validator->errors()->add('settings.lock_invoices', 'Locked Invoices Cannot Be Disabled');
}
});
}
public function messages()
{
return [

View File

@@ -213,6 +213,15 @@ class UpdateCompanyRequest extends Request
$settings[$protected_var] = str_replace("script", "", $settings[$protected_var]);
}
}
if($this->company->getSetting('e_invoice_type') == 'VERIFACTU') {
$settings['e_invoice_type'] = 'VERIFACTU';
}
}
if(isset($settings['e_invoice_type']) && $settings['e_invoice_type'] == 'VERIFACTU' && $this->company->verifactuEnabled()) {
$settings['lock_invoices'] = 'when_sent';
}
if (isset($settings['email_style_custom'])) {

View File

@@ -29,7 +29,7 @@ class RetrySendRequest extends Request
return true;
}
return $user->account->isPaid() && $user->isAdmin() && $user->company()->legal_entity_id != null;
return $user->account->isPaid() && $user->isAdmin() && ($user->company()->legal_entity_id != null || $user->company()->verifactuEnabled());
}
/**

View File

@@ -17,6 +17,7 @@ use App\Models\Client;
use App\Models\Company;
use App\Models\Invoice;
use App\Http\Requests\Request;
use App\Services\EDocument\Standards\Validation\Peppol\EntityLevel;
use Illuminate\Validation\Rule;
class ValidateEInvoiceRequest extends Request
@@ -76,7 +77,6 @@ class ValidateEInvoiceRequest extends Request
return false;
}
$class = Invoice::class;
match ($this->entity) {
@@ -93,4 +93,25 @@ class ValidateEInvoiceRequest extends Request
return $class::withTrashed()->find(is_string($this->entity_id) ? $this->decodePrimaryKey($this->entity_id) : $this->entity_id);
}
/**
* getValidatorClass
*
* Return the validator class based on the EInvoicing Standard
*
* @return \App\Services\EDocument\Standards\Validation\EntityLevelInterface
*/
public function getValidatorClass()
{
$user = auth()->user();
if($user->company()->settings->e_invoice_type == 'VERIFACTU') {
return new \App\Services\EDocument\Standards\Validation\Verifactu\EntityLevel();
}
// if($user->company()->settings->e_invoice_type == 'PEPPOL') {
return new \App\Services\EDocument\Standards\Validation\Peppol\EntityLevel();
// }
}
}

View File

@@ -47,6 +47,20 @@ class StoreGroupSettingRequest extends Request
return $rules;
}
public function withValidator($validator)
{
$validator->after(function ($validator) {
$user = auth()->user();
$company = $user->company();
if(isset($this->settings['lock_invoices']) && $company->verifactuEnabled() && $this->settings['lock_invoices'] != 'when_sent'){
$validator->errors()->add('settings.lock_invoices', 'Locked Invoices Cannot Be Disabled');
}
});
}
public function prepareForValidation()
{
$input = $this->all();

View File

@@ -41,6 +41,22 @@ class UpdateGroupSettingRequest extends Request
}
public function withValidator($validator)
{
$validator->after(function ($validator) {
$user = auth()->user();
$company = $user->company();
if(isset($this->settings['lock_invoices']) && $company->verifactuEnabled() && $this->settings['lock_invoices'] != 'when_sent'){
$validator->errors()->add('settings.lock_invoices', 'Locked Invoices Cannot Be Disabled');
}
});
}
public function prepareForValidation()
{
$input = $this->all();

View File

@@ -26,7 +26,7 @@ class ActionInvoiceRequest extends Request
*
* @return bool
*/
private $error_msg;
// private $error_msg;
// private $invoice;
@@ -38,36 +38,37 @@ class ActionInvoiceRequest extends Request
public function rules()
{
return [
'action' => 'required',
'action' => ['required'],
];
}
public function withValidator($validator)
{
$validator->after(function ($validator) {
if ($this->action == 'delete' && ! $this->invoiceDeletable($this->invoice)) {
$validator->errors()->add('action', 'This invoice cannot be deleted');
}elseif ($this->action == 'cancel' && ! $this->invoiceCancellable($this->invoice)) {
$validator->errors()->add('action', 'This invoice cannot be cancelled');
}elseif ($this->action == 'reverse' && ! $this->invoiceReversable($this->invoice)) {
$validator->errors()->add('action', 'This invoice cannot be reversed');
}elseif($this->action == 'restore' && ! $this->invoiceRestorable($this->invoice)) {
$validator->errors()->add('action', 'This invoice cannot be restored');
}elseif($this->action == 'mark_paid' && ! $this->invoicePayable($this->invoice)) {
$validator->errors()->add('action', 'This invoice cannot be marked as paid');
}
});
}
public function prepareForValidation()
{
$input = $this->all();
if ($this->action) {
$input['action'] = $this->action;
} elseif (! array_key_exists('action', $input)) {
$this->error_msg = 'Action is a required field';
} elseif (! $this->invoiceDeletable($this->invoice)) {
unset($input['action']);
$this->error_msg = 'This invoice cannot be deleted';
} elseif (! $this->invoiceCancellable($this->invoice)) {
unset($input['action']);
$this->error_msg = 'This invoice cannot be cancelled';
} elseif (! $this->invoiceReversable($this->invoice)) {
unset($input['action']);
$this->error_msg = 'This invoice cannot be reversed';
}
$input['action'] = $this->route('action');
$this->replace($input);
}
public function messages()
{
return [
'action' => $this->error_msg,
];
}
}

View File

@@ -12,11 +12,16 @@
namespace App\Http\Requests\Invoice;
use App\Models\Invoice;
use App\Http\Requests\Request;
use App\Utils\Traits\Invoice\ActionsInvoice;
use App\Utils\Traits\MakesHash;
use App\Exceptions\DuplicatePaymentException;
class BulkInvoiceRequest extends Request
{
use ActionsInvoice;
use MakesHash;
public function authorize(): bool
{
return true;
@@ -24,9 +29,12 @@ class BulkInvoiceRequest extends Request
public function rules()
{
/** @var \App\Models\User $user */
$user = auth()->user();
return [
'action' => 'required|string',
'ids' => 'required|array',
'action' => ['required', 'bail', 'string'],
'ids' => ['required', 'bail', 'array'],
'email_type' => 'sometimes|in:reminder1,reminder2,reminder3,reminder_endless,custom1,custom2,custom3,invoice,quote,credit,payment,payment_partial,statement,purchase_order',
'template' => 'sometimes|string',
'template_id' => 'sometimes|string',
@@ -35,6 +43,34 @@ class BulkInvoiceRequest extends Request
];
}
public function withValidator($validator)
{
/** @var \App\Models\User $user */
$user = auth()->user();
$action = $this->input('action');
$validator->after(function ($validator) use ($user, $action) {
Invoice::withTrashed()
->whereIn('id', $this->transformKeys($this->input('ids', [])))
->where('company_id', $user->company()->id)
->cursor()
->each(function ($invoice) use ($validator, $action) {
if ($action == 'delete' &&! $this->invoiceDeletable($invoice)) {
$validator->errors()->add('action', 'This invoice cannot be deleted');
} elseif ($action == 'cancel' && ! $this->invoiceCancellable($invoice)) {
$validator->errors()->add('action', 'This invoice cannot be cancelled');
} elseif ($action == 'reverse' && ! $this->invoiceReversable($invoice)) {
$validator->errors()->add('action', 'This invoice cannot be reversed');
} elseif($action == 'restore' && ! $this->invoiceRestorable($invoice)) {
$validator->errors()->add('action', 'This invoice cannot be restored');
} elseif($action == 'mark_paid' && ! $this->invoicePayable($invoice)) {
$validator->errors()->add('action', 'This invoice cannot be marked as paid');
}
});
});
}
public function prepareForValidation()
{

View File

@@ -12,12 +12,13 @@
namespace App\Http\Requests\Invoice;
use App\Http\Requests\Request;
use App\Http\ValidationRules\Project\ValidProjectForClient;
use App\Models\Invoice;
use App\Utils\Traits\CleanLineItems;
use App\Http\Requests\Request;
use App\Utils\Traits\MakesHash;
use Illuminate\Validation\Rule;
use App\Utils\Traits\CleanLineItems;
use App\Http\ValidationRules\Project\ValidProjectForClient;
use App\Http\ValidationRules\Invoice\VerifactuAmountCheck;
class StoreInvoiceRequest extends Request
{
@@ -45,7 +46,7 @@ class StoreInvoiceRequest extends Request
$rules = [];
$rules['client_id'] = ['required', 'bail', Rule::exists('clients', 'id')->where('company_id', $user->company()->id)->where('is_deleted', 0)];
$rules['client_id'] = ['required', 'bail', new VerifactuAmountCheck($this->all()) , Rule::exists('clients', 'id')->where('company_id', $user->company()->id)->where('is_deleted', 0)];
$rules['file'] = 'bail|sometimes|array';
$rules['file.*'] = $this->fileValidation();
@@ -62,7 +63,7 @@ class StoreInvoiceRequest extends Request
$rules['date'] = 'bail|sometimes|date:Y-m-d';
$rules['due_date'] = ['bail', 'sometimes', 'nullable', 'after:partial_due_date', Rule::requiredIf(fn () => strlen($this->partial_due_date ?? '') > 1), 'date'];
$rules['line_items'] = 'array';
$rules['line_items'] = ['bail', 'array'];
$rules['discount'] = 'sometimes|numeric|max:99999999999999';
$rules['tax_rate1'] = 'bail|sometimes|numeric';
$rules['tax_rate2'] = 'bail|sometimes|numeric';
@@ -80,6 +81,8 @@ class StoreInvoiceRequest extends Request
$rules['custom_surcharge4'] = ['sometimes', 'nullable', 'bail', 'numeric', 'max:99999999999999'];
$rules['location_id'] = ['nullable', 'sometimes','bail', Rule::exists('locations', 'id')->where('company_id', $user->company()->id)->where('client_id', $this->client_id)];
// $rules['modified_invoice_id'] = ['bail', 'sometimes', 'nullable', new CanGenerateModificationInvoice()];
return $rules;
}

View File

@@ -98,6 +98,14 @@ class UpdateInvoiceRequest extends Request
return $rules;
}
public function withValidator($validator)
{
if($this->invoice->company->verifactuEnabled() && $this->invoice->status_id !== \App\Models\Invoice::STATUS_DRAFT){
$validator->errors()->add('status_id', ctrans('texts.locked_invoice'));
}
}
public function prepareForValidation()
{

View File

@@ -17,6 +17,7 @@ use App\Utils\Traits\MakesHash;
use App\Http\ValidationRules\Scheduler\ValidClientIds;
use App\Http\ValidationRules\Scheduler\InvoiceWithNoExistingSchedule;
use App\Models\Invoice;
use Illuminate\Validation\Rule;
class StoreSchedulerRequest extends Request
{
@@ -41,7 +42,24 @@ class StoreSchedulerRequest extends Request
'converted',
'uninvoiced',
];
public array $templates = [
'invoice',
'quote',
'credit',
'purchase_order',
'quote',
'credit',
'purchase_order',
'invoice',
'reminder1',
'reminder2',
'reminder3',
'reminder_endless',
'custom1',
'custom2',
'custom3',
];
/**
* Determine if the user is authorized to make this request.
*
@@ -78,6 +96,7 @@ class StoreSchedulerRequest extends Request
'parameters.auto_send' => ['bail','sometimes', 'boolean', 'required_if:template,invoice_outstanding_tasks'],
'parameters.invoice_id' => ['bail', 'string', 'required_if:template,payment_schedule', new InvoiceWithNoExistingSchedule()],
'parameters.auto_bill' => ['bail', 'boolean', 'required_if:template,payment_schedule'],
'parameters.template' => ['bail', 'sometimes', 'string', Rule::in($this->templates)],
'parameters.schedule' => ['bail', 'array', 'required_if:template,payment_schedule', 'min:1'],
'parameters.schedule.*.id' => ['bail','sometimes', 'integer'],
'parameters.schedule.*.date' => ['bail','sometimes', 'date:Y-m-d'],

View File

@@ -14,6 +14,7 @@ namespace App\Http\Requests\TaskScheduler;
use App\Http\Requests\Request;
use App\Http\ValidationRules\Scheduler\ValidClientIds;
use Illuminate\Validation\Rule;
class UpdateSchedulerRequest extends Request
{
@@ -38,6 +39,24 @@ class UpdateSchedulerRequest extends Request
'uninvoiced',
];
public array $templates = [
'invoice',
'quote',
'credit',
'purchase_order',
'quote',
'credit',
'purchase_order',
'invoice',
'reminder1',
'reminder2',
'reminder3',
'reminder_endless',
'custom1',
'custom2',
'custom3',
];
/**
* Determine if the user is authorized to make this request.
*
@@ -74,6 +93,8 @@ class UpdateSchedulerRequest extends Request
'parameters.auto_send' => ['bail','sometimes', 'boolean', 'required_if:template,invoice_outstanding_tasks'],
// 'parameters.invoice_id' => ['bail','sometimes', 'string', 'required_if:template,payment_schedule'],
'parameters.auto_bill' => ['bail','sometimes', 'boolean', 'required_if:template,payment_schedule'],
'parameters.template' => ['bail', 'sometimes', 'string', Rule::in($this->templates)],
'parameters.schedule' => ['bail', 'array', 'required_if:template,payment_schedule','min:1'],
'parameters.schedule.*.id' => ['bail','sometimes', 'integer'],
'parameters.schedule.*.date' => ['bail','sometimes', 'date:Y-m-d'],

View File

@@ -0,0 +1,78 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\ValidationRules\Invoice;
use Closure;
use App\Models\Invoice;
use App\Utils\Traits\MakesHash;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Class CanGenerateModificationInvoice.
* @deprecated
*/
class CanGenerateModificationInvoice implements ValidationRule
{
use MakesHash;
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (empty($value)) {
return;
}
$user = auth()->user();
$company = $user->company();
/** For verifactu, we do not allow restores of deleted invoices */
if (!$company->verifactuEnabled())
$fail("Verifactu no está habilitado para esta empresa"); // Verifactu is not enabled for this company
$invoice = Invoice::withTrashed()->find($this->decodePrimaryKey($value));
\DB::connection(config('database.default'))->beginTransaction();
$array_data = request()->all();
unset($array_data['client_id']);
$invoice->fill($array_data);
$total = $invoice->calc()->getTotal();
\DB::connection(config('database.default'))->rollBack();
if (is_null($invoice)) {
$fail("Factura no encontrada."); // Invoice not found
} elseif($invoice->is_deleted) {
$fail("No se puede crear una factura de rectificación para una factura eliminada."); // Cannot create a rectification invoice for a deleted invoice
} elseif($invoice->backup->document_type !== 'F1') {
$fail("Solo las facturas originales F1 pueden ser rectificadas."); // Only original F1 invoices can be rectified
} elseif($invoice->status_id === Invoice::STATUS_DRAFT){
$fail("No se puede crear una factura de rectificación para una factura en borrador."); // Cannot create a rectification invoice for a draft invoice
} elseif(in_array($invoice->status_id, [Invoice::STATUS_PARTIAL, Invoice::STATUS_PAID])) {
$fail("No se puede crear una factura de rectificación cuando se ha realizado un pago."); // Cannot create a rectification invoice where a payment has been made
} elseif($invoice->status_id === Invoice::STATUS_CANCELLED ) {
$fail("No se puede crear una factura de rectificación para una factura cancelada."); // Cannot create a rectification invoice for a cancelled invoice
} elseif($invoice->status_id === Invoice::STATUS_REVERSED) {
$fail("No se puede crear una factura de rectificación para una factura revertida."); // Cannot create a rectification invoice for a reversed invoice
} elseif($invoice->backup->adjustable_amount < abs($total)){
$fail("El importe de la factura de rectificación no puede ser mayor al importe de la factura original."); // The rectification invoice amount cannot be greater than the original invoice amount
}
// } elseif ($invoice->status_id !== Invoice::STATUS_SENT) {
// $fail("Cannot create a modification invoice.");
// } elseif($invoice->amount <= 0){
// $fail("Cannot create a modification invoice for an invoice with an amount less than 0.");
}
}

View File

@@ -0,0 +1,108 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\ValidationRules\Invoice;
use Closure;
use App\Utils\BcMath;
use App\Models\Client;
use App\Models\Invoice;
use App\Utils\Traits\MakesHash;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Class VerifactuAmountCheck.
*/
class VerifactuAmountCheck implements ValidationRule
{
use MakesHash;
public function __construct(private array $input){}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (empty($value)) {
return;
}
$user = auth()->user();
$company = $user->company();
if ($company->verifactuEnabled() && isset($this->input['modified_invoice_id'])) { // Company level check if Verifactu is enabled
/** Harvest the parent invoice to check balances available for cancellation */
$invoice = Invoice::withTrashed()->where('id', $this->decodePrimaryKey($this->input['modified_invoice_id']))->company()->first();
if(!$invoice) {
$fail("Factura no encontrada."); // Invoice not found
} elseif($invoice->is_deleted) {
$fail("No se puede crear una factura de rectificación para una factura eliminada."); // Cannot create a rectification invoice for a deleted invoice
} elseif($invoice->backup->document_type !== 'F1') {
$fail("Solo las facturas originales F1 pueden ser rectificadas."); // Only original F1 invoices can be rectified
} elseif($invoice->status_id === Invoice::STATUS_DRAFT){
$fail("No se puede crear una factura de rectificación para una factura en borrador."); // Cannot create a rectification invoice for a draft invoice
} elseif(in_array($invoice->status_id, [Invoice::STATUS_PARTIAL, Invoice::STATUS_PAID])) {
$fail("No se puede crear una factura de rectificación cuando se ha realizado un pago."); // Cannot create a rectification invoice where a payment has been made
} elseif($invoice->status_id === Invoice::STATUS_CANCELLED ) {
$fail("No se puede crear una factura de rectificación para una factura cancelada."); // Cannot create a rectification invoice for a cancelled invoice
} elseif($invoice->status_id === Invoice::STATUS_REVERSED) {
$fail("No se puede crear una factura de rectificación para una factura revertida."); // Cannot create a rectification invoice for a reversed invoice
}
/** Sum previously refunded amounts */
$child_invoices_sum = Invoice::withTrashed()
->whereIn('id', $this->transformKeys($invoice->backup->child_invoice_ids->toArray()))
->get()
->sum('backup.adjustable_amount');
$child_invoices_sum = abs($child_invoices_sum);
/** Balance left to be cancelled */
$adjustable_amount = $invoice->backup->adjustable_amount - $child_invoices_sum;
if (BcMath::comp($adjustable_amount, 0) == 0) {
$fail("Invoice already credited in full");
}
$array_data = request()->all();
unset($array_data['client_id']);
$invoice->fill($array_data);
/** Total WITHOUT IRPF */
$total = $invoice->calc()->getTotal();
$invoice->refresh();
if($total >= 0) {
$fail("Only negative invoices can rectify a invoice.");
}
/** The Calculated amount that can be cancelled */
$adjustable_amount = $invoice->backup->adjustable_amount - $child_invoices_sum;
/** The client facing amount that can be cancelled This is the amount that will NOT contain IRPF amounts */
$client_facing_adjustable_amount = ($invoice->amount / $invoice->backup->adjustable_amount) * $adjustable_amount;
if(abs($total) > $client_facing_adjustable_amount) {
$fail("Total de ajuste {$total} no puede exceder el saldo de la factura {$client_facing_adjustable_amount}");
}
}
elseif($company->verifactuEnabled() && isset($this->input['amount']) && $this->input['amount'] < 0){
//Adhoc negative invoices cannot be created, they must be created as a rectification invoice against the original invoice.
$fail("El importe de la factura no puede ser negativo.");
}
}
}

View File

@@ -87,6 +87,11 @@ class InvoiceTransformer extends BaseTransformer
'tax_rate2' => $this->getFloat($invoice_data, 'invoice.tax_rate2'),
'tax_name3' => $this->getString($invoice_data, 'invoice.tax_name3'),
'tax_rate3' => $this->getFloat($invoice_data, 'invoice.tax_rate3'),
'is_amount_discount' => filter_var(
$this->getString($invoice_data, 'invoice.is_amount_discount'),
FILTER_VALIDATE_BOOLEAN,
FILTER_NULL_ON_FAILURE
),
'custom_value1' => $this->getString(
$invoice_data,
'invoice.custom_value1'

View File

@@ -93,7 +93,9 @@ class TaskTransformer extends BaseTransformer
$start_date = $this->resolveStartDate($item);
$end_date = $this->resolveEndDate($item);
} elseif (isset($item['task.duration'])) {
$duration = strtotime($item['task.duration']) - strtotime('TODAY');
$starttime = strtotime($item['task.duration']) ? strtotime($item['task.duration']) : strtotime('TODAY');
$duration = $starttime - strtotime('TODAY');
$start_date = $this->stubbed_timestamp;
$end_date = $this->stubbed_timestamp + $duration;
$this->stubbed_timestamp;

View File

@@ -18,6 +18,7 @@ use App\DataMapper\Tax\TaxModel;
use App\Libraries\MultiDB;
use App\Models\Company;
use App\Models\Country;
use App\Models\TaxRate;
use App\Utils\Ninja;
use App\Utils\Traits\MakesHash;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -164,10 +165,11 @@ class CreateCompany
$settings = $company->settings;
$settings->language_id = '7';
$settings->e_invoice_type = 'Facturae_3.2.2';
$settings->e_invoice_type = 'Facturae_3.2.2'; //change this to verifactu
$settings->currency_id = '3';
$settings->timezone_id = '42';
$settings->lock_invoices = 'when_sent';
$company->settings = $settings;
$company->save();
@@ -238,7 +240,7 @@ class CreateCompany
$company->settings = $settings;
$company->save();
return $company;
} catch (\Exception $e) {

View File

@@ -81,6 +81,12 @@ class BulkInvoiceJob implements ShouldQueue
$invoice->service()->markSent()->save();
if($invoice->verifactuEnabled() && !$invoice->hasSentAeat()) {
$invoice->invitations()->update(['email_error' => 'primed']); // Flag the invitations as primed for AEAT submission
$invoice->service()->sendVerifactu();
return false;
}
$invoice->invitations->each(function ($invitation) {
$template = $this->resolveTemplateString($this->reminder_template);

View File

@@ -218,6 +218,7 @@ class NinjaMailerJob implements ShouldQueue
} catch(\ErrorException $e){ //@todo - remove after symfony/mailer is updated with bug fix
nlog("Mailer failed with an Error Exception {$e->getMessage()}");
$message = "Attachment size is too large.";
$this->fail();
$this->logMailError($message, $this->company->clients()->first());

View File

@@ -104,25 +104,59 @@ class CheckACHStatus implements ShouldQueue
if (!$pi) {
try {
$pi = \Stripe\Charge::retrieve($p->transaction_reference, $stripe->stripe_connect_auth);
$charge = \Stripe\Charge::retrieve($p->transaction_reference, $stripe->stripe_connect_auth);
if($charge &&$charge->status == 'failed'){
$p->service()->deletePayment();
$p->status_id = \App\Models\Payment::STATUS_FAILED;
$p->save();
return;
}
elseif($charge && $charge->status == 'succeeded'){
$p->status_id = Payment::STATUS_COMPLETED;
$p->saveQuietly();
return;
}
} catch (\Exception $e) {
return;
}
}
if ($pi && $pi->status == 'succeeded') {
$p->status_id = Payment::STATUS_COMPLETED;
$p->saveQuietly();
} else {
if ($pi) {
nlog("{$p->id} did not complete {$p->transaction_reference}");
} else {
nlog("did not find a payment intent {$p->transaction_reference}");
}
return;
}
if($pi && $pi->latest_charge){
$charge = \Stripe\Charge::retrieve($pi->latest_charge, $stripe->stripe_connect_auth);
if($charge &&$charge->status == 'failed'){
$p->service()->deletePayment();
$p->status_id = \App\Models\Payment::STATUS_FAILED;
$p->save();
return;
}
elseif($charge && $charge->status == 'succeeded'){
$p->status_id = \App\Models\Payment::STATUS_COMPLETED;
$p->saveQuietly();
return;
}
}
if ($pi) {
nlog("{$p->id} did not complete {$p->transaction_reference}");
} else {
nlog("did not find a payment intent {$p->transaction_reference}");
}
});

View File

@@ -50,10 +50,10 @@ class QuoteEmailActivity implements ShouldQueue
$fields->user_id = $user_id;
$fields->quote_id = $event->invitation->quote->id;
$fields->quote_id = $event->invitation->quote_id;
$fields->client_id = $event->invitation->quote->client_id;
$fields->company_id = $event->invitation->quote->company_id;
$fields->client_contact_id = $event->invitation->quote->client_contact_id;
$fields->company_id = $event->invitation->company_id;
$fields->client_contact_id = $event->invitation->client_contact_id;
$fields->activity_type_id = Activity::EMAIL_QUOTE;
$this->activity_repo->save($fields, $event->invitation, $event->event_vars);

View File

@@ -40,6 +40,7 @@ use Laracasts\Presenter\PresentableTrait;
* @property string|null $plan_expires
* @property string|null $user_agent
* @property string|null $key
* @property string|null $e_invoice_token
* @property int|null $payment_id
* @property int $default_company_id
* @property string|null $trial_started
@@ -72,6 +73,7 @@ use Laracasts\Presenter\PresentableTrait;
* @property string|null $account_sms_verification_number
* @property bool $account_sms_verified
* @property string|null $bank_integration_account_id
* @property string|null $e_invoicing_token
* @property bool $is_trial
* @property int $e_invoice_quota
* @property int $docuninja_num_users

View File

@@ -288,6 +288,14 @@ class Activity extends StaticModel
public const PURGE_CLIENT = 153;
public const VERIFACTU_INVOICE_SENT = 150;
public const VERIFACTU_INVOICE_SENT_FAILURE = 151;
public const VERIFACTU_CANCELLATION_SENT = 152;
public const VERIFACTU_CANCELLATION_SENT_FAILURE = 153;
protected $casts = [
'is_system' => 'boolean',

View File

@@ -314,8 +314,13 @@ class BaseModel extends Model
}
// special catch here for einvoicing eventing
if ($event_id == Webhook::EVENT_SENT_INVOICE && ($this instanceof Invoice) && is_null($this->backup) && $this->client->peppolSendingEnabled()) {
\App\Services\EDocument\Jobs\SendEDocument::dispatch(get_class($this), $this->id, $this->company->db);
if ($event_id == Webhook::EVENT_SENT_INVOICE && ($this instanceof Invoice) && $this->backup->guid == "") {
if($this->client->peppolSendingEnabled()) {
\App\Services\EDocument\Jobs\SendEDocument::dispatch(get_class($this), $this->id, $this->company->db);
}
elseif($this->company->verifactuEnabled()) {
$this->service()->sendVerifactu();
}
}
}

View File

@@ -38,6 +38,7 @@ use Illuminate\Contracts\Translation\HasLocalePreference;
* @property int $id
* @property int $company_id
* @property int $user_id
* @property int|null $location_id
* @property int|null $assigned_user_id
* @property string|null $name
* @property string|null $website
@@ -82,15 +83,17 @@ use Illuminate\Contracts\Translation\HasLocalePreference;
* @property int|null $updated_at
* @property int|null $deleted_at
* @property string|null $id_number
* @property string|null $classification
* @property-read mixed $hashed_id
* @property-read \App\Models\User|null $assigned_user
* @property-read \App\Models\User $user
* @property-read \App\Models\Company $company
* @property-read \App\Models\Country|null $country
* @property-read \App\Models\Industry|null $industry
* @property-read \App\Models\Country|null $shipping_country
* @property-read \App\Models\Industry|null $industry
* @property-read \App\Models\Size|null $size
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Activity> $activities
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Location> $locations
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\CompanyLedger> $company_ledger
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ClientContact> $contacts
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Credit> $credits

View File

@@ -94,6 +94,7 @@ use Laracasts\Presenter\PresentableTrait;
* @property bool $markdown_enabled
* @property bool $use_comma_as_decimal_place
* @property bool $report_include_drafts
* @property bool $invoice_task_project_header
* @property array|null $client_registration_fields
* @property bool $convert_rate_to_client
* @property bool $markdown_email_enabled
@@ -131,14 +132,18 @@ use Laracasts\Presenter\PresentableTrait;
* @property int|null $smtp_port
* @property string|null $smtp_encryption
* @property string|null $smtp_local_domain
* @property boolean $invoice_task_item_description
* @property \App\DataMapper\QuickbooksSettings|null $quickbooks
* @property boolean $smtp_verify_peer
* @property object|null $origin_tax_data
* @property int|null $legal_entity_id
* @property bool $invoice_task_item_description
* @property bool $show_task_item_description
* @property bool $invoice_task_project_header
* @property-read \App\Models\Account $account
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Activity> $activities
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Location> $locations
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\VerifactuLog> $verifactu_logs
* @property-read int|null $activities_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Activity> $all_activities
* @property-read int|null $all_activities_count
@@ -434,6 +439,11 @@ class Company extends BaseModel
return $this->hasMany(Scheduler::class);
}
public function verifactu_logs(): HasMany
{
return $this->hasMany(VerifactuLog::class)->orderBy('id', 'DESC');
}
public function task_schedulers(): HasMany
{
return $this->hasMany(Scheduler::class);
@@ -1038,4 +1048,18 @@ class Company extends BaseModel
{
return !$this->account->is_flagged && $this->account->e_invoice_quota > 0 && isset($this->legal_entity_id) && isset($this->tax_data->acts_as_sender) && $this->tax_data->acts_as_sender;
}
/**
* verifactuEnabled
*
* Returns a flag if the current company is using verifactu as the e-invoice provider
*
* @return bool
*/
public function verifactuEnabled(): bool
{
return once(function () {
return $this->getSetting('e_invoice_type') == 'VERIFACTU';
});
}
}

View File

@@ -92,6 +92,9 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property string|null $reminder2_sent
* @property string|null $reminder3_sent
* @property string|null $reminder_last_sent
* @property object|null $tax_data
* @property object|null $e_invoice
* @property int|null $location_id
* @property float $paid_to_date
* @property int|null $location_id
* @property object|null $e_invoice
@@ -123,6 +126,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property \App\Models\Vendor|null $vendor
* @property-read \App\Models\Location|null $location
* @property-read mixed $pivot
* @property-read \App\Models\Location|null $location
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Activity> $activities
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\CompanyLedger> $company_ledger
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Document> $documents

View File

@@ -27,6 +27,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property object|null $design
* @property bool $is_deleted
* @property bool $is_template
* @property string|null $entities
* @property int|null $created_at
* @property int|null $updated_at
* @property int|null $deleted_at

View File

@@ -75,6 +75,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property-read \App\Models\Currency|null $currency
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Document> $documents
* @property-read \App\Models\PaymentType|null $payment_type
* @property-read \App\Models\Currency|null $invoice_currency
* @property-read \App\Models\Project|null $project
* @property-read \App\Models\PurchaseOrder|null $purchase_order
* @property-read \App\Models\User $user

View File

@@ -30,6 +30,7 @@ use App\Helpers\Invoice\InvoiceSumInclusive;
use App\Utils\Traits\Invoice\ActionsInvoice;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Events\Invoice\InvoiceReminderWasEmailed;
use App\DataMapper\InvoiceBackup;
use App\Jobs\Ninja\TaskScheduler;
use App\Utils\Number;
@@ -40,6 +41,7 @@ use App\Utils\Number;
* @property object|null $e_invoice
* @property int $client_id
* @property int $user_id
* @property int|null $location_id
* @property int|null $assigned_user_id
* @property int $company_id
* @property int $status_id
@@ -57,7 +59,7 @@ use App\Utils\Number;
* @property string|null $due_date
* @property bool $is_deleted
* @property object|array|string $line_items
* @property object|null $backup
* @property InvoiceBackup $backup
* @property object|null $sync
* @property string|null $footer
* @property string|null $public_notes
@@ -130,6 +132,8 @@ use App\Utils\Number;
* @property-read \App\Models\User $user
* @property-read \App\Models\Vendor|null $vendor
* @property-read \App\Models\Location|null $location
* @property-read \App\Models\Quote|null $quote
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\VerifactuLog> $verifactu_logs
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\TransactionEvent> $transaction_events
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Activity> $activities
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\CompanyLedger> $company_ledger
@@ -154,7 +158,6 @@ class Invoice extends BaseModel
use ActionsInvoice;
use Searchable;
protected $presenter = EntityPresenter::class;
protected $touches = [];
@@ -210,7 +213,7 @@ class Invoice extends BaseModel
protected $casts = [
'line_items' => 'object',
'backup' => 'object',
'backup' => InvoiceBackup::class,
'updated_at' => 'timestamp',
'created_at' => 'timestamp',
'deleted_at' => 'timestamp',
@@ -246,7 +249,7 @@ class Invoice extends BaseModel
public const STATUS_REVERSED = 6;
public const STATUS_OVERDUE = -1; //status < 4 || < 3 && !is_deleted && !trashed() && due_date < now()
public const STATUS_OVERDUE = -1; // status < 4 || < 3 && !is_deleted && !trashed() && due_date < now()
public const STATUS_UNPAID = -2; //status < 4 || < 3 && !is_deleted && !trashed()
@@ -423,6 +426,11 @@ class Invoice extends BaseModel
return $this->hasMany(Credit::class);
}
public function verifactu_logs(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(VerifactuLog::class)->orderBy('id', 'desc');
}
public function tasks(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Task::class);
@@ -906,4 +914,25 @@ class Invoice extends BaseModel
return ctrans('texts.payment_schedule_interval', ['index' => $index+1, 'total' => count($schedule_array), 'amount' => $amount]);
}
public function hasSentAeat(): bool
{
return $this->backup->guid != "";
}
/**
* verifactuEnabled
*
* Helper to determine whether the invoice / client combination falls under the Verifactu rules.
*
* @return bool
*/
public function verifactuEnabled(): bool
{
return once(function () {
$client_is_verifactu = in_array($this->client->country->iso_3166_2, (new \App\DataMapper\Tax\BaseRule())->eu_country_codes) &&
(strlen($this->client->vat_number ?? '') > 0 || strlen($this->client->id_number ?? '') > 0);
return $this->company->verifactuEnabled() && $client_is_verifactu;
});
}
}

View File

@@ -1,4 +1,13 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Models;
@@ -57,9 +66,9 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @method static \Illuminate\Database\Eloquent\Builder|Project withoutTrashed()
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Document> $documents
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Task> $tasks
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Expense> $expenses
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Invoice> $invoices
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Quote> $quotes
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Expense> $expenses
* @mixin \Eloquent
*/
class Project extends BaseModel
@@ -177,17 +186,17 @@ class Project extends BaseModel
return $this->hasMany(Task::class);
}
public function expenses(): HasMany
public function expenses(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Expense::class);
}
public function invoices(): HasMany
public function invoices(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Invoice::class)->withTrashed();
}
public function quotes(): HasMany
public function quotes(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Quote::class);
}

View File

@@ -60,6 +60,7 @@ use App\Events\PurchaseOrder\PurchaseOrderWasEmailed;
* @property float $tax_rate3
* @property float $total_taxes
* @property bool $uses_inclusive_taxes
* @property int|null $location_id
* @property string|null $reminder1_sent
* @property string|null $reminder2_sent
* @property string|null $reminder3_sent
@@ -106,6 +107,8 @@ use App\Events\PurchaseOrder\PurchaseOrderWasEmailed;
* @property \App\Models\PurchaseOrderInvitation $invitation
* @property \App\Models\Currency|null $currency
* @property \App\Models\Location|null $location
* @property object|null $tax_data
* @property object|null $e_invoice
* @method static \Illuminate\Database\Eloquent\Builder|PurchaseOrder exclude($columns)
* @method static \Database\Factories\PurchaseOrderFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder|PurchaseOrder filter(\App\Filters\QueryFilters $filters)

View File

@@ -83,6 +83,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property int $custom_surcharge_tax2
* @property int $custom_surcharge_tax3
* @property int $custom_surcharge_tax4
* @property int|null $location_id
* @property float $exchange_rate
* @property float $amount
* @property float $balance
@@ -96,6 +97,9 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property string|null $reminder2_sent
* @property string|null $reminder3_sent
* @property string|null $reminder_last_sent
* @property int|null $location_id
* @property object|null $tax_data
* @property object|null $e_invoice
* @property float $paid_to_date
* @property object|null $tax_data
* @property int|null $subscription_id

View File

@@ -85,6 +85,7 @@ use App\Models\Presenters\RecurringInvoicePresenter;
* @property bool $custom_surcharge_tax3
* @property bool $custom_surcharge_tax4
* @property string|null $due_date_days
* @property int|null $location_id
* @property string|null $partial_due_date
* @property float $exchange_rate
* @property float $paid_to_date

View File

@@ -59,6 +59,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property string|null $steps
* @property string|null $optional_product_ids
* @property string|null $optional_recurring_product_ids
* @property string|null $steps
* @property-read \App\Models\Company $company
* @property-read mixed $hashed_id
* @property-read \App\Models\GroupSetting|null $group_settings

View File

@@ -79,6 +79,10 @@ class SystemLog extends Model
public const CATEGORY_LOG = 6;
public const CATEGORY_VERIFACTU = 7;
public const CATEGORY_PEPPOL = 8;
/* Event IDs*/
public const EVENT_PAYMENT_RECONCILIATION_FAILURE = 10;
@@ -116,6 +120,14 @@ class SystemLog extends Model
public const EVENT_INBOUND_MAIL_BLOCKED = 62;
public const EVENT_VERIFACTU_FAILURE = 70;
public const EVENT_VERIFACTU_SUCCESS = 71;
public const EVENT_PEPPOL_FAILURE = 72;
public const EVENT_PEPPOL_SUCCESS = 73;
/*Type IDs*/
public const TYPE_PAYPAL = 300;
@@ -181,6 +193,16 @@ class SystemLog extends Model
public const TYPE_GENERIC = 900;
public const TYPE_VERIFACTU_CANCELLATION = 1000;
public const TYPE_VERIFACTU_INVOICE = 1001;
public const TYPE_VERIFACTU_RECTIFICATION = 1002;
public const TYPE_PEPPOL_SEND = 1100;
public const TYPE_PEPPOL_RECEIVE = 1101;
protected $fillable = [
'client_id',
'company_id',

View File

@@ -575,8 +575,8 @@ class User extends Authenticatable implements MustVerifyEmail
*
* Note, returning FALSE here means the user does NOT have the permission we want to exclude
*
* @param array $matched_permission
* @param array $excluded_permissions
* @param array $matched_permission = []
* @param array $excluded_permissions = []
* @return bool
*/
public function hasExcludedPermissions(array $matched_permission = [], array $excluded_permissions = []): bool

View File

@@ -55,6 +55,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property string|null $custom_value4
* @property string|null $vendor_hash
* @property string|null $public_notes
* @property string|null $classification
* @property string|null $id_number
* @property int|null $language_id
* @property int|null $last_login
@@ -74,6 +75,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\VendorContact> $primary_contact
* @property-read int|null $primary_contact_count
* @property-read \App\Models\User $user
* @property-read \App\Models\Language|null $language
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Location> $locations
* @property-read int|null $locations_count
* @method static \Illuminate\Database\Eloquent\Builder|BaseModel exclude($columns)
@@ -90,6 +92,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\VendorContact> $contacts
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Document> $documents
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\VendorContact> $primary_contact
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Location> $locations
* @mixin \Eloquent
*/
class Vendor extends BaseModel

View File

@@ -0,0 +1,60 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Models;
use App\Models\Company;
use Illuminate\Database\Eloquent\Model;
use App\Models\Invoice;
/**
* @property int $id
* @property int $company_id
* @property int $invoice_id
* @property string $nif
* @property \Carbon\Carbon $date
* @property string $invoice_number
* @property string $hash
* @property string $previous_hash
* @property string $status
* @property object|null $response
* @property string $state
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property-read \App\Models\Company $company
* @property-read \App\Models\Invoice $invoice
*/
class VerifactuLog extends Model
{
public $timestamps = true;
protected $casts = [
'date' => 'date',
'response' => 'object',
];
protected $guarded = ['id'];
public function company()
{
return $this->belongsTo(Company::class);
}
public function invoice()
{
return $this->belongsTo(Invoice::class);
}
public function deserialize()
{
return \App\Services\EDocument\Standards\Verifactu\Models\Invoice::unserialize($this->state);
}
}

View File

@@ -109,6 +109,7 @@ class ACH implements MethodInterface, LivewireMethodInterface
return redirect()->route('client.payment_methods.index')->withMessage(ctrans('texts.payment_method_added'));
} catch (\Exception $e) {
nlog($e->getMessage());
return $this->braintree->processInternallyFailedPayment($this->braintree, $e);
}
}

View File

@@ -82,6 +82,7 @@ class Bancontact implements LivewireMethodInterface
'amount' => $this->stripe->convertFromStripeAmount($this->stripe->payment_hash->data->stripe_amount, $this->stripe->client->currency()->precision, $this->stripe->client->currency()),
'transaction_reference' => $payment_intent,
'gateway_type_id' => GatewayType::BANCONTACT,
'idempotency_key' => $payment_intent.now()->format('Ymdhi'), //prevent duplicate payments
];
$payment = $this->stripe->createPayment($data, Payment::STATUS_PENDING);

View File

@@ -329,6 +329,11 @@ class BaseRepository
nlog($e->getMessage());
}
}
/** Verifactu modified invoice check */
if($model->company->verifactuEnabled()) {
$model->service()->modifyVerifactuWorkflow($data, $this->new_model)->save();
}
}
if ($model instanceof Credit) {

View File

@@ -80,9 +80,9 @@ class CreditService
return $this;
}
public function sendEmail($contact = null)
public function sendEmail($contact = null, $email_type = null)
{
$send_email = new SendEmail($this->credit, null, $contact);
$send_email = new SendEmail($this->credit, $email_type, $contact);
return $send_email->run();
}

View File

@@ -630,12 +630,23 @@ class Mutator implements MutatorInterface
$identifier = $this->getClientPublicIdentifier($code);
}
$identifier = str_ireplace(["FR","BE"], "", $identifier);
$identifier = str_ireplace(["FR", "BE"], "", $identifier);
$identifier = preg_replace("/[^a-zA-Z0-9]/", "", $identifier);
//Check the recipient is on the network, and perhaps, adjust the identifier accordingly
// if(!$this->storecove->exists($identifier, $code) && $this->invoice->client->country->iso_3166_2 == "BE"){
// nlog("identifier not found, adjusting for BE");
// $code = "BE:VAT";
// $identifier = "BE".$identifier;
// }
$this->setStorecoveMeta($this->buildRouting([
["scheme" => $code, "id" => $identifier]
]));
return $this;
}

View File

@@ -289,16 +289,30 @@ class Storecove
$scheme = $this->router->resolveTaxScheme($data['country'], $data['classification']);
$add_identifier_response = null;
$add_identifier_response = $this->addIdentifier(
legal_entity_id: $legal_entity_response['id'],
identifier: $data['classification'] === 'individual' ? str_replace('/', '', $data['id_number']) : str_replace(" ", "", $data['vat_number']),
scheme: $scheme,
);
if (! is_array($add_identifier_response)) {
return $add_identifier_response;
}
/** For Belgium, we register both the BE:VAT and BE:EN identifiers so that users can receive via HERMES */
if($data['country'] == "BE"){
$scheme = "BE:EN";
$identifier = $data['classification'] === 'individual' ? str_replace('/', '', $data['id_number']) : str_replace([" ","BE"], "", $data['vat_number']);
$add_identifier_response = $this->addIdentifier(
legal_entity_id: $legal_entity_response['id'],
identifier: $identifier,
scheme: $scheme,
);
}
/** For Denmark, we register both identifiers */
if($data['country'] == "DK"){
$add_identifier_response = $this->addIdentifier($legal_entity_response['id'], str_replace(" ", "", $data['vat_number']), "DK:DIGST");
}
@@ -435,7 +449,7 @@ class Storecove
return $data;
}
$this->deleteIdentifier($legal_entity_id);
// $this->deleteIdentifier($legal_entity_id);
return $r;
}
@@ -558,7 +572,6 @@ class Storecove
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/**
* getHeaders
*

View File

@@ -12,16 +12,20 @@
namespace App\Services\EDocument\Jobs;
use App\Services\Email\Email;
use App\Services\Email\EmailObject;
use Mail;
use App\Utils\Ninja;
use App\Models\Invoice;
use App\Libraries\MultiDB;
use App\Models\Activity;
use App\Models\SystemLog;
use App\Libraries\MultiDB;
use App\Models\EInvoicingLog;
use App\Services\Email\Email;
use Illuminate\Bus\Queueable;
use Illuminate\Support\Facades\Cache;
use App\Jobs\Util\SystemLogger;
use App\Services\Email\EmailObject;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -29,8 +33,6 @@ use Illuminate\Foundation\Bus\Dispatchable;
use App\Services\EDocument\Standards\Peppol;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use App\Services\EDocument\Gateway\Storecove\Storecove;
use Mail;
use Illuminate\Mail\Mailables\Address;
class SendEDocument implements ShouldQueue
{
@@ -60,7 +62,7 @@ class SendEDocument implements ShouldQueue
$model = $this->entity::withTrashed()->find($this->id);
if (isset($model->backup->guid) && is_string($model->backup->guid)) {
if(isset($model->backup->guid) && is_string($model->backup->guid) && strlen($model->backup->guid) > 3){
nlog("already sent!");
return;
}
@@ -119,6 +121,16 @@ class SendEDocument implements ShouldQueue
if ($r->failed()) {
nlog("Model {$model->number} failed to be accepted by invoice ninja, error follows:");
nlog($r->json());
(
new SystemLogger(
$r->json(),
SystemLog::CATEGORY_PEPPOL,
SystemLog::EVENT_PEPPOL_FAILURE,
SystemLog::TYPE_PEPPOL_SEND,
$model->client,
$model->company
)
)->handle();
$this->writeActivity($model, Activity::EINVOICE_DELIVERY_FAILURE, data_get($r->json(), 'errors.0.details', 'Unhandled error, check logs'));
}
@@ -220,9 +232,9 @@ class SendEDocument implements ShouldQueue
if ($activity_id == Activity::EINVOICE_DELIVERY_SUCCESS) {
$backup = ($model->backup && is_object($model->backup)) ? $model->backup : new \stdClass();
$backup->guid = str_replace('"', '', $notes);
$model->backup = $backup;
// $backup = ($model->backup && is_object($model->backup)) ? $model->backup : new \stdClass();
// $backup->guid = str_replace('"', '', $notes);
$model->backup->guid = str_replace('"', '', $notes);
$model->saveQuietly();
}

View File

@@ -22,7 +22,6 @@ use App\Helpers\Invoice\InvoiceSum;
use InvoiceNinja\EInvoice\EInvoice;
use App\Utils\Traits\NumberFormatter;
use App\Helpers\Invoice\InvoiceSumInclusive;
use App\Services\EDocument\Gateway\Qvalia\Qvalia;
use InvoiceNinja\EInvoice\Models\Peppol\ItemType\Item;
use App\Services\EDocument\Gateway\Storecove\Storecove;
use InvoiceNinja\EInvoice\Models\Peppol\PartyType\Party;
@@ -140,9 +139,9 @@ class Peppol extends AbstractService
private EInvoice $e;
private string $api_network = Storecove::class; // Storecove::class; // Qvalia::class;
private string $api_network = Storecove::class; // Storecove::class;
public Qvalia | Storecove $gateway;
public Storecove $gateway;
private string $customizationID = 'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0';
@@ -160,6 +159,7 @@ class Peppol extends AbstractService
public function __construct(public Invoice $invoice)
{
$this->company = $invoice->company;
$this->calc = $this->invoice->calc();
$this->e = new EInvoice();
@@ -1333,10 +1333,10 @@ class Peppol extends AbstractService
}
if (!isset($this->p_invoice->InvoicePeriod)) {
$ip = new InvoicePeriod();
$ip->StartDate = new \DateTime($this->invoice->date);
$ip->EndDate = new \DateTime($this->invoice->due_date ?? $this->invoice->date);
if(isset($this->invoice->e_invoice->Invoice->InvoicePeriod[0])){
$ip = new \InvoiceNinja\EInvoice\Models\Peppol\PeriodType\InvoicePeriod();
$ip->StartDate = new \DateTime($this->invoice->e_invoice->Invoice->InvoicePeriod[0]->StartDate);
$ip->EndDate = new \DateTime($this->invoice->e_invoice->Invoice->InvoicePeriod[0]->EndDate);
$this->p_invoice->InvoicePeriod = [$ip];
}

View File

@@ -0,0 +1,27 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\EDocument\Standards\Validation;
use App\Models\Client;
use App\Models\Company;
use App\Models\Invoice;
interface EntityLevelInterface
{
public function checkClient(Client $client): array;
public function checkCompany(Company $company): array;
public function checkInvoice(Invoice $invoice): array;
}

View File

@@ -22,10 +22,11 @@ use App\Models\Invoice;
use App\Models\PurchaseOrder;
use App\Services\EDocument\Standards\Peppol;
use App\Services\EDocument\Standards\Validation\XsltDocumentValidator;
use App\Services\EDocument\Standards\Validation\EntityLevelInterface;
use Illuminate\Support\Facades\App;
use XSLTProcessor;
class EntityLevel
class EntityLevel implements EntityLevelInterface
{
private array $eu_country_codes = [
'AT', // Austria
@@ -63,7 +64,7 @@ class EntityLevel
private array $client_fields = [
'address1',
'city',
// 'state',
'state',
'postal_code',
'country_id',
];
@@ -192,15 +193,17 @@ class EntityLevel
foreach ($this->client_fields as $field) {
if ($this->validString($client->{$field})) {
continue;
}
if ($field == 'country_id' && $client->country_id >= 1) {
continue;
}
$errors[] = ['field' => $field, 'label' => ctrans("texts.{$field}")];
if(in_array($field, ['address1', 'address2', 'city', 'state', 'postal_code']) && strlen($client->address1 ?? '') < 2){
$errors[] = ['field' => $field, 'label' => ctrans("texts.{$field}")];
}
if ($this->validString($client->{$field})) {
continue;
}
}

View File

@@ -0,0 +1,349 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\EDocument\Standards\Validation\Verifactu;
use App\Models\Quote;
use App\Models\Client;
use App\Models\Credit;
use App\Models\Vendor;
use App\Models\Company;
use App\Models\Invoice;
use App\Models\PurchaseOrder;
use Illuminate\Support\Facades\App;
use App\Services\EDocument\Standards\Validation\EntityLevelInterface;
//@todo - need to implement a rule set for verifactu for validation
class EntityLevel implements EntityLevelInterface
{
private array $errors = [];
private array $client_fields = [
// 'address1',
// 'city',
// 'state',
// 'postal_code',
// 'vat_number',
'country_id',
];
private array $company_settings_fields = [
// 'address1',
// 'city',
// 'state',
// 'postal_code',
'vat_number',
'country_id',
];
public function __construct(){}
private function init(string $locale): self
{
App::forgetInstance('translator');
$t = app('translator');
App::setLocale($locale);
return $this;
}
public function checkClient(Client $client): array
{
$this->init($client->locale());
$this->errors['client'] = $this->testClientState($client);
$this->errors['passes'] = count($this->errors['client']) == 0;
return $this->errors;
}
public function checkCompany(Company $company): array
{
$this->init($company->locale());
$this->errors['company'] = $this->testCompanyState($company);
$this->errors['passes'] = count($this->errors['company']) == 0;
return $this->errors;
}
public function checkInvoice(Invoice $invoice): array
{
$this->init($invoice->client->locale());
$this->errors['invoice'] = [];
$this->errors['client'] = $this->testClientState($invoice->client);
$this->errors['company'] = $this->testCompanyState($invoice->client); // uses client level settings which is what we want
if (count($this->errors['client']) > 0) {
$this->errors['passes'] = false;
return $this->errors;
}
$_invoice = (new \App\Services\EDocument\Standards\Verifactu\RegistroAlta($invoice))->run();
if($invoice->amount < 0) {
$_invoice = $_invoice->setRectification();
}
$xml = $_invoice->getInvoice()->toXmlString();
$xslt = new \App\Services\EDocument\Standards\Validation\VerifactuDocumentValidator($xml);
$xslt->validate();
$errors = $xslt->getVerifactuErrors();
nlog($errors);
if (isset($errors['stylesheet']) && count($errors['stylesheet']) > 0) {
$this->errors['invoice'] = array_merge($this->errors['invoice'], $errors['stylesheet']);
}
if (isset($errors['general']) && count($errors['general']) > 0) {
$this->errors['invoice'] = array_merge($this->errors['invoice'], $errors['general']);
}
if (isset($errors['xsd']) && count($errors['xsd']) > 0) {
$this->errors['invoice'] = array_merge($this->errors['invoice'], $errors['xsd']);
}
// $this->errors['invoice'][] = 'test error';
$this->errors['passes'] = count($this->errors['invoice']) === 0 && count($this->errors['company']) === 0; //no need to check client as we are using client level settings
return $this->errors;
// $p = new Peppol($invoice);
// $xml = false;
// try {
// $xml = $p->run()->toXml();
// if (count($p->getErrors()) >= 1) {
// foreach ($p->getErrors() as $error) {
// $this->errors['invoice'][] = $error;
// }
// }
// } catch (PeppolValidationException $e) {
// $this->errors['invoice'] = ['field' => $e->getInvalidField(), 'label' => $e->getInvalidField()];
// } catch (\Throwable $th) {
// }
// if ($xml) {
// // Second pass through the XSLT validator
// $xslt = new XsltDocumentValidator($xml);
// $errors = $xslt->validate()->getErrors();
// if (isset($errors['stylesheet']) && count($errors['stylesheet']) > 0) {
// $this->errors['invoice'] = array_merge($this->errors['invoice'], $errors['stylesheet']);
// }
// if (isset($errors['general']) && count($errors['general']) > 0) {
// $this->errors['invoice'] = array_merge($this->errors['invoice'], $errors['general']);
// }
// if (isset($errors['xsd']) && count($errors['xsd']) > 0) {
// $this->errors['invoice'] = array_merge($this->errors['invoice'], $errors['xsd']);
// }
// }
// $this->checkNexus($invoice->client);
}
private function testClientState(Client $client): array
{
$errors = [];
foreach ($this->client_fields as $field) {
if ($field == 'country_id' && $client->country_id >= 1 && in_array($client->country->iso_3166_2, (new \App\DataMapper\Tax\BaseRule())->eu_country_codes)) {
continue;
}
elseif($field == 'country_id'){
$errors[] = ['field' => 'country_id', 'label' => ctrans("texts.country_id") . " (Must be in the EU)"];
}
else
{
$errors[] = ['field' => $field, 'label' => ctrans("texts.{$field}")];
}
}
/** Spanish Client Validation requirements */
if ($client->country_id == 724) {
if (in_array($client->classification, ['','individual']) && strlen($client->id_number ?? '') == 0 && strlen($client->vat_number ?? '') == 0) {
$errors[] = ['field' => 'id_number', 'label' => ctrans("texts.id_number")];
}
elseif (strlen($client->vat_number ?? '') == 0) {
$errors[] = ['field' => 'vat_number', 'label' => ctrans("texts.vat_number")];
}
// elseif (!in_array($client->classification, ['','individual']) && strlen($client->vat_number ?? '')) {
// $errors[] = ['field' => 'vat_number', 'label' => ctrans("texts.vat_number")];
// }
}
elseif(empty($client->vat_number)) {
$errors[] = ['field' => 'vat_number', 'label' => ctrans("texts.vat_number")];
}
return $errors;
}
private function testCompanyState(mixed $entity): array
{
$client = false;
$vendor = false;
$settings_object = false;
$company = false;
if ($entity instanceof Client) {
$client = $entity;
$company = $entity->company;
$settings_object = $client;
} elseif ($entity instanceof Company) {
$company = $entity;
$settings_object = $company;
} elseif ($entity instanceof Vendor) {
$vendor = $entity;
$company = $entity->company;
$settings_object = $company;
} elseif ($entity instanceof Invoice || $entity instanceof Credit || $entity instanceof Quote) {
$client = $entity->client;
$company = $entity->company;
$settings_object = $entity->client;
} elseif ($entity instanceof PurchaseOrder) {
$vendor = $entity->vendor;
$company = $entity->company;
$settings_object = $company;
}
$errors = [];
foreach ($this->company_settings_fields as $field) {
if ($this->validString($settings_object->getSetting($field))) {
continue;
}
$errors[] = ['field' => $field, 'label' => ctrans("texts.{$field}")];
}
//If not an individual, you MUST have a VAT number
if ($company->getSetting('classification') == 'individual' && !$this->validString($company->getSetting('id_number'))) {
$errors[] = ['field' => 'id_number', 'label' => ctrans("texts.id_number")];
}elseif(!$this->validString($company->getSetting('vat_number'))) {
$errors[] = ['field' => 'vat_number', 'label' => ctrans("texts.vat_number")];
}
if(!$this->isValidSpanishVAT($company->getSetting('vat_number'))) {
$errors[] = ['field' => 'vat_number', 'label' => 'número de IVA no válido'];
}
return $errors;
}
private function validString(?string $string): bool
{
return iconv_strlen($string) >= 1;
}
public function isValidSpanishVAT(string $vat): bool
{
$vat = strtoupper(trim($vat));
// Quick format check
if (!preg_match('/^[A-Z]\d{7}[A-Z0-9]$|^\d{8}[A-Z]$|^[XYZ]\d{7}[A-Z]$/', $vat)) {
return false;
}
// NIF (individuals)
if (preg_match('/^\d{8}[A-Z]$/', $vat)) {
$number = (int)substr($vat, 0, 8);
$letter = substr($vat, -1);
$letters = 'TRWAGMYFPDXBNJZSQVHLCKE';
return $letter === $letters[$number % 23];
}
// NIE (foreigners)
if (preg_match('/^[XYZ]\d{7}[A-Z]$/', $vat)) {
$replace = ['X' => '0', 'Y' => '1', 'Z' => '2'];
$number = (int)($replace[$vat[0]] . substr($vat, 1, 7));
$letter = substr($vat, -1);
$letters = 'TRWAGMYFPDXBNJZSQVHLCKE';
return $letter === $letters[$number % 23];
}
// CIF (companies)
if (preg_match('/^[ABCDEFGHJKLMNPQRSUVW]\d{7}[0-9A-J]$/', $vat)) {
$controlLetter = substr($vat, -1);
$digits = substr($vat, 1, 7);
$sumEven = 0;
$sumOdd = 0;
for ($i = 0; $i < 7; $i++) {
$n = (int)$digits[$i];
if ($i % 2 === 0) { // Odd positions (0-based index)
$n = $n * 2;
if ($n > 9) {
$n = floor($n / 10) + ($n % 10);
}
$sumOdd += $n;
} else {
$sumEven += $n;
}
}
$total = $sumEven + $sumOdd;
$controlDigit = (10 - ($total % 10)) % 10;
$controlChar = 'JABCDEFGHI'[$controlDigit];
$firstLetter = $vat[0];
if (strpos('PQRSW', $firstLetter) !== false) {
return $controlLetter === $controlChar; // Must be letter
} elseif (strpos('ABEH', $firstLetter) !== false) {
return $controlLetter == $controlDigit; // Must be digit
} else {
return ($controlLetter == $controlDigit || $controlLetter === $controlChar);
}
}
return false;
}
// // Example usage:
// var_dump(isValidSpanishVAT("12345678Z")); // true
// var_dump(isValidSpanishVAT("B12345674")); // true (CIF example)
// var_dump(isValidSpanishVAT("X1234567L")); // true (NIE)
}

View File

@@ -0,0 +1,207 @@
<?php
namespace App\Services\EDocument\Standards\Validation\Verifactu;
use App\Services\EDocument\Standards\Verifactu\Models\Invoice;
use InvalidArgumentException;
class InvoiceValidator
{
/**
* Validate an invoice against AEAT business rules
*/
public function validate(Invoice $invoice): array
{
$errors = [];
// Validate NIF format
$errors = array_merge($errors, $this->validateNif($invoice));
// Validate date formats
$errors = array_merge($errors, $this->validateDates($invoice));
// Validate invoice numbers
$errors = array_merge($errors, $this->validateInvoiceNumbers($invoice));
// Validate amounts
$errors = array_merge($errors, $this->validateAmounts($invoice));
// Validate tax rates
$errors = array_merge($errors, $this->validateTaxRates($invoice));
// Validate business logic
$errors = array_merge($errors, $this->validateBusinessLogic($invoice));
return $errors;
}
/**
* Validate NIF format (Spanish tax identification)
*/
private function validateNif(Invoice $invoice): array
{
$errors = [];
// Check emitter NIF
if ($invoice->getTercero() && $invoice->getTercero()->getNif()) {
$nif = $invoice->getTercero()->getNif();
if (!$this->isValidNif($nif)) {
$errors[] = "Invalid emitter NIF format: {$nif}";
}
}
// Check system NIF
if ($invoice->getSistemaInformatico() && $invoice->getSistemaInformatico()->getNif()) {
$nif = $invoice->getSistemaInformatico()->getNif();
if (!$this->isValidNif($nif)) {
$errors[] = "Invalid system NIF format: {$nif}";
}
}
return $errors;
}
/**
* Validate date formats
*/
private function validateDates(Invoice $invoice): array
{
$errors = [];
// Validate FechaHoraHusoGenRegistro format (YYYY-MM-DDTHH:MM:SS+HH:MM)
$fechaHora = $invoice->getFechaHoraHusoGenRegistro();
if ($fechaHora && !preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/', $fechaHora)) {
$errors[] = "Invalid FechaHoraHusoGenRegistro format. Expected: YYYY-MM-DDTHH:MM:SS+HH:MM, Got: {$fechaHora}";
}
return $errors;
}
/**
* Validate amounts
*/
private function validateAmounts(Invoice $invoice): array
{
$errors = [];
// Validate total amounts
if ($invoice->getImporteTotal() <= 0) {
$errors[] = "ImporteTotal must be greater than 0";
}
if ($invoice->getCuotaTotal() < 0) {
$errors[] = "CuotaTotal cannot be negative (use rectification invoice for negative amounts)";
}
// Validate decimal places (AEAT expects 2 decimal places)
if (fmod($invoice->getImporteTotal() * 100, 1) !== 0.0) {
$errors[] = "ImporteTotal must have maximum 2 decimal places";
}
if (fmod($invoice->getCuotaTotal() * 100, 1) !== 0.0) {
$errors[] = "CuotaTotal must have maximum 2 decimal places";
}
return $errors;
}
/**
* Validate tax rates
*/
private function validateTaxRates(Invoice $invoice): array
{
$errors = [];
// Check if desglose exists and has valid tax rates
// if ($invoice->getDesglose()) {
// $desglose = $invoice->getDesglose();
// // Validate tax rates are standard Spanish rates
// $validRates = [0, 4, 10, 21];
// // This would need to be implemented based on your Desglose structure
// // $taxRate = $desglose->getTipoImpositivo();
// // if (!in_array($taxRate, $validRates)) {
// // $errors[] = "Invalid tax rate: {$taxRate}. Valid rates are: " . implode(', ', $validRates);
// // }
// }
return $errors;
}
/**
* Validate business logic rules
*/
private function validateBusinessLogic(Invoice $invoice): array
{
$errors = [];
// Check for required fields based on invoice type
if (in_array($invoice->getTipoFactura(), ['R1','R2']) && !$invoice->getTipoRectificativa()) {
$errors[] = "Rectification invoices (R1/R2) must specify TipoRectificativa";
}
// Check for simplified invoice requirements
if ($invoice->getTipoFactura() === 'F2' && !$invoice->getFacturaSimplificadaArt7273()) {
$errors[] = "Simplified invoices (F2) must specify FacturaSimplificadaArt7273";
}
// Check for system information requirements
if (!$invoice->getSistemaInformatico()) {
$errors[] = "SistemaInformatico is required for all invoices";
}
// Check for encadenamiento requirements
if (!$invoice->getEncadenamiento()) {
$errors[] = "Encadenamiento is required for all invoices";
}
return $errors;
}
/**
* Check if NIF format is valid for Spanish tax identification
*/
private function isValidNif(string $nif): bool
{
// Basic format validation for Spanish NIFs
// Company NIFs: Letter + 8 digits (e.g., B12345678)
// Individual NIFs: 8 digits + letter (e.g., 12345678A)
$pattern = '/^([A-Z]\d{8}|\d{8}[A-Z])$/';
return preg_match($pattern, $nif) === 1;
}
/**
* Get validation rules as array for documentation
*/
public function getValidationRules(): array
{
return [
'nif' => [
'format' => 'Company: Letter + 8 digits (B12345678), Individual: 8 digits + letter (12345678A)',
'required' => true
],
'dates' => [
'FechaHoraHusoGenRegistro' => 'YYYY-MM-DDTHH:MM:SS+HH:MM',
'FechaExpedicionFactura' => 'YYYY-MM-DD'
],
'amounts' => [
'decimal_places' => 'Maximum 2 decimal places',
'positive' => 'ImporteTotal must be positive',
'tax_rates' => 'Valid rates: 0%, 4%, 10%, 21%'
],
'invoice_numbers' => [
'min_length' => 'Test numbers should be at least 10 characters',
'characters' => 'Only letters, numbers, hyphens, underscores'
],
'business_logic' => [
'R1_invoices' => 'Must specify TipoRectificativa',
'F2_invoices' => 'Must specify FacturaSimplificadaArt7273',
'required_fields' => 'SistemaInformatico and Encadenamiento are required'
]
];
}
}

View File

@@ -0,0 +1,607 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\EDocument\Standards\Validation;
/**
* VerifactuDocumentValidator - Validates Verifactu XML documents
*
* Extends the base XsltDocumentValidator but is configured specifically for Verifactu
* validation using the correct XSD schemas and namespaces.
*/
class VerifactuDocumentValidator extends XsltDocumentValidator
{
private array $verifactu_stylesheets = [
// Add any Verifactu-specific stylesheets here if needed
// '/Services/EDocument/Standards/Validation/Verifactu/Stylesheets/verifactu-validation.xslt',
];
private string $verifactu_xsd = 'Services/EDocument/Standards/Verifactu/xsd/SuministroLR.xsd';
private string $verifactu_informacion_xsd = 'Services/EDocument/Standards/Verifactu/xsd/SuministroInformacion.xsd';
public function __construct(public string $xml_document)
{
parent::__construct($xml_document);
// Override the base configuration for Verifactu
$this->setXsd($this->verifactu_xsd);
$this->setStyleSheets($this->verifactu_stylesheets);
}
/**
* Validate Verifactu XML document
*
* @return self
*/
public function validate(): self
{
$this->validateVerifactuXsd()
->validateVerifactuSchema();
return $this;
}
/**
* Validate against Verifactu XSD schemas
*/
private function validateVerifactuXsd(): self
{
libxml_use_internal_errors(true);
$xml = new \DOMDocument();
$xml->loadXML($this->xml_document);
// Extract business content from SOAP envelope if needed
$businessContent = $this->extractBusinessContent($xml);
// Detect document type to determine which validation to apply
$documentType = $this->detectDocumentType($businessContent);
// For modifications, we need to use a different validation approach
// since the standard XSD doesn't support modification structure
if ($documentType === 'modification') {
$this->validateModificationDocument($businessContent);
} else {
// For registration and cancellation, use standard XSD validation
if (!$businessContent->schemaValidate(app_path($this->verifactu_xsd))) {
$errors = libxml_get_errors();
libxml_clear_errors();
foreach ($errors as $error) {
$this->errors['xsd'][] = $this->formatXsdError($error);
}
}
}
return $this;
}
/**
* Format XSD validation errors to be more human-readable
*
* @param \LibXMLError $error The libxml error object
* @return string Formatted error message
*/
private function formatXsdError(\LibXMLError $error): string
{
$message = trim($error->message);
$line = $error->line;
// Remove long namespace URLs to make errors more readable
$message = preg_replace(
'/\{https:\/\/www2\.agenciatributaria\.gob\.es\/static_files\/common\/internet\/dep\/aplicaciones\/es\/aeat\/tike\/cont\/ws\/[^}]+\}/',
'',
$message
);
// Clean up the message and make it more user-friendly
$message = $this->translateXsdError($message);
return sprintf('Line %d: %s', $line, $message);
}
/**
* Translate XSD error messages to more user-friendly Spanish/English descriptions
*
* @param string $message The original XSD error message
* @return string Translated and improved error message
*/
private function translateXsdError(string $message): string
{
// Handle missing child element error specifically
if (preg_match('/Missing child element\(s\)\. Expected is \( ([^)]+) \)/', $message, $matches)) {
$expectedElement = trim($matches[1]);
$message = "Missing required child element: $expectedElement";
}
// Common error patterns and their translations
$errorTranslations = [
// Element not found
'/Element ([^:]+): ([^:]+) not found/' => 'Element not found: $2',
// Invalid content
'/Element ([^:]+): ([^:]+) has invalid content/' => 'Invalid content in element: $2',
// Required attribute missing
'/The attribute ([^:]+) is required/' => 'Required attribute missing: $1',
// Value not allowed
'/Value ([^:]+) is not allowed/' => 'Value not allowed: $1',
// Pattern validation failed
'/Element ([^:]+): ([^:]+) is not a valid value of the atomic type/' => 'Invalid value for element: $2',
];
// Apply translations
foreach ($errorTranslations as $pattern => $replacement) {
if (preg_match($pattern, $message, $matches)) {
$message = preg_replace($pattern, $replacement, $message);
break;
}
}
// Clean up common element names and make them more readable
$elementTranslations = [
'Desglose' => 'Desglose (Tax Breakdown)',
'DetalleDesglose' => 'DetalleDesglose (Tax Detail)',
'TipoFactura' => 'TipoFactura (Invoice Type)',
'DescripcionOperacion' => 'DescripcionOperacion (Operation Description)',
'ImporteTotal' => 'ImporteTotal (Total Amount)',
'RegistroAlta' => 'RegistroAlta (Registration Record)',
'RegistroAnulacion' => 'RegistroAnulacion (Cancellation Record)',
'FacturasRectificadas' => 'FacturasRectificadas (Corrected Invoices)',
'IDFacturaRectificada' => 'IDFacturaRectificada (Corrected Invoice ID)',
'IDEmisorFactura' => 'IDEmisorFactura (Invoice Emitter ID)',
'NumSerieFactura' => 'NumSerieFactura (Invoice Series Number)',
'FechaExpedicionFactura' => 'FechaExpedicionFactura (Invoice Issue Date)',
'Impuestos' => 'Impuestos (Taxes)',
'DetalleIVA' => 'DetalleIVA (VAT Detail)',
'CuotaRepercutida' => 'CuotaRepercutida (Recharged Tax Amount)',
'FechaExpedicionFacturaEmisor' => 'FechaExpedicionFacturaEmisor (Emitter Invoice Issue Date)',
];
// Apply element translations
foreach ($elementTranslations as $element => $translation) {
$message = str_replace($element, $translation, $message);
}
// Remove extra whitespace and clean up the message
$message = preg_replace('/\s+/', ' ', $message);
$message = trim($message);
return $message;
}
/**
* Detect the type of Verifactu document
*/
private function detectDocumentType(\DOMDocument $doc): string
{
$xpath = new \DOMXPath($doc);
$xpath->registerNamespace('si', 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd');
$xpath->registerNamespace('sum1', 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd');
// Check for modification structure - look for RegistroAlta with TipoFactura R1
$registroAlta = $xpath->query('//si:RegistroAlta | //sum1:RegistroAlta');
if ($registroAlta->length > 0) {
$tipoFactura = $xpath->query('.//si:TipoFactura | .//sum1:TipoFactura', $registroAlta->item(0));
if ($tipoFactura->length > 0 && in_array($tipoFactura->item(0)->textContent,['R1','F3'])) {
return 'modification';
}
}
// Check for cancellation structure
$registroAnulacion = $xpath->query('//si:RegistroAnulacion | //sum1:RegistroAnulacion');
if ($registroAnulacion->length > 0) {
return 'cancellation';
}
// Check for registration structure (RegistroAlta with TipoFactura not R1)
if ($registroAlta->length > 0) {
$tipoFactura = $xpath->query('.//si:TipoFactura | .//sum1:TipoFactura', $registroAlta->item(0));
if ($tipoFactura->length === 0 || $tipoFactura->item(0)->textContent !== 'R1') {
return 'registration';
}
}
return 'unknown';
}
/**
* Validate modification documents using business rules instead of strict XSD
*/
private function validateModificationDocument(\DOMDocument $doc): void
{
$xpath = new \DOMXPath($doc);
$xpath->registerNamespace('si', 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd');
$xpath->registerNamespace('sum1', 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd');
$xpath->registerNamespace('lr', 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd');
// Validate modification-specific structure
$this->validateModificationStructure($xpath);
// Validate required elements for modifications
$this->validateModificationRequiredElements($xpath);
// Validate business rules for modifications
$this->validateModificationBusinessRules($xpath);
}
/**
* Validate modification structure
*/
private function validateModificationStructure(\DOMXPath $xpath): void
{
// Check for RegistroAlta with TipoFactura R1
$registroAlta = $xpath->query('//si:RegistroAlta');
if ($registroAlta === false || $registroAlta->length === 0) {
// Try alternative namespace
$registroAlta = $xpath->query('//sum1:RegistroAlta');
if ($registroAlta === false || $registroAlta->length === 0) {
$this->errors['structure'][] = "RegistroAlta element not found for modification";
return;
}
}
// Check for required modification elements within the RegistroAlta
$requiredElements = [
'.//si:TipoFactura' => 'TipoFactura',
'.//si:DescripcionOperacion' => 'DescripcionOperacion',
'.//si:ImporteTotal' => 'ImporteTotal'
];
foreach ($requiredElements as $xpathQuery => $elementName) {
$elements = $xpath->query($xpathQuery, $registroAlta->item(0));
if ($elements === false || $elements->length === 0) {
// Try alternative namespace
$altQuery = str_replace('si:', 'sum1:', $xpathQuery);
$elements = $xpath->query($altQuery, $registroAlta->item(0));
if ($elements === false || $elements->length === 0) {
$this->errors['structure'][] = "Required modification element not found: $elementName";
}
}
}
// Validate TipoFactura is R1 for modifications
$tipoFactura = $xpath->query('.//si:TipoFactura', $registroAlta->item(0));
if ($tipoFactura === false || $tipoFactura->length === 0) {
$tipoFactura = $xpath->query('.//sum1:TipoFactura', $registroAlta->item(0));
}
if ($tipoFactura !== false && $tipoFactura->length > 0 && !in_array($tipoFactura->item(0)->textContent, ['R1','F3'])) {
$this->errors['structure'][] = "TipoFactura must be 'R1' for modifications, found: " . $tipoFactura->item(0)->textContent;
}
}
/**
* Validate required elements for modifications
*/
private function validateModificationRequiredElements(\DOMXPath $xpath): void
{
// Check for required elements in FacturasRectificadas - look for both si: and sf: namespaces
$facturasRectificadas = $xpath->query('//si:FacturasRectificadas | //sf:FacturasRectificadas');
if ($facturasRectificadas !== false && $facturasRectificadas->length > 0) {
$idFacturasRectificadas = $xpath->query('//si:FacturasRectificadas/si:IDFacturaRectificada | //sf:FacturasRectificadas/sf:IDFacturaRectificada');
if ($idFacturasRectificadas === false || $idFacturasRectificadas->length === 0) {
$this->errors['structure'][] = "At least one IDFacturaRectificada is required in FacturasRectificadas";
} else {
// Validate each IDFacturaRectificada has required elements
foreach ($idFacturasRectificadas as $index => $idFacturaRectificada) {
$idEmisorFactura = $xpath->query('.//si:IDEmisorFactura | .//sf:IDEmisorFactura', $idFacturaRectificada);
$numSerieFactura = $xpath->query('.//si:NumSerieFactura | .//sf:NumSerieFactura', $idFacturaRectificada);
$fechaExpedicionFactura = $xpath->query('.//si:FechaExpedicionFactura | .//sf:FechaExpedicionFactura', $idFacturaRectificada);
if ($idEmisorFactura === false || $idEmisorFactura->length === 0) {
$this->errors['structure'][] = "IDEmisorFactura is required in IDFacturaRectificada " . ($index + 1);
}
if ($numSerieFactura === false || $numSerieFactura->length === 0) {
$this->errors['structure'][] = "NumSerieFactura is required in IDFacturaRectificada " . ($index + 1);
}
if ($fechaExpedicionFactura === false || $fechaExpedicionFactura->length === 0) {
$this->errors['structure'][] = "FechaExpedicionFactura is required in IDFacturaRectificada " . ($index + 1);
}
}
}
}
// Check for tax information - look for both si: and sf: namespaces
$impuestos = $xpath->query('//si:Impuestos | //sf:Impuestos');
if ($impuestos !== false && $impuestos->length > 0) {
$detalleIVA = $xpath->query('//si:Impuestos/si:DetalleIVA | //sf:Impuestos/sf:DetalleIVA');
if ($detalleIVA === false || $detalleIVA->length === 0) {
$this->errors['structure'][] = "DetalleIVA is required when Impuestos is present";
}
}
}
/**
* Validate business rules for modifications
*/
private function validateModificationBusinessRules(\DOMXPath $xpath): void
{
// Validate ImporteTotal is numeric and positive
$importeTotal = $xpath->query('//si:ImporteTotal');
if ($importeTotal->length > 0) {
$value = $importeTotal->item(0)->textContent;
if (!is_numeric($value) || floatval($value) <= 0) {
$this->errors['business'][] = "ImporteTotal must be a positive number, found: $value";
}
}
// Validate tax amounts are consistent
$cuotaRepercutida = $xpath->query('//si:CuotaRepercutida');
if ($cuotaRepercutida->length > 0) {
$value = $cuotaRepercutida->item(0)->textContent;
if (!is_numeric($value)) {
$this->errors['business'][] = "CuotaRepercutida must be numeric, found: $value";
}
}
// Validate date formats
$fechaExpedicion = $xpath->query('//si:FechaExpedicionFacturaEmisor');
if ($fechaExpedicion->length > 0) {
$value = $fechaExpedicion->item(0)->textContent;
if (!preg_match('/^\d{2}-\d{2}-\d{4}$/', $value)) {
$this->errors['business'][] = "FechaExpedicionFacturaEmisor must be in DD-MM-YYYY format, found: $value";
}
}
}
/**
* Validate against Verifactu-specific schema rules
*/
private function validateVerifactuSchema(): self
{
try {
// Add any Verifactu-specific validation logic here
// This could include business rule validation, format checks, etc.
// For now, we'll just do basic structure validation
$this->validateVerifactuStructure();
} catch (\Throwable $th) {
$this->errors['general'][] = $th->getMessage();
}
return $this;
}
/**
* Extract business content from SOAP envelope
*/
private function extractBusinessContent(\DOMDocument $doc): \DOMDocument
{
$xpath = new \DOMXPath($doc);
$xpath->registerNamespace('lr', 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd');
$regFactuElements = $xpath->query('//lr:RegFactuSistemaFacturacion');
if ($regFactuElements->length > 0) {
$businessContent = $regFactuElements->item(0);
$businessDoc = new \DOMDocument();
$businessDoc->appendChild($businessDoc->importNode($businessContent, true));
return $businessDoc;
}
// If no business content found, return the original document
return $doc;
}
/**
* Validate Verifactu-specific structure requirements
*/
private function validateVerifactuStructure(): void
{
$doc = new \DOMDocument();
$doc->loadXML($this->xml_document);
$xpath = new \DOMXPath($doc);
$xpath->registerNamespace('si', 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd');
// Check for required elements
$requiredElements = [
'//si:TipoFactura',
'//si:DescripcionOperacion',
'//si:ImporteTotal'
];
foreach ($requiredElements as $element) {
$nodes = $xpath->query($element);
if ($nodes->length === 0) {
$this->errors['structure'][] = "Required element not found: $element";
}
}
// Check for modification-specific elements
$modificationElements = $xpath->query('//si:ModificacionFactura');
if ($modificationElements->length > 0) {
// Validate modification structure
$tipoRectificativa = $xpath->query('//si:TipoRectificativa');
if ($tipoRectificativa->length === 0) {
$this->errors['structure'][] = "TipoRectificativa is required for modifications";
}
$facturasRectificadas = $xpath->query('//si:FacturasRectificadas');
if ($facturasRectificadas->length === 0) {
$this->errors['structure'][] = "FacturasRectificadas is required for modifications";
}
}
}
/**
* Get Verifactu-specific errors
*/
public function getVerifactuErrors(): array
{
return $this->getErrors();
}
/**
* Get detailed error information with suggestions for fixing common issues
*
* @return array Detailed error information with context and suggestions
*/
public function getDetailedErrors(): array
{
$detailedErrors = [];
foreach ($this->errors as $errorType => $errors) {
foreach ($errors as $error) {
$detailedErrors[] = [
'type' => $errorType,
'message' => $error,
'context' => $this->getErrorContext($error),
'suggestion' => $this->getErrorSuggestion($error),
'severity' => $this->getErrorSeverity($errorType)
];
}
}
return $detailedErrors;
}
/**
* Get context information for an error
*
* @param string $error The error message
* @return string Context information
*/
private function getErrorContext(string $error): string
{
if (strpos($error, 'Desglose') !== false) {
return 'The Desglose (Tax Breakdown) element requires a DetalleDesglose (Tax Detail) child element to specify the tax breakdown structure.';
}
if (strpos($error, 'TipoFactura') !== false) {
return 'The TipoFactura (Invoice Type) element specifies the type of invoice being processed (e.g., F1 for regular invoice, R1 for modification).';
}
if (strpos($error, 'DescripcionOperacion') !== false) {
return 'The DescripcionOperacion (Operation Description) element provides a description of the business operation being documented.';
}
if (strpos($error, 'ImporteTotal') !== false) {
return 'The ImporteTotal (Total Amount) element contains the total amount of the invoice including all taxes.';
}
if (strpos($error, 'FacturasRectificadas') !== false) {
return 'The FacturasRectificadas (Corrected Invoices) element is required for modification invoices to reference the original invoices being corrected.';
}
return 'This error indicates a structural issue with the XML document that prevents it from conforming to the Verifactu schema requirements.';
}
/**
* Get suggestions for fixing an error
*
* @param string $error The error message
* @return string Suggestion for fixing the error
*/
private function getErrorSuggestion(string $error): string
{
if (strpos($error, 'Missing child element') !== false && strpos($error, 'DetalleDesglose') !== false) {
return 'Add a DetalleDesglose element within the Desglose element to specify the tax breakdown details. Example: <DetalleDesglose><TipoImpositivo>21</TipoImpositivo><BaseImponible>100.00</BaseImponible><CuotaRepercutida>21.00</CuotaRepercutida></DetalleDesglose>';
}
if (strpos($error, 'TipoFactura') !== false) {
return 'Ensure the TipoFactura element contains a valid value: F1 (regular invoice), F2 (simplified invoice), F3 (modification), or R1 (modification).';
}
if (strpos($error, 'DescripcionOperacion') !== false) {
return 'Add a DescripcionOperacion element with a clear description of the business operation, such as "Venta de mercancías" or "Prestación de servicios".';
}
if (strpos($error, 'ImporteTotal') !== false) {
return 'Ensure the ImporteTotal element contains a valid numeric value representing the total invoice amount including taxes.';
}
if (strpos($error, 'FacturasRectificadas') !== false) {
return 'For modification invoices, add the FacturasRectificadas element with at least one IDFacturaRectificada containing the original invoice details.';
}
return 'Review the XML structure against the Verifactu schema requirements and ensure all required elements are present with valid content.';
}
/**
* Get error severity level
*
* @param string $errorType The type of error
* @return string Severity level
*/
private function getErrorSeverity(string $errorType): string
{
return match($errorType) {
'xsd' => 'high',
'structure' => 'medium',
'business' => 'low',
'general' => 'medium',
default => 'medium'
};
}
/**
* Get a user-friendly summary of validation errors
*
* @return string Summary of validation errors
*/
public function getErrorSummary(): string
{
if (empty($this->errors)) {
return 'Document validation passed successfully.';
}
$summary = [];
$totalErrors = 0;
foreach ($this->errors as $errorType => $errors) {
$count = count($errors);
$totalErrors += $count;
$typeLabel = match($errorType) {
'xsd' => 'Schema Validation Errors',
'structure' => 'Structural Errors',
'business' => 'Business Rule Violations',
'general' => 'General Errors',
default => ucfirst($errorType) . ' Errors'
};
$summary[] = "$typeLabel: $count";
}
$summaryText = "Validation failed with $totalErrors total error(s):\n";
$summaryText .= implode(', ', $summary);
return $summaryText;
}
/**
* Get errors formatted for display in logs or user interfaces
*
* @return array Formatted errors grouped by type
*/
public function getFormattedErrors(): array
{
$formatted = [];
foreach ($this->errors as $errorType => $errors) {
$formatted[$errorType] = [
'count' => count($errors),
'messages' => $errors,
'severity' => $this->getErrorSeverity($errorType)
];
}
return $formatted;
}
}

View File

@@ -27,7 +27,7 @@ class XsltDocumentValidator
// private string $peppol_stylesheetx = 'Services/EDocument/Standards/Validation/Peppol/Stylesheets/ubl_stylesheet.xslt';
// private string $peppol_stylesheet = 'Services/EDocument/Standards/Validation/Peppol/Stylesheets/ci_to_ubl_stylesheet.xslt';
private array $errors = [];
public array $errors = [];
public function __construct(public string $xml_document)
{

View File

@@ -0,0 +1,240 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\EDocument\Standards;
use App\Models\Invoice;
use App\Models\VerifactuLog;
use App\Services\AbstractService;
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Writer\PngWriter;
use Endroid\QrCode\Encoding\Encoding;
use App\Services\EDocument\Standards\Verifactu\AeatClient;
use App\Services\EDocument\Standards\Verifactu\RegistroAlta;
use App\Services\EDocument\Standards\Verifactu\Models\Invoice as VerifactuInvoice;
use Endroid\QrCode\ErrorCorrectionLevel;
use Endroid\QrCode\Label\Font\OpenSans;
use App\Utils\Traits\MakesHash;
class Verifactu extends AbstractService
{
use MakesHash;
private AeatClient $aeat_client;
private string $soapXml;
//store the current document state
private VerifactuInvoice $_document;
public RegistroAlta $registro_alta;
//store the current huella
private string $_huella;
private string $_previous_huella;
public function __construct(public Invoice $invoice)
{
$this->aeat_client = new AeatClient();
}
/**
* Entry point for building document
*
* @return self
*/
public function run(): self
{
$v_logs = $this->invoice->company->verifactu_logs;
$i_logs = $this->invoice->verifactu_logs;
$registro_alta = (new RegistroAlta($this->invoice))->run();
if($this->invoice->amount < 0) {
$registro_alta = $registro_alta->setRectification();
}
$this->registro_alta = $registro_alta;
$document = $registro_alta->getInvoice();
//keep this state for logging later on successful send
$this->_document = $document;
$this->_previous_huella = '';
if($v_logs->count() >= 1){
$v_log = $v_logs->first();
$this->_previous_huella = $v_log->hash;
}
$this->_huella = $this->calculateHash($document, $this->_previous_huella); // careful with this! we'll need to reference this later
$document->setHuella($this->_huella);
$this->setEnvelope($document->toSoapEnvelope());
return $this;
}
/**
* setHuella
* We need this for cancellation documents.
*
* @param string $huella
* @return self
*/
public function setHuella(string $huella): self
{
$this->_huella = $huella;
return $this;
}
public function getInvoice()
{
return $this->_document;
}
public function setInvoice(VerifactuInvoice $invoice): self
{
$this->_document = $invoice;
return $this;
}
public function getEnvelope(): string
{
return $this->soapXml;
}
public function setTestMode(): self
{
$this->aeat_client->setTestMode();
return $this;
}
/**
* setPreviousHash
*
* **only used for testing**
* @param string $previous_hash
* @return self
*/
public function setPreviousHash(string $previous_hash): self
{
$this->_previous_huella = $previous_hash;
return $this;
}
private function setEnvelope(string $soapXml): self
{
$this->soapXml = $soapXml;
return $this;
}
public function writeLog(array $response)
{
VerifactuLog::create([
'invoice_id' => $this->invoice->id,
'company_id' => $this->invoice->company_id,
'invoice_number' => $this->invoice->number,
'date' => $this->invoice->date,
'hash' => $this->_huella,
'nif' => $this->_document->getIdFactura()->getIdEmisorFactura(),
'previous_hash' => $this->_previous_huella,
'state' => $this->_document->serialize(),
'response' => $response,
'status' => $response['guid'],
]);
}
/**
* calculateHash
*
* @param mixed $document
* @param string $huella
* @return string
*/
public function calculateHash($document, string $huella): string
{
$idEmisorFactura = $document->getIdFactura()->getIdEmisorFactura();
$numSerieFactura = $document->getIdFactura()->getNumSerieFactura();
$fechaExpedicionFactura = $document->getIdFactura()->getFechaExpedicionFactura();
$tipoFactura = $document->getTipoFactura();
$cuotaTotal = $document->getCuotaTotal();
$importeTotal = $document->getImporteTotal();
$fechaHoraHusoGenRegistro = $document->getFechaHoraHusoGenRegistro();
$hashInput = "IDEmisorFactura={$idEmisorFactura}&" .
"NumSerieFactura={$numSerieFactura}&" .
"FechaExpedicionFactura={$fechaExpedicionFactura}&" .
"TipoFactura={$tipoFactura}&" .
"CuotaTotal={$cuotaTotal}&" .
"ImporteTotal={$importeTotal}&" .
"Huella={$huella}&" .
"FechaHoraHusoGenRegistro={$fechaHoraHusoGenRegistro}";
return strtoupper(hash('sha256', $hashInput));
}
public function calculateQrCode(VerifactuLog $log)
{
try{
$csv = $log->status;
$nif = $log->nif;
$invoiceNumber = $log->invoice_number;
$date = $log->date->format('d-m-Y');
$total = (string)round($log->invoice->amount, 2);
$url = sprintf(
$this->aeat_client->base_qr_url,
urlencode($csv),
urlencode($nif),
urlencode($invoiceNumber),
urlencode($date),
urlencode($total)
);
$result = Builder::create()
->writer(new PngWriter())
->data($url)
->encoding(new Encoding('UTF-8'))
->errorCorrectionLevel(ErrorCorrectionLevel::Medium) // AEAT: level M or higher
->size(300) // AEAT minimum recommended: 30x30 mm ≈ 300px @ 254 DPI
->margin(10)
->labelText('VERI*FACTU')
->labelFont(new OpenSans(14))
->build();
return $result->getString();
} catch (\Exception $e) {
nlog("VERIFACTU ERROR: [qr]" . $e->getMessage());
return '';
}
}
public function send(string $soapXml): array
{
nlog("VERIFACTU: [send]" . $soapXml);
$response = $this->aeat_client->send($soapXml);
if($response['success'] || $response['status'] == 'ParcialmenteCorrecto'){
$this->writeLog($response);
}
return $response;
}
}

View File

@@ -0,0 +1,106 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\EDocument\Standards\Verifactu;
use App\Services\EDocument\Standards\Verifactu\ResponseProcessor;
use Illuminate\Support\Facades\Http;
class AeatAuthority
{
// @todo - in the UI, the user must navigate to AEAT link, and add Invoice Ninja as a third party. We cannot send without this.
// @todo - need to store the verification of this in the company
// https://sede.agenciatributaria.gob.es/Sede/ayuda/consultas-informaticas/otros-servicios-ayuda-tecnica/consultar-confirmar-renunciar-apoderamiento-recibido.html
// @todo - register with AEAT as a third party - power of attorney
// Log in with their certificate, DNIe, or Cl@ve PIN.
// Select: "Otorgar poder a un tercero"
// Enter:
// Your SaaS company's NIF as the authorized party
// Power code: LGTINVDI (or GENERALDATPE)
// Confirm
// https://sede.agenciatributaria.gob.es/wlpl/BDC/conapoderWS
/*
* Production URL works, sandbox URL does not!
*/
private string $base_url = 'https://sede.agenciatributaria.gob.es/wlpl/BDC/conapoderWS';
private string $sandbox_url = 'https://prewww1.aeat.es/wlpl/BDC/conapoderWS';
public function __construct()
{
}
public function setTestMode(): self
{
$this->base_url = $this->sandbox_url;
return $this;
}
public function run(string $client_nif): array
{
$sender_nif = config('services.verifactu.sender_nif');
$certificate = config('services.verifactu.certificate');
$ssl_key = config('services.verifactu.ssl_key');
$xml = <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:apod="http://www2.agenciatributaria.gob.es/apoderamiento/ws/apoderamientos">
<soapenv:Header/>
<soapenv:Body>
<apod:ConsultaApoderamiento>
<apod:identificadorApoderado>
<apod:nifRepresentante>{$sender_nif}</apod:nifRepresentante>
</apod:identificadorApoderado>
<apod:identificadorPoderdante>
<apod:nifPoderdante>{$client_nif}</apod:nifPoderdante>
</apod:identificadorPoderdante>
<apod:codigoPoder>LGTINVDI</apod:codigoPoder>
</apod:ConsultaApoderamiento>
</soapenv:Body>
</soapenv:Envelope>
XML;
$signingService = new \App\Services\EDocument\Standards\Verifactu\Signing\SigningService($xml, file_get_contents($ssl_key), file_get_contents($certificate));
$soapXml = $signingService->sign();
$response = Http::withHeaders([
'Content-Type' => 'text/xml; charset=utf-8',
'SOAPAction' => '',
])
->withOptions([
'cert' => $certificate,
'ssl_key' => $ssl_key,
'verify' => false,
'timeout' => 30,
])
->withBody($soapXml, 'text/xml')
->post($this->base_url);
$success = $response->successful();
nlog($soapXml);
$responseProcessor = new ResponseProcessor();
$parsedResponse = $responseProcessor->processResponse($response->body());
nlog($response->body());
nlog($parsedResponse);
return $parsedResponse;
}
}

View File

@@ -0,0 +1,90 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\EDocument\Standards\Verifactu;
use Illuminate\Support\Facades\Http;
use App\Services\EDocument\Standards\Verifactu\ResponseProcessor;
class AeatClient
{
private string $base_url = 'https://www1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP';
private string $sandbox_url = 'https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP';
public string $base_qr_url = 'https://www2.agenciatributaria.gob.es/wlpl/TIKE-CONT/ValidarQR?csv=%s&nif=%s&numserie=%s&fecha=%s&importe=%s';
private string $sandbox_qr_url = 'https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?csv=%s&nif=%s&numserie=%s&fecha=%s&importe=%s';
public function __construct(private ?string $certificate = null, private ?string $ssl_key = null)
{
$this->init();
}
/**
* initialize the certificates
*
* @return self
*/
private function init(): self
{
$this->certificate = $this->certificate ?? config('services.verifactu.certificate');
$this->ssl_key = $this->ssl_key ?? config('services.verifactu.ssl_key');
if(config('services.verifactu.test_mode')) {
$this->setTestMode();
}
return $this;
}
/**
* setTestMode
*
* @return self
*/
public function setTestMode(): self
{
$this->base_url = $this->sandbox_url;
$this->base_qr_url = $this->sandbox_qr_url;
return $this;
}
public function send($xml): array
{
$response = Http::withHeaders([
'Content-Type' => 'text/xml; charset=utf-8',
'SOAPAction' => '',
])
->withOptions([
'cert' => $this->certificate,
'ssl_key' => $this->ssl_key,
'verify' => false,
'timeout' => 30,
])
->withBody($xml, 'text/xml')
->post($this->base_url);
$success = $response->successful();
$responseProcessor = new ResponseProcessor();
$parsedResponse = $responseProcessor->processResponse($response->body());
nlog($parsedResponse);
return $parsedResponse;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Services\EDocument\Standards\Verifactu\Models;
class BaseTypes
{
// Common types
public const VERSION_TYPE = 'string';
public const NIF_TYPE = 'string';
public const FECHA_TYPE = 'string'; // ISO 8601 date
public const TEXT_MAX_18_TYPE = 'string';
public const TEXT_MAX_20_TYPE = 'string';
public const TEXT_MAX_30_TYPE = 'string';
public const TEXT_MAX_50_TYPE = 'string';
public const TEXT_MAX_60_TYPE = 'string';
public const TEXT_MAX_64_TYPE = 'string';
public const TEXT_MAX_70_TYPE = 'string';
public const TEXT_MAX_100_TYPE = 'string';
public const TEXT_MAX_120_TYPE = 'string';
public const TEXT_MAX_500_TYPE = 'string';
public const IMPORTE_SGN_12_2_TYPE = 'float';
public const TIPO_HUELLA_TYPE = 'string';
public const TIPO_PERIODO_TYPE = 'string';
public const CLAVE_TIPO_FACTURA_TYPE = 'string';
public const CLAVE_TIPO_RECTIFICATIVA_TYPE = 'string';
public const SIMPLIFICADA_CUALIFICADA_TYPE = 'string';
public const COMPLETA_SIN_DESTINATARIO_TYPE = 'string';
public const MACRODATO_TYPE = 'string';
public const TERCEROS_O_DESTINATARIO_TYPE = 'string';
public const GENERADO_POR_TYPE = 'string';
public const SIN_REGISTRO_PREVIO_TYPE = 'string';
public const SUBSANACION_TYPE = 'string';
public const RECHAZO_PREVIO_TYPE = 'string';
public const FIN_REQUERIMIENTO_TYPE = 'string';
public const INCIDENCIA_TYPE = 'string';
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Services\EDocument\Standards\Verifactu\Models;
abstract class BaseXmlModel
{
public const XML_NAMESPACE = 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd';
protected const XML_NAMESPACE_PREFIX = 'sum1';
protected const XML_DS_NAMESPACE = 'http://www.w3.org/2000/09/xmldsig#';
protected const XML_DS_NAMESPACE_PREFIX = 'ds';
protected function createElement(\DOMDocument $doc, string $name, ?string $value = null, array $attributes = []): \DOMElement
{
$element = $doc->createElementNS(self::XML_NAMESPACE, self::XML_NAMESPACE_PREFIX . ':' . $name);
if ($value !== null) {
$textNode = $doc->createTextNode($value);
$element->appendChild($textNode);
}
foreach ($attributes as $attrName => $attrValue) {
$element->setAttribute($attrName, $attrValue);
}
return $element;
}
protected function createDsElement(\DOMDocument $doc, string $name, ?string $value = null, array $attributes = []): \DOMElement
{
$element = $doc->createElementNS(self::XML_DS_NAMESPACE, self::XML_DS_NAMESPACE_PREFIX . ':' . $name);
if ($value !== null) {
$textNode = $doc->createTextNode($value);
$element->appendChild($textNode);
}
foreach ($attributes as $attrName => $attrValue) {
$element->setAttribute($attrName, $attrValue);
}
return $element;
}
protected function getElementValue(\DOMElement $parent, string $name, string $namespace = self::XML_NAMESPACE): ?string
{
$elements = $parent->getElementsByTagNameNS($namespace, $name);
if ($elements->length > 0) {
return $elements->item(0)->textContent;
}
return null;
}
abstract public function toXml(\DOMDocument $doc): \DOMElement;
public static function fromXml($xml): self
{
if ($xml instanceof \DOMElement) {
return static::fromDOMElement($xml);
}
if (!is_string($xml)) {
throw new \InvalidArgumentException('Input must be either a string or DOMElement');
}
$doc = new \DOMDocument();
$doc->formatOutput = true;
$doc->preserveWhiteSpace = false;
if (!$doc->loadXML($xml)) {
throw new \DOMException('Failed to load XML: Invalid XML format');
}
return static::fromDOMElement($doc->documentElement);
}
abstract public static function fromDOMElement(\DOMElement $element): self;
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Services\EDocument\Standards\Verifactu\Models;
class Cupon extends BaseXmlModel
{
protected string $idCupon;
protected string $fechaExpedicionCupon;
protected float $importeCupon;
protected ?string $descripcionCupon = null;
public function toXml(\DOMDocument $doc): \DOMElement
{
$root = $this->createElement($doc, 'Cupon');
// Add required elements
$root->appendChild($this->createElement($doc, 'IDCupon', $this->idCupon));
$root->appendChild($this->createElement($doc, 'FechaExpedicionCupon', $this->fechaExpedicionCupon));
$root->appendChild($this->createElement($doc, 'ImporteCupon', (string)$this->importeCupon));
// Add optional description
if ($this->descripcionCupon !== null) {
$root->appendChild($this->createElement($doc, 'DescripcionCupon', $this->descripcionCupon));
}
return $root;
}
public static function fromDOMElement(\DOMElement $element): self
{
$cupon = new self();
$cupon->setIdCupon($cupon->getElementValue($element, 'IDCupon'));
$cupon->setFechaExpedicionCupon($cupon->getElementValue($element, 'FechaExpedicionCupon'));
$cupon->setImporteCupon((float)$cupon->getElementValue($element, 'ImporteCupon'));
$descripcionCupon = $cupon->getElementValue($element, 'DescripcionCupon');
if ($descripcionCupon !== null) {
$cupon->setDescripcionCupon($descripcionCupon);
}
return $cupon;
}
public function getIdCupon(): string
{
return $this->idCupon;
}
public function setIdCupon(string $idCupon): self
{
$this->idCupon = $idCupon;
return $this;
}
public function getFechaExpedicionCupon(): string
{
return $this->fechaExpedicionCupon;
}
public function setFechaExpedicionCupon(string $fechaExpedicionCupon): self
{
$this->fechaExpedicionCupon = $fechaExpedicionCupon;
return $this;
}
public function getImporteCupon(): float
{
return $this->importeCupon;
}
public function setImporteCupon(float $importeCupon): self
{
$this->importeCupon = $importeCupon;
return $this;
}
public function getDescripcionCupon(): ?string
{
return $this->descripcionCupon;
}
public function setDescripcionCupon(?string $descripcionCupon): self
{
$this->descripcionCupon = $descripcionCupon;
return $this;
}
}

View File

@@ -0,0 +1,332 @@
<?php
namespace App\Services\EDocument\Standards\Verifactu\Models;
class Desglose extends BaseXmlModel
{
protected ?array $desgloseFactura = null;
protected ?array $desgloseTipoOperacion = null;
protected ?array $desgloseIVA = null;
protected ?array $desgloseIGIC = null;
protected ?array $desgloseIRPF = null;
protected ?array $desgloseIS = null;
protected ?DetalleDesglose $detalleDesglose = null;
public function toXml(\DOMDocument $doc): \DOMElement
{
$root = $this->createElement($doc, 'Desglose');
// If we have DetalleDesglose objects in the desgloseIVA array, use them
if ($this->desgloseIVA !== null && is_array($this->desgloseIVA) && count($this->desgloseIVA) > 0) {
foreach ($this->desgloseIVA as $detalleDesglose) {
if ($detalleDesglose instanceof DetalleDesglose) {
$root->appendChild($detalleDesglose->toXml($doc));
}
}
return $root;
}
// If we have a single DetalleDesglose object, use it
if ($this->detalleDesglose !== null) {
$root->appendChild($this->detalleDesglose->toXml($doc));
return $root;
}
// Always create a DetalleDesglose element if we have any data
$detalleDesglose = $this->createElement($doc, 'DetalleDesglose');
// Handle regular invoice desglose
if ($this->desgloseFactura !== null) {
// Add Impuesto if present
if (isset($this->desgloseFactura['Impuesto'])) {
$detalleDesglose->appendChild($this->createElement($doc, 'Impuesto', $this->desgloseFactura['Impuesto']));
} else {
// Default Impuesto for IVA
$detalleDesglose->appendChild($this->createElement($doc, 'Impuesto', '01'));
}
// Add ClaveRegimen if present
if (isset($this->desgloseFactura['ClaveRegimen']) ) {
$detalleDesglose->appendChild($this->createElement($doc, 'ClaveRegimen', $this->desgloseFactura['ClaveRegimen']));
} else {
// Default ClaveRegimen
$detalleDesglose->appendChild($this->createElement($doc, 'ClaveRegimen', '01'));
}
// Add CalificacionOperacion
$detalleDesglose->appendChild($this->createElement($doc, 'CalificacionOperacion',
$this->desgloseFactura['CalificacionOperacion'] ?? 'S1'));
// Add TipoImpositivo if present
if (isset($this->desgloseFactura['TipoImpositivo']) && $this->desgloseFactura['CalificacionOperacion'] == 'S1') {
$detalleDesglose->appendChild($this->createElement($doc, 'TipoImpositivo',
number_format((float)$this->desgloseFactura['TipoImpositivo'], 2, '.', '')));
}
// else {
// // Default TipoImpositivo
// $detalleDesglose->appendChild($this->createElement($doc, 'TipoImpositivo', '0'));
// }
// Convert BaseImponible to BaseImponibleOimporteNoSujeto if needed
$baseImponible = isset($this->desgloseFactura['BaseImponible'])
? $this->desgloseFactura['BaseImponible']
: ($this->desgloseFactura['BaseImponibleOimporteNoSujeto'] ?? '0');
$detalleDesglose->appendChild($this->createElement($doc, 'BaseImponibleOimporteNoSujeto',
number_format((float)$baseImponible, 2, '.', '')));
if(isset($this->desgloseFactura['Cuota']) && $this->desgloseFactura['CalificacionOperacion'] == 'S1'){
$detalleDesglose->appendChild($this->createElement($doc, 'CuotaRepercutida',
number_format((float)$this->desgloseFactura['Cuota'], 2, '.', '')));
}
// Add TipoRecargoEquivalencia if present
if (isset($this->desgloseFactura['TipoRecargoEquivalencia'])) {
$detalleDesglose->appendChild($this->createElement($doc, 'TipoRecargoEquivalencia',
number_format((float)$this->desgloseFactura['TipoRecargoEquivalencia'], 2, '.', '')));
}
// Add CuotaRecargoEquivalencia if present
if (isset($this->desgloseFactura['CuotaRecargoEquivalencia'])) {
$detalleDesglose->appendChild($this->createElement($doc, 'CuotaRecargoEquivalencia',
number_format((float)$this->desgloseFactura['CuotaRecargoEquivalencia'], 2, '.', '')));
}
}
// Handle simplified invoice desglose (IVA)
if ($this->desgloseIVA !== null) {
$taxRates = $this->normalizeTaxRates($this->desgloseIVA);
foreach ($taxRates as $taxRate) {
$detalleDesglose = $this->createDetalleDesglose($doc, $taxRate);
$root->appendChild($detalleDesglose);
}
}
// // If we still don't have any data, create a default DetalleDesglose
// if (!$detalleDesglose->hasChildNodes()) {
// // Create a default DetalleDesglose with basic IVA information
// $detalleDesglose->appendChild($this->createElement($doc, 'Impuesto', '01'));
// $detalleDesglose->appendChild($this->createElement($doc, 'ClaveRegimen', '01'));
// $detalleDesglose->appendChild($this->createElement($doc, 'CalificacionOperacion', 'S1'));
// $detalleDesglose->appendChild($this->createElement($doc, 'TipoImpositivo', '0'));
// $detalleDesglose->appendChild($this->createElement($doc, 'BaseImponibleOimporteNoSujeto', '0'));
// $detalleDesglose->appendChild($this->createElement($doc, 'CuotaRepercutida', '0'));
// }
$root->appendChild($detalleDesglose);
return $root;
}
public static function fromDOMElement(\DOMElement $element): self
{
$desglose = new self();
// Parse DesgloseFactura
$desgloseFacturaElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'DesgloseFactura')->item(0);
if ($desgloseFacturaElement) {
$desgloseFactura = [];
foreach ($desgloseFacturaElement->childNodes as $child) {
if ($child instanceof \DOMElement) {
$desgloseFactura[$child->localName] = $child->nodeValue;
}
}
$desglose->setDesgloseFactura($desgloseFactura);
}
// Parse DesgloseTipoOperacion
$desgloseTipoOperacionElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'DesgloseTipoOperacion')->item(0);
if ($desgloseTipoOperacionElement) {
$desgloseTipoOperacion = [];
foreach ($desgloseTipoOperacionElement->childNodes as $child) {
if ($child instanceof \DOMElement) {
$desgloseTipoOperacion[$child->localName] = $child->nodeValue;
}
}
$desglose->setDesgloseTipoOperacion($desgloseTipoOperacion);
}
// Parse DesgloseIVA
$desgloseIvaElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'DesgloseIVA')->item(0);
if ($desgloseIvaElement) {
$desgloseIva = [];
foreach ($desgloseIvaElement->childNodes as $child) {
if ($child instanceof \DOMElement) {
$desgloseIva[$child->localName] = $child->nodeValue;
}
}
$desglose->setDesgloseIVA($desgloseIva);
}
// Parse DesgloseIGIC
$desgloseIgicElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'DesgloseIGIC')->item(0);
if ($desgloseIgicElement) {
$desgloseIgic = [];
foreach ($desgloseIgicElement->childNodes as $child) {
if ($child instanceof \DOMElement) {
$desgloseIgic[$child->localName] = $child->nodeValue;
}
}
$desglose->setDesgloseIGIC($desgloseIgic);
}
// Parse DesgloseIRPF
$desgloseIrpfElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'DesgloseIRPF')->item(0);
if ($desgloseIrpfElement) {
$desgloseIrpf = [];
foreach ($desgloseIrpfElement->childNodes as $child) {
if ($child instanceof \DOMElement) {
$desgloseIrpf[$child->localName] = $child->nodeValue;
}
}
$desglose->setDesgloseIRPF($desgloseIrpf);
}
// Parse DesgloseIS
$desgloseIsElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'DesgloseIS')->item(0);
if ($desgloseIsElement) {
$desgloseIs = [];
foreach ($desgloseIsElement->childNodes as $child) {
if ($child instanceof \DOMElement) {
$desgloseIs[$child->localName] = $child->nodeValue;
}
}
$desglose->setDesgloseIS($desgloseIs);
}
return $desglose;
}
public function getDesgloseFactura(): ?array
{
return $this->desgloseFactura;
}
public function setDesgloseFactura(?array $desgloseFactura): self
{
$this->desgloseFactura = $desgloseFactura;
return $this;
}
public function getDesgloseTipoOperacion(): ?array
{
return $this->desgloseTipoOperacion;
}
public function setDesgloseTipoOperacion(?array $desgloseTipoOperacion): self
{
$this->desgloseTipoOperacion = $desgloseTipoOperacion;
return $this;
}
public function getDesgloseIVA(): ?array
{
return $this->desgloseIVA;
}
public function setDesgloseIVA(?array $desgloseIVA): self
{
$this->desgloseIVA = $desgloseIVA;
return $this;
}
public function addDesgloseIVA(DetalleDesglose $desgloseIVA): self
{
$this->desgloseIVA[] = $desgloseIVA;
return $this;
}
public function getDesgloseIGIC(): ?array
{
return $this->desgloseIGIC;
}
public function setDesgloseIGIC(?array $desgloseIGIC): self
{
$this->desgloseIGIC = $desgloseIGIC;
return $this;
}
public function getDesgloseIRPF(): ?array
{
return $this->desgloseIRPF;
}
public function setDesgloseIRPF(?array $desgloseIRPF): self
{
$this->desgloseIRPF = $desgloseIRPF;
return $this;
}
public function getDesgloseIS(): ?array
{
return $this->desgloseIS;
}
public function setDesgloseIS(?array $desgloseIS): self
{
$this->desgloseIS = $desgloseIS;
return $this;
}
public function setDetalleDesglose(?DetalleDesglose $detalleDesglose): self
{
$this->detalleDesglose = $detalleDesglose;
return $this;
}
public function getDetalleDesglose(): ?DetalleDesglose
{
return $this->detalleDesglose;
}
/**
* Normalize tax rates to ensure consistent array structure
*/
private function normalizeTaxRates(array $desgloseIVA): array
{
// Check if first element is an array (multiple tax rates)
if (!empty($desgloseIVA) && is_array($desgloseIVA[0] ?? null)) {
return $desgloseIVA;
}
// Single tax rate - wrap in array
return [$desgloseIVA];
}
/**
* Create DetalleDesglose XML element from tax rate data
*/
private function createDetalleDesglose(\DOMDocument $doc, array $taxRate): \DOMElement
{
$detalleDesglose = $this->createElement($doc, 'DetalleDesglose');
// Add Impuesto (required for IVA)
$detalleDesglose->appendChild($this->createElement($doc, 'Impuesto', $taxRate['Impuesto'] ?? '01'));
// Add ClaveRegimen
$detalleDesglose->appendChild($this->createElement($doc, 'ClaveRegimen', $taxRate['ClaveRegimen'] ?? '01'));
// Add CalificacionOperacion
$detalleDesglose->appendChild($this->createElement($doc, 'CalificacionOperacion', $taxRate['CalificacionOperacion'] ?? 'S1'));
// Add TipoImpositivo if present
if (isset($taxRate['TipoImpositivo'])) {
$detalleDesglose->appendChild($this->createElement($doc, 'TipoImpositivo',
number_format((float)$taxRate['TipoImpositivo'], 2, '.', '')));
}
// Convert BaseImponible to BaseImponibleOimporteNoSujeto if needed
$baseImponible = $taxRate['BaseImponible'] ?? $taxRate['BaseImponibleOimporteNoSujeto'] ?? '0';
$detalleDesglose->appendChild($this->createElement($doc, 'BaseImponibleOimporteNoSujeto',
number_format((float)$baseImponible, 2, '.', '')));
// Convert Cuota to CuotaRepercutida if needed
$cuota = $taxRate['Cuota'] ?? $taxRate['CuotaRepercutida'] ?? '0';
$detalleDesglose->appendChild($this->createElement($doc, 'CuotaRepercutida',
number_format((float)$cuota, 2, '.', '')));
return $detalleDesglose;
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Services\EDocument\Standards\Verifactu\Models;
/**
* DesgloseRectificacion - Rectification Breakdown
*
* This class represents the DesgloseRectificacionType from the Spanish tax authority schema.
* It contains the breakdown of base and tax amounts for rectified invoices.
*/
class DesgloseRectificacion extends BaseXmlModel
{
protected float $baseRectificada;
protected float $cuotaRectificada;
protected ?float $cuotaRecargoRectificado = null;
public function __construct(float $baseRectificada, float $cuotaRectificada, ?float $cuotaRecargoRectificado = null)
{
$this->baseRectificada = $baseRectificada;
$this->cuotaRectificada = $cuotaRectificada;
$this->cuotaRecargoRectificado = $cuotaRecargoRectificado;
}
public function getBaseRectificada(): float
{
return $this->baseRectificada;
}
public function setBaseRectificada(float $baseRectificada): self
{
$this->baseRectificada = $baseRectificada;
return $this;
}
public function getCuotaRectificada(): float
{
return $this->cuotaRectificada;
}
public function setCuotaRectificada(float $cuotaRectificada): self
{
$this->cuotaRectificada = $cuotaRectificada;
return $this;
}
public function getCuotaRecargoRectificado(): ?float
{
return $this->cuotaRecargoRectificado;
}
public function setCuotaRecargoRectificado(?float $cuotaRecargoRectificado): self
{
$this->cuotaRecargoRectificado = $cuotaRecargoRectificado;
return $this;
}
public function toXml(\DOMDocument $doc): \DOMElement
{
$root = $doc->createElementNS(self::XML_NAMESPACE, self::XML_NAMESPACE_PREFIX . ':ImporteRectificacion');
// Add BaseRectificada (required)
$root->appendChild($this->createElement($doc, 'BaseRectificada', number_format($this->baseRectificada, 2, '.', '')));
// Add CuotaRectificada (required)
$root->appendChild($this->createElement($doc, 'CuotaRectificada', number_format($this->cuotaRectificada, 2, '.', '')));
// Add CuotaRecargoRectificado (optional)
if ($this->cuotaRecargoRectificado !== null) {
$root->appendChild($this->createElement($doc, 'CuotaRecargoRectificado', number_format($this->cuotaRecargoRectificado, 2, '.', '')));
}
return $root;
}
public static function fromDOMElement(\DOMElement $element): self
{
$baseRectificada = (float)self::getElementText($element, 'BaseRectificada');
$cuotaRectificada = (float)self::getElementText($element, 'CuotaRectificada');
$cuotaRecargoRectificado = self::getElementText($element, 'CuotaRecargoRectificado');
return new self(
$baseRectificada,
$cuotaRectificada,
$cuotaRecargoRectificado ? (float)$cuotaRecargoRectificado : null
);
}
protected static function getElementText(\DOMElement $element, string $tagName): ?string
{
$node = $element->getElementsByTagNameNS(self::XML_NAMESPACE, $tagName)->item(0);
return $node ? $node->nodeValue : null;
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Services\EDocument\Standards\Verifactu\Models;
class DetalleDesglose extends BaseXmlModel
{
protected array $desgloseIVA = [];
public function setDesgloseIVA(array $desglose): self
{
$this->desgloseIVA = $desglose;
return $this;
}
public function getDesgloseIVA(): array
{
return $this->desgloseIVA;
}
public function toXml(\DOMDocument $doc): \DOMElement
{
$root = $this->createElement($doc, 'DetalleDesglose');
// Add IVA details directly under DetalleDesglose
$root->appendChild($this->createElement($doc, 'Impuesto', $this->desgloseIVA['Impuesto']));
if(isset($this->desgloseIVA['ClaveRegimen']) && in_array($this->desgloseIVA['ClaveRegimen'], ['01','03'])){
$root->appendChild($this->createElement($doc, 'ClaveRegimen', $this->desgloseIVA['ClaveRegimen']));
}
$root->appendChild($this->createElement($doc, 'CalificacionOperacion', $this->desgloseIVA['CalificacionOperacion']));
if(isset($this->desgloseIVA['TipoImpositivo']) && $this->desgloseIVA['CalificacionOperacion'] == 'S1') {
$root->appendChild($this->createElement($doc, 'TipoImpositivo', (string)$this->desgloseIVA['TipoImpositivo']));
}
$root->appendChild($this->createElement($doc, 'BaseImponibleOimporteNoSujeto', (string)$this->desgloseIVA['BaseImponible']));
if(isset($this->desgloseIVA['Cuota']) && $this->desgloseIVA['CalificacionOperacion'] == 'S1') {
$root->appendChild($this->createElement($doc, 'CuotaRepercutida', (string)$this->desgloseIVA['Cuota']));
}
return $root;
}
public static function fromDOMElement(\DOMElement $element): self
{
$detalleDesglose = new self();
$desglose = [
'Impuesto' => self::getElementText($element, 'Impuesto'),
'ClaveRegimen' => self::getElementText($element, 'ClaveRegimen'),
'CalificacionOperacion' => self::getElementText($element, 'CalificacionOperacion'),
'BaseImponible' => (float)self::getElementText($element, 'BaseImponibleOimporteNoSujeto'),
'TipoImpositivo' => (float)self::getElementText($element, 'TipoImpositivo'),
'Cuota' => (float)self::getElementText($element, 'CuotaRepercutida')
];
$detalleDesglose->setDesgloseIVA($desglose);
return $detalleDesglose;
}
protected static function getElementText(\DOMElement $element, string $tagName): ?string
{
$node = $element->getElementsByTagNameNS(self::XML_NAMESPACE, $tagName)->item(0);
return $node ? $node->nodeValue : null;
}
}

View File

@@ -0,0 +1,121 @@
<?php
namespace App\Services\EDocument\Standards\Verifactu\Models;
use App\Services\EDocument\Standards\Verifactu\Models\RegistroAnterior;
class Encadenamiento extends BaseXmlModel
{
protected ?string $primerRegistro = null;
protected ?RegistroAnterior $registroAnterior = null;
protected ?RegistroAnterior $registroPosterior = null;
public function toXml(\DOMDocument $doc): \DOMElement
{
$root = $this->createElement($doc, 'Encadenamiento');
if ($this->registroAnterior !== null) {
$root->appendChild($this->registroAnterior->toXml($doc));
} else {
// Always include PrimerRegistro if no RegistroAnterior is set
$root->appendChild($this->createElement($doc, 'PrimerRegistro', 'S'));
}
if ($this->registroPosterior !== null) {
$root->appendChild($this->registroPosterior->toXml($doc));
}
return $root;
}
public static function fromXml($xml): BaseXmlModel
{
$encadenamiento = new self();
if (is_string($xml)) {
error_log("Loading XML in Encadenamiento::fromXml: " . $xml);
$dom = new \DOMDocument();
if (!$dom->loadXML($xml)) {
error_log("Failed to load XML in Encadenamiento::fromXml");
throw new \DOMException('Invalid XML');
}
$element = $dom->documentElement;
} else {
$element = $xml;
}
try {
// Handle PrimerRegistro
$primerRegistro = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'PrimerRegistro')->item(0);
if ($primerRegistro) {
$encadenamiento->setPrimerRegistro($primerRegistro->nodeValue);
}
// Handle RegistroAnterior
$registroAnterior = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'RegistroAnterior')->item(0);
if ($registroAnterior) {
$encadenamiento->setRegistroAnterior(RegistroAnterior::fromDOMElement($registroAnterior));
}
return $encadenamiento;
} catch (\Exception $e) {
error_log("Error parsing XML in Encadenamiento::fromXml: " . $e->getMessage());
throw new \InvalidArgumentException('Error parsing XML: ' . $e->getMessage());
}
}
public static function fromDOMElement(\DOMElement $element): self
{
$encadenamiento = new self();
// Handle PrimerRegistro
$primerRegistro = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'PrimerRegistro')->item(0);
if ($primerRegistro) {
$encadenamiento->setPrimerRegistro($primerRegistro->nodeValue);
}
// Handle RegistroAnterior
$registroAnterior = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'RegistroAnterior')->item(0);
if ($registroAnterior) {
$encadenamiento->setRegistroAnterior(RegistroAnterior::fromDOMElement($registroAnterior));
}
return $encadenamiento;
}
public function getPrimerRegistro(): ?string
{
return $this->primerRegistro;
}
public function setPrimerRegistro(?string $primerRegistro): self
{
if ($primerRegistro !== null && $primerRegistro !== 'S') {
throw new \InvalidArgumentException('PrimerRegistro must be "S" or null');
}
$this->primerRegistro = $primerRegistro;
return $this;
}
public function getRegistroAnterior(): ?RegistroAnterior
{
return $this->registroAnterior;
}
public function setRegistroAnterior(?RegistroAnterior $registroAnterior): self
{
$this->registroAnterior = $registroAnterior;
return $this;
}
public function getRegistroPosterior(): ?RegistroAnterior
{
return $this->registroPosterior;
}
public function setRegistroPosterior(?RegistroAnterior $registroPosterior): self
{
$this->registroPosterior = $registroPosterior;
return $this;
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Services\EDocument\Standards\Verifactu\Models;
use DOMDocument;
use DOMElement;
class IDFactura extends BaseXmlModel
{
protected string $idEmisorFactura;
protected string $numSerieFactura;
protected string $fechaExpedicionFactura;
public function __construct()
{
// Initialize with default values
$this->idEmisorFactura = 'B12345678';
$this->numSerieFactura = '';
$this->fechaExpedicionFactura = now()->format('d-m-Y');
}
public function getIdEmisorFactura(): string
{
return $this->idEmisorFactura;
}
public function setIdEmisorFactura(string $idEmisorFactura): self
{
$this->idEmisorFactura = $idEmisorFactura;
return $this;
}
public function getNumSerieFactura(): string
{
return $this->numSerieFactura;
}
public function setNumSerieFactura(string $numSerieFactura): self
{
$this->numSerieFactura = $numSerieFactura;
return $this;
}
public function getFechaExpedicionFactura(): string
{
return $this->fechaExpedicionFactura;
}
public function setFechaExpedicionFactura(string $fechaExpedicionFactura): self
{
$this->fechaExpedicionFactura = $fechaExpedicionFactura;
return $this;
}
public function toXml(DOMDocument $doc): DOMElement
{
$idFactura = $this->createElement($doc, 'IDFactura');
$idFactura->appendChild($this->createElement($doc, 'IDEmisorFactura', $this->idEmisorFactura));
$idFactura->appendChild($this->createElement($doc, 'NumSerieFactura', $this->numSerieFactura));
$idFactura->appendChild($this->createElement($doc, 'FechaExpedicionFactura', $this->fechaExpedicionFactura));
return $idFactura;
}
public static function fromDOMElement(DOMElement $element): self
{
$idFactura = new self();
// Parse IDEmisorFactura
$idEmisorFactura = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'IDEmisorFactura')->item(0);
if ($idEmisorFactura) {
$idFactura->setIdEmisorFactura($idEmisorFactura->nodeValue);
}
// Parse NumSerieFactura
$numSerieFactura = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'NumSerieFactura')->item(0);
if ($numSerieFactura) {
$idFactura->setNumSerieFactura($numSerieFactura->nodeValue);
}
// Parse FechaExpedicionFactura
$fechaExpedicionFactura = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'FechaExpedicionFactura')->item(0);
if ($fechaExpedicionFactura) {
$idFactura->setFechaExpedicionFactura($fechaExpedicionFactura->nodeValue);
}
return $idFactura;
}
public function serialize(): string
{
return serialize($this);
}
public static function unserialize(string $data): self
{
$object = unserialize($data);
if (!$object instanceof self) {
throw new \InvalidArgumentException('Invalid serialized data - not an IDFactura object');
}
return $object;
}
}

View File

@@ -0,0 +1,153 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\EDocument\Standards\Verifactu\Models;
use App\Services\EDocument\Standards\Verifactu\Models\BaseXmlModel;
class IDOtro extends BaseXmlModel
{
private const VALID_ID_TYPES = [
'01', // NIF IVA (EU operator with VAT number, non-Spanish)
'02', // NIF in Spain
'03', // VAT number (EU operator without Spanish NIF)
'04', // Passport
'05', // Official ID document
'06', // Residence certificate
'07', // Person without identification code
'08', // Other supporting document
'09', // Tax ID from third country
];
private ?string $nombreRazon = '';
/**
* __construct
*
* @param string $codigoPais ISO 3166-1 alpha-2 country code (e.g., ES, FR, US)
* @param string $idType AEAT ID type code (e.g., '07' = Person without identification code)
* @param string $id Identifier value, e.g., passport number, tax ID, or placeholder
* @return void
*/
public function __construct(private string $codigoPais = 'ES', private string $idType = '06', private string $id = 'NO_DISPONIBLE')
{
}
public function getNombreRazon(): string
{
return $this->nombreRazon;
}
public function getCodigoPais(): string
{
return $this->codigoPais;
}
public function getIdType(): string
{
return $this->idType;
}
public function getId(): string
{
return $this->id;
}
public function setNombreRazon(string $nombreRazon): self
{
$this->nombreRazon = $nombreRazon;
return $this;
}
public function setCodigoPais(string $codigoPais): self
{
$this->codigoPais = strtoupper($codigoPais);
return $this;
}
public function setIdType(string $idType): self
{
$this->idType = $idType;
return $this;
}
public function setId(string $id): self
{
$this->id = $id;
return $this;
}
/**
* Returns the array structure for serialization to XML
*/
public function toArray(): array
{
return [
'CodigoPais' => $this->codigoPais,
'IDType' => $this->idType,
'ID' => $this->id,
];
}
/**
* Returns the XML fragment for IDOtro
*/
public function toXml(\DOMDocument $doc): \DOMElement
{
$root = $this->createElement($doc, 'IDOtro');
$root->appendChild($this->createElement($doc, 'CodigoPais', $this->codigoPais));
$root->appendChild($this->createElement($doc, 'IDType', $this->idType));
$root->appendChild($this->createElement($doc, 'ID', $this->id));
return $root;
}
/**
* Create a PersonaFisicaJuridica instance from XML string or DOMElement
*/
public static function fromXml($xml): BaseXmlModel
{
if (is_string($xml)) {
$doc = new \DOMDocument();
$doc->loadXML($xml);
$element = $doc->documentElement;
} else {
$element = $xml;
}
return self::fromDOMElement($element);
}
public static function fromDOMElement(\DOMElement $element): self
{
$idOtro = new self();
$codigoPaisElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'CodigoPais')->item(0);
if ($codigoPaisElement) {
$idOtro->setCodigoPais($codigoPaisElement->nodeValue);
}
$idTypeElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'IDType')->item(0);
if ($idTypeElement) {
$idOtro->setIdType($idTypeElement->nodeValue);
}
$idElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'ID')->item(0);
if ($idElement) {
$idOtro->setId($idElement->nodeValue);
}
return $idOtro;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,220 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\EDocument\Standards\Verifactu\Models;
use App\Services\EDocument\Standards\Verifactu\Models\BaseXmlModel;
class PersonaFisicaJuridica extends BaseXmlModel
{
protected ?string $nif = null;
protected ?string $nombreRazon = null;
protected ?string $apellidos = null;
protected ?string $nombre = null;
protected ?string $razonSocial = null;
protected ?string $tipoIdentificacion = null;
protected ?IDOtro $idOtro = null;
protected ?string $pais = null;
public function getNif(): ?string
{
return $this->nif;
}
public function setNif(?string $nif): self
{
if ($nif !== null && strlen($nif) !== 9) {
throw new \InvalidArgumentException('NIF must be exactly 9 characters long');
}
$this->nif = $nif;
return $this;
}
public function getNombreRazon(): ?string
{
return $this->nombreRazon;
}
public function setNombreRazon(?string $nombreRazon): self
{
$this->nombreRazon = $nombreRazon;
return $this;
}
public function getApellidos(): ?string
{
return $this->apellidos;
}
public function setApellidos(?string $apellidos): self
{
$this->apellidos = $apellidos;
return $this;
}
public function getNombre(): ?string
{
return $this->nombre;
}
public function setNombre(?string $nombre): self
{
$this->nombre = $nombre;
return $this;
}
public function getRazonSocial(): ?string
{
return $this->razonSocial;
}
public function setRazonSocial(?string $razonSocial): self
{
$this->razonSocial = $razonSocial;
return $this;
}
public function getTipoIdentificacion(): ?string
{
return $this->tipoIdentificacion;
}
public function setTipoIdentificacion(?string $tipoIdentificacion): self
{
$this->tipoIdentificacion = $tipoIdentificacion;
return $this;
}
public function getIdOtro(): IDOtro
{
return $this->idOtro;
}
public function setIdOtro(IDOtro $idOtro): self
{
$this->idOtro = $idOtro;
return $this;
}
public function getPais(): ?string
{
return $this->pais;
}
public function setPais(?string $pais): self
{
$this->pais = $pais;
return $this;
}
public function toXml(\DOMDocument $doc): \DOMElement
{
$root = $this->createElement($doc, 'PersonaFisicaJuridica');
if ($this->nif !== null) {
$root->appendChild($this->createElement($doc, 'NIF', $this->nif));
}
if ($this->nombreRazon !== null) {
$root->appendChild($this->createElement($doc, 'NombreRazon', $this->nombreRazon));
}
if ($this->apellidos !== null) {
$root->appendChild($this->createElement($doc, 'Apellidos', $this->apellidos));
}
if ($this->nombre !== null) {
$root->appendChild($this->createElement($doc, 'Nombre', $this->nombre));
}
if ($this->razonSocial !== null) {
$root->appendChild($this->createElement($doc, 'RazonSocial', $this->razonSocial));
}
if ($this->tipoIdentificacion !== null) {
$root->appendChild($this->createElement($doc, 'TipoIdentificacion', $this->tipoIdentificacion));
}
if ($this->idOtro !== null) {
$root->appendChild($this->idOtro->toXml($doc));
}
if ($this->pais !== null) {
$root->appendChild($this->createElement($doc, 'Pais', $this->pais));
}
return $root;
}
/**
* Create a PersonaFisicaJuridica instance from XML string or DOMElement
*/
public static function fromXml($xml): BaseXmlModel
{
if (is_string($xml)) {
$doc = new \DOMDocument();
$doc->loadXML($xml);
$element = $doc->documentElement;
} else {
$element = $xml;
}
return self::fromDOMElement($element);
}
public static function fromDOMElement(\DOMElement $element): self
{
$persona = new self();
$nifElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'NIF')->item(0);
if ($nifElement) {
$persona->setNif($nifElement->nodeValue);
}
$nombreRazonElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'NombreRazon')->item(0);
if ($nombreRazonElement) {
$persona->setNombreRazon($nombreRazonElement->nodeValue);
}
$apellidosElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'Apellidos')->item(0);
if ($apellidosElement) {
$persona->setApellidos($apellidosElement->nodeValue);
}
$nombreElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'Nombre')->item(0);
if ($nombreElement) {
$persona->setNombre($nombreElement->nodeValue);
}
$razonSocialElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'RazonSocial')->item(0);
if ($razonSocialElement) {
$persona->setRazonSocial($razonSocialElement->nodeValue);
}
$tipoIdentificacionElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'TipoIdentificacion')->item(0);
if ($tipoIdentificacionElement) {
$persona->setTipoIdentificacion($tipoIdentificacionElement->nodeValue);
}
$idOtroElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'IDOtro')->item(0);
if ($idOtroElement) {
$persona->setIdOtro($idOtroElement->nodeValue);
}
$paisElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'Pais')->item(0);
if ($paisElement) {
$persona->setPais($paisElement->nodeValue);
}
return $persona;
}
}

View File

@@ -0,0 +1,157 @@
<?php
namespace App\Services\EDocument\Standards\Verifactu\Models;
/**
* RegistroAnterior - Previous Record Information
*
* This class represents the previous record information required for Verifactu e-invoicing
* chain linking. It contains the details of the previous invoice in the chain.
*/
class RegistroAnterior extends BaseXmlModel
{
protected string $idEmisorFactura;
protected string $numSerieFactura;
protected string $fechaExpedicionFactura;
protected string $huella;
public function toXml(\DOMDocument $doc): \DOMElement
{
$root = $this->createElement($doc, 'RegistroAnterior');
$root->appendChild($this->createElement($doc, 'IDEmisorFactura', $this->idEmisorFactura));
$root->appendChild($this->createElement($doc, 'NumSerieFactura', $this->numSerieFactura));
$root->appendChild($this->createElement($doc, 'FechaExpedicionFactura', $this->fechaExpedicionFactura));
$root->appendChild($this->createElement($doc, 'Huella', $this->huella));
return $root;
}
public static function fromDOMElement(\DOMElement $element): self
{
$registroAnterior = new self();
// Handle IDEmisorFactura
$idEmisorFactura = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'IDEmisorFactura')->item(0);
if ($idEmisorFactura) {
$registroAnterior->setIdEmisorFactura($idEmisorFactura->nodeValue);
}
// Handle NumSerieFactura
$numSerieFactura = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'NumSerieFactura')->item(0);
if ($numSerieFactura) {
$registroAnterior->setNumSerieFactura($numSerieFactura->nodeValue);
}
// Handle FechaExpedicionFactura
$fechaExpedicionFactura = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'FechaExpedicionFactura')->item(0);
if ($fechaExpedicionFactura) {
$registroAnterior->setFechaExpedicionFactura($fechaExpedicionFactura->nodeValue);
}
// Handle Huella
$huella = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'Huella')->item(0);
if ($huella) {
$registroAnterior->setHuella($huella->nodeValue);
}
return $registroAnterior;
}
public static function fromXml($xml): self
{
if ($xml instanceof \DOMElement) {
return static::fromDOMElement($xml);
}
if (!is_string($xml)) {
throw new \InvalidArgumentException('Input must be either a string or DOMElement');
}
// Enable user error handling for XML parsing
$previousErrorSetting = libxml_use_internal_errors(true);
try {
$doc = new \DOMDocument();
if (!$doc->loadXML($xml)) {
$errors = libxml_get_errors();
libxml_clear_errors();
throw new \DOMException('Failed to load XML: ' . ($errors ? $errors[0]->message : 'Invalid XML format'));
}
return static::fromDOMElement($doc->documentElement);
} finally {
// Restore previous error handling setting
libxml_use_internal_errors($previousErrorSetting);
}
}
/**
* Get the NIF of the invoice issuer from the previous record
*/
public function getIdEmisorFactura(): string
{
return $this->idEmisorFactura;
}
/**
* Set the NIF of the invoice issuer from the previous record
*/
public function setIdEmisorFactura(string $idEmisorFactura): self
{
$this->idEmisorFactura = $idEmisorFactura;
return $this;
}
/**
* Get the invoice number from the previous record
*/
public function getNumSerieFactura(): string
{
return $this->numSerieFactura;
}
/**
* Set the invoice number from the previous record
*/
public function setNumSerieFactura(string $numSerieFactura): self
{
$this->numSerieFactura = $numSerieFactura;
return $this;
}
/**
* Get the invoice issue date from the previous record
*/
public function getFechaExpedicionFactura(): string
{
return $this->fechaExpedicionFactura;
}
/**
* Set the invoice issue date from the previous record
*
* @param string $fechaExpedicionFactura Date in DD-MM-YYYY format
*/
public function setFechaExpedicionFactura(string $fechaExpedicionFactura): self
{
$this->fechaExpedicionFactura = $fechaExpedicionFactura;
return $this;
}
/**
* Get the digital fingerprint/hash from the previous record
*/
public function getHuella(): string
{
return $this->huella;
}
/**
* Set the digital fingerprint/hash from the previous record
*/
public function setHuella(string $huella): self
{
$this->huella = $huella;
return $this;
}
}

View File

@@ -0,0 +1,454 @@
<?php
namespace App\Services\EDocument\Standards\Verifactu\Models;
/**
* RegistroAnulacion - Invoice Cancellation Record
*
* This class represents the cancellation record information required for Verifactu e-invoicing
* modification operations. It contains the details of the invoice to be cancelled.
*/
class RegistroAnulacion extends BaseXmlModel
{
protected string $idVersion;
protected string $idEmisorFactura;
protected string $numSerieFactura;
protected string $fechaExpedicionFactura;
protected string $motivoAnulacion;
protected string $nombreRazonEmisor;
// Additional properties required by XSD schema
protected ?string $refExterna = null;
protected ?string $sinRegistroPrevio = null;
protected ?string $rechazoPrevio = null;
protected ?string $generadoPor = null;
protected ?PersonaFisicaJuridica $generador = null;
protected Encadenamiento $encadenamiento;
protected SistemaInformatico $sistemaInformatico;
protected string $fechaHoraHusoGenRegistro;
protected string $tipoHuella;
protected string $huella;
protected ?string $signature = null;
public function __construct()
{
$this->idVersion = '1.0';
$this->motivoAnulacion = '1'; // Default: Sustitución por otra factura
$this->encadenamiento = new Encadenamiento();
$this->sistemaInformatico = new SistemaInformatico();
$this->fechaHoraHusoGenRegistro = now()->format('Y-m-d\TH:i:sP');
$this->tipoHuella = '01';
$this->huella = '';
}
public function getIdVersion(): string
{
return $this->idVersion;
}
public function setIdVersion(string $idVersion): self
{
$this->idVersion = $idVersion;
return $this;
}
public function getIdEmisorFactura(): string
{
return $this->idEmisorFactura;
}
public function setIdEmisorFactura(string $idEmisorFactura): self
{
$this->idEmisorFactura = $idEmisorFactura;
return $this;
}
public function getNumSerieFactura(): string
{
return $this->numSerieFactura;
}
public function setNumSerieFactura(string $numSerieFactura): self
{
$this->numSerieFactura = $numSerieFactura;
return $this;
}
public function getFechaExpedicionFactura(): string
{
return $this->fechaExpedicionFactura;
}
public function setFechaExpedicionFactura(string $fechaExpedicionFactura): self
{
$this->fechaExpedicionFactura = $fechaExpedicionFactura;
return $this;
}
public function getMotivoAnulacion(): string
{
return $this->motivoAnulacion;
}
public function setMotivoAnulacion(string $motivoAnulacion): self
{
$this->motivoAnulacion = $motivoAnulacion;
return $this;
}
public function getRefExterna(): ?string
{
return $this->refExterna;
}
public function setRefExterna(?string $refExterna): self
{
$this->refExterna = $refExterna;
return $this;
}
public function getSinRegistroPrevio(): ?string
{
return $this->sinRegistroPrevio;
}
public function setSinRegistroPrevio(?string $sinRegistroPrevio): self
{
$this->sinRegistroPrevio = $sinRegistroPrevio;
return $this;
}
public function getRechazoPrevio(): ?string
{
return $this->rechazoPrevio;
}
public function setRechazoPrevio(?string $rechazoPrevio): self
{
$this->rechazoPrevio = $rechazoPrevio;
return $this;
}
public function getGeneradoPor(): ?string
{
return $this->generadoPor;
}
public function setGeneradoPor(?string $generadoPor): self
{
$this->generadoPor = $generadoPor;
return $this;
}
public function getGenerador(): ?PersonaFisicaJuridica
{
return $this->generador;
}
public function setGenerador(?PersonaFisicaJuridica $generador): self
{
$this->generador = $generador;
return $this;
}
public function getEncadenamiento(): Encadenamiento
{
return $this->encadenamiento;
}
public function setEncadenamiento(Encadenamiento $encadenamiento): self
{
$this->encadenamiento = $encadenamiento;
return $this;
}
public function getSistemaInformatico(): SistemaInformatico
{
return $this->sistemaInformatico;
}
public function setSistemaInformatico(SistemaInformatico $sistemaInformatico): self
{
$this->sistemaInformatico = $sistemaInformatico;
return $this;
}
public function getFechaHoraHusoGenRegistro(): string
{
return $this->fechaHoraHusoGenRegistro;
}
public function setFechaHoraHusoGenRegistro(string $fechaHoraHusoGenRegistro): self
{
$this->fechaHoraHusoGenRegistro = $fechaHoraHusoGenRegistro;
return $this;
}
public function getTipoHuella(): string
{
return $this->tipoHuella;
}
public function setTipoHuella(string $tipoHuella): self
{
$this->tipoHuella = $tipoHuella;
return $this;
}
public function getHuella(): string
{
return $this->huella;
}
public function setHuella(string $huella): self
{
$this->huella = $huella;
return $this;
}
public function getSignature(): ?string
{
return $this->signature;
}
public function setSignature(?string $signature): self
{
$this->signature = $signature;
return $this;
}
public function getNombreRazonEmisor(): string
{
return $this->nombreRazonEmisor;
}
public function setNombreRazonEmisor(string $nombreRazonEmisor): self
{
$this->nombreRazonEmisor = $nombreRazonEmisor;
return $this;
}
public function toXml(\DOMDocument $doc): \DOMElement
{
$root = $doc->createElementNS(self::XML_NAMESPACE, self::XML_NAMESPACE_PREFIX . ':RegistroAnulacion');
// Add IDVersion
$root->appendChild($this->createElement($doc, 'IDVersion', $this->idVersion));
// Create IDFactura structure
$idFactura = $this->createElement($doc, 'IDFactura');
$idFactura->appendChild($this->createElement($doc, 'IDEmisorFacturaAnulada', $this->idEmisorFactura));
$idFactura->appendChild($this->createElement($doc, 'NumSerieFacturaAnulada', $this->numSerieFactura));
$idFactura->appendChild($this->createElement($doc, 'FechaExpedicionFacturaAnulada', $this->fechaExpedicionFactura));
$root->appendChild($idFactura);
// Add optional RefExterna
if ($this->refExterna !== null) {
$root->appendChild($this->createElement($doc, 'RefExterna', $this->refExterna));
}
// Add optional SinRegistroPrevio
if ($this->sinRegistroPrevio !== null) {
$root->appendChild($this->createElement($doc, 'SinRegistroPrevio', $this->sinRegistroPrevio));
}
// Add optional RechazoPrevio
if ($this->rechazoPrevio !== null) {
$root->appendChild($this->createElement($doc, 'RechazoPrevio', $this->rechazoPrevio));
}
// Add optional GeneradoPor
if ($this->generadoPor !== null) {
$root->appendChild($this->createElement($doc, 'GeneradoPor', $this->generadoPor));
}
// Add optional Generador
if ($this->generador !== null) {
$root->appendChild($this->generador->toXml($doc));
}
// Add Encadenamiento using actual property
$encadenamientoElement = $this->encadenamiento->toXml($doc);
$root->appendChild($encadenamientoElement);
// Add SistemaInformatico using actual property
$sistemaInformaticoElement = $this->sistemaInformatico->toXml($doc);
$root->appendChild($sistemaInformaticoElement);
// Add FechaHoraHusoGenRegistro using actual property
$root->appendChild($this->createElement($doc, 'FechaHoraHusoGenRegistro', $this->fechaHoraHusoGenRegistro));
// Add TipoHuella using actual property
$root->appendChild($this->createElement($doc, 'TipoHuella', $this->tipoHuella));
// Add Huella using actual property
$root->appendChild($this->createElement($doc, 'Huella', $this->huella));
// Add optional Signature
if ($this->signature !== null) {
$root->appendChild($this->createDsElement($doc, 'Signature', $this->signature));
}
return $root;
}
public static function fromDOMElement(\DOMElement $element): self
{
$registroAnulacion = new self();
// Handle IDVersion
$idVersion = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'IDVersion')->item(0);
if ($idVersion) {
$registroAnulacion->setIdVersion($idVersion->nodeValue);
}
// Handle IDFactura
$idFactura = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'IDFactura')->item(0);
if ($idFactura) {
$idEmisorFactura = $idFactura->getElementsByTagNameNS(self::XML_NAMESPACE, 'IDEmisorFacturaAnulada')->item(0);
if ($idEmisorFactura) {
$registroAnulacion->setIdEmisorFactura($idEmisorFactura->nodeValue);
}
$numSerieFactura = $idFactura->getElementsByTagNameNS(self::XML_NAMESPACE, 'NumSerieFacturaAnulada')->item(0);
if ($numSerieFactura) {
$registroAnulacion->setNumSerieFactura($numSerieFactura->nodeValue);
}
$fechaExpedicionFactura = $idFactura->getElementsByTagNameNS(self::XML_NAMESPACE, 'FechaExpedicionFacturaAnulada')->item(0);
if ($fechaExpedicionFactura) {
$registroAnulacion->setFechaExpedicionFactura($fechaExpedicionFactura->nodeValue);
}
}
// Handle optional elements
$refExterna = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'RefExterna')->item(0);
if ($refExterna) {
$registroAnulacion->setRefExterna($refExterna->nodeValue);
}
$sinRegistroPrevio = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'SinRegistroPrevio')->item(0);
if ($sinRegistroPrevio) {
$registroAnulacion->setSinRegistroPrevio($sinRegistroPrevio->nodeValue);
}
$rechazoPrevio = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'RechazoPrevio')->item(0);
if ($rechazoPrevio) {
$registroAnulacion->setRechazoPrevio($rechazoPrevio->nodeValue);
}
$generadoPor = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'GeneradoPor')->item(0);
if ($generadoPor) {
$registroAnulacion->setGeneradoPor($generadoPor->nodeValue);
}
// Handle Generador
$generador = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'Generador')->item(0);
if ($generador) {
$registroAnulacion->setGenerador(PersonaFisicaJuridica::fromDOMElement($generador));
}
// Handle Encadenamiento
$encadenamiento = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'Encadenamiento')->item(0);
if ($encadenamiento) {
$registroAnulacion->setEncadenamiento(Encadenamiento::fromDOMElement($encadenamiento));
}
// Handle SistemaInformatico
$sistemaInformatico = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'SistemaInformatico')->item(0);
if ($sistemaInformatico) {
$registroAnulacion->setSistemaInformatico(SistemaInformatico::fromDOMElement($sistemaInformatico));
}
// Handle FechaHoraHusoGenRegistro
$fechaHoraHusoGenRegistro = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'FechaHoraHusoGenRegistro')->item(0);
if ($fechaHoraHusoGenRegistro) {
$registroAnulacion->setFechaHoraHusoGenRegistro($fechaHoraHusoGenRegistro->nodeValue);
}
// Handle TipoHuella
$tipoHuella = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'TipoHuella')->item(0);
if ($tipoHuella) {
$registroAnulacion->setTipoHuella($tipoHuella->nodeValue);
}
// Handle Huella
$huella = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'Huella')->item(0);
if ($huella) {
$registroAnulacion->setHuella($huella->nodeValue);
}
// Handle MotivoAnulacion
$motivoAnulacion = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'MotivoAnulacion')->item(0);
if ($motivoAnulacion) {
$registroAnulacion->setMotivoAnulacion($motivoAnulacion->nodeValue);
}
return $registroAnulacion;
}
public function toXmlString(): string
{
$doc = new \DOMDocument('1.0', 'UTF-8');
$doc->preserveWhiteSpace = false;
$doc->formatOutput = true;
$root = $this->toXml($doc);
$doc->appendChild($root);
return $doc->saveXML();
}
public function toSoapEnvelope(): string
{
// Create the SOAP document
$soapDoc = new \DOMDocument('1.0', 'UTF-8');
$soapDoc->preserveWhiteSpace = false;
$soapDoc->formatOutput = true;
// Create SOAP envelope with namespaces
$envelope = $soapDoc->createElementNS('http://schemas.xmlsoap.org/soap/envelope/', 'soapenv:Envelope');
$envelope->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:soapenv', 'http://schemas.xmlsoap.org/soap/envelope/');
$envelope->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:sum', 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd');
$envelope->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:sum1', 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd');
$soapDoc->appendChild($envelope);
// Create Header
$header = $soapDoc->createElementNS('http://schemas.xmlsoap.org/soap/envelope/', 'soapenv:Header');
$envelope->appendChild($header);
// Create Body
$body = $soapDoc->createElementNS('http://schemas.xmlsoap.org/soap/envelope/', 'soapenv:Body');
$envelope->appendChild($body);
// Create RegFactuSistemaFacturacion
$regFactu = $soapDoc->createElementNS('https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd', 'sum:RegFactuSistemaFacturacion');
$body->appendChild($regFactu);
// Create Cabecera
$cabecera = $soapDoc->createElementNS('https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd', 'sum:Cabecera');
$regFactu->appendChild($cabecera);
// Create ObligadoEmision
$obligadoEmision = $soapDoc->createElementNS('https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd', 'sum1:ObligadoEmision');
$cabecera->appendChild($obligadoEmision);
// Add ObligadoEmision content (using default values for now)
$obligadoEmision->appendChild($soapDoc->createElementNS('https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd', 'sum1:NombreRazon', $this->getNombreRazonEmisor()));
$obligadoEmision->appendChild($soapDoc->createElementNS('https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd', 'sum1:NIF', $this->getIdEmisorFactura()));
// Create RegistroFactura
$registroFactura = $soapDoc->createElementNS('https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd', 'sum:RegistroFactura');
$regFactu->appendChild($registroFactura);
// Import your existing XML into the RegistroFactura
$yourXmlDoc = new \DOMDocument();
$yourXmlDoc->loadXML($this->toXmlString());
// Import the root element from your XML
$importedNode = $soapDoc->importNode($yourXmlDoc->documentElement, true);
$registroFactura->appendChild($importedNode);
return $soapDoc->saveXML();
}
}

View File

@@ -0,0 +1,243 @@
<?php
namespace App\Services\EDocument\Standards\Verifactu\Models;
class SistemaInformatico extends BaseXmlModel
{
protected string $nombreRazon;
protected ?string $nif = null;
protected ?string $idOtro = null;
protected string $nombreSistemaInformatico;
protected string $idSistemaInformatico;
protected string $version;
protected string $numeroInstalacion;
protected string $tipoUsoPosibleSoloVerifactu = 'S';
protected string $tipoUsoPosibleMultiOT = 'S';
protected string $indicadorMultiplesOT = 'S';
public function __construct()
{
// Initialize required properties with default values
$this->nombreRazon = 'InvoiceNinja System';
$this->nombreSistemaInformatico = 'InvoiceNinja';
$this->idSistemaInformatico = '01';
$this->version = '1.0.0';
$this->numeroInstalacion = '001';
$this->nif = 'B12345678'; // Default NIF
}
public function toXml(\DOMDocument $doc): \DOMElement
{
$root = $this->createElement($doc, 'SistemaInformatico');
// Add nombreRazon (first element in nested sequence)
$root->appendChild($this->createElement($doc, 'NombreRazon', $this->nombreRazon));
// Add either NIF or IDOtro (second element in nested sequence)
if ($this->nif !== null) {
$root->appendChild($this->createElement($doc, 'NIF', $this->nif));
} elseif ($this->idOtro !== null) {
$root->appendChild($this->createElement($doc, 'IDOtro', $this->idOtro));
} else {
// If neither NIF nor IDOtro is set, we need to set a default NIF
$root->appendChild($this->createElement($doc, 'NIF', 'B12345678'));
}
// Add remaining elements (outside the nested sequence)
$root->appendChild($this->createElement($doc, 'NombreSistemaInformatico', $this->nombreSistemaInformatico));
$root->appendChild($this->createElement($doc, 'IdSistemaInformatico', $this->idSistemaInformatico));
$root->appendChild($this->createElement($doc, 'Version', $this->version));
$root->appendChild($this->createElement($doc, 'NumeroInstalacion', $this->numeroInstalacion));
$root->appendChild($this->createElement($doc, 'TipoUsoPosibleSoloVerifactu', $this->tipoUsoPosibleSoloVerifactu));
$root->appendChild($this->createElement($doc, 'TipoUsoPosibleMultiOT', $this->tipoUsoPosibleMultiOT));
$root->appendChild($this->createElement($doc, 'IndicadorMultiplesOT', $this->indicadorMultiplesOT));
return $root;
}
/**
* Create a SistemaInformatico instance from XML string
*/
public static function fromXml($xml): BaseXmlModel
{
if (is_string($xml)) {
$doc = new \DOMDocument();
$doc->loadXML($xml);
$element = $doc->documentElement;
} else {
$element = $xml;
}
return self::fromDOMElement($element);
}
public static function fromDOMElement(\DOMElement $element): self
{
$sistemaInformatico = new self();
// Parse NombreRazon
$nombreRazonElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'NombreRazon')->item(0);
if ($nombreRazonElement) {
$sistemaInformatico->setNombreRazon($nombreRazonElement->nodeValue);
}
// Parse NIF or IDOtro
$nifElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'NIF')->item(0);
if ($nifElement) {
$sistemaInformatico->setNif($nifElement->nodeValue);
} else {
$idOtroElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'IDOtro')->item(0);
if ($idOtroElement) {
$sistemaInformatico->setIdOtro($idOtroElement->nodeValue);
}
}
// Parse remaining elements
$nombreSistemaElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'NombreSistemaInformatico')->item(0);
if ($nombreSistemaElement) {
$sistemaInformatico->setNombreSistemaInformatico($nombreSistemaElement->nodeValue);
}
$idSistemaElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'IdSistemaInformatico')->item(0);
if ($idSistemaElement) {
$sistemaInformatico->setIdSistemaInformatico($idSistemaElement->nodeValue);
}
$versionElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'Version')->item(0);
if ($versionElement) {
$sistemaInformatico->setVersion($versionElement->nodeValue);
}
$numeroInstalacionElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'NumeroInstalacion')->item(0);
if ($numeroInstalacionElement) {
$sistemaInformatico->setNumeroInstalacion($numeroInstalacionElement->nodeValue);
}
$tipoUsoPosibleSoloVerifactuElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'TipoUsoPosibleSoloVerifactu')->item(0);
if ($tipoUsoPosibleSoloVerifactuElement) {
$sistemaInformatico->setTipoUsoPosibleSoloVerifactu($tipoUsoPosibleSoloVerifactuElement->nodeValue);
}
$tipoUsoPosibleMultiOTElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'TipoUsoPosibleMultiOT')->item(0);
if ($tipoUsoPosibleMultiOTElement) {
$sistemaInformatico->setTipoUsoPosibleMultiOT($tipoUsoPosibleMultiOTElement->nodeValue);
}
$indicadorMultiplesOTElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'IndicadorMultiplesOT')->item(0);
if ($indicadorMultiplesOTElement) {
$sistemaInformatico->setIndicadorMultiplesOT($indicadorMultiplesOTElement->nodeValue);
}
return $sistemaInformatico;
}
public function getNombreRazon(): string
{
return $this->nombreRazon;
}
public function setNombreRazon(string $nombreRazon): self
{
$this->nombreRazon = $nombreRazon;
return $this;
}
public function getNif(): ?string
{
return $this->nif;
}
public function setNif(?string $nif): self
{
$this->nif = $nif;
return $this;
}
public function getIdOtro(): ?string
{
return $this->idOtro;
}
public function setIdOtro(?string $idOtro): self
{
$this->idOtro = $idOtro;
return $this;
}
public function getNombreSistemaInformatico(): string
{
return $this->nombreSistemaInformatico;
}
public function setNombreSistemaInformatico(string $nombreSistemaInformatico): self
{
$this->nombreSistemaInformatico = $nombreSistemaInformatico;
return $this;
}
public function getIdSistemaInformatico(): string
{
return $this->idSistemaInformatico;
}
public function setIdSistemaInformatico(string $idSistemaInformatico): self
{
$this->idSistemaInformatico = $idSistemaInformatico;
return $this;
}
public function getVersion(): string
{
return $this->version;
}
public function setVersion(string $version): self
{
$this->version = $version;
return $this;
}
public function getNumeroInstalacion(): string
{
return $this->numeroInstalacion;
}
public function setNumeroInstalacion(string $numeroInstalacion): self
{
$this->numeroInstalacion = $numeroInstalacion;
return $this;
}
public function getTipoUsoPosibleSoloVerifactu(): string
{
return $this->tipoUsoPosibleSoloVerifactu;
}
public function setTipoUsoPosibleSoloVerifactu(string $tipoUsoPosibleSoloVerifactu): self
{
$this->tipoUsoPosibleSoloVerifactu = $tipoUsoPosibleSoloVerifactu;
return $this;
}
public function getTipoUsoPosibleMultiOT(): string
{
return $this->tipoUsoPosibleMultiOT;
}
public function setTipoUsoPosibleMultiOT(string $tipoUsoPosibleMultiOT): self
{
$this->tipoUsoPosibleMultiOT = $tipoUsoPosibleMultiOT;
return $this;
}
public function getIndicadorMultiplesOT(): string
{
return $this->indicadorMultiplesOT;
}
public function setIndicadorMultiplesOT(string $indicadorMultiplesOT): self
{
$this->indicadorMultiplesOT = $indicadorMultiplesOT;
return $this;
}
}

View File

@@ -0,0 +1,22 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\EDocument\Standards\Verifactu\Models;
interface XmlModelInterface
{
public function toXmlString(): string;
public function toXml(\DOMDocument $doc): \DOMElement;
public function toSoapEnvelope(): string;
}

View File

@@ -0,0 +1,449 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\EDocument\Standards\Verifactu;
use App\Models\Company;
use App\Models\Invoice;
use App\Models\Product;
use App\Models\VerifactuLog;
use App\Helpers\Invoice\Taxer;
use App\Utils\Traits\MakesHash;
use App\DataMapper\Tax\BaseRule;
use App\Services\AbstractService;
use App\Helpers\Invoice\InvoiceSum;
use App\Utils\Traits\NumberFormatter;
use App\Helpers\Invoice\InvoiceSumInclusive;
use App\Services\EDocument\Standards\Verifactu\Models\IDOtro;
use App\Services\EDocument\Standards\Verifactu\Models\Desglose;
use App\Services\EDocument\Standards\Verifactu\Models\IDFactura;
use App\Services\EDocument\Standards\Verifactu\Models\Encadenamiento;
use App\Services\EDocument\Standards\Verifactu\Models\DetalleDesglose;
use App\Services\EDocument\Standards\Verifactu\Models\RegistroAnterior;
use App\Services\EDocument\Standards\Verifactu\Models\SistemaInformatico;
use App\Services\EDocument\Standards\Verifactu\Models\PersonaFisicaJuridica;
use App\Services\EDocument\Standards\Verifactu\Models\Invoice as VerifactuInvoice;
use App\Utils\BcMath;
class RegistroAlta
{
use Taxer;
use NumberFormatter;
use MakesHash;
private Company $company;
public InvoiceSum | InvoiceSumInclusive $calc;
private VerifactuInvoice $v_invoice;
private ?VerifactuLog $v_log;
private array $errors = [];
private string $current_timestamp;
private array $calculated_invoice_values = [];
private array $impuesto_codes = [
'01' => 'IVA (Impuesto sobre el Valor Añadido)', // Value Added Tax - Standard Spanish VAT
'02' => 'IPSI (Impuesto sobre la Producción, los Servicios y la Importación)', // Production, Services and Import Tax - Ceuta and Melilla
'03' => 'IGIC (Impuesto General Indirecto Canario)', // Canary Islands General Indirect Tax
'05' => 'Otros (Others)', // Other taxes
'06' => 'IAE', //local taxes - rarely used
'07' => 'Non-Vat / Exempt operations'
];
private array $clave_regimen_codes = [
'01' => 'Régimen General', // General Regime - Standard VAT regime for most businesses
'02' => 'Régimen Simplificado', // Simplified Regime - For small businesses with simplified accounting
'03' => 'Régimen Especial de Agrupaciones de Módulos', // Special Module Grouping Regime - For agricultural activities
'04' => 'Régimen Especial del Recargo de Equivalencia', // Special Equivalence Surcharge Regime - For retailers
'05' => 'Régimen Especial de las Agencias de Viajes', // Special Travel Agencies Regime
'06' => 'Régimen Especial de los Bienes Usados', // Special Used Goods Regime
'07' => 'Régimen Especial de los Objetos de Arte', // Special Art Objects Regime
'08' => 'Régimen Especial de las Antigüedades', // Special Antiques Regime
'09' => 'Régimen Especial de los Objetos de Colección', // Special Collectibles Regime
'10' => 'Régimen Especial de los Bienes de Inversión', // Special Investment Goods Regime
'11' => 'Régimen Especial de los Servicios', // Special Services Regime
'12' => 'Régimen Especial de los Bienes de Inversión y Servicios', // Special Investment Goods and Services Regime
'13' => 'Régimen Especial de los Bienes de Inversión y Servicios (Inversión del Sujeto Pasivo)', // Special Investment Goods and Services Regime (Reverse Charge)
'14' => 'Régimen Especial de los Bienes de Inversión y Servicios (Inversión del Sujeto Pasivo - Bienes de Inversión)', // Special Investment Goods and Services Regime (Reverse Charge - Investment Goods)
'15' => 'Régimen Especial de los Bienes de Inversión y Servicios (Inversión del Sujeto Pasivo - Servicios)', // Special Investment Goods and Services Regime (Reverse Charge - Services)
'16' => 'Régimen Especial de los Bienes de Inversión y Servicios (Inversión del Sujeto Pasivo - Bienes de Inversión y Servicios)', // Special Investment Goods and Services Regime (Reverse Charge - Investment Goods and Services)
'17' => 'Régimen Especial de los Bienes de Inversión y Servicios (Inversión del Sujeto Pasivo - Bienes de Inversión y Servicios - Inversión del Sujeto Pasivo)', // Special Investment Goods and Services Regime (Reverse Charge - Investment Goods and Services - Reverse Charge)
'18' => 'Régimen Especial de los Bienes de Inversión y Servicios (Inversión del Sujeto Pasivo - Bienes de Inversión y Servicios - Inversión del Sujeto Pasivo - Bienes de Inversión)', // Special Investment Goods and Services Regime (Reverse Charge - Investment Goods and Services - Reverse Charge - Investment Goods)
'19' => 'Régimen Especial de los Bienes de Inversión y Servicios (Inversión del Sujeto Pasivo - Bienes de Inversión y Servicios - Inversión del Sujeto Pasivo - Servicios)', // Special Investment Goods and Services Regime (Reverse Charge - Investment Goods and Services - Reverse Charge - Services)
'20' => 'Régimen Especial de los Bienes de Inversión y Servicios (Inversión del Sujeto Pasivo - Bienes de Inversión y Servicios - Inversión del Sujeto Pasivo - Bienes de Inversión y Servicios)' // Special Investment Goods and Services Regime (Reverse Charge - Investment Goods and Services - Reverse Charge - Investment Goods and Services)
];
private array $calificacion_operacion_codes = [
'S1' => 'OPERACIÓN SUJETA Y NO EXENTA - SIN INVERSIÓN DEL SUJETO PASIVO', // Subject and Non-Exempt Operation - Without Reverse Charge
'S2' => 'OPERACIÓN SUJETA Y NO EXENTA - CON INVERSIÓN DEL SUJETO PASIVO', // Subject and Non-Exempt Operation - With Reverse Charge
'N1' => 'OPERACIÓN NO SUJETA ARTÍCULO 7, 14, OTROS', // Non-Subject Operation Article 7, 14, Others
'N2' => 'OPERACIÓN NO SUJETA POR REGLAS DE LOCALIZACIÓN' // Non-Subject Operation by Location Rules
];
public function __construct(public Invoice $invoice)
{
$this->company = $invoice->company;
// $this->calc = $this->invoice->calc();
$this->v_invoice = new VerifactuInvoice();
}
private function setInvoiceValues(Invoice $invoice): self
{
$line_items = $invoice->line_items;
foreach($line_items as $key => $value){
if(stripos($value->tax_name1, 'irpf') !== false){
$line_items[$key]->tax_name1 = '';
$line_items[$key]->tax_rate1 = 0;
}
elseif(stripos($value->tax_name2, 'irpf') !== false){
$line_items[$key]->tax_name2 = '';
$line_items[$key]->tax_rate2 = 0;
}
elseif(stripos($value->tax_name3, 'irpf') !== false){
$line_items[$key]->tax_name3 = '';
$line_items[$key]->tax_rate3 = 0;
}
}
$invoice->line_items = $line_items;
$this->calc = $invoice->calc();
return $this;
}
/**
* Entry point for building document
*
* @return self
*/
public function run(): self
{
// Get the previous invoice log
$this->v_log = $this->company->verifactu_logs()->first();
$this->setInvoiceValues(clone $this->invoice);
$this->current_timestamp = now()->setTimezone('Europe/Madrid')->format('Y-m-d\TH:i:sP');
$date = \Carbon\Carbon::parse($this->invoice->date);
// Ensure its not later than "now" in Spain
$now = \Carbon\Carbon::now('Europe/Madrid');
if ($date->greaterThan($now)) {
$date = $now;
$this->invoice->date = $date->format('Y-m-d');
}
$formattedDate = $date->format('d-m-Y');
$this->v_invoice
->setIdVersion('1.0')
->setIdFactura((new IDFactura())
->setIdEmisorFactura($this->company->settings->vat_number)
->setNumSerieFactura($this->invoice->number)
->setFechaExpedicionFactura($formattedDate))
->setNombreRazonEmisor($this->company->present()->name()) //company name
->setTipoFactura('F1') //invoice type
->setDescripcionOperacion('Alta')// It IS! manadatory - max chars 500
->setCuotaTotal($this->calc->getTotalTaxes()) //total taxes
->setImporteTotal($this->calc->getTotal()) //total invoice amount
->setFechaHoraHusoGenRegistro($this->current_timestamp) //creation/submission timestamp
->setTipoHuella('01') //sha256
->setHuella('PLACEHOLDER_HUELLA');
/** The business entity that is issuing the invoice */
$emisor = new PersonaFisicaJuridica();
$emisor->setNif(substr($this->company->settings->vat_number, 0, 9))
->setNombreRazon($this->invoice->company->present()->name());
/** The business entity (Client) that is receiving the invoice */
$destinatarios = [];
$destinatario = new PersonaFisicaJuridica();
//Spanish NIF/VAT
if($this->invoice->client->country_id == 724 && strlen($this->invoice->client->vat_number ?? '') > 5) {
$destinatario
->setNif($this->invoice->client->vat_number)
->setNombreRazon($this->invoice->client->present()->name());
}
elseif($this->invoice->client->country_id == 724) { // Spanish Passport
$destinatario = new IDOtro();
$destinatario->setNombreRazon($this->invoice->client->present()->name());
$destinatario->setCodigoPais('ES')
->setIdType('03')
->setId($this->invoice->client->id_number);
}
else {
$locationData = $this->invoice->service()->location();
$destinatario = new IDOtro();
$destinatario->setNombreRazon($this->invoice->client->present()->name());
$destinatario->setCodigoPais($locationData['country_code']);
$br = new \App\DataMapper\Tax\BaseRule();
if(in_array($locationData['country_code'], $br->eu_country_codes) && strlen($this->invoice->client->vat_number ?? '') > 0) {
$destinatario->setIdType('03');
$destinatario->setId($this->invoice->client->vat_number);
}
}
$destinatarios[] = $destinatario;
$this->v_invoice->setDestinatarios($destinatarios);
// The tax breakdown
$desglose = new Desglose();
//Combine the line taxes with invoice taxes here to get a total tax amount
$taxes = $this->calc->getTaxMap();
$desglose_iva = [];
foreach ($taxes as $tax) {
$desglose_iva = [
'Impuesto' => $this->calculateTaxType($tax['name']), //tax type
'ClaveRegimen' => $this->calculateRegimeClassification($tax['name']), //tax regime classification code
'CalificacionOperacion' => $this->calculateOperationClassification($tax['name']), //operation classification code
'BaseImponible' => $tax['base_amount'] ?? $this->calc->getNetSubtotal(), // taxable base amount - fixed: key matches DetalleDesglose::toXml()
'TipoImpositivo' => $tax['tax_rate'], // Tax Rate
'Cuota' => $tax['total'] // Tax Amount - fixed: key matches DetalleDesglose::toXml()
];
$detalle_desglose = new DetalleDesglose();
$detalle_desglose->setDesgloseIVA($desglose_iva);
$desglose->addDesgloseIVA($detalle_desglose);
};
if(count($taxes) == 0) {
$client_country_code = $this->invoice->client->country->iso_3166_2;
/** By Default we assume a Spanish transaction */
$impuesto = 'S2';
$clave_regimen = '08';
$calificacion = 'S1';
$br = new \App\DataMapper\Tax\BaseRule();
/** EU B2B */
if (in_array($client_country_code, $br->eu_country_codes) && $this->invoice->client->classification != 'individual') {
$impuesto = '05';
$clave_regimen = '05';
$calificacion = 'N2';
} /** EU B2C */
elseif (in_array($client_country_code, $br->eu_country_codes) && $this->invoice->client->classification == 'individual') {
$impuesto = '08';
$clave_regimen = '05';
$calificacion = 'N2';
}
else { /** Non-EU */
$impuesto = '05';
$clave_regimen = '05';
$calificacion = 'N2';
}
$desglose_iva = [
'Impuesto' => $impuesto, //tax type
'ClaveRegimen' => $clave_regimen, //tax regime classification code
'CalificacionOperacion' => $calificacion, //operation classification code
'BaseImponible' => $this->calc->getNetSubtotal(), // taxable base amount - fixed: key matches DetalleDesglose::toXml()
];
$detalle_desglose = new DetalleDesglose();
$detalle_desglose->setDesgloseIVA($desglose_iva);
$desglose->addDesgloseIVA($detalle_desglose);
}
$this->v_invoice->setDesglose($desglose);
// Encadenamiento
$encadenamiento = new Encadenamiento();
// We chain the previous hash to the current invoice to ensure consistency
if($this->v_log){
$registro_anterior = new RegistroAnterior();
$registro_anterior->setIDEmisorFactura($this->v_log->nif);
$registro_anterior->setNumSerieFactura($this->v_log->invoice_number);
$registro_anterior->setFechaExpedicionFactura($this->v_log->date->format('d-m-Y'));
$registro_anterior->setHuella($this->v_log->hash);
$encadenamiento->setRegistroAnterior($registro_anterior);
}
else {
$encadenamiento->setPrimerRegistro('S');
}
$this->v_invoice->setEncadenamiento($encadenamiento);
//Sending system information - We automatically generate the obligado emision from this later
$sistema = new SistemaInformatico();
$sistema
// ->setNombreRazon('Sistema de Facturación')
->setNombreRazon(config('services.verifactu.sender_name')) //must match the cert name
->setNif(config('services.verifactu.sender_nif'))
->setNombreSistemaInformatico('InvoiceNinja')
->setIdSistemaInformatico('77')
->setVersion('1.0.03')
->setNumeroInstalacion('383')
->setTipoUsoPosibleSoloVerifactu('N')
->setTipoUsoPosibleMultiOT('S')
->setIndicadorMultiplesOT('S');
$this->v_invoice->setSistemaInformatico($sistema);
return $this;
}
public function setRectification(): self
{
$document_type = 'R2';
//need to harvest the parent invoice!!
$_i = Invoice::withTrashed()->find($this->decodePrimaryKey($this->invoice->backup->parent_invoice_id));
if(!$_i) {
throw new \Exception('Parent invoice not found');
}
if(BcMath::lessThan(abs($this->invoice->amount), $_i->amount)) {
$document_type = 'R1';
}
$this->v_invoice->setTipoFactura($document_type);
$this->v_invoice->setTipoRectificativa('I'); // S for substitutive rectification
if(strlen($this->invoice->backup->notes ?? '') > 0) {
$this->v_invoice->setDescripcionOperacion($this->invoice->backup->notes);
}
// Set up rectified invoice information
$facturasRectificadas = [
[
'IDEmisorFactura' => $this->company->settings->vat_number,
'NumSerieFactura' => $_i->number,
'FechaExpedicionFactura' => \Carbon\Carbon::parse($_i->date)->format('d-m-Y')
]
];
$this->v_invoice->setFacturasRectificadas($facturasRectificadas);
$this->invoice->backup->document_type = $document_type;
$this->invoice->saveQuietly();
return $this;
}
public function getInvoice(): VerifactuInvoice
{
return $this->v_invoice;
}
private function calculateRegimeClassification(string $tax_name): string
{
$client_country_code = $this->invoice->client->country->iso_3166_2;
if($client_country_code == 'ES') {
if(stripos($tax_name, 'iva') !== false) {
return '01';
}
if(stripos($tax_name, 'igic') !== false) {
return '03';
}
if(stripos($tax_name, 'ipsi') !== false) {
return '02';
}
if(stripos($tax_name, 'otros') !== false) {
return '05';
}
return '01';
}
$br = new \App\DataMapper\Tax\BaseRule();
if (in_array($client_country_code, $br->eu_country_codes) && $this->invoice->client->classification != 'individual') {
return '08';
} elseif (in_array($client_country_code, $br->eu_country_codes) && $this->invoice->client->classification == 'individual') {
return '05';
}
return '07';
}
private function calculateTaxType(string $tax_name): string
{
$client_country_code = $this->invoice->client->country->iso_3166_2;
if($client_country_code == 'ES') {
if(stripos($tax_name, 'iva') !== false) {
return '01';
}
if(stripos($tax_name, 'igic') !== false) {
return '03';
}
if(stripos($tax_name, 'ipsi') !== false) {
return '02';
}
if(stripos($tax_name, 'otros') !== false) {
return '05';
}
return '01';
}
$br = new \App\DataMapper\Tax\BaseRule();
if (in_array($client_country_code, $br->eu_country_codes) && $this->invoice->client->classification != 'individual') {
return '08';
}
elseif (in_array($client_country_code, $br->eu_country_codes) && $this->invoice->client->classification == 'individual') {
return '05';
}
return '07';
}
private function calculateOperationClassification(string $tax_name): string
{
if($this->invoice->client->country_id == 724 || stripos($tax_name, 'iva') !== false) {
return 'S1';
}
return 'N2';
}
}

View File

@@ -0,0 +1,340 @@
<?php
namespace App\Services\EDocument\Standards\Verifactu;
use DOMDocument;
use DOMElement;
use DOMNodeList;
use Exception;
use Illuminate\Support\Facades\Log;
class ResponseProcessor
{
private DOMDocument $dom;
private ?DOMElement $root = null;
public function __construct()
{
$this->dom = new DOMDocument();
}
/**
* Process AEAT XML response and return structured array
*/
public function processResponse(string $xmlResponse): array
{
try {
$this->loadXml($xmlResponse);
nlog($this->dom->saveXML());
return [
'success' => $this->isSuccessful(),
'status' => $this->getStatus(),
'errors' => $this->getErrors(),
'warnings' => $this->getWarnings(),
'data' => $this->getResponseData(),
'metadata' => $this->getMetadata(),
'guid' => $this->getGuid(),
'raw_response' => $xmlResponse
];
} catch (Exception $e) {
Log::error('Error processing AEAT response', [
'error' => $e->getMessage(),
'xml' => $xmlResponse
]);
return [
'success' => false,
'error' => 'Failed to process response: ' . $e->getMessage(),
'raw_response' => $xmlResponse
];
}
}
/**
* Load XML into DOM
*/
private function loadXml(string $xml): void
{
libxml_use_internal_errors(true);
libxml_clear_errors();
if (!$this->dom->loadXML($xml)) {
$errors = libxml_get_errors();
libxml_clear_errors();
throw new Exception('Invalid XML: ' . ($errors[0]->message ?? 'Unknown error'));
}
$this->root = $this->dom->documentElement;
}
private function getGuid(): ?string
{
return $this->getElementText('.//tikR:CSV') ?? null;
}
/**
* Check if response indicates success
*/
private function isSuccessful(): bool
{
$estadoEnvio = $this->getElementText('//tikR:EstadoEnvio');
return $estadoEnvio === 'Correcto';
}
/**
* Get response status
*/
private function getStatus(): string
{
return $this->getElementText('//tikR:EstadoEnvio') ?? 'Unknown';
}
/**
* Get all errors from response
*/
private function getErrors(): array
{
$errors = [];
// Check for SOAP faults
$fault = $this->getElementText('//env:Fault/faultstring');
if ($fault) {
$errors[] = [
'type' => 'SOAP_Fault',
'code' => $this->getElementText('//env:Fault/faultcode'),
'message' => $fault,
'details' => $this->getElementText('//env:Fault/detail/callstack')
];
}
// Check for business logic errors
$respuestaLineas = $this->dom->getElementsByTagNameNS(
'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/RespuestaSuministro.xsd',
'RespuestaLinea'
);
foreach ($respuestaLineas as $linea) {
$estadoRegistro = $this->getElementText('.//tikR:EstadoRegistro', $linea);
if ($estadoRegistro === 'Incorrecto') {
$errors[] = [
'type' => 'Business_Error',
'code' => $this->getElementText('.//tikR:CodigoErrorRegistro', $linea),
'message' => $this->getElementText('.//tikR:DescripcionErrorRegistro', $linea),
'invoice_data' => $this->getInvoiceData($linea)
];
}
}
return $errors;
}
/**
* Get warnings from response
*/
private function getWarnings(): array
{
$warnings = [];
// Check for subsanacion (correction) messages
$subsanacion = $this->getElementText('//tikR:RespuestaLinea/tikR:Subsanacion');
if ($subsanacion) {
$warnings[] = [
'type' => 'Subsanacion',
'message' => $subsanacion
];
}
return $warnings;
}
/**
* Get response data
*/
private function getResponseData(): array
{
$data = [];
// Get header information
$cabecera = $this->getElement('//tikR:Cabecera');
if ($cabecera) {
$data['header'] = [
'obligado_emision' => [
'nombre_razon' => $this->getElementText('.//tik:NombreRazon', $cabecera),
'nif' => $this->getElementText('.//tik:NIF', $cabecera)
]
];
}
// Get processing information
$data['processing'] = [
'tiempo_espera_envio' => $this->getElementText('//tikR:TiempoEsperaEnvio'),
'estado_envio' => $this->getElementText('//tikR:EstadoEnvio')
];
// Get invoice responses
$data['invoices'] = $this->getInvoiceResponses();
return $data;
}
/**
* Get metadata from response
*/
private function getMetadata(): array
{
return [
'request_id' => $this->getElementText('//tikR:RespuestaLinea/tikR:IDFactura/tik:IDEmisorFactura'),
'invoice_series' => $this->getElementText('//tikR:RespuestaLinea/tikR:IDFactura/tik:NumSerieFactura'),
'invoice_date' => $this->getElementText('//tikR:RespuestaLinea/tikR:IDFactura/tik:FechaExpedicionFactura'),
'operation_type' => $this->getElementText('//tikR:RespuestaLinea/tikR:Operacion/tik:TipoOperacion'),
'external_reference' => $this->getElementText('//tikR:RespuestaLinea/tikR:RefExterna')
];
}
/**
* Get invoice responses
*/
private function getInvoiceResponses(): array
{
$invoices = [];
$respuestaLineas = $this->dom->getElementsByTagNameNS(
'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/RespuestaSuministro.xsd',
'RespuestaLinea'
);
foreach ($respuestaLineas as $linea) {
$invoices[] = [
'id_emisor' => $this->getElementText('.//tikR:IDFactura/tik:IDEmisorFactura', $linea),
'num_serie' => $this->getElementText('.//tikR:IDFactura/tik:NumSerieFactura', $linea),
'fecha_expedicion' => $this->getElementText('.//tikR:IDFactura/tik:FechaExpedicionFactura', $linea),
'tipo_operacion' => $this->getElementText('.//tikR:Operacion/tik:TipoOperacion', $linea),
'ref_externa' => $this->getElementText('.//tikR:RefExterna', $linea),
'estado_registro' => $this->getElementText('.//tikR:EstadoRegistro', $linea),
'codigo_error' => $this->getElementText('.//tikR:CodigoErrorRegistro', $linea),
'descripcion_error' => $this->getElementText('.//tikR:DescripcionErrorRegistro', $linea),
'subsanacion' => $this->getElementText('.//tikR:Subsanacion', $linea)
];
}
return $invoices;
}
/**
* Get invoice data from response line
*/
private function getInvoiceData(DOMElement $linea): array
{
return [
'id_emisor' => $this->getElementText('.//tikR:IDFactura/tik:IDEmisorFactura', $linea),
'num_serie' => $this->getElementText('.//tikR:IDFactura/tik:NumSerieFactura', $linea),
'fecha_expedicion' => $this->getElementText('.//tikR:IDFactura/tik:FechaExpedicionFactura', $linea),
'tipo_operacion' => $this->getElementText('.//tikR:Operacion/tik:TipoOperacion', $linea),
'ref_externa' => $this->getElementText('.//tikR:RefExterna', $linea)
];
}
/**
* Get element text by XPath
*/
private function getElementText(string $xpath, ?DOMElement $context = null): ?string
{
$xpathObj = new \DOMXPath($this->dom);
// Register namespaces
$xpathObj->registerNamespace('env', 'http://schemas.xmlsoap.org/soap/envelope/');
$xpathObj->registerNamespace('tikR', 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/RespuestaSuministro.xsd');
$xpathObj->registerNamespace('tik', 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd');
$nodeList = $context ? $xpathObj->query($xpath, $context) : $xpathObj->query($xpath);
if ($nodeList && $nodeList->length > 0) {
return trim($nodeList->item(0)->nodeValue);
}
return null;
}
/**
* Get element by XPath
*/
private function getElement(string $xpath): ?DOMElement
{
$xpathObj = new \DOMXPath($this->dom);
// Register namespaces
$xpathObj->registerNamespace('env', 'http://schemas.xmlsoap.org/soap/envelope/');
$xpathObj->registerNamespace('tikR', 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/RespuestaSuministro.xsd');
$xpathObj->registerNamespace('tik', 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd');
$nodeList = $xpathObj->query($xpath);
if ($nodeList && $nodeList->length > 0) {
$node = $nodeList->item(0);
return $node instanceof DOMElement ? $node : null;
}
return null;
}
/**
* Check if response has errors
*/
public function hasErrors(): bool
{
return !empty($this->getErrors());
}
/**
* Get first error message
*/
public function getFirstError(): ?string
{
$errors = $this->getErrors();
return $errors[0]['message'] ?? null;
}
/**
* Get error codes
*/
public function getErrorCodes(): array
{
$codes = [];
$errors = $this->getErrors();
foreach ($errors as $error) {
if (isset($error['code'])) {
$codes[] = $error['code'];
}
}
return $codes;
}
/**
* Check if specific error code exists
*/
public function hasErrorCode(string $code): bool
{
return in_array($code, $this->getErrorCodes());
}
/**
* Get summary of response
*/
public function getSummary(): array
{
return [
'success' => $this->isSuccessful(),
'status' => $this->getStatus(),
'error_count' => count($this->getErrors()),
'warning_count' => count($this->getWarnings()),
'invoice_count' => count($this->getInvoiceResponses()),
'first_error' => $this->getFirstError(),
'error_codes' => $this->getErrorCodes()
];
}
}

View File

@@ -0,0 +1,260 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\EDocument\Standards\Verifactu;
use Mail;
use App\Models\Company;
use App\Models\Invoice;
use App\Models\Activity;
use App\Models\SystemLog;
use App\Libraries\MultiDB;
use Illuminate\Bus\Queueable;
use App\Jobs\Util\SystemLogger;
use Illuminate\Queue\SerializesModels;
use App\Repositories\ActivityRepository;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use App\Services\EDocument\Standards\Verifactu;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use App\Utils\Traits\MakesHash;
class SendToAeat implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
use MakesHash;
public $tries = 5;
public $deleteWhenMissingModels = true;
/**
* Modification Invoices - (modify)
* - If Amount < 0 - We generate a R2 document which is a negative modification on the original invoice.
* Create Invoices - (create) Generates a F1 document.
* Cancellation Invoices - (cancel) Generates a R3 document with full negative values of the original invoice.
*/
/**
* __construct
*
* @param int $invoice_id
* @param Company $company
* @param string $action create, modify, cancel
* @return void
*/
public function __construct(private int $invoice_id, private Company $company, private string $action)
{
}
public function backoff()
{
return [5, 30, 240, 3600, 7200];
}
public function handle(ActivityRepository $activity_repository)
{
MultiDB::setDB($this->company->db);
$invoice = Invoice::withTrashed()->find($this->invoice_id);
$invoice = $invoice->service()->markSent()->save();
switch($this->action) {
case 'create':
$this->createInvoice($invoice);
break;
case 'cancel':
$this->cancelInvoice($invoice);
break;
}
}
/**
* modifyInvoice
*
* Two code paths here:
* 1. F3 - we are replacing the invoice with a new one: ie. invoice->amount >=0
* 2. R2 - we are modifying the invoice with a negative amount: ie. invoice->amount < 0
* @param Invoice $invoice
* @return void
*/
public function createInvoice(Invoice $invoice)
{
sleep(rand(1,2));
$invoice = $invoice->fresh();
/** Return Early if we have already sent the invoice to the end client */
if(strlen($invoice->backup->guid) >= 1 || $invoice->is_deleted) {
return;
}
$verifactu = new Verifactu($invoice);
$verifactu->run();
$envelope = $verifactu->getEnvelope();
$response = $verifactu->send($envelope);
nlog($response);
$message = '';
if (isset($response['errors'][0]['message'])) {
$message = $response['errors'][0]['message'];
}
if($response['success']) {
$invoice->backup->guid = $response['guid'];
$invoice->saveQuietly();
}
$this->writeActivity($invoice, $response['success'] ? Activity::VERIFACTU_INVOICE_SENT : Activity::VERIFACTU_INVOICE_SENT_FAILURE, $message);
$this->systemLog($invoice, $response, $response['success'] ? SystemLog::EVENT_VERIFACTU_SUCCESS : SystemLog::EVENT_VERIFACTU_FAILURE, SystemLog::TYPE_VERIFACTU_INVOICE);
/** Check if we have emailed the invoice to the end client - if not - do it now! */
$invoice->invitations()
->where('email_error', 'primed') // This is a special flag for AEAT submission
->whereHas('contact', function($query) {
$query->where(function ($sq){
$sq->whereNotNull('email')
->orWhere('email', '!=', '');
})->where('is_locked', false)
->withoutTrashed();
})->each(function($invitation) {
$invitation->invoice->service()->sendEmail($invitation->contact);
$invitation->email_error = '';
$invitation->saveQuietly();
});
}
public function cancelInvoice(Invoice $invoice)
{
$verifactu = new Verifactu($invoice);
$document = (new RegistroAlta($invoice))->run()->getInvoice();
$document->setNumSerieFactura($invoice->backup->parent_invoice_number);
$last_hash = $invoice->company->verifactu_logs()->first();
$huella = $this->cancellationHash($document, $last_hash->hash);
$cancellation = $document->createCancellation();
$cancellation->setHuella($huella);
$soapXml = $cancellation->toSoapEnvelope();
$response = $verifactu->setInvoice($document)
->setHuella($huella)
->setPreviousHash($last_hash->hash)
->send($soapXml);
nlog($response);
$message = '';
if($response['success']) {
//if successful, we need to pop this invoice from the child array of the parent invoice!
nlog("searching for parent invoice ".$invoice->backup->parent_invoice_id);
$parent = Invoice::withTrashed()->find($this->decodePrimaryKey($invoice->backup->parent_invoice_id));
if($parent) {
$parent->backup->child_invoice_ids = $parent->backup->child_invoice_ids->reject(fn($id) => $id === $invoice->hashed_id);
$parent->saveQuietly();
}
$invoice->backup->guid = $response['guid'];
$invoice->saveQuietly();
}
if(isset($response['errors'][0]['message'])){
$message = $response['errors'][0]['message'];
}
//@todo - verifactu logging
$this->writeActivity($invoice, $response['success'] ? Activity::VERIFACTU_CANCELLATION_SENT : Activity::VERIFACTU_CANCELLATION_SENT_FAILURE, $message);
$this->systemLog($invoice, $response, $response['success'] ? SystemLog::EVENT_VERIFACTU_SUCCESS : SystemLog::EVENT_VERIFACTU_FAILURE, SystemLog::TYPE_VERIFACTU_CANCELLATION);
}
public function middleware()
{
return [(new WithoutOverlapping("send_to_aeat_{$this->company->company_key}"))->releaseAfter(30)->expireAfter(30)];
}
public function failed($exception = null)
{
nlog($exception);
}
private function writeActivity(Invoice $invoice, int $activity_id, string $notes = ''): void
{
$activity = new Activity();
$activity->user_id = $invoice->user_id;
$activity->client_id = $invoice->client_id;
$activity->company_id = $invoice->company_id;
$activity->account_id = $invoice->company->account_id;
$activity->activity_type_id = $activity_id;
$activity->invoice_id = $invoice->id;
$activity->notes = str_replace('"', '', $notes);
$activity->is_system = true;
$activity->save();
}
private function systemLog(Invoice $invoice, array $data, int $event_id, int $type_id): void
{
(new SystemLogger(
$data,
SystemLog::CATEGORY_VERIFACTU,
$event_id,
$type_id,
$invoice->client,
$invoice->company
)
)->handle();
}
/**
* cancellationHash
*
* @param mixed $document
* @param string $huella
* @return string
*/
private function cancellationHash($document, string $huella): string
{
$idEmisorFacturaAnulada = $document->getIdFactura()->getIdEmisorFactura();
$numSerieFacturaAnulada = $document->getIdFactura()->getNumSerieFactura();
$fechaExpedicionFacturaAnulada = $document->getIdFactura()->getFechaExpedicionFactura();
$fechaHoraHusoGenRegistro = $document->getFechaHoraHusoGenRegistro();
$hashInput = "IDEmisorFacturaAnulada={$idEmisorFacturaAnulada}&" .
"NumSerieFacturaAnulada={$numSerieFacturaAnulada}&" .
"FechaExpedicionFacturaAnulada={$fechaExpedicionFacturaAnulada}&" .
"Huella={$huella}&" .
"FechaHoraHusoGenRegistro={$fechaHoraHusoGenRegistro}";
return strtoupper(hash('sha256', $hashInput));
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Services\EDocument\Standards\Verifactu\Signing;
use RobRichards\XMLSecLibs\XMLSecurityDSig;
use RobRichards\XMLSecLibs\XMLSecurityKey;
class SigningService
{
public function __construct(
private string $xml,
private string $private_key,
private string $certificate
) {
}
public function sign()
{
$doc = new \DOMDocument();
$doc->loadXML($this->xml);
$objDSig = new XMLSecurityDSig();
$objDSig->setCanonicalMethod(XMLSecurityDSig::EXC_C14N);
$objDSig->addReference(
$doc,
XMLSecurityDSig::SHA256,
['http://www.w3.org/2000/09/xmldsig#enveloped-signature'],
['force_uri' => true]
);
$objKey = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, ['type' => 'private']);
$objKey->loadKey($this->private_key, false);
// Attach the certificate (public) to the KeyInfo
$objDSig->add509Cert($this->certificate, true, false, ['subjectName' => true]);
$objDSig->sign($objKey);
$objDSig->appendSignature($doc->documentElement);
// --- 3. Return signed XML as string ---
return $doc->saveXML();
}
}

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- editado con XMLSpy v2019 sp1 (x64) (http://www.altova.com) por AEAT (Agencia Estatal de Administracion Tributaria ((AEAT))) -->
<!-- edited with XMLSpy v2009 sp1 (http://www.altova.com) by PC Corporativo (AGENCIA TRIBUTARIA) -->
<schema xmlns="http://www.w3.org/2001/XMLSchema" xmlns:sfLRC="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/ConsultaLR.xsd" xmlns:sf="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd" targetNamespace="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/ConsultaLR.xsd" elementFormDefault="qualified">
<import namespace="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd" schemaLocation="SuministroInformacion.xsd"/>
<!-- edited with XMLSpy v2009 sp1 (http://www.altova.com) by PC Corporativo (AGENCIA TRIBUTARIA) -->
<element name="ConsultaFactuSistemaFacturacion" type="sfLRC:ConsultaFactuSistemaFacturacionType">
<annotation>
<documentation>Servicio de consulta Registros Facturacion</documentation>
</annotation>
</element>
<complexType name="ConsultaFactuSistemaFacturacionType">
<sequence>
<element name="Cabecera" type="sf:CabeceraConsultaSf"/>
<element name="FiltroConsulta" type="sfLRC:LRFiltroRegFacturacionType"/>
</sequence>
</complexType>
<complexType name="LRFiltroRegFacturacionType">
<sequence>
<!-- <element name="PeriodoImputacion" type="sf:PeriodoImputacionType"/> -->
<element name="NumSerieFactura" type="sf:TextoIDFacturaType" minOccurs="0">
<annotation>
<documentation xml:lang="es"> Nº Serie+Nº Factura de la Factura del Emisor.</documentation>
</annotation>
</element>
<element name="Contraparte" type="sf:ContraparteConsultaType" minOccurs="0">
<annotation>
<documentation xml:lang="es">Contraparte del NIF de la cabecera que realiza la consulta.
Obligado si la cosulta la realiza el Destinatario de los registros de facturacion.
Destinatario si la cosulta la realiza el Obligado dde los registros de facturacion.</documentation>
</annotation>
</element>
<element name="FechaExpedicionFactura" type="sf:FechaExpedicionConsultaType" minOccurs="0"/>
<element name="SistemaInformatico" type="sf:SistemaInformaticoType" minOccurs="0"/>
<element name="ClavePaginacion" type="sf:IDFacturaExpedidaBCType" minOccurs="0"/>
</sequence>
</complexType>
</schema>

View File

@@ -0,0 +1,823 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- editado con XMLSpy v2019 sp1 (x64) (http://www.altova.com) por AEAT (Agencia Estatal de Administracion Tributaria ((AEAT))) -->
<!-- edited with XMLSpy v2009 sp1 (http://www.altova.com) by PC Corporativo (AGENCIA TRIBUTARIA) -->
<schema xmlns="http://www.w3.org/2001/XMLSchema" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:sf="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/EventosSIF.xsd" targetNamespace="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/EventosSIF.xsd" elementFormDefault="qualified">
<import namespace="http://www.w3.org/2000/09/xmldsig#" schemaLocation="http://www.w3.org/TR/xmldsig-core/xmldsig-core-schema.xsd"/>
<element name="RegistroEvento">
<complexType>
<sequence>
<element name="IDVersion" type="sf:VersionType"/>
<element name="Evento" type="sf:EventoType"/>
</sequence>
</complexType>
</element>
<complexType name="EventoType">
<sequence>
<element name="SistemaInformatico" type="sf:SistemaInformaticoType"/>
<element name="ObligadoEmision" type="sf:PersonaFisicaJuridicaESType">
<annotation>
<documentation xml:lang="es"> Obligado a expedir la factura. </documentation>
</annotation>
</element>
<element name="EmitidaPorTerceroODestinatario" type="sf:TercerosODestinatarioType" minOccurs="0"/>
<element name="TerceroODestinatario" type="sf:PersonaFisicaJuridicaType" minOccurs="0"/>
<element name="FechaHoraHusoGenEvento" type="dateTime">
<annotation>
<documentation xml:lang="es">Formato: YYYY-MM-DDThh:mm:ssTZD (ej: 2024-01-01T19:20:30+01:00) (ISO 8601)</documentation>
</annotation>
</element>
<element name="TipoEvento" type="sf:TipoEventoType"/>
<element name="DatosPropiosEvento" type="sf:DatosPropiosEventoType" minOccurs="0"/>
<element name="OtrosDatosEvento" type="sf:TextMax100Type" minOccurs="0"/>
<element name="Encadenamiento" type="sf:EncadenamientoType"/>
<element name="TipoHuella" type="sf:TipoHuellaType"/>
<element name="HuellaEvento" type="sf:TextMax64Type"/>
<element ref="ds:Signature"/>
</sequence>
</complexType>
<complexType name="SistemaInformaticoType">
<sequence>
<sequence>
<element name="NombreRazon" type="sf:TextMax120Type"/>
<choice>
<element name="NIF" type="sf:NIFType"/>
<element name="IDOtro" type="sf:IDOtroType"/>
</choice>
</sequence>
<element name="NombreSistemaInformatico" type="sf:TextMax30Type" minOccurs="0"/>
<element name="IdSistemaInformatico" type="sf:TextMax2Type"/>
<element name="Version" type="sf:TextMax50Type"/>
<element name="NumeroInstalacion" type="sf:TextMax100Type"/>
<element name="TipoUsoPosibleSoloVerifactu" type="sf:SiNoType" minOccurs="0"/>
<element name="TipoUsoPosibleMultiOT" type="sf:SiNoType" minOccurs="0"/>
<element name="IndicadorMultiplesOT" type="sf:SiNoType" minOccurs="0"/>
</sequence>
</complexType>
<complexType name="DatosPropiosEventoType">
<choice>
<element name="LanzamientoProcesoDeteccionAnomaliasRegFacturacion" type="sf:LanzamientoProcesoDeteccionAnomaliasRegFacturacionType"/>
<element name="DeteccionAnomaliasRegFacturacion" type="sf:DeteccionAnomaliasRegFacturacionType"/>
<element name="LanzamientoProcesoDeteccionAnomaliasRegEvento" type="sf:LanzamientoProcesoDeteccionAnomaliasRegEventoType"/>
<element name="DeteccionAnomaliasRegEvento" type="sf:DeteccionAnomaliasRegEventoType"/>
<element name="ExportacionRegFacturacionPeriodo" type="sf:ExportacionRegFacturacionPeriodoType"/>
<element name="ExportacionRegEventoPeriodo" type="sf:ExportacionRegEventoPeriodoType"/>
<element name="ResumenEventos" type="sf:ResumenEventosType"/>
</choice>
</complexType>
<complexType name="EncadenamientoType">
<choice>
<element name="PrimerEvento" type="sf:TextMax1Type"/>
<element name="EventoAnterior" type="sf:RegEventoAntType"/>
</choice>
</complexType>
<complexType name="LanzamientoProcesoDeteccionAnomaliasRegFacturacionType">
<sequence>
<element name="RealizadoProcesoSobreIntegridadHuellasRegFacturacion" type="sf:SiNoType"/>
<element name="NumeroDeRegistrosFacturacionProcesadosSobreIntegridadHuellas" type="sf:DigitosMax7Type" minOccurs="0"/>
<element name="RealizadoProcesoSobreIntegridadFirmasRegFacturacion" type="sf:SiNoType"/>
<element name="NumeroDeRegistrosFacturacionProcesadosSobreIntegridadFirmas" type="sf:DigitosMax7Type" minOccurs="0"/>
<element name="RealizadoProcesoSobreTrazabilidadCadenaRegFacturacion" type="sf:SiNoType"/>
<element name="NumeroDeRegistrosFacturacionProcesadosSobreTrazabilidadCadena" type="sf:DigitosMax7Type" minOccurs="0"/>
<element name="RealizadoProcesoSobreTrazabilidadFechasRegFacturacion" type="sf:SiNoType"/>
<element name="NumeroDeRegistrosFacturacionProcesadosSobreTrazabilidadFechas" type="sf:DigitosMax7Type" minOccurs="0"/>
</sequence>
</complexType>
<complexType name="DeteccionAnomaliasRegFacturacionType">
<sequence>
<element name="TipoAnomalia" type="sf:TipoAnomaliaType"/>
<element name="OtrosDatosAnomalia" type="sf:TextMax100Type" minOccurs="0"/>
<element name="RegistroFacturacionAnomalo" type="sf:IDFacturaExpedidaType" minOccurs="0"/>
</sequence>
</complexType>
<complexType name="LanzamientoProcesoDeteccionAnomaliasRegEventoType">
<sequence>
<element name="RealizadoProcesoSobreIntegridadHuellasRegEvento" type="sf:SiNoType"/>
<element name="NumeroDeRegistrosEventoProcesadosSobreIntegridadHuellas" type="sf:DigitosMax5Type" minOccurs="0"/>
<element name="RealizadoProcesoSobreIntegridadFirmasRegEvento" type="sf:SiNoType"/>
<element name="NumeroDeRegistrosEventoProcesadosSobreIntegridadFirmas" type="sf:DigitosMax5Type" minOccurs="0"/>
<element name="RealizadoProcesoSobreTrazabilidadCadenaRegEvento" type="sf:SiNoType"/>
<element name="NumeroDeRegistrosEventoProcesadosSobreTrazabilidadCadena" type="sf:DigitosMax5Type" minOccurs="0"/>
<element name="RealizadoProcesoSobreTrazabilidadFechasRegEvento" type="sf:SiNoType"/>
<element name="NumeroDeRegistrosEventoProcesadosSobreTrazabilidadFechas" type="sf:DigitosMax5Type" minOccurs="0"/>
</sequence>
</complexType>
<complexType name="DeteccionAnomaliasRegEventoType">
<sequence>
<element name="TipoAnomalia" type="sf:TipoAnomaliaType"/>
<element name="OtrosDatosAnomalia" type="sf:TextMax100Type" minOccurs="0"/>
<element name="RegEventoAnomalo" type="sf:RegEventoType" minOccurs="0"/>
</sequence>
</complexType>
<complexType name="ExportacionRegFacturacionPeriodoType">
<sequence>
<element name="FechaHoraHusoInicioPeriodoExport" type="dateTime">
<annotation>
<documentation xml:lang="es">Formato: YYYY-MM-DDThh:mm:ssTZD (ej: 2024-01-01T19:20:30+01:00) (ISO 8601)</documentation>
</annotation>
</element>
<element name="FechaHoraHusoFinPeriodoExport" type="dateTime">
<annotation>
<documentation xml:lang="es">Formato: YYYY-MM-DDThh:mm:ssTZD (ej: 2024-01-01T19:20:30+01:00) (ISO 8601)</documentation>
</annotation>
</element>
<element name="RegistroFacturacionInicialPeriodo" type="sf:IDFacturaExpedidaHuellaType"/>
<element name="RegistroFacturacionFinalPeriodo" type="sf:IDFacturaExpedidaHuellaType"/>
<element name="NumeroDeRegistrosFacturacionAltaExportados" type="sf:DigitosMax9Type"/>
<element name="SumaCuotaTotalAlta" type="sf:ImporteSgn12.2Type"/>
<element name="SumaImporteTotalAlta" type="sf:ImporteSgn12.2Type"/>
<element name="NumeroDeRegistrosFacturacionAnulacionExportados" type="sf:DigitosMax9Type"/>
<element name="RegistrosFacturacionExportadosDejanDeConservarse" type="sf:SiNoType"/>
</sequence>
</complexType>
<complexType name="ExportacionRegEventoPeriodoType">
<sequence>
<element name="FechaHoraHusoInicioPeriodoExport" type="dateTime">
<annotation>
<documentation xml:lang="es">Formato: YYYY-MM-DDThh:mm:ssTZD (ej: 2024-01-01T19:20:30+01:00) (ISO 8601)</documentation>
</annotation>
</element>
<element name="FechaHoraHusoFinPeriodoExport" type="dateTime">
<annotation>
<documentation xml:lang="es">Formato: YYYY-MM-DDThh:mm:ssTZD (ej: 2024-01-01T19:20:30+01:00) (ISO 8601)</documentation>
</annotation>
</element>
<element name="RegistroEventoInicialPeriodo" type="sf:RegEventoType"/>
<element name="RegistroEventoFinalPeriodo" type="sf:RegEventoType"/>
<element name="NumeroDeRegEventoExportados" type="sf:DigitosMax7Type"/>
<element name="RegEventoExportadosDejanDeConservarse" type="sf:SiNoType"/>
</sequence>
</complexType>
<complexType name="ResumenEventosType">
<sequence>
<element name="TipoEvento" type="sf:TipoEventoAgrType" maxOccurs="20"/>
<element name="RegistroFacturacionInicialPeriodo" type="sf:IDFacturaExpedidaHuellaType" minOccurs="0"/>
<element name="RegistroFacturacionFinalPeriodo" type="sf:IDFacturaExpedidaHuellaType" minOccurs="0"/>
<element name="NumeroDeRegistrosFacturacionAltaGenerados" type="sf:DigitosMax6Type"/>
<element name="SumaCuotaTotalAlta" type="sf:ImporteSgn12.2Type"/>
<element name="SumaImporteTotalAlta" type="sf:ImporteSgn12.2Type"/>
<element name="NumeroDeRegistrosFacturacionAnulacionGenerados" type="sf:DigitosMax6Type"/>
</sequence>
</complexType>
<complexType name="RegEventoType">
<sequence>
<element name="TipoEvento" type="sf:TipoEventoType"/>
<element name="FechaHoraHusoEvento" type="dateTime">
<annotation>
<documentation xml:lang="es">Formato: YYYY-MM-DDThh:mm:ssTZD (ej: 2024-01-01T19:20:30+01:00) (ISO 8601)</documentation>
</annotation>
</element>
<element name="HuellaEvento" type="sf:TextMax64Type"/>
</sequence>
</complexType>
<complexType name="RegEventoAntType">
<sequence>
<element name="TipoEvento" type="sf:TipoEventoType"/>
<element name="FechaHoraHusoGenEvento" type="dateTime">
<annotation>
<documentation xml:lang="es">Formato: YYYY-MM-DDThh:mm:ssTZD (ej: 2024-01-01T19:20:30+01:00) (ISO 8601)</documentation>
</annotation>
</element>
<element name="HuellaEvento" type="sf:TextMax64Type"/>
</sequence>
</complexType>
<complexType name="TipoEventoAgrType">
<sequence>
<element name="TipoEvento" type="sf:TipoEventoType"/>
<element name="NumeroDeEventos" type="sf:DigitosMax4Type"/>
</sequence>
</complexType>
<!-- Datos de persona Física o jurídica : Denominación, representación, identificación (NIF) -->
<complexType name="PersonaFisicaJuridicaESType">
<annotation>
<documentation xml:lang="es">Datos de una persona física o jurídica Española con un NIF asociado</documentation>
</annotation>
<sequence>
<element name="NombreRazon" type="sf:TextMax120Type"/>
<element name="NIF" type="sf:NIFType"/>
</sequence>
</complexType>
<simpleType name="NIFType">
<annotation>
<documentation xml:lang="es">NIF</documentation>
</annotation>
<restriction base="string">
<length value="9"/>
</restriction>
</simpleType>
<!-- Datos de persona Física o jurídica : Denominación, representación, identificación (NIF/Otro) -->
<complexType name="PersonaFisicaJuridicaType">
<annotation>
<documentation xml:lang="es">Datos de una persona física o jurídica Española o Extranjera</documentation>
</annotation>
<sequence>
<element name="NombreRazon" type="sf:TextMax120Type"/>
<choice>
<element name="NIF" type="sf:NIFType"/>
<element name="IDOtro" type="sf:IDOtroType"/>
</choice>
</sequence>
</complexType>
<!-- Datos de persona Física o jurídica : Denominación, representación, identificación (NIF/Otro) -->
<complexType name="IDOtroType">
<annotation>
<documentation xml:lang="es">Identificador de persona Física o jurídica distinto del NIF
(Código pais, Tipo de Identificador, y hasta 15 caractéres)
No se permite CodigoPais=ES e IDType=01-NIFContraparte
para ese caso, debe utilizarse NIF en lugar de IDOtro.
</documentation>
</annotation>
<sequence>
<element name="CodigoPais" type="sf:CountryType2" minOccurs="0"/>
<element name="IDType" type="sf:PersonaFisicaJuridicaIDTypeType"/>
<element name="ID" type="sf:TextMax20Type"/>
</sequence>
</complexType>
<!-- Tercero o Destinatario -->
<simpleType name="TercerosODestinatarioType">
<restriction base="string">
<enumeration value="D">
<annotation>
<documentation xml:lang="es">Destinatario</documentation>
</annotation>
</enumeration>
<enumeration value="T">
<annotation>
<documentation xml:lang="es">Tercero</documentation>
</annotation>
</enumeration>
</restriction>
</simpleType>
<simpleType name="SiNoType">
<restriction base="string">
<enumeration value="S"/>
<enumeration value="N"/>
</restriction>
</simpleType>
<simpleType name="VersionType">
<restriction base="string">
<enumeration value="1.0"/>
</restriction>
</simpleType>
<!-- Cadena de 120 caracteres -->
<simpleType name="TextMax120Type">
<restriction base="string">
<maxLength value="120"/>
</restriction>
</simpleType>
<!-- Cadena de 100 caracteres -->
<simpleType name="TextMax100Type">
<restriction base="string">
<maxLength value="100"/>
</restriction>
</simpleType>
<!-- Cadena de 64 caracteres -->
<simpleType name="TextMax64Type">
<restriction base="string">
<maxLength value="64"/>
</restriction>
</simpleType>
<!-- Cadena de 60 caracteres -->
<simpleType name="TextMax60Type">
<restriction base="string">
<maxLength value="60"/>
</restriction>
</simpleType>
<!-- Cadena de 50 caracteres -->
<simpleType name="TextMax50Type">
<restriction base="string">
<maxLength value="50"/>
</restriction>
</simpleType>
<!-- Cadena de 30 caracteres -->
<simpleType name="TextMax30Type">
<restriction base="string">
<maxLength value="30"/>
</restriction>
</simpleType>
<!-- Cadena de 20 caracteres -->
<simpleType name="TextMax20Type">
<restriction base="string">
<maxLength value="20"/>
</restriction>
</simpleType>
<!-- Cadena de 2 caracteres -->
<simpleType name="TextMax2Type">
<restriction base="string">
<maxLength value="2"/>
</restriction>
</simpleType>
<!-- Cadena de 1 caracteres -->
<simpleType name="TextMax1Type">
<restriction base="string">
<maxLength value="1"/>
</restriction>
</simpleType>
<!-- Definición de un tipo simple restringido a 9 dígitos -->
<simpleType name="DigitosMax9Type">
<restriction base="string">
<maxLength value="9"/>
<pattern value="\d{1,9}"/>
</restriction>
</simpleType>
<!-- Definición de un tipo simple restringido a 7 dígitos -->
<simpleType name="DigitosMax7Type">
<restriction base="string">
<maxLength value="7"/>
<pattern value="\d{1,7}"/>
</restriction>
</simpleType>
<!-- Definición de un tipo simple restringido a 6 dígitos -->
<simpleType name="DigitosMax6Type">
<restriction base="string">
<maxLength value="6"/>
<pattern value="\d{1,6}"/>
</restriction>
</simpleType>
<!-- Definición de un tipo simple restringido a 5 dígitos -->
<simpleType name="DigitosMax5Type">
<restriction base="string">
<maxLength value="5"/>
<pattern value="\d{1,5}"/>
</restriction>
</simpleType>
<!-- Definición de un tipo simple restringido a 4 dígitos -->
<simpleType name="DigitosMax4Type">
<restriction base="string">
<maxLength value="4"/>
<pattern value="\d{1,4}"/>
</restriction>
</simpleType>
<!-- Fecha (dd-mm-yyyy) -->
<simpleType name="fecha">
<restriction base="string">
<length value="10"/>
<pattern value="\d{2,2}-\d{2,2}-\d{4,4}"/>
</restriction>
</simpleType>
<!-- Importe de 15 dígitos (12+2) "." como separador decimal -->
<simpleType name="ImporteSgn12.2Type">
<restriction base="string">
<pattern value="(\+|-)?\d{1,12}(\.\d{0,2})?"/>
</restriction>
</simpleType>
<!-- Tipo de identificador fiscal de persona Física o jurídica -->
<simpleType name="PersonaFisicaJuridicaIDTypeType">
<restriction base="string">
<enumeration value="02">
<annotation>
<documentation xml:lang="es">NIF-IVA</documentation>
</annotation>
</enumeration>
<enumeration value="03">
<annotation>
<documentation xml:lang="es">Pasaporte</documentation>
</annotation>
</enumeration>
<enumeration value="04">
<annotation>
<documentation xml:lang="es">IDEnPaisResidencia</documentation>
</annotation>
</enumeration>
<enumeration value="05">
<annotation>
<documentation xml:lang="es">Certificado Residencia</documentation>
</annotation>
</enumeration>
<enumeration value="06">
<annotation>
<documentation xml:lang="es">Otro documento Probatorio</documentation>
</annotation>
</enumeration>
<enumeration value="07">
<annotation>
<documentation xml:lang="es">No Censado</documentation>
</annotation>
</enumeration>
</restriction>
</simpleType>
<!-- Tipo Hash -->
<simpleType name="TipoHuellaType">
<restriction base="string">
<enumeration value="01">
<annotation>
<documentation xml:lang="es">SHA-256</documentation>
</annotation>
</enumeration>
</restriction>
</simpleType>
<simpleType name="TipoEventoType">
<restriction base="string">
<enumeration value="01">
<annotation>
<documentation xml:lang="es">Inicio del funcionamiento del sistema informático como «NO VERI*FACTU».</documentation>
</annotation>
</enumeration>
<enumeration value="02">
<annotation>
<documentation xml:lang="es">Fin del funcionamiento del sistema informático como «NO VERI*FACTU».</documentation>
</annotation>
</enumeration>
<enumeration value="03">
<annotation>
<documentation xml:lang="es">Lanzamiento del proceso de detección de anomalías en los registros de facturación.</documentation>
</annotation>
</enumeration>
<enumeration value="04">
<annotation>
<documentation xml:lang="es">Detección de anomalías en la integridad, inalterabilidad y trazabilidad de registros de facturación.</documentation>
</annotation>
</enumeration>
<enumeration value="05">
<annotation>
<documentation xml:lang="es">Lanzamiento del proceso de detección de anomalías en los registros de evento.</documentation>
</annotation>
</enumeration>
<enumeration value="06">
<annotation>
<documentation xml:lang="es">Detección de anomalías en la integridad, inalterabilidad y trazabilidad de registros de evento.</documentation>
</annotation>
</enumeration>
<enumeration value="07">
<annotation>
<documentation xml:lang="es">Restauración de copia de seguridad, cuando ésta se gestione desde el propio sistema informático de facturación.</documentation>
</annotation>
</enumeration>
<enumeration value="08">
<annotation>
<documentation xml:lang="es">Exportación de registros de facturación generados en un periodo.</documentation>
</annotation>
</enumeration>
<enumeration value="09">
<annotation>
<documentation xml:lang="es">Exportación de registros de evento generados en un periodo.</documentation>
</annotation>
</enumeration>
<enumeration value="10">
<annotation>
<documentation xml:lang="es">Registro resumen de eventos</documentation>
</annotation>
</enumeration>
<enumeration value="90">
<annotation>
<documentation xml:lang="es">Otros tipos de eventos a registrar voluntariamente por la persona o entidad productora del sistema informático.
</documentation>
</annotation>
</enumeration>
</restriction>
</simpleType>
<simpleType name="TipoAnomaliaType">
<restriction base="string">
<enumeration value="01">
<annotation>
<documentation xml:lang="es">Integridad-huella</documentation>
</annotation>
</enumeration>
<enumeration value="02">
<annotation>
<documentation xml:lang="es">Integridad-firma</documentation>
</annotation>
</enumeration>
<enumeration value="03">
<annotation>
<documentation xml:lang="es">Integridad - Otros</documentation>
</annotation>
</enumeration>
<enumeration value="04">
<annotation>
<documentation xml:lang="es">Trazabilidad-cadena-registro - Reg. no primero pero con reg. anterior no anotado o inexistente</documentation>
</annotation>
</enumeration>
<enumeration value="05">
<annotation>
<documentation xml:lang="es">Trazabilidad-cadena-registro - Reg. no último pero con reg. posterior no anotado o inexistente</documentation>
</annotation>
</enumeration>
<enumeration value="06">
<annotation>
<documentation xml:lang="es">Trazabilidad-cadena-registro - Otros</documentation>
</annotation>
</enumeration>
<enumeration value="07">
<annotation>
<documentation xml:lang="es">Trazabilidad-cadena-huella - Huella del reg. no se corresponde con la 'huella del reg. anterior' almacenada en el registro posterior</documentation>
</annotation>
</enumeration>
<enumeration value="08">
<annotation>
<documentation xml:lang="es">Trazabilidad-cadena-huella - Campo 'huella del reg. anterior' no se corresponde con la huella del reg. anterior</documentation>
</annotation>
</enumeration>
<enumeration value="09">
<annotation>
<documentation xml:lang="es">Trazabilidad-cadena-huella - Otros</documentation>
</annotation>
</enumeration>
<enumeration value="10">
<annotation>
<documentation xml:lang="es">Trazabilidad-cadena - Otros</documentation>
</annotation>
</enumeration>
<enumeration value="11">
<annotation>
<documentation xml:lang="es">Trazabilidad-fechas - Fecha-hora anterior a la fecha del reg. anterior</documentation>
</annotation>
</enumeration>
<enumeration value="12">
<annotation>
<documentation xml:lang="es">Trazabilidad-fechas - Fecha-hora posterior a la fecha del reg. posterior</documentation>
</annotation>
</enumeration>
<enumeration value="13">
<annotation>
<documentation xml:lang="es">Trazabilidad-fechas - Reg. con fecha-hora de generación posterior a la fecha-hora actual del sistema</documentation>
</annotation>
</enumeration>
<enumeration value="14">
<annotation>
<documentation xml:lang="es">Trazabilidad-fechas - Otros</documentation>
</annotation>
</enumeration>
<enumeration value="15">
<annotation>
<documentation xml:lang="es">Trazabilidad - Otros</documentation>
</annotation>
</enumeration>
<enumeration value="90">
<annotation>
<documentation xml:lang="es">Otros</documentation>
</annotation>
</enumeration>
</restriction>
</simpleType>
<complexType name="IDFacturaExpedidaType">
<annotation>
<documentation xml:lang="es"> Datos de identificación de factura expedida para operaciones de consulta</documentation>
</annotation>
<sequence>
<element name="IDEmisorFactura" type="sf:NIFType"/>
<element name="NumSerieFactura" type="sf:TextMax60Type"/>
<element name="FechaExpedicionFactura" type="sf:fecha"/>
</sequence>
</complexType>
<complexType name="IDFacturaExpedidaHuellaType">
<annotation>
<documentation xml:lang="es">Datos de encadenamiento </documentation>
</annotation>
<sequence>
<element name="IDEmisorFactura" type="sf:NIFType"/>
<element name="NumSerieFactura" type="sf:TextMax60Type"/>
<element name="FechaExpedicionFactura" type="sf:fecha"/>
<element name="Huella" type="sf:TextMax64Type"/>
</sequence>
</complexType>
<!-- ISO 3166-1 alpha-2 codes -->
<simpleType name="CountryType2">
<restriction base="string">
<enumeration value="AF"/>
<enumeration value="AL"/>
<enumeration value="DE"/>
<enumeration value="AD"/>
<enumeration value="AO"/>
<enumeration value="AI"/>
<enumeration value="AQ"/>
<enumeration value="AG"/>
<enumeration value="SA"/>
<enumeration value="DZ"/>
<enumeration value="AR"/>
<enumeration value="AM"/>
<enumeration value="AW"/>
<enumeration value="AU"/>
<enumeration value="AT"/>
<enumeration value="AZ"/>
<enumeration value="BS"/>
<enumeration value="BH"/>
<enumeration value="BD"/>
<enumeration value="BB"/>
<enumeration value="BE"/>
<enumeration value="BZ"/>
<enumeration value="BJ"/>
<enumeration value="BM"/>
<enumeration value="BY"/>
<enumeration value="BO"/>
<enumeration value="BA"/>
<enumeration value="BW"/>
<enumeration value="BV"/>
<enumeration value="BR"/>
<enumeration value="BN"/>
<enumeration value="BG"/>
<enumeration value="BF"/>
<enumeration value="BI"/>
<enumeration value="BT"/>
<enumeration value="CV"/>
<enumeration value="KY"/>
<enumeration value="KH"/>
<enumeration value="CM"/>
<enumeration value="CA"/>
<enumeration value="CF"/>
<enumeration value="CC"/>
<enumeration value="CO"/>
<enumeration value="KM"/>
<enumeration value="CG"/>
<enumeration value="CD"/>
<enumeration value="CK"/>
<enumeration value="KP"/>
<enumeration value="KR"/>
<enumeration value="CI"/>
<enumeration value="CR"/>
<enumeration value="HR"/>
<enumeration value="CU"/>
<enumeration value="TD"/>
<enumeration value="CZ"/>
<enumeration value="CL"/>
<enumeration value="CN"/>
<enumeration value="CY"/>
<enumeration value="CW"/>
<enumeration value="DK"/>
<enumeration value="DM"/>
<enumeration value="DO"/>
<enumeration value="EC"/>
<enumeration value="EG"/>
<enumeration value="AE"/>
<enumeration value="ER"/>
<enumeration value="SK"/>
<enumeration value="SI"/>
<enumeration value="ES"/>
<enumeration value="US"/>
<enumeration value="EE"/>
<enumeration value="ET"/>
<enumeration value="FO"/>
<enumeration value="PH"/>
<enumeration value="FI"/>
<enumeration value="FJ"/>
<enumeration value="FR"/>
<enumeration value="GA"/>
<enumeration value="GM"/>
<enumeration value="GE"/>
<enumeration value="GS"/>
<enumeration value="GH"/>
<enumeration value="GI"/>
<enumeration value="GD"/>
<enumeration value="GR"/>
<enumeration value="GL"/>
<enumeration value="GU"/>
<enumeration value="GT"/>
<enumeration value="GG"/>
<enumeration value="GN"/>
<enumeration value="GQ"/>
<enumeration value="GW"/>
<enumeration value="GY"/>
<enumeration value="HT"/>
<enumeration value="HM"/>
<enumeration value="HN"/>
<enumeration value="HK"/>
<enumeration value="HU"/>
<enumeration value="IN"/>
<enumeration value="ID"/>
<enumeration value="IR"/>
<enumeration value="IQ"/>
<enumeration value="IE"/>
<enumeration value="IM"/>
<enumeration value="IS"/>
<enumeration value="IL"/>
<enumeration value="IT"/>
<enumeration value="JM"/>
<enumeration value="JP"/>
<enumeration value="JE"/>
<enumeration value="JO"/>
<enumeration value="KZ"/>
<enumeration value="KE"/>
<enumeration value="KG"/>
<enumeration value="KI"/>
<enumeration value="KW"/>
<enumeration value="LA"/>
<enumeration value="LS"/>
<enumeration value="LV"/>
<enumeration value="LB"/>
<enumeration value="LR"/>
<enumeration value="LY"/>
<enumeration value="LI"/>
<enumeration value="LT"/>
<enumeration value="LU"/>
<enumeration value="XG"/>
<enumeration value="MO"/>
<enumeration value="MK"/>
<enumeration value="MG"/>
<enumeration value="MY"/>
<enumeration value="MW"/>
<enumeration value="MV"/>
<enumeration value="ML"/>
<enumeration value="MT"/>
<enumeration value="FK"/>
<enumeration value="MP"/>
<enumeration value="MA"/>
<enumeration value="MH"/>
<enumeration value="MU"/>
<enumeration value="MR"/>
<enumeration value="YT"/>
<enumeration value="UM"/>
<enumeration value="MX"/>
<enumeration value="FM"/>
<enumeration value="MD"/>
<enumeration value="MC"/>
<enumeration value="MN"/>
<enumeration value="ME"/>
<enumeration value="MS"/>
<enumeration value="MZ"/>
<enumeration value="MM"/>
<enumeration value="NA"/>
<enumeration value="NR"/>
<enumeration value="CX"/>
<enumeration value="NP"/>
<enumeration value="NI"/>
<enumeration value="NE"/>
<enumeration value="NG"/>
<enumeration value="NU"/>
<enumeration value="NF"/>
<enumeration value="NO"/>
<enumeration value="NC"/>
<enumeration value="NZ"/>
<enumeration value="IO"/>
<enumeration value="OM"/>
<enumeration value="NL"/>
<enumeration value="BQ"/>
<enumeration value="PK"/>
<enumeration value="PW"/>
<enumeration value="PA"/>
<enumeration value="PG"/>
<enumeration value="PY"/>
<enumeration value="PE"/>
<enumeration value="PN"/>
<enumeration value="PF"/>
<enumeration value="PL"/>
<enumeration value="PT"/>
<enumeration value="PR"/>
<enumeration value="QA"/>
<enumeration value="GB"/>
<enumeration value="RW"/>
<enumeration value="RO"/>
<enumeration value="RU"/>
<enumeration value="SB"/>
<enumeration value="SV"/>
<enumeration value="WS"/>
<enumeration value="AS"/>
<enumeration value="KN"/>
<enumeration value="SM"/>
<enumeration value="SX"/>
<enumeration value="PM"/>
<enumeration value="VC"/>
<enumeration value="SH"/>
<enumeration value="LC"/>
<enumeration value="ST"/>
<enumeration value="SN"/>
<enumeration value="RS"/>
<enumeration value="SC"/>
<enumeration value="SL"/>
<enumeration value="SG"/>
<enumeration value="SY"/>
<enumeration value="SO"/>
<enumeration value="LK"/>
<enumeration value="SZ"/>
<enumeration value="ZA"/>
<enumeration value="SD"/>
<enumeration value="SS"/>
<enumeration value="SE"/>
<enumeration value="CH"/>
<enumeration value="SR"/>
<enumeration value="TH"/>
<enumeration value="TW"/>
<enumeration value="TZ"/>
<enumeration value="TJ"/>
<enumeration value="PS"/>
<enumeration value="TF"/>
<enumeration value="TL"/>
<enumeration value="TG"/>
<enumeration value="TK"/>
<enumeration value="TO"/>
<enumeration value="TT"/>
<enumeration value="TN"/>
<enumeration value="TC"/>
<enumeration value="TM"/>
<enumeration value="TR"/>
<enumeration value="TV"/>
<enumeration value="UA"/>
<enumeration value="UG"/>
<enumeration value="UY"/>
<enumeration value="UZ"/>
<enumeration value="VU"/>
<enumeration value="VA"/>
<enumeration value="VE"/>
<enumeration value="VN"/>
<enumeration value="VG"/>
<enumeration value="VI"/>
<enumeration value="WF"/>
<enumeration value="YE"/>
<enumeration value="DJ"/>
<enumeration value="ZM"/>
<enumeration value="ZW"/>
<enumeration value="QU"/>
<enumeration value="XB"/>
<enumeration value="XU"/>
<enumeration value="XN"/>
</restriction>
</simpleType>
</schema>

Some files were not shown because too many files have changed in this diff Show More