1
0
mirror of https://github.com/invoiceninja/invoiceninja.git synced 2024-11-10 13:12:50 +01:00

Merge pull request #8748 from turbo124/v5-develop

Updates for credit reports
This commit is contained in:
David Bomba 2023-08-23 20:37:17 +10:00 committed by GitHub
commit 822a53a530
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 534 additions and 43 deletions

View File

@ -86,15 +86,20 @@ class CreditExport extends BaseExport
{
$query = $this->init();
$header = $this->buildHeader();
$headerdisplay = $this->buildHeader();
$header = [];
foreach ($this->input['report_keys'] as $key => $value) {
$header[] = ['identifier' => $value, 'display_value' => $headerdisplay[$key]];
}
$report = $query->cursor()
->map(function ($credit) {
$row = $this->buildRow($credit);
return $this->processMetaData($row, $credit);
})->toArray();
return array_merge([$header], $report);
return array_merge(['columns' => $header], $report);
}
private function processMetaData(array $row, Credit $credit): array
@ -112,6 +117,7 @@ class CreditExport extends BaseExport
$clean_row[$key]['id'] = $report_keys[1] ?? $report_keys[0];
$clean_row[$key]['hashed_id'] = $report_keys[0] == 'credit' ? null : $credit->{$report_keys[0]}->hashed_id ?? null;
$clean_row[$key]['value'] = $row[$column_key];
$clean_row[$key]['identifier'] = $value;
if(in_array($clean_row[$key]['id'], ['amount', 'balance', 'partial', 'refunded', 'applied','unit_cost','cost','price']))
$clean_row[$key]['display_value'] = Number::formatMoney($row[$column_key], $credit->client);
@ -134,14 +140,6 @@ class CreditExport extends BaseExport
if (count($this->input['report_keys']) == 0) {
$this->input['report_keys'] = array_values($this->entity_keys);
// $this->input['report_keys'] = collect(array_values($this->entity_keys))->map(function ($value){
// // if(in_array($value,['client_id','country_id']))
// // return $value;
// // else
// return 'credit.'.$value;
// })->toArray();
}
$query = Credit::query()

View File

@ -0,0 +1,70 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Controllers;
use App\Http\Requests\Email\ClientEmailHistoryRequest;
use App\Http\Requests\Email\EntityEmailHistoryRequest;
use App\Models\Client;
use App\Models\SystemLog;
use App\Utils\Traits\MakesHash;
class EmailHistoryController extends BaseController
{
use MakesHash;
public function __construct()
{
}
public function clientHistory(ClientEmailHistoryRequest $request, Client $client)
{
$data = SystemLog::where('client_id', $client->id)
->where('category_id', SystemLog::CATEGORY_MAIL)
->orderBy('id', 'DESC')
->cursor()
->map(function ($system_log) {
if($system_log->log['history'] ?? false) {
return $system_log->log['history'];
// return json_decode($system_log->log['history'], true);
}
});
return response()->json($data, 200);
}
/**
* May need to expand on this using
* just the message-id and search for the
* entity in the invitations
*/
public function entityHistory(EntityEmailHistoryRequest $request)
{
/** @var \App\Models\User $user */
$user = auth()->user();
$data = SystemLog::where('company_id', $user->company()->id)
->where('category_id', SystemLog::CATEGORY_MAIL)
->whereJsonContains('log->history->entity_id', $this->encodePrimaryKey($request->entity_id))
->orderBy('id', 'DESC')
->cursor()
->map(function ($system_log) {
if($system_log->log['history'] ?? false) {
return $system_log->log['history'];
// return json_decode($system_log->log['history'], true);
}
});
return response()->json($data, 200);
}
}

View File

@ -0,0 +1,55 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Requests\Email;
use App\Http\Requests\Request;
use App\Utils\Traits\MakesHash;
use Illuminate\Support\Str;
class ClientEmailHistoryRequest extends Request
{
use MakesHash;
private string $error_message = '';
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize() : bool
{
/** @var \App\Models\User $user */
$user = auth()->user();
return $user->can('view', $this->client);
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
];
}
public function prepareForValidation()
{
$input = $this->all();
$this->replace($input);
}
}

View File

@ -0,0 +1,62 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Requests\Email;
use Illuminate\Support\Str;
use App\Http\Requests\Request;
use App\Utils\Traits\MakesHash;
use Illuminate\Validation\Rule;
class EntityEmailHistoryRequest extends Request
{
use MakesHash;
private string $error_message = '';
private string $entity_plural = '';
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize() : bool
{
//handle authorization in controller
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
/** @var \App\Models\User $user */
$user = auth()->user();
return [
'entity' => 'bail|required|string|in:invoice,quote,credit,recurring_invoice,purchase_order',
'entity_id' => ['bail','required',Rule::exists($this->entity_plural, 'id')->where('company_id', $user->company()->id)],
];
}
public function prepareForValidation()
{
$input = $this->all();
$this->entity_plural = Str::plural($input['entity']) ?? '';
$input['entity_id'] = $this->decodePrimaryKey($input['entity_id']);
$this->replace($input);
}
}

View File

@ -11,27 +11,24 @@
namespace App\Jobs\PostMark;
use App\DataMapper\Analytics\Mail\EmailBounce;
use App\DataMapper\Analytics\Mail\EmailSpam;
use App\Jobs\Util\SystemLogger;
use App\Models\SystemLog;
use App\Libraries\MultiDB;
use App\Models\Company;
use Postmark\PostmarkClient;
use Illuminate\Bus\Queueable;
use App\Jobs\Util\SystemLogger;
use App\Models\QuoteInvitation;
use App\Models\CreditInvitation;
use App\Models\InvoiceInvitation;
use App\Models\Payment;
use App\Models\PurchaseOrderInvitation;
use App\Models\QuoteInvitation;
use App\Models\RecurringInvoiceInvitation;
use App\Models\SystemLog;
use App\Notifications\Ninja\EmailBounceNotification;
use App\Notifications\Ninja\EmailSpamNotification;
use App\Utils\Ninja;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Turbo124\Beacon\Facades\LightLogs;
use App\Models\PurchaseOrderInvitation;
use Illuminate\Queue\InteractsWithQueue;
use App\Models\RecurringInvoiceInvitation;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use App\DataMapper\Analytics\Mail\EmailSpam;
use App\DataMapper\Analytics\Mail\EmailBounce;
use App\Notifications\Ninja\EmailSpamNotification;
class ProcessPostmarkWebhook implements ShouldQueue
{
@ -41,6 +38,16 @@ class ProcessPostmarkWebhook implements ShouldQueue
public $invitation;
private $entity;
private array $default_response = [
'recipients' => '',
'subject' => 'Message not found.',
'entity' => '',
'entity_id' => '',
'events' => [],
];
/**
* Create a new job instance.
*
@ -126,8 +133,10 @@ class ProcessPostmarkWebhook implements ShouldQueue
$this->invitation->opened_date = now();
$this->invitation->save();
$data = array_merge($this->request, ['history' => $this->fetchMessage()]);
(new SystemLogger(
$this->request,
$data,
SystemLog::CATEGORY_MAIL,
SystemLog::EVENT_MAIL_OPENED,
SystemLog::TYPE_WEBHOOK_RESPONSE,
@ -155,8 +164,10 @@ class ProcessPostmarkWebhook implements ShouldQueue
$this->invitation->email_status = 'delivered';
$this->invitation->save();
$data = array_merge($this->request, ['history' => $this->fetchMessage()]);
(new SystemLogger(
$this->request,
$data,
SystemLog::CATEGORY_MAIL,
SystemLog::EVENT_MAIL_DELIVERY,
SystemLog::TYPE_WEBHOOK_RESPONSE,
@ -204,7 +215,9 @@ class ProcessPostmarkWebhook implements ShouldQueue
LightLogs::create($bounce)->send();
(new SystemLogger($this->request, SystemLog::CATEGORY_MAIL, SystemLog::EVENT_MAIL_BOUNCED, SystemLog::TYPE_WEBHOOK_RESPONSE, $this->invitation->contact->client, $this->invitation->company))->handle();
$data = array_merge($this->request, ['history' => $this->fetchMessage()]);
(new SystemLogger($data, SystemLog::CATEGORY_MAIL, SystemLog::EVENT_MAIL_BOUNCED, SystemLog::TYPE_WEBHOOK_RESPONSE, $this->invitation->contact->client, $this->invitation->company))->handle();
// if(config('ninja.notification.slack'))
// $this->invitation->company->notification(new EmailBounceNotification($this->invitation->company->account))->ninja();
@ -248,7 +261,9 @@ class ProcessPostmarkWebhook implements ShouldQueue
LightLogs::create($spam)->send();
(new SystemLogger($this->request, SystemLog::CATEGORY_MAIL, SystemLog::EVENT_MAIL_SPAM_COMPLAINT, SystemLog::TYPE_WEBHOOK_RESPONSE, $this->invitation->contact->client, $this->invitation->company))->handle();
$data = array_merge($this->request, ['history' => $this->fetchMessage()]);
(new SystemLogger($data, SystemLog::CATEGORY_MAIL, SystemLog::EVENT_MAIL_SPAM_COMPLAINT, SystemLog::TYPE_WEBHOOK_RESPONSE, $this->invitation->contact->client, $this->invitation->company))->handle();
if (config('ninja.notification.slack')) {
$this->invitation->company->notification(new EmailSpamNotification($this->invitation->company->account))->ninja();
@ -260,17 +275,65 @@ class ProcessPostmarkWebhook implements ShouldQueue
$invitation = false;
if ($invitation = InvoiceInvitation::where('message_id', $message_id)->first()) {
$this->entity = 'invoice';
return $invitation;
} elseif ($invitation = QuoteInvitation::where('message_id', $message_id)->first()) {
$this->entity = 'quote';
return $invitation;
} elseif ($invitation = RecurringInvoiceInvitation::where('message_id', $message_id)->first()) {
$this->entity = 'recurring_invoice';
return $invitation;
} elseif ($invitation = CreditInvitation::where('message_id', $message_id)->first()) {
$this->entity = 'credit';
return $invitation;
} elseif ($invitation = PurchaseOrderInvitation::where('message_id', $message_id)->first()) {
$this->entity = 'purchase_order';
return $invitation;
} else {
return $invitation;
}
}
private function fetchMessage(): array
{
if(strlen($this->request['MessageID']) < 1){
return $this->default_response;
}
try {
$postmark = new PostmarkClient(config('services.postmark.token'));
$messageDetail = $postmark->getOutboundMessageDetails($this->request['MessageID']);
$recipients = collect($messageDetail['recipients'])->flatten()->implode(',');
$subject = $messageDetail->subject ?? '';
$events = collect($messageDetail->messageevents)->map(function ($event) {
return [
'recipient' => $event->Recipient ?? '',
'status' => $event->Type ?? '',
'delivery_message' => $event->Details->DeliveryMessage ?? $event->Details->Summary ?? '',
'server' => $event->Details->DestinationServer ?? '',
'server_ip' => $event->Details->DestinationIP ?? '',
'date' => \Carbon\Carbon::parse($event->ReceivedAt)->format('Y-m-d H:m:s') ?? '',
];
})->toArray();
return [
'recipients' => $recipients,
'subject' => $subject,
'entity' => $this->entity ?? '',
'entity_id' => $this->invitation->{$this->entity}->hashed_id ?? '',
'events' => $events,
];
}
catch (\Exception $e) {
return $this->default_response;
}
}
}

View File

@ -394,8 +394,10 @@ class SquarePaymentDriver extends BaseDriver
//getsubscriptionid here
$subscription_id = $this->checkWebhooks();
if(!$subscription_id)
return nlog('No Subscription Found');
if(!$subscription_id){
nlog('No Subscription Found');
return;
}
$api_response = $this->square->getWebhookSubscriptionsApi()->testWebhookSubscription($subscription_id, $body);

View File

@ -0,0 +1,96 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Client;
use App\Models\Client;
use App\Models\SystemLog;
use Postmark\PostmarkClient;
use App\Services\AbstractService;
/** @deprecated */
class EmailHistory extends AbstractService
{
private string $postmark_token;
private PostmarkClient $postmark;
private array $default_response = [
'subject' => 'Message not found.',
'status' => '',
'recipient' => '',
'type' => '',
'delivery_message' => '',
'server' => '',
'server_ip' => '',
];
public function __construct(public Client $client)
{
}
public function run(): array
{
// $settings = $this->client->getMergedSettings();
// if($settings->email_sending_method == 'default'){
// $this->postmark_token = config('services.postmark.token');
// }
// elseif($settings->email_sending_method == 'client_postmark'){
// $this->postmark_token = $settings->postmark_secret;
// }
// else{
// return [];
// }
// $this->postmark = new PostmarkClient($this->postmark_token);
return SystemLog::query()
->where('client_id', $this->client->id)
->where('category_id', SystemLog::CATEGORY_MAIL)
->orderBy('id','DESC')
->cursor()
->map(function ($system_log) {
if($system_log->log['history'] ?? false){
return json_decode($system_log->log['history'],true);
}
})->toArray();
}
private function fetchMessage(string $message_id): array
{
if(strlen($message_id) < 1){
return $this->default_response;
}
try {
$messageDetail = $this->postmark->getOutboundMessageDetails($message_id);
return [
'subject' => $messageDetail->subject ?? '',
'status' => $messageDetail->status ?? '',
'recipient' => $messageDetail->messageevents[0]['Recipient'] ?? '',
'type' => $messageDetail->messageevents[0]->Type ?? '',
'delivery_message' => $messageDetail->messageevents[0]->Details->DeliveryMessage ?? '',
'server' => $messageDetail->messageevents[0]->Details->DestinationServer ?? '',
'server_ip' => $messageDetail->messageevents[0]->Details->DestinationIP ?? '',
];
}
catch (\Exception $e) {
return $this->default_response;
}
}
}

View File

@ -240,7 +240,6 @@ class Email implements ShouldQueue
}
if ($this->client_mailgun_secret) {
$mailer->mailgun_config($this->client_mailgun_secret, $this->client_mailgun_domain, $this->client_mailgun_endpoint);
}
@ -254,6 +253,7 @@ class Email implements ShouldQueue
LightLogs::create(new EmailSuccess($this->company->company_key))
->send();
} catch(\Symfony\Component\Mime\Exception\RfcComplianceException $e) {
nlog("Mailer failed with a Logic Exception {$e->getMessage()}");
$this->fail();

View File

@ -76,6 +76,7 @@
"omnipay/paypal": "^3.0",
"payfast/payfast-php-sdk": "^1.1",
"pragmarx/google2fa": "^8.0",
"psr/http-message": "^1.0",
"pusher/pusher-php-server": "^7.2",
"razorpay/razorpay": "2.*",
"sentry/sentry-laravel": "^3",
@ -96,7 +97,7 @@
"twilio/sdk": "^6.40",
"webpatser/laravel-countries": "dev-master#75992ad",
"wepay/php-sdk": "^0.3",
"psr/http-message": "^1.0"
"wildbit/postmark-php": "^4.0"
},
"require-dev": {
"php": "^8.1",

40
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "673ca66ddfdb05c3ea29012594a196d3",
"content-hash": "70ade0ea4925946765213166010b0f32",
"packages": [
{
"name": "adrienrn/php-mimetyper",
@ -14230,6 +14230,44 @@
"source": "https://github.com/wepay/PHP-SDK/tree/master"
},
"time": "2017-01-21T07:03:26+00:00"
},
{
"name": "wildbit/postmark-php",
"version": "v4.0.5",
"source": {
"type": "git",
"url": "https://github.com/ActiveCampaign/postmark-php.git",
"reference": "b71efba061de7cf7e1f853d211b1c5edce4e3c5b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ActiveCampaign/postmark-php/zipball/b71efba061de7cf7e1f853d211b1c5edce4e3c5b",
"reference": "b71efba061de7cf7e1f853d211b1c5edce4e3c5b",
"shasum": ""
},
"require": {
"guzzlehttp/guzzle": "^6.0|^7.0",
"php": ">=7.0.0"
},
"require-dev": {
"phpunit/phpunit": "^6.0.0"
},
"type": "library",
"autoload": {
"psr-0": {
"Postmark\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "The officially supported client for Postmark (http://postmarkapp.com)",
"support": {
"issues": "https://github.com/ActiveCampaign/postmark-php/issues",
"source": "https://github.com/ActiveCampaign/postmark-php/tree/v4.0.5"
},
"time": "2023-02-03T15:00:17+00:00"
}
],
"packages-dev": [

View File

@ -1150,7 +1150,7 @@ $LANG = array(
'plan_status' => 'Plan Status',
'plan_upgrade' => 'Upgrade',
'plan_change' => 'Change Plan',
'plan_change' => 'Manage Plan',
'pending_change_to' => 'Changes To',
'plan_changes_to' => ':plan on :date',
'plan_term_changes_to' => ':plan (:term) on :date',
@ -4330,7 +4330,7 @@ $LANG = array(
'include_drafts' => 'Include Drafts',
'include_drafts_help' => 'Include draft records in reports',
'is_invoiced' => 'Is Invoiced',
'change_plan' => 'Change Plan',
'change_plan' => 'Manage Plan',
'persist_data' => 'Persist Data',
'customer_count' => 'Customer Count',
'verify_customers' => 'Verify Customers',
@ -5158,8 +5158,7 @@ $LANG = array(
'unlinked_transaction' => 'Successfully unlinked transaction',
'view_dashboard_permission' => 'Allow user to access the dashboard, data is limited to available permissions',
'marked_sent_credits' => 'Successfully marked credits sent',
);
);
return $LANG;

View File

@ -58,6 +58,7 @@ use App\Http\Controllers\TaskStatusController;
use App\Http\Controllers\Bank\YodleeController;
use App\Http\Controllers\CompanyUserController;
use App\Http\Controllers\PaymentTermController;
use App\Http\Controllers\EmailHistoryController;
use App\Http\Controllers\GroupSettingController;
use App\Http\Controllers\OneTimeTokenController;
use App\Http\Controllers\SubscriptionController;
@ -202,6 +203,8 @@ Route::group(['middleware' => ['throttle:api', 'api_db', 'token_auth', 'locale']
Route::post('documents/bulk', [DocumentController::class, 'bulk'])->name('documents.bulk');
Route::post('emails', [EmailController::class, 'send'])->name('email.send')->middleware('user_verified');
Route::post('emails/clientHistory/{client}', [EmailHistoryController::class, 'clientHistory'])->name('email.clientHistory');
Route::post('emails/entityHistory', [EmailHistoryController::class, 'entityHistory'])->name('email.entityHistory');
Route::resource('expenses', ExpenseController::class); // name = (expenses. index / create / show / update / destroy / edit
Route::put('expenses/{expense}/upload', [ExpenseController::class, 'upload']);

View File

@ -11,14 +11,15 @@
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\SystemLog;
use Tests\MockAccountData;
use App\Jobs\Entity\EmailEntity;
use App\Utils\Traits\GeneratesCounter;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Session;
use Tests\MockAccountData;
use Tests\TestCase;
use Illuminate\Validation\ValidationException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
/**
* @test
@ -46,6 +47,109 @@ class InvoiceEmailTest extends TestCase
}
public function testClientEmailHistory()
{
$system_log = new SystemLog();
$system_log->company_id = $this->company->id;
$system_log->client_id = $this->client->id;
$system_log->category_id = SystemLog::CATEGORY_MAIL;
$system_log->event_id = SystemLog::EVENT_MAIL_SEND;
$system_log->type_id = SystemLog::TYPE_WEBHOOK_RESPONSE;
$system_log->log = [
'history' => [
'entity_id' => $this->invoice->hashed_id,
'entity_type' => 'invoice',
'subject' => 'Invoice #1',
'events' => [
[
'recipient' => 'bob@gmail.com',
'status' => 'Delivered',
'delivery_message' => 'A message that was deliveryed',
'server' => 'email.mx.com',
'server_ip' => '127.0.0.1',
'date' => \Carbon\Carbon::parse('2023-10-10')->format('Y-m-d H:m:s') ?? '',
],
],
]
];
$system_log->save();
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/emails/clientHistory/'.$this->client->hashed_id);
$response->assertStatus(200);
$arr = $response->json();
$this->assertEquals('invoice', $arr[0]['entity_type']);
$count = SystemLog::where('client_id', $this->client->id)
->where('category_id', SystemLog::CATEGORY_MAIL)
->orderBy('id', 'DESC')
->count();
$this->assertEquals(1, $count);
}
public function testEntityEmailHistory()
{
$system_log = new SystemLog();
$system_log->company_id = $this->company->id;
$system_log->client_id = $this->client->id;
$system_log->category_id = SystemLog::CATEGORY_MAIL;
$system_log->event_id = SystemLog::EVENT_MAIL_SEND;
$system_log->type_id = SystemLog::TYPE_WEBHOOK_RESPONSE;
$system_log->log = [
'history' => [
'entity_id' => $this->invoice->hashed_id,
'entity_type' => 'invoice',
'subject' => 'Invoice #1',
'events' => [
[
'recipient' => 'bob@gmail.com',
'status' => 'Delivered',
'delivery_message' => 'A message that was deliveryed',
'server' => 'email.mx.com',
'server_ip' => '127.0.0.1',
'date' => \Carbon\Carbon::parse('2023-10-10')->format('Y-m-d H:m:s') ?? '',
],
],
]
];
$system_log->save();
$data = [
'entity' => 'invoice',
'entity_id' => $this->invoice->hashed_id,
];
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/emails/entityHistory/', $data);
$response->assertStatus(200);
$arr = $response->json();
$this->assertEquals('invoice', $arr[0]['entity_type']);
$this->assertEquals($this->invoice->hashed_id, $arr[0]['entity_id']);
$count = SystemLog::where('company_id', $this->company->id)
->where('category_id', SystemLog::CATEGORY_MAIL)
->whereJsonContains('log->history->entity_id', $this->invoice->hashed_id)
->count();
$this->assertEquals(1, $count);
}
public function testTemplateValidation()
{
$data = [