mirror of
https://github.com/invoiceninja/invoiceninja.git
synced 2026-04-18 12:10:50 +00:00
Add Peppol forwarding
This commit is contained in:
@@ -487,6 +487,10 @@ class CompanySettings extends BaseSettings
|
||||
|
||||
public $enable_e_invoice = false;
|
||||
|
||||
public $e_invoice_forward_email = '';
|
||||
|
||||
public $skip_automatic_email_with_peppol = false;
|
||||
|
||||
public $delivery_note_design_id = '';
|
||||
|
||||
public $statement_design_id = '';
|
||||
@@ -569,6 +573,8 @@ class CompanySettings extends BaseSettings
|
||||
'classification' => 'string',
|
||||
'default_expense_payment_type_id' => 'string',
|
||||
'e_invoice_type' => 'string',
|
||||
'e_invoice_forward_email' => 'string',
|
||||
'skip_automatic_email_with_peppol' => 'bool',
|
||||
'mailgun_endpoint' => 'string',
|
||||
'client_initiated_payments' => 'bool',
|
||||
'client_initiated_payments_minimum' => 'float',
|
||||
|
||||
@@ -26,6 +26,9 @@ class TaxEntity
|
||||
/** @var array<string> */
|
||||
public array $received_documents = [];
|
||||
|
||||
/** @var array<string> */
|
||||
public array $sent_documents = [];
|
||||
|
||||
/** @var bool $acts_as_sender */
|
||||
public bool $acts_as_sender = true;
|
||||
|
||||
|
||||
@@ -100,7 +100,9 @@ class UpdateCompanyRequest extends Request
|
||||
$rules['settings.ses_from_address'] = 'required_if:settings.email_sending_method,client_ses'; //ses specific rules
|
||||
$rules['settings.reply_to_email'] = 'sometimes|nullable|email'; // ensures that the reply to email address is a valid email address
|
||||
$rules['settings.bcc_email'] = ['sometimes', 'nullable', new \App\Rules\CommaSeparatedEmails()]; //ensure that the BCC's are valid comma separated emails
|
||||
|
||||
$rules['settings.e_invoice_forward_email'] = 'sometimes|nullable|email';
|
||||
$rules['settings.skip_automatic_email_with_peppol'] = 'sometimes|boolean';
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use App\Services\EDocument\Gateway\Storecove\Storecove;
|
||||
use App\Services\EDocument\Gateway\Storecove\EInvoiceForwarder;
|
||||
use App\Utils\Traits\Notifications\UserNotifies;
|
||||
|
||||
class EInvoicePullDocs implements ShouldQueue
|
||||
@@ -122,22 +123,34 @@ class EInvoicePullDocs implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
$this->pullSentDocuments($company);
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes received documents pulled from the hosted server.
|
||||
*
|
||||
* Creates expenses and vendors from each Storecove invoice, saves
|
||||
* HTML/XML/attachments to the expense, and forwards the XML to
|
||||
* the company's forwarding email if configured. Flushes the
|
||||
* documents from S3 after processing.
|
||||
*
|
||||
* @param array<int, array> $received_documents
|
||||
* @param Company $company
|
||||
* @param string $hash Confirmation hash for flushing
|
||||
* @return void
|
||||
*/
|
||||
private function handleSuccess(array $received_documents, Company $company, string $hash): void
|
||||
{
|
||||
|
||||
$storecove = new Storecove();
|
||||
|
||||
$mail_payload = [];
|
||||
|
||||
// $this->einvoice_received_count = count($received_documents);
|
||||
$forwarder = new EInvoiceForwarder($company);
|
||||
|
||||
foreach ($received_documents as $document) {
|
||||
|
||||
|
||||
nlog($document);
|
||||
|
||||
if(!isset($document['document']['invoice'])) {
|
||||
@@ -162,6 +175,10 @@ class EInvoicePullDocs implements ShouldQueue
|
||||
$upload_document = TempFile::UploadedFileFromBase64($document['original_base64_xml'], "{$file_name}.xml", 'application/xml');
|
||||
$this->saveDocument($upload_document, $expense, true);
|
||||
$upload_document = null;
|
||||
|
||||
if ($forwarder->isConfigured()) {
|
||||
$forwarder->forward(base64_decode($document['original_base64_xml']), "{$file_name}.xml", 'received');
|
||||
}
|
||||
}
|
||||
|
||||
if(isset($document['document']['invoice']['attachments'])){
|
||||
@@ -199,6 +216,73 @@ class EInvoicePullDocs implements ShouldQueue
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls confirmed-sent XML documents from the hosted server and forwards
|
||||
* them to the company's forwarding email.
|
||||
*
|
||||
* Only runs when the company has a valid forwarding email configured.
|
||||
* Retrieves sent documents via /api/einvoice/peppol/documents/sent,
|
||||
* forwards each XML, then flushes the documents from S3.
|
||||
*
|
||||
* @param Company $company
|
||||
* @return void
|
||||
*/
|
||||
private function pullSentDocuments(Company $company): void
|
||||
{
|
||||
$forwarder = new EInvoiceForwarder($company);
|
||||
|
||||
if (!$forwarder->isConfigured()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$response = \Illuminate\Support\Facades\Http::baseUrl(config('ninja.hosted_ninja_url'))
|
||||
->withHeaders([
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
'X-EInvoice-Token' => $company->account->e_invoicing_token,
|
||||
])
|
||||
->post('/api/einvoice/peppol/documents/sent', data: [
|
||||
'license_key' => config('ninja.license_key'),
|
||||
'account_key' => $company->account->key,
|
||||
'company_key' => $company->company_key,
|
||||
'legal_entity_id' => $company->legal_entity_id,
|
||||
]);
|
||||
|
||||
if (!$response->successful()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sent_documents = $response->json();
|
||||
$hash = $response->header('X-CONFIRMATION-HASH');
|
||||
|
||||
if (empty($sent_documents)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($sent_documents as $document) {
|
||||
$guid = $document['guid'] ?? '';
|
||||
$xml_base64 = $document['xml_base64'] ?? '';
|
||||
|
||||
if (strlen($xml_base64) > 5) {
|
||||
$forwarder->forward(base64_decode($xml_base64), "{$guid}.xml", 'sent');
|
||||
}
|
||||
}
|
||||
|
||||
\Illuminate\Support\Facades\Http::baseUrl(config('ninja.hosted_ninja_url'))
|
||||
->withHeaders([
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
'X-EInvoice-Token' => $company->account->e_invoicing_token,
|
||||
])
|
||||
->post('/api/einvoice/peppol/documents/sent/flush', data: [
|
||||
'license_key' => config('ninja.license_key'),
|
||||
'account_key' => $company->account->key,
|
||||
'company_key' => $company->company_key,
|
||||
'legal_entity_id' => $company->legal_entity_id,
|
||||
'hash' => $hash,
|
||||
]);
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception)
|
||||
{
|
||||
nlog($exception->getMessage());
|
||||
|
||||
@@ -156,6 +156,11 @@ class SendRecurring implements ShouldQueue
|
||||
*/
|
||||
private function sendRecurringEmails(Invoice $invoice): void
|
||||
{
|
||||
if ($invoice->client->getSetting('skip_automatic_email_with_peppol') && $invoice->client->peppolSendingEnabled()) {
|
||||
nlog("Skipping automatic email for invoice {$invoice->number} - client is on Peppol network");
|
||||
return;
|
||||
}
|
||||
|
||||
//Admin notification for recurring invoice sent.
|
||||
if ($invoice->invitations->count() >= 1) {
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Services\EDocument\Gateway\Storecove;
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Services\Email\Email;
|
||||
use App\Services\Email\EmailObject;
|
||||
use Illuminate\Mail\Mailables\Address;
|
||||
|
||||
/**
|
||||
* Forwards Peppol XML documents to an external accounting system
|
||||
* (e.g. Yuki, WinAuditor, Exact Online) via email.
|
||||
*
|
||||
* Reads the forwarding address from company->settings->e_invoice_forward_email.
|
||||
* Callers should check isConfigured() before invoking forward() to avoid
|
||||
* unnecessary work when no forwarding address is set.
|
||||
*/
|
||||
class EInvoiceForwarder
|
||||
{
|
||||
private string $forward_email;
|
||||
|
||||
public function __construct(private Company $company)
|
||||
{
|
||||
$this->forward_email = $this->company->settings->e_invoice_forward_email ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a valid forwarding email address is configured.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return filter_var($this->forward_email, FILTER_VALIDATE_EMAIL) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches an email with the Peppol XML attached to the configured
|
||||
* forwarding address using the application's Email service.
|
||||
*
|
||||
* @param string $xml Raw XML content
|
||||
* @param string $filename Attachment filename (e.g. INV-001.xml)
|
||||
* @param string $direction 'sent' or 'received'
|
||||
* @return void
|
||||
*/
|
||||
public function forward(string $xml, string $filename, string $direction): void
|
||||
{
|
||||
$mo = new EmailObject();
|
||||
$mo->subject = "Peppol Document ({$direction}): {$filename}";
|
||||
$mo->body = "Peppol e-invoice document ({$direction}): {$filename}";
|
||||
$mo->text_body = "Peppol e-invoice document ({$direction}): {$filename}";
|
||||
$mo->company_key = $this->company->company_key;
|
||||
$mo->html_template = 'email.template.admin';
|
||||
$mo->to = [new Address($this->forward_email)];
|
||||
$mo->attachments = [
|
||||
['file' => base64_encode($xml), 'name' => $filename],
|
||||
];
|
||||
|
||||
Email::dispatch($mo, $this->company);
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,10 @@ class Mutator implements MutatorInterface
|
||||
|
||||
$code = $this->getClientRoutingCode();
|
||||
|
||||
if ($code === 'Email') {
|
||||
return $this->setEmailRouting($this->getIndividualEmailRoute());
|
||||
}
|
||||
|
||||
$identifier = false;
|
||||
|
||||
// Non-VAT routing schemes (DK:DIGST, SE:ORGNR, FI:OVT, EE:CC, NO:ORG, LT:LEC, etc.)
|
||||
|
||||
@@ -265,29 +265,28 @@ class StorecoveAdapter
|
||||
//resolve and set the public identifier for the customer
|
||||
$accounting_customer_party = $this->storecove_invoice->getAccountingCustomerParty();
|
||||
|
||||
if (strlen($this->ninja_invoice->client->vat_number ?? '') > 2) {
|
||||
$id = preg_replace("/[^a-zA-Z0-9]/", "", $this->ninja_invoice->client->vat_number ?? '');
|
||||
$country = $this->ninja_invoice->client->country->iso_3166_2;
|
||||
$classification = $this->ninja_invoice->client->classification ?? 'individual';
|
||||
$router = $this->storecove->router->setInvoice($this->ninja_invoice);
|
||||
$scheme = $router->resolveTaxScheme($country, $classification);
|
||||
$client = $this->ninja_invoice->client;
|
||||
$country = $client->country->iso_3166_2;
|
||||
$classification = $client->classification ?? 'individual';
|
||||
$router = $this->storecove->router->setInvoice($this->ninja_invoice);
|
||||
$scheme = $router->resolveRouting($country, $classification);
|
||||
|
||||
if (empty($scheme)) {
|
||||
$scheme = $router->resolveIdentifierScheme($country, $classification);
|
||||
if (!empty($scheme) && !preg_match('/^\d{4}:/', $scheme)) {
|
||||
$is_vat_scheme = str_contains($scheme, ':VAT') || str_contains($scheme, ':IVA') || str_contains($scheme, ':CF');
|
||||
|
||||
if (!$is_vat_scheme && strlen($client->id_number ?? '') > 1) {
|
||||
$id = preg_replace("/[^a-zA-Z0-9]/", "", $client->id_number);
|
||||
} elseif (strlen($client->vat_number ?? '') > 2) {
|
||||
$id = preg_replace("/[^a-zA-Z0-9]/", "", $client->vat_number);
|
||||
} else {
|
||||
$id = null;
|
||||
}
|
||||
|
||||
// If the value doesn't match the tax scheme format (e.g. UEN in vat_number
|
||||
// instead of GST number), fall back to the identifier scheme for this country.
|
||||
if ($scheme && !$router->matchesSchemeFormat($scheme, $id)) {
|
||||
$altScheme = $router->resolveIdentifierScheme($country, $classification);
|
||||
if ($altScheme && $router->matchesSchemeFormat($altScheme, $id)) {
|
||||
$scheme = $altScheme;
|
||||
}
|
||||
if ($id) {
|
||||
$pi = new \App\Services\EDocument\Gateway\Storecove\Models\PublicIdentifiers($scheme, $id);
|
||||
$accounting_customer_party->addPublicIdentifiers($pi);
|
||||
$this->storecove_invoice->setAccountingCustomerParty($accounting_customer_party);
|
||||
}
|
||||
|
||||
$pi = new \App\Services\EDocument\Gateway\Storecove\Models\PublicIdentifiers($scheme, $id);
|
||||
$accounting_customer_party->addPublicIdentifiers($pi);
|
||||
$this->storecove_invoice->setAccountingCustomerParty($accounting_customer_party);
|
||||
}
|
||||
|
||||
return $this;
|
||||
|
||||
@@ -212,19 +212,27 @@ if (strlen($company->settings->vat_number ?? '') <= 1
|
||||
|
||||
$id = new \InvoiceNinja\EInvoice\Models\Peppol\IdentifierType\EndpointID();
|
||||
$routing_id = $invoice->client->routing_id ?? '';
|
||||
$resolved_scheme = $this->resolveScheme(true);
|
||||
|
||||
if (str_contains($routing_id, ':')) {
|
||||
if ($resolved_scheme === 'Email') {
|
||||
// Countries routed via email (IN, SA) have no Peppol EAS scheme —
|
||||
// use EAS 0202 as a generic endpoint with the client's tax/id number.
|
||||
$id->schemeID = '0202';
|
||||
$id->value = preg_replace("/[^a-zA-Z0-9]/", "", $invoice->client->vat_number ?? '')
|
||||
?: preg_replace("/[^a-zA-Z0-9]/", "", $invoice->client->id_number ?? '')
|
||||
?: $invoice->client->present()->email();
|
||||
} elseif (str_contains($routing_id, ':')) {
|
||||
// routing_id stored as "SCHEME:value" or already as "0088:value"
|
||||
[$scheme, $value] = explode(':', $routing_id, 2);
|
||||
$id->schemeID = $this->peppol->getGateway()->router->resolveIso6523Scheme($scheme);
|
||||
$id->value = $value;
|
||||
} elseif (strlen($routing_id) > 1) {
|
||||
// Raw routing value — scheme resolved from country/classification
|
||||
$id->schemeID = $this->resolveScheme(true);
|
||||
$id->schemeID = $resolved_scheme;
|
||||
$id->value = $routing_id;
|
||||
} else {
|
||||
// No routing_id — fall back to VAT or id_number
|
||||
$id->schemeID = $this->resolveScheme(true);
|
||||
$id->schemeID = $resolved_scheme;
|
||||
$id->value = preg_replace("/[^a-zA-Z0-9]/", "", $invoice->client->vat_number ?? '')
|
||||
?: preg_replace("/[^a-zA-Z0-9]/", "", $invoice->client->id_number ?? '')
|
||||
?: 'fallback1234';
|
||||
|
||||
@@ -85,7 +85,7 @@ class AdminEmailMailable extends Mailable
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_buffer($finfo, base64_decode($file['file']));
|
||||
$mime = $mime ?: 'application/octet-stream';
|
||||
finfo_close($finfo);
|
||||
$finfo = null;
|
||||
|
||||
return Attachment::fromData(fn() => base64_decode($file['file']), $file['name'])->withMime($mime);
|
||||
});
|
||||
|
||||
@@ -162,7 +162,7 @@ class TaxPeriodReport extends BaseExport
|
||||
})
|
||||
->map(function ($group) {
|
||||
return $group->first();
|
||||
})->each(function ($pp) {
|
||||
})->each(function (\App\Models\Paymentable $pp) {
|
||||
(new InvoiceTransactionEventEntryCash())->run($pp->paymentable, \Carbon\Carbon::parse($pp->created_at)->startOfMonth()->format('Y-m-d'), \Carbon\Carbon::parse($pp->created_at)->endOfMonth()->format('Y-m-d'));
|
||||
});
|
||||
|
||||
|
||||
@@ -44,13 +44,13 @@
|
||||
"babenkoivan/elastic-migrations": "^4.0|^5.0",
|
||||
"babenkoivan/elastic-scout-driver": "^4.0",
|
||||
"babenkoivan/elastic-scout-driver-plus": "^5.1",
|
||||
"bacon/bacon-qr-code": "^2.0",
|
||||
"bacon/bacon-qr-code": "^3.0",
|
||||
"beganovich/snappdf": "dev-master",
|
||||
"braintree/braintree_php": "^6.28",
|
||||
"btcpayserver/btcpayserver-greenfield-php": "^2.6",
|
||||
"checkout/checkout-sdk-php": "^3.0",
|
||||
"endroid/qr-code": "^5",
|
||||
"eway/eway-rapid-php": "^1.3",
|
||||
"eway/eway-rapid-php": "^2.0",
|
||||
"fakerphp/faker": "^1.14",
|
||||
"getbrevo/brevo-php": "^1.0",
|
||||
"gocardless/gocardless-pro": "^4.12",
|
||||
@@ -79,9 +79,9 @@
|
||||
"lbuchs/webauthn": "^2.2",
|
||||
"league/csv": "^9.6",
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"league/fractal": "^0.20.0",
|
||||
"league/fractal": "^0.21",
|
||||
"livewire/livewire": "^3",
|
||||
"mailgun/mailgun-php": "^3.6",
|
||||
"mailgun/mailgun-php": "^4.0",
|
||||
"microsoft/microsoft-graph": "^1.69",
|
||||
"mindee/mindee": "^1.8",
|
||||
"mollie/mollie-api-php": "^2.36",
|
||||
@@ -91,7 +91,7 @@
|
||||
"payfast/payfast-php-sdk": "^1.1",
|
||||
"phpoffice/phpspreadsheet": "^2.2",
|
||||
"pragmarx/google2fa": "^8.0",
|
||||
"predis/predis": "^2",
|
||||
"predis/predis": "^3",
|
||||
"psr/http-message": "^1.0",
|
||||
"pusher/pusher-php-server": "^7.2",
|
||||
"quickbooks/v3-php-sdk": "^6.2",
|
||||
|
||||
422
composer.lock
generated
422
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -5956,6 +5956,9 @@ $lang = array(
|
||||
'cloned_client' => 'Successfully cloned client',
|
||||
'update_tax_details' => 'Update Tax Details',
|
||||
'cc_only' => 'CC Only',
|
||||
);
|
||||
'e_invoice_forward_email' => 'PEPPOL Forwarding Email Service',
|
||||
'e_invoice_forward_email_help' => 'Forwards sent/received e-invoices to a third party processing service like Yuki or WinAuditor',
|
||||
'skip_automatic_email_with_peppol' => 'Disable Email When Sent via PEPPOL',
|
||||
'skip_automatic_email_with_peppol_help' => 'If enabled, invoices sent through the PEPPOL network will not be emailed to the client.',);
|
||||
|
||||
return $lang;
|
||||
|
||||
361
tests/Feature/NPlusOneListTest.php
Normal file
361
tests/Feature/NPlusOneListTest.php
Normal file
@@ -0,0 +1,361 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Models\Task;
|
||||
use App\Models\Quote;
|
||||
use App\Models\Client;
|
||||
use App\Models\Credit;
|
||||
use App\Models\Vendor;
|
||||
use App\Models\Expense;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Payment;
|
||||
use App\Models\Product;
|
||||
use App\Models\Project;
|
||||
use App\Models\ClientContact;
|
||||
use App\Models\VendorContact;
|
||||
use App\Models\RecurringInvoice;
|
||||
use App\Models\RecurringExpense;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\PurchaseOrderInvitation;
|
||||
use App\Models\InvoiceInvitation;
|
||||
use App\Models\QuoteInvitation;
|
||||
use App\Models\CreditInvitation;
|
||||
use App\Models\RecurringInvoiceInvitation;
|
||||
use Tests\MockAccountData;
|
||||
use App\Utils\Traits\MakesHash;
|
||||
use App\Factory\InvoiceFactory;
|
||||
use App\Factory\CreditFactory;
|
||||
use App\Factory\QuoteFactory;
|
||||
use App\Factory\PurchaseOrderFactory;
|
||||
use App\Factory\InvoiceItemFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
/**
|
||||
* Tests for N+1 query detection on list endpoints.
|
||||
*
|
||||
* Each test creates N entities, measures query count, then creates
|
||||
* N more entities and measures again. If query count scales with
|
||||
* entity count, it indicates an N+1 problem.
|
||||
*/
|
||||
class NPlusOneListTest extends TestCase
|
||||
{
|
||||
use MakesHash;
|
||||
use DatabaseTransactions;
|
||||
use MockAccountData;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->makeTestData();
|
||||
Session::start();
|
||||
Model::reguard();
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure query count for a GET request to the given endpoint.
|
||||
* Returns [queryCount, entityCount].
|
||||
*/
|
||||
private function measureQueryCount(string $url): array
|
||||
{
|
||||
DB::flushQueryLog();
|
||||
DB::enableQueryLog();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-API-SECRET' => config('ninja.api_secret'),
|
||||
'X-API-TOKEN' => $this->token,
|
||||
])->getJson($url);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$queries = DB::getQueryLog();
|
||||
DB::disableQueryLog();
|
||||
|
||||
$arr = $response->json();
|
||||
$entityCount = count($arr['data']);
|
||||
|
||||
return [count($queries), $entityCount, $queries];
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that query count does not scale with entity count.
|
||||
*/
|
||||
private function assertNoNPlusOne(string $endpoint, string $includes, callable $factory, int $batchSize = 5): void
|
||||
{
|
||||
// Create initial batch
|
||||
for ($i = 0; $i < $batchSize; $i++) {
|
||||
$factory($i);
|
||||
}
|
||||
|
||||
$url = "/api/v1/{$endpoint}?per_page=100";
|
||||
if ($includes) {
|
||||
$url .= "&include={$includes}";
|
||||
}
|
||||
|
||||
[$baselineCount, $baselineEntities] = $this->measureQueryCount($url);
|
||||
|
||||
// Create second batch
|
||||
for ($i = 0; $i < $batchSize; $i++) {
|
||||
$factory($batchSize + $i);
|
||||
}
|
||||
|
||||
[$secondCount, $secondEntities, $secondQueries] = $this->measureQueryCount($url);
|
||||
|
||||
$this->assertGreaterThan(
|
||||
$baselineEntities,
|
||||
$secondEntities,
|
||||
"Expected more entities in second request for {$endpoint}"
|
||||
);
|
||||
|
||||
$queryDescriptions = array_map(fn ($q) => $q['query'], $secondQueries);
|
||||
|
||||
// Allow tolerance of 2 queries for minor variations
|
||||
$this->assertLessThanOrEqual(
|
||||
$baselineCount + 2,
|
||||
$secondCount,
|
||||
"N+1 on GET /api/v1/{$endpoint}?include={$includes}: "
|
||||
. "queries grew from {$baselineCount} ({$baselineEntities} entities) "
|
||||
. "to {$secondCount} ({$secondEntities} entities).\n"
|
||||
. "Queries:\n" . implode("\n", $queryDescriptions)
|
||||
);
|
||||
}
|
||||
|
||||
public function testClientListNPlusOne(): void
|
||||
{
|
||||
$this->assertNoNPlusOne('clients', 'group_settings', function ($i) {
|
||||
$client = Client::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
'name' => "N+1 Client {$i}",
|
||||
]);
|
||||
|
||||
ClientContact::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
'client_id' => $client->id,
|
||||
'is_primary' => true,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function testInvoiceListNPlusOne(): void
|
||||
{
|
||||
$contact = ClientContact::query()
|
||||
->where('client_id', $this->client->id)
|
||||
->first();
|
||||
|
||||
$this->assertNoNPlusOne('invoices', 'client,payments', function ($i) use ($contact) {
|
||||
$invoice = InvoiceFactory::create($this->company->id, $this->user->id);
|
||||
$invoice->client_id = $this->client->id;
|
||||
$invoice->line_items = InvoiceItemFactory::generate(1);
|
||||
$invoice->uses_inclusive_taxes = false;
|
||||
$invoice->save();
|
||||
|
||||
InvoiceInvitation::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
'client_contact_id' => $contact->id,
|
||||
'invoice_id' => $invoice->id,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function testQuoteListNPlusOne(): void
|
||||
{
|
||||
$contact = ClientContact::query()
|
||||
->where('client_id', $this->client->id)
|
||||
->first();
|
||||
|
||||
$this->assertNoNPlusOne('quotes', 'client', function ($i) use ($contact) {
|
||||
$quote = QuoteFactory::create($this->company->id, $this->user->id);
|
||||
$quote->client_id = $this->client->id;
|
||||
$quote->line_items = InvoiceItemFactory::generate(1);
|
||||
$quote->uses_inclusive_taxes = false;
|
||||
$quote->save();
|
||||
|
||||
QuoteInvitation::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
'client_contact_id' => $contact->id,
|
||||
'quote_id' => $quote->id,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function testCreditListNPlusOne(): void
|
||||
{
|
||||
$contact = ClientContact::query()
|
||||
->where('client_id', $this->client->id)
|
||||
->first();
|
||||
|
||||
$this->assertNoNPlusOne('credits', 'client', function ($i) use ($contact) {
|
||||
$credit = CreditFactory::create($this->company->id, $this->user->id);
|
||||
$credit->client_id = $this->client->id;
|
||||
$credit->line_items = InvoiceItemFactory::generate(1);
|
||||
$credit->uses_inclusive_taxes = false;
|
||||
$credit->save();
|
||||
|
||||
CreditInvitation::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
'client_contact_id' => $contact->id,
|
||||
'credit_id' => $credit->id,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function testExpenseListNPlusOne(): void
|
||||
{
|
||||
$this->assertNoNPlusOne('expenses', 'client,vendor,category', function ($i) {
|
||||
Expense::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
'client_id' => $this->client->id,
|
||||
'vendor_id' => $this->vendor->id,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function testPaymentListNPlusOne(): void
|
||||
{
|
||||
$this->assertNoNPlusOne('payments', 'client,invoices', function ($i) {
|
||||
Payment::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
'client_id' => $this->client->id,
|
||||
'amount' => 100,
|
||||
'applied' => 0,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function testProjectListNPlusOne(): void
|
||||
{
|
||||
$this->assertNoNPlusOne('projects', 'user,assigned_user,client', function ($i) {
|
||||
Project::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'assigned_user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
'client_id' => $this->client->id,
|
||||
'name' => "N+1 Project {$i}",
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function testTaskListNPlusOne(): void
|
||||
{
|
||||
$this->assertNoNPlusOne('tasks', 'user,assigned_user,client,status,project', function ($i) {
|
||||
Task::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
'client_id' => $this->client->id,
|
||||
'project_id' => $this->project->id,
|
||||
'status_id' => $this->task_status->id,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function testVendorListNPlusOne(): void
|
||||
{
|
||||
$this->assertNoNPlusOne('vendors', '', function ($i) {
|
||||
$vendor = Vendor::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
'name' => "N+1 Vendor {$i}",
|
||||
]);
|
||||
|
||||
VendorContact::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
'vendor_id' => $vendor->id,
|
||||
'is_primary' => true,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function testProductListNPlusOne(): void
|
||||
{
|
||||
$this->assertNoNPlusOne('products', 'user', function ($i) {
|
||||
Product::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
'product_key' => "n1-product-{$i}",
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function testRecurringInvoiceListNPlusOne(): void
|
||||
{
|
||||
$contact = ClientContact::query()
|
||||
->where('client_id', $this->client->id)
|
||||
->first();
|
||||
|
||||
$this->assertNoNPlusOne('recurring_invoices', 'client', function ($i) use ($contact) {
|
||||
$ri = RecurringInvoice::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
'client_id' => $this->client->id,
|
||||
'frequency_id' => 5,
|
||||
'line_items' => InvoiceItemFactory::generate(1),
|
||||
'uses_inclusive_taxes' => false,
|
||||
]);
|
||||
|
||||
RecurringInvoiceInvitation::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
'client_contact_id' => $contact->id,
|
||||
'recurring_invoice_id' => $ri->id,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function testRecurringExpenseListNPlusOne(): void
|
||||
{
|
||||
$this->assertNoNPlusOne('recurring_expenses', 'client,vendor', function ($i) {
|
||||
RecurringExpense::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
'frequency_id' => 5,
|
||||
'amount' => 100,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function testPurchaseOrderListNPlusOne(): void
|
||||
{
|
||||
$vendorContact = VendorContact::query()
|
||||
->where('vendor_id', $this->vendor->id)
|
||||
->first();
|
||||
|
||||
$this->assertNoNPlusOne('purchase_orders', 'vendor', function ($i) use ($vendorContact) {
|
||||
$po = PurchaseOrderFactory::create($this->company->id, $this->user->id);
|
||||
$po->vendor_id = $this->vendor->id;
|
||||
$po->amount = 10;
|
||||
$po->balance = 10;
|
||||
$po->line_items = InvoiceItemFactory::generate(1);
|
||||
$po->uses_inclusive_taxes = false;
|
||||
$po->save();
|
||||
|
||||
PurchaseOrderInvitation::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
'vendor_contact_id' => $vendorContact->id,
|
||||
'purchase_order_id' => $po->id,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -969,7 +969,7 @@ class RecurringInvoiceTest extends TestCase
|
||||
'user_id' => $this->user->id,
|
||||
'cost' => 10,
|
||||
'price' => 10,
|
||||
'product_key' => $this->faker->word,
|
||||
'product_key' => $this->faker->word(),
|
||||
]);
|
||||
|
||||
$p2 = Product::factory()->create([
|
||||
@@ -977,7 +977,7 @@ class RecurringInvoiceTest extends TestCase
|
||||
'user_id' => $this->user->id,
|
||||
'cost' => 20,
|
||||
'price' => 20,
|
||||
'product_key' => $this->faker->word,
|
||||
'product_key' => $this->faker->word(),
|
||||
]);
|
||||
|
||||
$recurring_invoice = RecurringInvoiceFactory::create($this->company->id, $this->user->id);
|
||||
@@ -1044,7 +1044,7 @@ class RecurringInvoiceTest extends TestCase
|
||||
'user_id' => $this->user->id,
|
||||
'cost' => 10,
|
||||
'price' => 10,
|
||||
'product_key' => $this->faker->word,
|
||||
'product_key' => $this->faker->word(),
|
||||
]);
|
||||
|
||||
$p2 = Product::factory()->create([
|
||||
@@ -1052,7 +1052,7 @@ class RecurringInvoiceTest extends TestCase
|
||||
'user_id' => $this->user->id,
|
||||
'cost' => 20,
|
||||
'price' => 20,
|
||||
'product_key' => $this->faker->word,
|
||||
'product_key' => $this->faker->word(),
|
||||
]);
|
||||
|
||||
$recurring_invoice = RecurringInvoiceFactory::create($this->company->id, $this->user->id);
|
||||
|
||||
@@ -2097,5 +2097,62 @@ class StorecoveTest extends TestCase
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* testSgToInReceiverUsesEmailRouting
|
||||
*
|
||||
* When sending SG -> IN, the Indian recipient's routing code resolves to "Email".
|
||||
* The routing payload must use emails (not eIdentifiers with the GSTIN).
|
||||
*/
|
||||
public function testSgToInReceiverUsesEmailRouting(): void
|
||||
{
|
||||
$this->routing_id = 290868;
|
||||
|
||||
$scenario = [
|
||||
'company_vat' => '',
|
||||
'company_id_number' => 'T08GA0028A',
|
||||
'company_country' => 'SG',
|
||||
'company_classification' => 'business',
|
||||
'client_country' => 'IN',
|
||||
'client_vat' => '22AAAAA0000A1Z5',
|
||||
'client_id_number' => '',
|
||||
'classification' => 'business',
|
||||
'has_valid_vat' => false,
|
||||
'over_threshold' => false,
|
||||
'legal_entity_id' => 290868,
|
||||
'is_tax_exempt' => false,
|
||||
];
|
||||
|
||||
$data = $this->setupTestData($scenario);
|
||||
|
||||
$invoice = $data['invoice'];
|
||||
$invoice = $invoice->calc()->getInvoice();
|
||||
$client = $data['client'];
|
||||
|
||||
$this->assertEquals('SG', $data['company']->country()->iso_3166_2);
|
||||
$this->assertEquals('IN', $client->country->iso_3166_2);
|
||||
|
||||
$invoice->save();
|
||||
|
||||
$p = new Peppol($invoice);
|
||||
$p->run();
|
||||
|
||||
$identifiers = $p->gateway->mutator->setClientRoutingCode()->getStorecoveMeta();
|
||||
|
||||
// Must use email routing, NOT eIdentifiers
|
||||
$this->assertArrayHasKey('routing', $identifiers);
|
||||
$this->assertArrayHasKey('emails', $identifiers['routing'], 'IN receiver should use email routing, not eIdentifiers');
|
||||
$this->assertArrayNotHasKey('eIdentifiers', $identifiers['routing'], 'IN receiver should not have eIdentifiers — GSTIN must not be sent as an email-scheme identifier');
|
||||
|
||||
// The email should be the client contact's email
|
||||
$contactEmail = $client->present()->email();
|
||||
$this->assertContains($contactEmail, $identifiers['routing']['emails']);
|
||||
|
||||
// Peppol XML EndpointID must use a valid EAS code (0088/GLN), not "Email"
|
||||
$peppolInvoice = $p->getInvoice();
|
||||
$endpointId = $peppolInvoice->AccountingCustomerParty->Party->EndpointID;
|
||||
$this->assertEquals('0202', $endpointId->schemeID, 'EndpointID schemeID must be 0202 for email-routed countries');
|
||||
$this->assertEquals('22AAAAA0000A1Z5', $endpointId->value, 'EndpointID value must be the client GSTIN for IN receivers');
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Currency;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
class RedisVsDatabaseTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// $this->markTestSkipped('Skip test no company gateways installed');
|
||||
}
|
||||
|
||||
public function testRedisSpeed()
|
||||
{
|
||||
app('currencies');
|
||||
|
||||
$start = microtime(true);
|
||||
|
||||
$currencies = Cache::get('currencies');
|
||||
|
||||
$currencies->first(function ($item) {
|
||||
return $item->id == 17;
|
||||
});
|
||||
|
||||
// nlog(microtime(true) - $start);
|
||||
|
||||
$this->assertTrue(true);
|
||||
// nlog($total_time);
|
||||
//0.0012960433959961
|
||||
}
|
||||
|
||||
public function testDbSpeed()
|
||||
{
|
||||
$start = microtime(true);
|
||||
|
||||
$currency = Currency::find(17);
|
||||
|
||||
// nlog(microtime(true) - $start);
|
||||
|
||||
$this->assertTrue(true);
|
||||
// nlog($total_time);
|
||||
// 0.006152868270874
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user