Add Peppol forwarding

This commit is contained in:
David Bomba
2026-04-13 15:04:06 +10:00
parent a16a2ee4f8
commit e0afbd97b4
18 changed files with 889 additions and 278 deletions

View File

@@ -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',

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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());

View File

@@ -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) {

View File

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

View File

@@ -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.)

View File

@@ -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;

View File

@@ -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';

View File

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

View File

@@ -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'));
});

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View 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,
]);
});
}
}

View File

@@ -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);

View File

@@ -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');
}
}

View File

@@ -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
}
}