diff --git a/app/Events/Quote/QuoteWasRejected.php b/app/Events/Quote/QuoteWasRejected.php new file mode 100644 index 0000000000..760d030af3 --- /dev/null +++ b/app/Events/Quote/QuoteWasRejected.php @@ -0,0 +1,54 @@ +contact = $contact; + $this->quote = $quote; + $this->company = $company; + $this->notes = $notes; + $this->event_vars = $event_vars; + } +} diff --git a/app/Filters/InvoiceFilters.php b/app/Filters/InvoiceFilters.php index 8c9d5c11e1..2d8d78bd84 100644 --- a/app/Filters/InvoiceFilters.php +++ b/app/Filters/InvoiceFilters.php @@ -287,9 +287,15 @@ class InvoiceFilters extends QueryFilters 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') - ->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); } diff --git a/app/Listeners/Quote/QuoteRejectedActivity.php b/app/Listeners/Quote/QuoteRejectedActivity.php new file mode 100644 index 0000000000..ba511a2966 --- /dev/null +++ b/app/Listeners/Quote/QuoteRejectedActivity.php @@ -0,0 +1,61 @@ +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); + } +} diff --git a/app/Listeners/Quote/QuoteRejectedNotification.php b/app/Listeners/Quote/QuoteRejectedNotification.php new file mode 100644 index 0000000000..bec9e481ed --- /dev/null +++ b/app/Listeners/Quote/QuoteRejectedNotification.php @@ -0,0 +1,80 @@ +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; + } + } + } +} diff --git a/app/Mail/Admin/QuoteRejectedObject.php b/app/Mail/Admin/QuoteRejectedObject.php new file mode 100644 index 0000000000..f008e49df6 --- /dev/null +++ b/app/Mail/Admin/QuoteRejectedObject.php @@ -0,0 +1,101 @@ +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; + } +} diff --git a/app/Models/Activity.php b/app/Models/Activity.php index c6f9ec5be2..edd8bdd192 100644 --- a/app/Models/Activity.php +++ b/app/Models/Activity.php @@ -296,6 +296,8 @@ class Activity extends StaticModel public const VERIFACTU_CANCELLATION_SENT_FAILURE = 157; + public const QUOTE_REJECTED = 158; + protected $casts = [ 'is_system' => 'boolean', 'updated_at' => 'timestamp', diff --git a/app/Models/Quote.php b/app/Models/Quote.php index a2a10aed3f..73b7b29677 100644 --- a/app/Models/Quote.php +++ b/app/Models/Quote.php @@ -213,6 +213,8 @@ class Quote extends BaseModel public const STATUS_CONVERTED = 4; + public const STATUS_REJECTED = 5; + public const STATUS_EXPIRED = -1; public function toSearchableArray() diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 803a4a0b56..457510fddd 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -74,6 +74,7 @@ use App\Events\Credit\CreditWasViewed; use App\Events\Invoice\InvoiceWasPaid; use App\Events\Quote\QuoteWasApproved; use App\Events\Quote\QuoteWasArchived; +use App\Events\Quote\QuoteWasRejected; use App\Events\Quote\QuoteWasRestored; use App\Events\Vendor\VendorWasMerged; use App\Listeners\LogResponseReceived; @@ -162,6 +163,7 @@ use App\Listeners\Invoice\InvoicePaidActivity; use App\Listeners\Payment\PaymentNotification; use App\Listeners\Quote\QuoteApprovedActivity; use App\Listeners\Quote\QuoteArchivedActivity; +use App\Listeners\Quote\QuoteRejectedActivity; use App\Listeners\Quote\QuoteRestoredActivity; use App\Listeners\Quote\ReachWorkflowSettings; use App\Events\Company\CompanyDocumentsDeleted; @@ -570,6 +572,9 @@ class EventServiceProvider extends ServiceProvider CreatedQuoteActivity::class, QuoteCreatedNotification::class, ], + QuoteWasRejected::class => [ + QuoteRejectedActivity::class, + ], QuoteWasUpdated::class => [ QuoteUpdatedActivity::class, ], diff --git a/lang/en/texts.php b/lang/en/texts.php index 7c524acb58..f329b7e94f 100644 --- a/lang/en/texts.php +++ b/lang/en/texts.php @@ -5644,10 +5644,6 @@ $lang = array( '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' => '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', 'outdent' => 'Outdent', 'indent' => 'Indent', @@ -5669,6 +5665,17 @@ $lang = array( '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.', '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;