Quote Rejection

This commit is contained in:
David Bomba
2025-12-16 17:00:08 +11:00
parent 6f0417a933
commit c209246c83
9 changed files with 324 additions and 6 deletions

View File

@@ -0,0 +1,54 @@
<?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\Events\Quote;
use App\Models\ClientContact;
use App\Models\Company;
use App\Models\Quote;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class QuoteWasRejected
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
public $contact;
public $quote;
public $company;
public $event_vars;
public $notes;
/**
* Create a new event instance.
*
* @param ClientContact $contact
* @param Quote $quote
* @param Company $company
* @param array $event_vars
*/
public function __construct(ClientContact $contact, Quote $quote, Company $company, string $notes, array $event_vars)
{
$this->contact = $contact;
$this->quote = $quote;
$this->company = $company;
$this->notes = $notes;
$this->event_vars = $event_vars;
}
}

View File

@@ -287,9 +287,15 @@ class InvoiceFilters extends QueryFilters
if ($sort_col[0] == 'client_id') { if ($sort_col[0] == 'client_id') {
return $this->builder->orderByRaw('ISNULL(client_id), client_id '. $dir)
//2025-12-16: Better filtering for clients.
return $this->builder->orderByRaw('client_id IS NULL')
->orderBy(\App\Models\Client::select('name') ->orderBy(\App\Models\Client::select('name')
->whereColumn('clients.id', 'invoices.client_id'), $dir); ->whereColumn('clients.id', 'invoices.client_id')
->limit(1), $dir);
// return $this->builder->orderByRaw('ISNULL(client_id), client_id '. $dir)
// ->orderBy(\App\Models\Client::select('name')
// ->whereColumn('clients.id', 'invoices.client_id'), $dir);
} }

View File

@@ -0,0 +1,61 @@
<?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\Listeners\Quote;
use App\Libraries\MultiDB;
use App\Models\Activity;
use App\Repositories\ActivityRepository;
use Illuminate\Contracts\Queue\ShouldQueue;
use stdClass;
class QuoteRejectedActivity implements ShouldQueue
{
protected $activity_repo;
public $delay = 5;
/**
* Create the event listener.
*
* @param ActivityRepository $activity_repo
*/
public function __construct(ActivityRepository $activity_repo)
{
$this->activity_repo = $activity_repo;
}
/**
* Handle the event.
*
* @param object $event
* @return void
*/
public function handle($event)
{
MultiDB::setDb($event->company->db);
$fields = new stdClass();
$user_id = isset($event->event_vars['user_id']) ? $event->event_vars['user_id'] : $event->quote->user_id;
$fields->user_id = $user_id;
$fields->quote_id = $event->quote->id;
$fields->client_id = $event->quote->client_id;
$fields->client_contact_id = $event->contact->id;
$fields->company_id = $event->quote->company_id;
$fields->activity_type_id = Activity::QUOTE_REJECTED;
$fields->notes = $event->notes ?? '';
$this->activity_repo->save($fields, $event->quote, $event->event_vars);
}
}

View File

@@ -0,0 +1,80 @@
<?php
/**
* Quote Ninja (https://quoteninja.com).
*
* @link https://github.com/quoteninja/quoteninja source repository
*
* @copyright Copyright (c) 2022. Quote Ninja LLC (https://quoteninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Listeners\Quote;
use App\Libraries\MultiDB;
use App\Jobs\Mail\NinjaMailer;
use App\Jobs\Mail\NinjaMailerJob;
use App\Jobs\Mail\NinjaMailerObject;
use App\Mail\Admin\QuoteRejectedObject;
use Illuminate\Contracts\Queue\ShouldQueue;
use App\Utils\Traits\Notifications\UserNotifies;
class QuoteRejectedNotification implements ShouldQueue
{
use UserNotifies;
public $delay = 8;
public function __construct()
{
}
/**
* Handle the event.
*
* @param object $event
* @return void
*/
public function handle($event)
{
MultiDB::setDb($event->company->db);
$first_notification_sent = true;
$quote = $event->quote;
/* We loop through each user and determine whether they need to be notified */
foreach ($event->company->company_users as $company_user) {
/* The User */
$user = $company_user->user;
if (! $user) {
continue;
}
/* Returns an array of notification methods */
$methods = $this->findUserNotificationTypes($quote->invitations()->first(), $company_user, 'quote', ['all_notifications', 'quote_rejected', 'quote_rejected_all', 'quote_rejected_user']);
/* If one of the methods is email then we fire the EntitySentMailer */
if (($key = array_search('mail', $methods)) !== false) {
unset($methods[$key]);
$nmo = new NinjaMailerObject();
$nmo->mailable = new NinjaMailer((new QuoteRejectedObject($quote, $event->company, $company_user->portalType(), $event->notes))->build());
$nmo->company = $quote->company;
$nmo->settings = $quote->company->settings;
$nmo->to_user = $user;
(new NinjaMailerJob($nmo))->handle();
$nmo = null;
/* This prevents more than one notification being sent */
$first_notification_sent = false;
}
}
}
}

View File

@@ -0,0 +1,101 @@
<?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\Mail\Admin;
use App\Libraries\MultiDB;
use App\Models\Company;
use App\Models\Quote;
use App\Utils\Ninja;
use App\Utils\Number;
use Illuminate\Support\Facades\App;
use stdClass;
class QuoteRejectedObject
{
public function __construct(public Quote $quote, public Company $company, public bool $use_react_url, public string $notes)
{
}
public function build()
{
MultiDB::setDb($this->company->db);
if (! $this->quote) {
return;
}
App::forgetInstance('translator');
/* Init a new copy of the translator*/
$t = app('translator');
/* Set the locale*/
App::setLocale($this->company->getLocale());
/* Set customized translations _NOW_ */
$t->replace(Ninja::transformTranslations($this->company->settings));
$mail_obj = new stdClass();
$mail_obj->amount = $this->getAmount();
$mail_obj->subject = $this->getSubject();
$mail_obj->data = $this->getData();
$mail_obj->markdown = 'email.admin.generic';
$mail_obj->tag = $this->company->company_key;
$mail_obj->text_view = 'email.template.text';
return $mail_obj;
}
private function getAmount()
{
return Number::formatMoney($this->quote->amount, $this->quote->client);
}
private function getSubject()
{
return
ctrans(
'texts.notification_quote_rejected_subject',
[
'client' => $this->quote->client->present()->name(),
'quote' => $this->quote->number,
]
);
}
private function getData()
{
$settings = $this->quote->client->getMergedSettings();
$content = ctrans(
'texts.notification_quote_rejected',
[
'amount' => $this->getAmount(),
'client' => $this->quote->client->present()->name(),
'quote' => $this->quote->number,
'notes' => $this->notes,
]
);
$data = [
'title' => $this->getSubject(),
'content' => $content,
'url' => $this->quote->invitations->first()->getAdminLink($this->use_react_url),
'button' => ctrans('texts.view_quote'),
'signature' => $settings->email_signature,
'logo' => $this->company->present()->logo(),
'settings' => $settings,
'whitelabel' => $this->company->account->isPaid() ? true : false,
'text_body' => $content,
'template' => $this->company->account->isPremium() ? 'email.template.admin_premium' : 'email.template.admin',
];
return $data;
}
}

View File

@@ -296,6 +296,8 @@ class Activity extends StaticModel
public const VERIFACTU_CANCELLATION_SENT_FAILURE = 157; public const VERIFACTU_CANCELLATION_SENT_FAILURE = 157;
public const QUOTE_REJECTED = 158;
protected $casts = [ protected $casts = [
'is_system' => 'boolean', 'is_system' => 'boolean',
'updated_at' => 'timestamp', 'updated_at' => 'timestamp',

View File

@@ -213,6 +213,8 @@ class Quote extends BaseModel
public const STATUS_CONVERTED = 4; public const STATUS_CONVERTED = 4;
public const STATUS_REJECTED = 5;
public const STATUS_EXPIRED = -1; public const STATUS_EXPIRED = -1;
public function toSearchableArray() public function toSearchableArray()

View File

@@ -74,6 +74,7 @@ use App\Events\Credit\CreditWasViewed;
use App\Events\Invoice\InvoiceWasPaid; use App\Events\Invoice\InvoiceWasPaid;
use App\Events\Quote\QuoteWasApproved; use App\Events\Quote\QuoteWasApproved;
use App\Events\Quote\QuoteWasArchived; use App\Events\Quote\QuoteWasArchived;
use App\Events\Quote\QuoteWasRejected;
use App\Events\Quote\QuoteWasRestored; use App\Events\Quote\QuoteWasRestored;
use App\Events\Vendor\VendorWasMerged; use App\Events\Vendor\VendorWasMerged;
use App\Listeners\LogResponseReceived; use App\Listeners\LogResponseReceived;
@@ -162,6 +163,7 @@ use App\Listeners\Invoice\InvoicePaidActivity;
use App\Listeners\Payment\PaymentNotification; use App\Listeners\Payment\PaymentNotification;
use App\Listeners\Quote\QuoteApprovedActivity; use App\Listeners\Quote\QuoteApprovedActivity;
use App\Listeners\Quote\QuoteArchivedActivity; use App\Listeners\Quote\QuoteArchivedActivity;
use App\Listeners\Quote\QuoteRejectedActivity;
use App\Listeners\Quote\QuoteRestoredActivity; use App\Listeners\Quote\QuoteRestoredActivity;
use App\Listeners\Quote\ReachWorkflowSettings; use App\Listeners\Quote\ReachWorkflowSettings;
use App\Events\Company\CompanyDocumentsDeleted; use App\Events\Company\CompanyDocumentsDeleted;
@@ -570,6 +572,9 @@ class EventServiceProvider extends ServiceProvider
CreatedQuoteActivity::class, CreatedQuoteActivity::class,
QuoteCreatedNotification::class, QuoteCreatedNotification::class,
], ],
QuoteWasRejected::class => [
QuoteRejectedActivity::class,
],
QuoteWasUpdated::class => [ QuoteWasUpdated::class => [
QuoteUpdatedActivity::class, QuoteUpdatedActivity::class,
], ],

View File

@@ -5644,10 +5644,6 @@ $lang = array(
'verifactu_cancellation_send_success' => 'Invoice cancellation for :invoice sent to AEAT successfully', 'verifactu_cancellation_send_success' => 'Invoice cancellation for :invoice sent to AEAT successfully',
'verifactu_cancellation_send_failure' => 'Invoice cancellation for :invoice failed to send to AEAT :notes', 'verifactu_cancellation_send_failure' => 'Invoice cancellation for :invoice failed to send to AEAT :notes',
'verifactu' => 'Verifactu', 'verifactu' => 'Verifactu',
'activity_150' => 'E-Invoice :invoice for :client sent to AEAT successfully',
'activity_151' => 'E-Invoice :invoice for :client failed to send to AEAT :notes',
'activity_152' => 'Invoice cancellation for :invoice sent to AEAT successfully',
'activity_153' => 'Invoice cancellation for :invoice failed to send to AEAT :notes',
'justify' => 'Justify', 'justify' => 'Justify',
'outdent' => 'Outdent', 'outdent' => 'Outdent',
'indent' => 'Indent', 'indent' => 'Indent',
@@ -5669,6 +5665,17 @@ $lang = array(
'enable_e_invoice_received_notification' => 'Enable E-Invoice Received Notification', 'enable_e_invoice_received_notification' => 'Enable E-Invoice Received Notification',
'enable_e_invoice_received_notification_help' => 'Receive an email notification when a new E-Invoice is received.', 'enable_e_invoice_received_notification_help' => 'Receive an email notification when a new E-Invoice is received.',
'price_changes' => 'Plan Price Changes from January 1st 2026', 'price_changes' => 'Plan Price Changes from January 1st 2026',
'notification_quote_rejected_subject' => 'Quote :quote was rejected by :client',
'notification_quote_rejected' => 'The following client :client rejected Quote :quote for :amount :notes.',
'activity_150' => 'Account was deleted :notes',
'activity_151' => 'Client :notes was merged into :client by :user',
'activity_152' => 'Vendor :notes was merged into :vendor by :user',
'activity_153' => 'Client :notes was purged by :user',
'activity_154' => 'E-Invoice :invoice for :client sent to AEAT successfully',
'activity_155' => 'E-Invoice :invoice for :client failed to send to AEAT :notes',
'activity_156' => 'Invoice cancellation for :invoice sent to AEAT successfully',
'activity_157' => 'Invoice cancellation for :invoice failed to send to AEAT :notes',
'activity_158' => 'Quote :quote was rejected by :client :notes',
); );
return $lang; return $lang;