From 1adf627c0db06176740af93ea3ffcb0b6f6beafe Mon Sep 17 00:00:00 2001
From: 15061959991
+ This view is loaded from module: {!! config('$ALIAS$.name') !!}
+ |Hello World
+
+ '));
+ }
+ }
+
+ protected static function getBodyPurifier(): \HTMLPurifier
+ {
+ if (static::$bodyPurifier instanceof \HTMLPurifier) {
+ return static::$bodyPurifier;
+ }
+
+ $config = \HTMLPurifier_Config::createDefault();
+ $config->set('Cache.DefinitionImpl', null);
+ $config->set('URI.DisableJavaScript', true);
+ $config->set('HTML.ForbiddenElements', ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'textarea', 'select', 'option', 'meta', 'link']);
+
+ static::$bodyPurifier = new \HTMLPurifier($config);
+
+ return static::$bodyPurifier;
+ }
+
+ public function getTitleAttribute()
+ {
+ return trans($this->name);
+ }
+
+ public function getGroupAttribute()
+ {
+ if (Str::startsWith($this->alias, 'invoice_')) {
+ $group = 'general.invoices';
+ } elseif (Str::startsWith($this->alias, 'bill_')) {
+ $group = 'general.bills';
+ } elseif (Str::startsWith($this->alias, 'payment_')) {
+ $group = 'general.payments';
+ } else {
+ $group = 'general.others';
+ }
+
+ return $group;
+ }
+
+ /**
+ * Scope to only include email templates of a given alias.
+ *
+ * @param \Illuminate\Database\Eloquent\Builder $query
+ * @param mixed $alias
+ * @return \Illuminate\Database\Eloquent\Builder
+ */
+ public function scopeAlias($query, $alias)
+ {
+ return $query->where('alias', $alias);
+ }
+
+ /**
+ * Scope to only include email templates of a given module alias (class).
+ *
+ * @param \Illuminate\Database\Eloquent\Builder $query
+ * @param string $alias
+ * @return \Illuminate\Database\Eloquent\Builder
+ */
+ public function scopeModuleAlias($query, $alias)
+ {
+ $class = ($alias == 'core') ? 'App\\\\' : 'Modules\\\\' . Str::studly($alias) . '\\\\';
+
+ return $query->where('class', 'like', $class . '%');
+ }
+}
diff --git a/app/Models/Setting/Setting.php b/app/Models/Setting/Setting.php
new file mode 100644
index 00000000..30741cb9
--- /dev/null
+++ b/app/Models/Setting/Setting.php
@@ -0,0 +1,37 @@
+where('key', 'like', $prefix . '.%');
+ }
+}
diff --git a/app/Models/Setting/Tax.php b/app/Models/Setting/Tax.php
new file mode 100644
index 00000000..39e08a48
--- /dev/null
+++ b/app/Models/Setting/Tax.php
@@ -0,0 +1,182 @@
+ 'double',
+ 'enabled' => 'boolean',
+ 'deleted_at' => 'datetime',
+ ];
+
+ /**
+ * Sortable columns.
+ *
+ * @var array
+ */
+ public $sortable = ['name', 'type', 'rate'];
+
+ public function items()
+ {
+ return $this->hasMany('App\Models\Common\ItemTax');
+ }
+
+ public function document_items()
+ {
+ return $this->hasMany('App\Models\Document\DocumentItemTax');
+ }
+
+ public function bill_items()
+ {
+ return $this->document_items()->where('document_item_taxes.type', Document::BILL_TYPE);
+ }
+
+ public function invoice_items()
+ {
+ return $this->document_items()->where('document_item_taxes.type', Document::INVOICE_TYPE);
+ }
+
+ public function scopeName($query, $name)
+ {
+ return $query->where('name', '=', $name);
+ }
+
+ public function scopeRate($query, $rate)
+ {
+ return $query->where('rate', '=', $rate);
+ }
+
+ public function scopeNotRate($query, $rate)
+ {
+ return $query->where('rate', '<>', $rate);
+ }
+
+ public function scopeType($query, $types)
+ {
+ if (empty($types)) {
+ return $query;
+ }
+
+ return $query->whereIn($this->qualifyColumn('type'), (array) $types);
+ }
+
+ public function scopeFixed($query)
+ {
+ return $query->where($this->qualifyColumn('type'), '=', 'fixed');
+ }
+
+ public function scopeNormal($query)
+ {
+ return $query->where($this->qualifyColumn('type'), '=', 'normal');
+ }
+
+ public function scopeInclusive($query)
+ {
+ return $query->where($this->qualifyColumn('type'), '=', 'inclusive');
+ }
+
+ public function scopeCompound($query)
+ {
+ return $query->where($this->qualifyColumn('type'), '=', 'compound');
+ }
+
+ public function scopeWithholding($query)
+ {
+ return $query->where($this->qualifyColumn('type'), '=', 'withholding');
+ }
+
+ public function scopeNotWithholding($query)
+ {
+ return $query->where($this->qualifyColumn('type'), '<>', 'withholding');
+ }
+
+ /**
+ * Get the name including rate.
+ *
+ * @return string
+ */
+ public function getTitleAttribute()
+ {
+ $title = $this->name . ' (';
+
+ if (setting('localisation.percent_position', 'after') == 'after') {
+ $title .= $this->getAttribute('type') == 'fixed' ? $this->rate : $this->rate . '%';
+ } else {
+ $title .= $this->getAttribute('type') == 'fixed' ? $this->rate : '%' . $this->rate;
+ }
+ $title .= ')';
+
+ return $title;
+ }
+
+ /**
+ * Get the line actions.
+ *
+ * @return array
+ */
+ public function getLineActionsAttribute()
+ {
+ $actions = [];
+
+ $actions[] = [
+ 'title' => trans('general.edit'),
+ 'icon' => 'edit',
+ 'url' => route('taxes.edit', $this->id),
+ 'permission' => 'update-settings-taxes',
+ 'attributes' => [
+ 'id' => 'index-line-actions-edit-tax-' . $this->id,
+ ],
+ ];
+
+ $actions[] = [
+ 'type' => 'delete',
+ 'icon' => 'delete',
+ 'route' => 'taxes.destroy',
+ 'permission' => 'delete-settings-taxes',
+ 'attributes' => [
+ 'id' => 'index-line-actions-delete-tax-' . $this->id,
+ ],
+ 'model' => $this,
+ ];
+
+ return $actions;
+ }
+
+ /**
+ * Create a new factory instance for the model.
+ *
+ * @return \Illuminate\Database\Eloquent\Factories\Factory
+ */
+ protected static function newFactory()
+ {
+ return \Database\Factories\Tax::new();
+ }
+}
diff --git a/app/Models/Settings/DatabaseConnection.php b/app/Models/Settings/DatabaseConnection.php
new file mode 100644
index 00000000..e95ad8aa
--- /dev/null
+++ b/app/Models/Settings/DatabaseConnection.php
@@ -0,0 +1,30 @@
+ 'encrypted',
+ 'enabled' => 'boolean',
+ ];
+
+ protected $hidden = ['password'];
+}
diff --git a/app/Notifications/Auth/Invitation.php b/app/Notifications/Auth/Invitation.php
new file mode 100644
index 00000000..4f079d5d
--- /dev/null
+++ b/app/Notifications/Auth/Invitation.php
@@ -0,0 +1,53 @@
+invitation = $invitation;
+ }
+
+ /**
+ * Get the notification's channels.
+ *
+ * @param mixed $notifiable
+ * @return array|string
+ */
+ public function via($notifiable)
+ {
+ return ['mail'];
+ }
+
+ /**
+ * Build the mail representation of the notification.
+ *
+ * @param mixed $notifiable
+ * @return \Illuminate\Notifications\Messages\MailMessage
+ */
+ public function toMail($notifiable): MailMessage
+ {
+ return (new MailMessage)
+ ->from(config('mail.from.address'), config('mail.from.name'))
+ ->line(trans('auth.invitation.message_1'))
+ ->action(trans('auth.invitation.button'), route('register', $this->invitation->token))
+ ->line(trans('auth.invitation.message_2'));
+ }
+}
diff --git a/app/Notifications/Auth/Reset.php b/app/Notifications/Auth/Reset.php
new file mode 100644
index 00000000..ee8cd105
--- /dev/null
+++ b/app/Notifications/Auth/Reset.php
@@ -0,0 +1,61 @@
+token = $token;
+ $this->email = $email;
+ }
+
+ /**
+ * Get the notification's channels.
+ *
+ * @param mixed $notifiable
+ * @return array|string
+ */
+ public function via($notifiable)
+ {
+ return ['mail'];
+ }
+
+ /**
+ * Build the mail representation of the notification.
+ *
+ * @param mixed $notifiable
+ * @return \Illuminate\Notifications\Messages\MailMessage
+ */
+ public function toMail($notifiable): MailMessage
+ {
+ return (new MailMessage)
+ ->from(config('mail.from.address'), config('mail.from.name'))
+ ->line(trans('auth.notification.message_1'))
+ ->action(trans('auth.notification.button'), route('reset', ['token' => $this->token, 'email' => $this->email]))
+ ->line(trans('auth.notification.message_2'));
+ }
+}
diff --git a/app/Notifications/Banking/Transaction.php b/app/Notifications/Banking/Transaction.php
new file mode 100644
index 00000000..a99abe28
--- /dev/null
+++ b/app/Notifications/Banking/Transaction.php
@@ -0,0 +1,159 @@
+transaction = $transaction;
+ $this->template = EmailTemplate::alias($template_alias)->first();
+ $this->attach_pdf = $attach_pdf;
+ $this->custom_mail = $custom_mail;
+ $this->attachments = $attachments;
+ }
+
+ /**
+ * Get the mail representation of the notification.
+ *
+ * @param mixed $notifiable
+ */
+ public function toMail($notifiable): MailMessage
+ {
+ if (! empty($this->custom_mail['to'])) {
+ $notifiable->email = $this->custom_mail['to'];
+ }
+
+ $message = $this->initMailMessage();
+
+ $func = is_local_storage() ? 'fromPath' : 'fromStorage';
+
+ // Attach the PDF file
+ if ($this->attach_pdf) {
+ $path = $this->storeTransactionPdfAndGetPath($this->transaction);
+ $file = Attachment::$func($path)->withMime('application/pdf');
+
+ $message->attach($file);
+ }
+
+ // Attach selected attachments
+ if (! empty($this->transaction->attachment)) {
+ foreach ($this->transaction->attachment as $attachment) {
+ if (! in_array($attachment->id, $this->attachments)) {
+ continue;
+ }
+
+ $path = is_local_storage() ? $attachment->getAbsolutePath() : $attachment->getDiskPath();
+ $file = Attachment::$func($path)->withMime($attachment->mime_type);
+
+ $message->attach($file);
+ }
+ }
+
+ return $message;
+ }
+
+ /**
+ * Get the array representation of the notification.
+ *
+ * @param mixed $notifiable
+ * @return array
+ */
+ public function toArray($notifiable): array
+ {
+ $this->initArrayMessage();
+
+ return [
+ 'template_alias' => $this->template->alias,
+ 'title' => trans('notifications.menu.' . $this->template->alias . '.title'),
+ 'description' => trans('notifications.menu.' . $this->template->alias . '.description', $this->getTagsBinding()),
+ 'transaction_id' => $this->transaction->id,
+ 'contact_name' => $this->transaction->contact->name,
+ 'amount' => $this->transaction->amount,
+ 'transaction_date' => company_date($this->transaction->paid_at),
+ ];
+ }
+
+ public function getTags(): array
+ {
+ return [
+ '{payment_amount}',
+ '{payment_date}',
+ '{payment_guest_link}',
+ '{payment_admin_link}',
+ '{payment_portal_link}',
+ '{contact_name}',
+ '{company_name}',
+ '{company_email}',
+ '{company_tax_number}',
+ '{company_phone}',
+ '{company_address}',
+ ];
+ }
+
+ public function getTagsReplacement(): array
+ {
+ $route_params = [
+ 'company_id' => $this->transaction->company_id,
+ 'transaction' => $this->transaction->id,
+ 'payment' => $this->transaction->id,
+ ];
+
+ return [
+ money($this->transaction->amount, $this->transaction->currency_code),
+ company_date($this->transaction->paid_at),
+ URL::signedRoute('signed.payments.show', $route_params),
+ route('transactions.show', $route_params),
+ route('portal.payments.show', $route_params),
+ $this->transaction->contact->name,
+ $this->transaction->company->name,
+ $this->transaction->company->email,
+ $this->transaction->company->tax_number,
+ $this->transaction->company->phone,
+ nl2br(trim($this->transaction->company->address)),
+ ];
+ }
+}
diff --git a/app/Notifications/Common/DownloadCompleted.php b/app/Notifications/Common/DownloadCompleted.php
new file mode 100644
index 00000000..796cc8f0
--- /dev/null
+++ b/app/Notifications/Common/DownloadCompleted.php
@@ -0,0 +1,81 @@
+translation = $translation;
+ $this->file_name = $file_name;
+ $this->download_url = $download_url;
+
+ $this->onQueue('notifications');
+ }
+
+ /**
+ * Get the notification's channels.
+ *
+ * @param mixed $notifiable
+ * @return array|string
+ */
+ public function via($notifiable)
+ {
+ return ['mail', 'database'];
+ }
+
+ /**
+ * Build the mail representation of the notification.
+ *
+ * @param mixed $notifiable
+ * @return \Illuminate\Notifications\Messages\MailMessage
+ */
+ public function toMail($notifiable): MailMessage
+ {
+ return (new MailMessage)
+ ->from(config('mail.from.address'), config('mail.from.name'))
+ ->subject(trans('notifications.download.completed.title'))
+ ->line(new HtmlString('
'))
+ ->line(trans('notifications.download.completed.description'))
+ ->action(trans('general.download'), $this->download_url);
+ }
+
+ /**
+ * Get the array representation of the notification.
+ *
+ * @param mixed $notifiable
+ * @return array
+ */
+ public function toArray($notifiable)
+ {
+ return [
+ 'title' => trans('notifications.menu.download_completed.title'),
+ 'description' => trans('notifications.menu.download_completed.description', [
+ 'type' => $this->translation,
+ 'url' => $this->download_url,
+ ]),
+ 'translation' => $this->translation,
+ 'file_name' => $this->file_name,
+ 'download_url' => $this->download_url,
+ ];
+ }
+}
diff --git a/app/Notifications/Common/DownloadFailed.php b/app/Notifications/Common/DownloadFailed.php
new file mode 100644
index 00000000..2badb04e
--- /dev/null
+++ b/app/Notifications/Common/DownloadFailed.php
@@ -0,0 +1,77 @@
+message = $message;
+
+ $this->onQueue('notifications');
+ }
+
+ /**
+ * Get the notification's channels.
+ *
+ * @param mixed $notifiable
+ * @return array|string
+ */
+ public function via($notifiable)
+ {
+ return ['mail', 'database'];
+ }
+
+ /**
+ * Build the mail representation of the notification.
+ *
+ * @param mixed $notifiable
+ * @return \Illuminate\Notifications\Messages\MailMessage
+ */
+ public function toMail($notifiable): MailMessage
+ {
+ return (new MailMessage)
+ ->from(config('mail.from.address'), config('mail.from.name'))
+ ->subject(trans('notifications.download.failed.title'))
+ ->line(new HtmlString('
'))
+ ->line(trans('notifications.download.failed.description'))
+ ->line(new HtmlString('
'))
+ ->line($this->message)
+ ->line(new HtmlString('
'));
+ }
+
+ /**
+ * Get the array representation of the notification.
+ *
+ * @param mixed $notifiable
+ * @return array
+ */
+ public function toArray($notifiable)
+ {
+ return [
+ 'title' => trans('notifications.menu.download_failed.title'),
+ 'description' => trans('notifications.menu.download_failed.description'),
+ 'message' => $this->message,
+ ];
+ }
+}
diff --git a/app/Notifications/Common/ExportCompleted.php b/app/Notifications/Common/ExportCompleted.php
new file mode 100644
index 00000000..aac6521d
--- /dev/null
+++ b/app/Notifications/Common/ExportCompleted.php
@@ -0,0 +1,81 @@
+translation = $translation;
+ $this->file_name = $file_name;
+ $this->download_url = $download_url;
+
+ $this->onQueue('notifications');
+ }
+
+ /**
+ * Get the notification's channels.
+ *
+ * @param mixed $notifiable
+ * @return array|string
+ */
+ public function via($notifiable)
+ {
+ return ['mail', 'database'];
+ }
+
+ /**
+ * Build the mail representation of the notification.
+ *
+ * @param mixed $notifiable
+ * @return \Illuminate\Notifications\Messages\MailMessage
+ */
+ public function toMail($notifiable): MailMessage
+ {
+ return (new MailMessage)
+ ->from(config('mail.from.address'), config('mail.from.name'))
+ ->subject(trans('notifications.export.completed.title'))
+ ->line(new HtmlString('
'))
+ ->line(trans('notifications.export.completed.description'))
+ ->action(trans('general.download'), $this->download_url);
+ }
+
+ /**
+ * Get the array representation of the notification.
+ *
+ * @param mixed $notifiable
+ * @return array
+ */
+ public function toArray($notifiable)
+ {
+ return [
+ 'title' => trans('notifications.menu.export_completed.title'),
+ 'description' => trans('notifications.menu.export_completed.description', [
+ 'type' => $this->translation,
+ 'url' => $this->download_url,
+ ]),
+ 'translation' => $this->translation,
+ 'file_name' => $this->file_name,
+ 'download_url' => $this->download_url,
+ ];
+ }
+}
diff --git a/app/Notifications/Common/ExportFailed.php b/app/Notifications/Common/ExportFailed.php
new file mode 100644
index 00000000..5d524975
--- /dev/null
+++ b/app/Notifications/Common/ExportFailed.php
@@ -0,0 +1,77 @@
+message = $message;
+
+ $this->onQueue('notifications');
+ }
+
+ /**
+ * Get the notification's channels.
+ *
+ * @param mixed $notifiable
+ * @return array|string
+ */
+ public function via($notifiable)
+ {
+ return ['mail', 'database'];
+ }
+
+ /**
+ * Build the mail representation of the notification.
+ *
+ * @param mixed $notifiable
+ * @return \Illuminate\Notifications\Messages\MailMessage
+ */
+ public function toMail($notifiable): MailMessage
+ {
+ return (new MailMessage)
+ ->from(config('mail.from.address'), config('mail.from.name'))
+ ->subject(trans('notifications.export.failed.title'))
+ ->line(new HtmlString('
'))
+ ->line(trans('notifications.export.failed.description'))
+ ->line(new HtmlString('
'))
+ ->line($this->message)
+ ->line(new HtmlString('
'));
+ }
+
+ /**
+ * Get the array representation of the notification.
+ *
+ * @param mixed $notifiable
+ * @return array
+ */
+ public function toArray($notifiable)
+ {
+ return [
+ 'title' => trans('notifications.menu.export_failed.title'),
+ 'description' => trans('notifications.menu.export_failed.description'),
+ 'message' => $this->message,
+ ];
+ }
+}
diff --git a/app/Notifications/Common/ImportCompleted.php b/app/Notifications/Common/ImportCompleted.php
new file mode 100644
index 00000000..99e712c5
--- /dev/null
+++ b/app/Notifications/Common/ImportCompleted.php
@@ -0,0 +1,77 @@
+translation = $translation;
+ $this->total_rows = $total_rows;
+
+ $this->onQueue('notifications');
+ }
+
+ /**
+ * Get the notification's channels.
+ *
+ * @param mixed $notifiable
+ * @return array|string
+ */
+ public function via($notifiable)
+ {
+ return ['mail', 'database'];
+ }
+
+ /**
+ * Build the mail representation of the notification.
+ *
+ * @param mixed $notifiable
+ * @return \Illuminate\Notifications\Messages\MailMessage
+ */
+ public function toMail($notifiable): MailMessage
+ {
+ $dashboard_url = route('dashboard', ['company_id' => company_id()]);
+
+ return (new MailMessage)
+ ->from(config('mail.from.address'), config('mail.from.name'))
+ ->subject(trans('notifications.import.completed.title'))
+ ->line(new HtmlString('
'))
+ ->line(trans('notifications.import.completed.description'))
+ ->action(trans_choice('general.dashboards', 1), $dashboard_url);
+ }
+
+ /**
+ * Get the array representation of the notification.
+ *
+ * @param mixed $notifiable
+ * @return array
+ */
+ public function toArray($notifiable)
+ {
+ return [
+ 'title' => trans('notifications.menu.import_completed.title'),
+ 'description' => trans('notifications.menu.import_completed.description', [
+ 'type' => $this->translation,
+ 'count' => $this->total_rows,
+ ]),
+ 'translation' => $this->translation,
+ 'total_rows' => $this->total_rows,
+ ];
+ }
+}
diff --git a/app/Notifications/Common/ImportFailed.php b/app/Notifications/Common/ImportFailed.php
new file mode 100644
index 00000000..b04a9fc9
--- /dev/null
+++ b/app/Notifications/Common/ImportFailed.php
@@ -0,0 +1,83 @@
+errors = $errors;
+
+ $this->onQueue('notifications');
+ }
+
+ /**
+ * Get the notification's channels.
+ *
+ * @param mixed $notifiable
+ * @return array|string
+ */
+ public function via($notifiable)
+ {
+ return ['mail', 'database'];
+ }
+
+ /**
+ * Build the mail representation of the notification.
+ *
+ * @param mixed $notifiable
+ * @return \Illuminate\Notifications\Messages\MailMessage
+ */
+ public function toMail($notifiable): MailMessage
+ {
+ $message = (new MailMessage)
+ ->from(config('mail.from.address'), config('mail.from.name'))
+ ->subject(trans('notifications.import.failed.title'))
+ ->line(new HtmlString('
'))
+ ->line(trans('notifications.import.failed.description'));
+
+ foreach ($this->errors as $error) {
+ $message->line(new HtmlString('
'));
+ $message->line($error);
+ }
+
+ $message->line(new HtmlString('
'));
+
+ return $message;
+ }
+
+ /**
+ * Get the array representation of the notification.
+ *
+ * @param mixed $notifiable
+ * @return array
+ */
+ public function toArray($notifiable)
+ {
+ return [
+ 'title' => trans('notifications.menu.import_failed.title'),
+ 'description' => trans('notifications.menu.import_failed.description'),
+ 'errors' => $this->errors,
+ ];
+ }
+}
diff --git a/app/Notifications/Email/InvalidEmail.php b/app/Notifications/Email/InvalidEmail.php
new file mode 100644
index 00000000..e59bc714
--- /dev/null
+++ b/app/Notifications/Email/InvalidEmail.php
@@ -0,0 +1,80 @@
+email = $email;
+
+ $this->type = $type;
+
+ $this->error = $error;
+
+ $this->onQueue('notifications');
+ }
+
+ /**
+ * Get the notification's channels.
+ *
+ * @param mixed $notifiable
+ * @return array|string
+ */
+ public function via($notifiable)
+ {
+ return ['mail', 'database'];
+ }
+
+ /**
+ * Build the mail representation of the notification.
+ *
+ * @param mixed $notifiable
+ * @return \Illuminate\Notifications\Messages\MailMessage
+ */
+ public function toMail($notifiable): MailMessage
+ {
+ $dashboard_url = route('dashboard', ['company_id' => company_id()]);
+
+ return (new MailMessage)
+ ->from(config('mail.from.address'), config('mail.from.name'))
+ ->subject(trans('notifications.email.invalid.title', ['type' => $this->type]))
+ ->line(new HtmlString('
'))
+ ->line(trans('notifications.email.invalid.description', ['email' => $this->email]))
+ ->line(new HtmlString('
'))
+ ->line(new HtmlString('' . $this->error . ''))
+ ->action(trans_choice('general.dashboards', 1), $dashboard_url);
+ }
+
+ /**
+ * Get the array representation of the notification.
+ *
+ * @param mixed $notifiable
+ * @return array
+ */
+ public function toArray($notifiable)
+ {
+ return [
+ 'title' => trans('notifications.menu.invalid_email.title', ['type' => $this->type]),
+ 'description' => trans('notifications.menu.invalid_email.description', ['email' => $this->email]),
+ 'email' => $this->email,
+ ];
+ }
+}
diff --git a/app/Notifications/Install/UpdateFailed.php b/app/Notifications/Install/UpdateFailed.php
new file mode 100644
index 00000000..5506bd82
--- /dev/null
+++ b/app/Notifications/Install/UpdateFailed.php
@@ -0,0 +1,125 @@
+event = $event;
+ $this->notifications = config('update.notifications');
+ }
+
+ /**
+ * Get the notification's channels.
+ *
+ * @param mixed $notifiable
+ * @return array|string
+ */
+ public function via($notifiable)
+ {
+ $channels = [];
+
+ foreach ($this->notifications as $channel => $settings) {
+ if (empty($settings['enabled'])) {
+ continue;
+ }
+
+ $channels[] = $channel;
+ }
+
+ return $channels;
+ }
+
+ /**
+ * Build the mail representation of the notification.
+ *
+ * @param mixed $notifiable
+ * @return \Illuminate\Notifications\Messages\MailMessage
+ */
+ public function toMail($notifiable)
+ {
+ $subject = trans('notifications.update.mail.title', [
+ 'domain' => request()->getHttpHost(),
+ ]);
+
+ $message = trans('notifications.update.mail.description', [
+ 'alias' => $this->getAliasName(),
+ 'current_version' => $this->event->old,
+ 'new_version' => $this->event->new,
+ 'step' => $this->event->step,
+ 'error_message' => $this->event->message,
+ ]);
+
+ return (new MailMessage)
+ ->from($this->notifications['mail']['from'], $this->notifications['mail']['name'])
+ ->subject($subject)
+ ->line($message);
+ }
+
+ /**
+ * Get the Slack representation of the notification.
+ *
+ * @param mixed $notifiable
+ * @return SlackMessage
+ */
+ public function toSlack($notifiable)
+ {
+ $message = trans('notifications.update.slack.description', [
+ 'domain' => request()->getHttpHost(),
+ ]);
+
+ return (new SlackMessage)
+ ->error()
+ ->from($this->notifications['slack']['from'], $this->notifications['slack']['emoji'])
+ ->to($this->notifications['slack']['channel'])
+ ->content($message)
+ ->attachment(function ($attachment) {
+ $attachment->fields([
+ 'Alias' => $this->getAliasName(),
+ 'Current Version' => $this->event->old,
+ 'New Version' => $this->event->new,
+ 'Step' => $this->event->step,
+ 'Error Message' => $this->event->message,
+ ]);
+ });
+ }
+
+ protected function getAliasName()
+ {
+ if ($this->event->alias == 'core') {
+ return config('app.name');
+ }
+
+ $module = module($this->event->alias);
+
+ if (empty($module)) {
+ return ucfirst($this->event->alias);
+ }
+
+ return $module->getName();
+ }
+}
diff --git a/app/Notifications/Portal/PaymentReceived.php b/app/Notifications/Portal/PaymentReceived.php
new file mode 100644
index 00000000..28ff24ce
--- /dev/null
+++ b/app/Notifications/Portal/PaymentReceived.php
@@ -0,0 +1,152 @@
+invoice = $invoice;
+ $this->transaction = $transaction;
+ $this->template = EmailTemplate::alias($template_alias)->first();
+ $this->attach_pdf = $attach_pdf;
+ }
+
+ /**
+ * Build the mail representation of the notification.
+ *
+ * @param mixed $notifiable
+ */
+ public function toMail($notifiable): MailMessage
+ {
+ $message = $this->initMailMessage();
+
+ // Attach the PDF file
+ if ($this->attach_pdf) {
+ $func = is_local_storage() ? 'fromPath' : 'fromStorage';
+
+ $path = $this->storeDocumentPdfAndGetPath($this->invoice);
+ $file = Attachment::$func($path)->withMime('application/pdf');
+
+ $message->attach($file);
+ }
+
+ return $message;
+ }
+
+ /**
+ * Get the array representation of the notification.
+ *
+ * @param mixed $notifiable
+ */
+ public function toArray($notifiable): array
+ {
+ $this->initArrayMessage();
+
+ return [
+ 'template_alias' => $this->template->alias,
+ 'title' => trans('notifications.menu.' . $this->template->alias . '.title'),
+ 'description' => trans('notifications.menu.' . $this->template->alias . '.description', $this->getTagsBinding()),
+ 'invoice_id' => $this->invoice->id,
+ 'invoice_number' => $this->invoice->document_number,
+ 'customer_name' => $this->invoice->contact_name,
+ 'amount' => $this->invoice->amount,
+ 'invoice_at' => $this->invoice->issued_at,
+ 'due_at' => $this->invoice->due_at,
+ 'status' => $this->invoice->status,
+ ];
+ }
+
+ public function getTags(): array
+ {
+ return [
+ '{invoice_number}',
+ '{invoice_total}',
+ '{invoice_due_date}',
+ '{invoice_status}',
+ '{invoice_guest_link}',
+ '{invoice_admin_link}',
+ '{invoice_portal_link}',
+ '{transaction_total}',
+ '{transaction_paid_date}',
+ '{transaction_payment_method}',
+ '{customer_name}',
+ '{company_name}',
+ '{company_email}',
+ '{company_tax_number}',
+ '{company_phone}',
+ '{company_address}',
+ ];
+ }
+
+ public function getTagsReplacement(): array
+ {
+ $route_params = [
+ 'company_id' => $this->invoice->company_id,
+ 'invoice' => $this->invoice->id,
+ ];
+
+ return [
+ $this->invoice->document_number,
+ money($this->invoice->amount, $this->invoice->currency_code),
+ company_date($this->invoice->due_at),
+ trans('documents.statuses.' . $this->invoice->status),
+ URL::signedRoute('signed.invoices.show', $route_params),
+ route('invoices.show', $route_params),
+ route('portal.invoices.show', $route_params),
+ money($this->transaction->amount, $this->transaction->currency_code),
+ company_date($this->transaction->paid_at),
+ $this->transaction->payment_method,
+ $this->invoice->contact_name,
+ $this->invoice->company->name,
+ $this->invoice->company->email,
+ $this->invoice->company->tax_number,
+ $this->invoice->company->phone,
+ nl2br(trim($this->invoice->company->address)),
+ ];
+ }
+}
diff --git a/app/Notifications/Purchase/Bill.php b/app/Notifications/Purchase/Bill.php
new file mode 100644
index 00000000..4ec44e94
--- /dev/null
+++ b/app/Notifications/Purchase/Bill.php
@@ -0,0 +1,113 @@
+bill = $bill;
+ $this->template = EmailTemplate::alias($template_alias)->first();
+ }
+
+ /**
+ * Build the mail representation of the notification.
+ *
+ * @param mixed $notifiable
+ */
+ public function toMail($notifiable): MailMessage
+ {
+ $message = $this->initMailMessage();
+
+ return $message;
+ }
+
+ /**
+ * Get the array representation of the notification.
+ *
+ * @param mixed $notifiable
+ */
+ public function toArray($notifiable): array
+ {
+ $this->initArrayMessage();
+
+ return [
+ 'template_alias' => $this->template->alias,
+ 'title' => trans('notifications.menu.' . $this->template->alias . '.title'),
+ 'description' => trans('notifications.menu.' . $this->template->alias . '.description', $this->getTagsBinding()),
+ 'bill_id' => $this->bill->id,
+ 'bill_number' => $this->bill->document_number,
+ 'vendor_name' => $this->bill->contact_name,
+ 'amount' => $this->bill->amount,
+ 'billed_date' => company_date($this->bill->issued_at),
+ 'bill_due_date' => company_date($this->bill->due_at),
+ 'status' => $this->bill->status,
+ ];
+ }
+
+ public function getTags(): array
+ {
+ return [
+ '{bill_number}',
+ '{bill_total}',
+ '{bill_amount_due}',
+ '{billed_date}',
+ '{bill_due_date}',
+ '{bill_admin_link}',
+ '{vendor_name}',
+ '{company_name}',
+ '{company_email}',
+ '{company_tax_number}',
+ '{company_phone}',
+ '{company_address}',
+ ];
+ }
+
+ public function getTagsReplacement(): array
+ {
+ $route_params = [
+ 'company_id' => $this->bill->company_id,
+ 'bill' => $this->bill->id,
+ ];
+
+ return [
+ $this->bill->document_number,
+ money($this->bill->amount, $this->bill->currency_code),
+ money($this->bill->amount_due, $this->bill->currency_code),
+ company_date($this->bill->issued_at),
+ company_date($this->bill->due_at),
+ route('bills.show', $route_params),
+ $this->bill->contact_name,
+ $this->bill->company->name,
+ $this->bill->company->email,
+ $this->bill->company->tax_number,
+ $this->bill->company->phone,
+ nl2br(trim($this->bill->company->address)),
+ ];
+ }
+}
diff --git a/app/Notifications/Sale/Invoice.php b/app/Notifications/Sale/Invoice.php
new file mode 100644
index 00000000..73b48d28
--- /dev/null
+++ b/app/Notifications/Sale/Invoice.php
@@ -0,0 +1,166 @@
+invoice = $invoice;
+ $this->template = EmailTemplate::alias($template_alias)->first();
+ $this->attach_pdf = $attach_pdf;
+ $this->custom_mail = $custom_mail;
+ $this->attachments = $attachments;
+ }
+
+ /**
+ * Get the mail representation of the notification.
+ *
+ * @param mixed $notifiable
+ */
+ public function toMail($notifiable): MailMessage
+ {
+ if (!empty($this->custom_mail['to'])) {
+ $notifiable->email = $this->custom_mail['to'];
+ }
+
+ $message = $this->initMailMessage();
+
+ $func = is_local_storage() ? 'fromPath' : 'fromStorage';
+
+ // Attach the PDF file
+ if ($this->attach_pdf) {
+ $path = $this->storeDocumentPdfAndGetPath($this->invoice);
+ $file = Attachment::$func($path)->withMime('application/pdf');
+
+ $message->attach($file);
+ }
+
+ // Attach selected attachments
+ if (! empty($this->invoice->attachment)) {
+ foreach ($this->invoice->attachment as $attachment) {
+ if (! in_array($attachment->id, $this->attachments)) {
+ continue;
+ }
+
+ $path = is_local_storage() ? $attachment->getAbsolutePath() : $attachment->getDiskPath();
+ $file = Attachment::$func($path)->withMime($attachment->mime_type);
+
+ $message->attach($file);
+ }
+ }
+
+ return $message;
+ }
+
+ /**
+ * Get the array representation of the notification.
+ *
+ * @param mixed $notifiable
+ */
+ public function toArray($notifiable): array
+ {
+ $this->initArrayMessage();
+
+ return [
+ 'template_alias' => $this->template->alias,
+ 'title' => trans('notifications.menu.' . $this->template->alias . '.title'),
+ 'description' => trans('notifications.menu.' . $this->template->alias . '.description', $this->getTagsBinding()),
+ 'invoice_id' => $this->invoice->id,
+ 'invoice_number' => $this->invoice->document_number,
+ 'customer_name' => $this->invoice->contact_name,
+ 'amount' => $this->invoice->amount,
+ 'invoiced_date' => company_date($this->invoice->issued_at),
+ 'invoice_due_date' => company_date($this->invoice->due_at),
+ 'status' => $this->invoice->status,
+ ];
+ }
+
+ public function getTags(): array
+ {
+ return [
+ '{invoice_number}',
+ '{invoice_total}',
+ '{invoice_amount_due}',
+ '{invoiced_date}',
+ '{invoice_due_date}',
+ '{invoice_guest_link}',
+ '{invoice_admin_link}',
+ '{invoice_portal_link}',
+ '{customer_name}',
+ '{company_name}',
+ '{company_email}',
+ '{company_tax_number}',
+ '{company_phone}',
+ '{company_address}',
+ ];
+ }
+
+ public function getTagsReplacement(): array
+ {
+ $route_params = [
+ 'company_id' => $this->invoice->company_id,
+ 'invoice' => $this->invoice->id,
+ ];
+
+ return [
+ $this->invoice->document_number,
+ money($this->invoice->amount, $this->invoice->currency_code),
+ money($this->invoice->amount_due, $this->invoice->currency_code),
+ company_date($this->invoice->issued_at),
+ company_date($this->invoice->due_at),
+ URL::signedRoute('signed.invoices.show', $route_params),
+ route('invoices.show', $route_params),
+ route('portal.invoices.show', $route_params),
+ $this->invoice->contact_name,
+ $this->invoice->company->name,
+ $this->invoice->company->email,
+ $this->invoice->company->tax_number,
+ $this->invoice->company->phone,
+ nl2br(trim($this->invoice->company->address)),
+ ];
+ }
+}
diff --git a/app/Observers/Transaction.php b/app/Observers/Transaction.php
new file mode 100644
index 00000000..7f6e2101
--- /dev/null
+++ b/app/Observers/Transaction.php
@@ -0,0 +1,80 @@
+document_id)) {
+ $type = ($transaction->type == 'income') ? Document::INVOICE_TYPE : Document::BILL_TYPE;
+
+ $this->updateDocument($transaction, $type);
+ }
+
+ if (! empty($transaction->split_id)) {
+ $this->updateTransaction($transaction);
+ }
+ }
+
+ protected function updateDocument($transaction, $type)
+ {
+ $document = $transaction->{$type};
+
+ if (empty($document)) {
+ return;
+ }
+
+ $document->transactions_count = $document->transactions->count();
+
+ event(new TransactionsCounted($document));
+
+ $document->status = ($type == Document::INVOICE_TYPE) ? 'sent' : 'received';
+
+ if ($document->transactions_count > 0) {
+ $document->status = 'partial';
+ }
+
+ unset($document->transactions_count);
+
+ $document->save();
+
+ $this->dispatch(new CreateDocumentHistory($document, 0, $this->getDescription($transaction)));
+ }
+
+ protected function getDescription($transaction)
+ {
+ $amount = money((double) $transaction->amount, (string) $transaction->currency_code)->format();
+
+ return trans('messages.success.deleted', ['type' => $amount . ' ' . trans_choice('general.payments', 1)]);
+ }
+
+ protected function updateTransaction($transaction)
+ {
+ $splitted_transaction = Model::find($transaction->split_id);
+
+ if ($splitted_transaction->splits->count() == 0) {
+ $values = [
+ 'type' => $transaction->type,
+ ];
+
+ $this->dispatch(new UpdateTransaction($splitted_transaction, $values));
+ }
+ }
+}
diff --git a/app/Providers/App.php b/app/Providers/App.php
new file mode 100644
index 00000000..81c196d9
--- /dev/null
+++ b/app/Providers/App.php
@@ -0,0 +1,55 @@
+app->register(\Barryvdh\Debugbar\ServiceProvider::class);
+ }
+
+ if (! env_is_production()) {
+ $this->app->register(\Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class);
+ }
+
+ Sanctum::ignoreMigrations();
+ }
+
+ /**
+ * Bootstrap any application services.
+ *
+ * @return void
+ */
+ public function boot()
+ {
+ // Laravel db fix
+ Schema::defaultStringLength(191);
+
+ Paginator::useBootstrap();
+
+ Model::preventLazyLoading(config('app.eager_load'));
+
+ Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
+ if (config('logging.default') == 'sentry') {
+ \Sentry\Laravel\Integration::lazyLoadingViolationReporter();
+ } else {
+ $class = get_class($model);
+
+ report("Attempted to lazy load [{$relation}] on model [{$class}].");
+ }
+ });
+ }
+}
diff --git a/app/Providers/Auth.php b/app/Providers/Auth.php
new file mode 100644
index 00000000..4b8b7651
--- /dev/null
+++ b/app/Providers/Auth.php
@@ -0,0 +1,27 @@
+ 'App\Policies\ModelPolicy',
+ ];
+
+ /**
+ * Register any authentication / authorization services.
+ *
+ * @return void
+ */
+ public function boot()
+ {
+ $this->registerPolicies();
+ }
+}
diff --git a/app/Providers/Binding.php b/app/Providers/Binding.php
new file mode 100644
index 00000000..3a4d2a05
--- /dev/null
+++ b/app/Providers/Binding.php
@@ -0,0 +1,22 @@
+ DocumentNumber::class,
+ TransactionNumberInterface::class => TransactionNumber::class,
+ ];
+}
diff --git a/app/Providers/Blade.php b/app/Providers/Blade.php
new file mode 100644
index 00000000..b7a5db9d
--- /dev/null
+++ b/app/Providers/Blade.php
@@ -0,0 +1,55 @@
+";
+ });
+
+ Facade::directive('widget', function ($expression) {
+ return "";
+ });
+
+ Facade::directive('moduleIsEnabled', function ($expression) {
+ return "";
+ });
+
+ Facade::if('readonly', function () {
+ return config('read-only.enabled');
+ });
+
+ Facade::component('form.group.switch', Sswitch::class);
+ Facade::component('media.file', MFile::class);
+ Facade::component('form.input.file', Ffile::class);
+ Facade::component('index.default', Ddefault::class);
+ }
+
+ /**
+ * Register any application services.
+ *
+ * @return void
+ */
+ public function register()
+ {
+ //
+ }
+}
diff --git a/app/Providers/Broadcast.php b/app/Providers/Broadcast.php
new file mode 100644
index 00000000..e41c6aec
--- /dev/null
+++ b/app/Providers/Broadcast.php
@@ -0,0 +1,20 @@
+ [
+ \App\Listeners\Update\CreateModuleUpdatedHistory::class,
+ \App\Listeners\Module\UpdateExtraModules::class,
+ \App\Listeners\Update\V30\Version300::class,
+ \App\Listeners\Update\V30\Version303::class,
+ \App\Listeners\Update\V30\Version304::class,
+ \App\Listeners\Update\V30\Version305::class,
+ \App\Listeners\Update\V30\Version307::class,
+ \App\Listeners\Update\V30\Version309::class,
+ \App\Listeners\Update\V30\Version3013::class,
+ \App\Listeners\Update\V30\Version3014::class,
+ \App\Listeners\Update\V30\Version3015::class,
+ \App\Listeners\Update\V30\Version3016::class,
+ \App\Listeners\Update\V30\Version3017::class,
+ \App\Listeners\Update\V31\Version310::class,
+ \App\Listeners\Update\V31\Version315::class,
+ \App\Listeners\Update\V31\Version317::class,
+ \App\Listeners\Update\V31\Version318::class,
+ \App\Listeners\Update\V31\Version3112::class,
+ \App\Listeners\Update\V31\Version3115::class,
+ \App\Listeners\Update\V31\Version3119::class,
+ \App\Listeners\Update\V32\Version320::class,
+ ],
+ \Illuminate\Routing\Events\PreparingResponse::class => [
+ \App\Listeners\Common\PreparingResponse::class,
+ ],
+ \Illuminate\Auth\Events\Login::class => [
+ \App\Listeners\Auth\Login::class,
+ ],
+ \Illuminate\Auth\Events\Logout::class => [
+ \App\Listeners\Auth\Logout::class,
+ ],
+ //'Illuminate\Console\Events\ScheduledTaskStarting' => [
+ \Illuminate\Console\Events\CommandStarting::class => [
+ \App\Listeners\Common\SkipScheduleInReadOnlyMode::class,
+ ],
+ \App\Events\Auth\LandingPageShowing::class => [
+ \App\Listeners\Auth\AddLandingPages::class,
+ ],
+ \App\Events\Document\DocumentCreated::class => [
+ \App\Listeners\Document\CreateDocumentCreatedHistory::class,
+ \App\Listeners\Document\IncreaseNextDocumentNumber::class,
+ \App\Listeners\Document\SettingFieldCreated::class,
+ ],
+ \App\Events\Document\DocumentReceived::class => [
+ \App\Listeners\Document\MarkDocumentReceived::class,
+ ],
+ \App\Events\Document\DocumentCancelled::class => [
+ \App\Listeners\Document\MarkDocumentCancelled::class,
+ ],
+ \App\Events\Document\DocumentRestored::class => [
+ \App\Listeners\Document\RestoreDocument::class,
+ ],
+ \App\Events\Document\DocumentRecurring::class => [
+ \App\Listeners\Document\SendDocumentRecurringNotification::class,
+ ],
+ \App\Events\Document\DocumentReminded::class => [
+ \App\Listeners\Document\SendDocumentReminderNotification::class,
+ ],
+ \App\Events\Document\PaymentReceived::class => [
+ \App\Listeners\Document\CreateDocumentTransaction::class,
+ \App\Listeners\Document\SendDocumentPaymentNotification::class,
+ ],
+ \App\Events\Document\DocumentMarkedSent::class => [
+ \App\Listeners\Document\MarkDocumentSent::class,
+ ],
+ \App\Events\Document\DocumentSent::class => [
+ \App\Listeners\Document\MarkDocumentSent::class,
+ ],
+ \App\Events\Document\DocumentUpdated::class => [
+ \App\Listeners\Document\SettingFieldUpdated::class,
+ ],
+ \App\Events\Document\DocumentViewed::class => [
+ \App\Listeners\Document\MarkDocumentViewed::class,
+ \App\Listeners\Document\SendDocumentViewNotification::class,
+ ],
+ \App\Events\Install\UpdateFailed::class => [
+ \App\Listeners\Update\SendNotificationOnFailure::class,
+ ],
+ \App\Events\Menu\NotificationsCreated::class => [
+ \App\Listeners\Menu\ShowInNotifications::class,
+ ],
+ \App\Events\Menu\AdminCreated::class => [
+ \App\Listeners\Menu\ShowInAdmin::class,
+ ],
+ \App\Events\Menu\ProfileCreated::class => [
+ \App\Listeners\Menu\ShowInProfile::class,
+ ],
+ \App\Events\Menu\SettingsCreated::class => [
+ \App\Listeners\Menu\ShowInSettings::class,
+ ],
+ \App\Events\Menu\NewwCreated::class => [
+ \App\Listeners\Menu\ShowInNeww::class,
+ ],
+ \App\Events\Menu\PortalCreated::class => [
+ \App\Listeners\Menu\ShowInPortal::class,
+ ],
+ \App\Events\Module\Installed::class => [
+ \App\Listeners\Module\InstallExtraModules::class,
+ \App\Listeners\Module\FinishInstallation::class,
+ ],
+ \App\Events\Module\Uninstalled::class => [
+ \App\Listeners\Module\FinishUninstallation::class,
+ ],
+ \App\Events\Banking\TransactionCreated::class => [
+ \App\Listeners\Banking\IncreaseNextTransactionNumber::class,
+ ],
+ \App\Events\Setting\CategoryDeleted::class => [
+ \App\Listeners\Setting\DeleteCategoryDeletedSubCategories::class,
+ ],
+ \App\Events\Email\TooManyEmailsSent::class => [
+ \App\Listeners\Email\ReportTooManyEmailsSent::class,
+ \App\Listeners\Email\TellFirewallTooManyEmailsSent::class,
+ ],
+ \App\Events\Email\InvalidEmailDetected::class => [
+ \App\Listeners\Email\DisablePersonDueToInvalidEmail::class,
+ \App\Listeners\Email\SendInvalidEmailNotification::class,
+ ],
+ ];
+
+ /**
+ * The subscriber classes to register.
+ *
+ * @var array
+ */
+ protected $subscribe = [
+ \App\Listeners\Common\ClearPlansCache::class,
+ \App\Listeners\Module\ClearCache::class,
+ \App\Listeners\Report\AddAccounts::class,
+ \App\Listeners\Report\AddContacts::class,
+ \App\Listeners\Report\AddCustomers::class,
+ \App\Listeners\Report\AddVendors::class,
+ \App\Listeners\Report\AddExpenseCategories::class,
+ \App\Listeners\Report\AddIncomeCategories::class,
+ \App\Listeners\Report\AddIncomeExpenseCategories::class,
+ \App\Listeners\Report\AddSearchString::class,
+ \App\Listeners\Report\AddRowsToTax::class,
+ \App\Listeners\Report\AddGroup::class,
+ \App\Listeners\Report\AddBasis::class,
+ \App\Listeners\Report\AddPeriod::class,
+ \App\Listeners\Report\AddDate::class,
+ \App\Listeners\Report\AddDiscount::class,
+ ];
+}
diff --git a/app/Providers/Macro.php b/app/Providers/Macro.php
new file mode 100644
index 00000000..e57a7c14
--- /dev/null
+++ b/app/Providers/Macro.php
@@ -0,0 +1,157 @@
+is(config('api.prefix') . '/*');
+ });
+
+ Request::macro('isNotApi', function () {
+ return ! $this->isApi();
+ });
+
+ Request::macro('isAuth', function () {
+ return $this->is('auth/*');
+ });
+
+ Request::macro('isNotAuth', function () {
+ return ! $this->isAuth();
+ });
+
+ Request::macro('isInstall', function () {
+ return $this->is('install/*');
+ });
+
+ Request::macro('isNotInstall', function () {
+ return ! $this->isInstall();
+ });
+
+ Request::macro('isPreview', function ($company_id) {
+ return $this->is($company_id . '/preview/*');
+ });
+
+ Request::macro('isNotPreview', function ($company_id) {
+ return !$this->isPreview($company_id);
+ });
+
+ Request::macro('isSigned', function ($company_id) {
+ return $this->is($company_id . '/signed/*');
+ });
+
+ Request::macro('isNotSigned', function ($company_id) {
+ return ! $this->isSigned($company_id);
+ });
+
+ Request::macro('isPortal', function ($company_id) {
+ return $this->is($company_id . '/portal') || $this->is($company_id . '/portal/*');
+ });
+
+ Request::macro('isNotPortal', function ($company_id) {
+ return ! $this->isPortal($company_id);
+ });
+
+ Request::macro('isWizard', function ($company_id) {
+ return $this->is($company_id . '/wizard') || $this->is($company_id . '/wizard/*');
+ });
+
+ Request::macro('isNotWizard', function ($company_id) {
+ return ! $this->isWizard($company_id);
+ });
+
+ Request::macro('isAdmin', function ($company_id) {
+ return $this->isNotApi()
+ && $this->isNotAuth()
+ && $this->isNotOAuth()
+ && $this->isNotInstall()
+ && $this->isNotSigned($company_id)
+ && $this->isNotPortal($company_id)
+ && $this->isNotWizard($company_id);
+ });
+
+ Request::macro('isNotAdmin', function ($company_id) {
+ return ! $this->isAdmin($company_id);
+ });
+
+ Request::macro('isOAuth', function () {
+ return $this->is(config('oauth.prefix', 'oauth') . '/*');
+ });
+
+ Request::macro('isNotOAuth', function () {
+ return ! $this->isOAuth();
+ });
+
+ Request::macro('isMcp', function () {
+ return $this->is('mcp') || $this->is('mcp/*');
+ });
+
+ Request::macro('isNotMcp', function () {
+ return ! $this->isMcp();
+ });
+
+ Request::macro('isCloudHost', function () {
+ return $this->getHost() == config('cloud.host', 'app.akaunting.com');
+ });
+
+ Request::macro('isNotCloudHost', function () {
+ return ! $this->isCloudHost();
+ });
+
+ Str::macro('filename', function ($string, $separator = '-') {
+ // Replace @ with the word 'at'
+ $string = str_replace('@', $separator.'at'.$separator, $string);
+
+ // Remove all characters that are not the separator, letters, numbers, or whitespace.
+ $string = preg_replace('![^'.preg_quote($separator).'\pL\pN\s]+!u', '', $string);
+
+ // Remove multiple whitespaces
+ $string = preg_replace('/\s+/', ' ', $string);
+
+ return $string;
+ });
+
+ ViewFactory::macro('hasStack', function (...$sections) {
+ foreach ($sections as $section) {
+ if (isset($this->pushes[$section]) || isset($this->prepends[$section])) {
+ return true;
+ }
+ }
+
+ return false;
+ });
+
+ Collection::macro('withChildren', function ($relation, $addChildren) {
+ $list = new Collection();
+
+ foreach ($this as $model) {
+ $addChildren($list, $model, $relation, 0, $addChildren);
+ }
+
+ return $list;
+ });
+ }
+}
diff --git a/app/Providers/Observer.php b/app/Providers/Observer.php
new file mode 100644
index 00000000..27af5bdc
--- /dev/null
+++ b/app/Providers/Observer.php
@@ -0,0 +1,29 @@
+createPayloadUsing(function ($connection, $queue, $payload) {
+ $company_id = company_id();
+
+ if (empty($company_id)) {
+ return [];
+ }
+
+ return ['company_id' => $company_id];
+ });
+
+ app('events')->listen(JobProcessing::class, function ($event) {
+ $payload = $event->job->payload();
+
+ if (! array_key_exists('company_id', $payload)) {
+ return;
+ }
+
+ try {
+ $company = company($payload['company_id']);
+ } catch (\Throwable $e) {
+ $event->job->delete();
+
+ logger()->warning('Company could not be resolved for queued job, job deleted.', [
+ 'company_id' => $payload['company_id'],
+ 'payload' => $payload,
+ 'exception' => $e->getMessage(),
+ ]);
+
+ return;
+ }
+
+ if (empty($company)) {
+ $event->job->delete();
+
+ logger()->warning('Company not found, job deleted.', [
+ 'company_id' => $payload['company_id'],
+ 'payload' => $payload,
+ ]);
+
+ return;
+ }
+
+ $company->makeCurrent();
+
+ // TODO: Move queue control at the beginning of the closure
+ if (should_queue()) {
+ $this->registerModules();
+ }
+ });
+
+ app('events')->listen(JobFailed::class, function ($event) {
+ if (!$event->exception instanceof \Maatwebsite\Excel\Validators\ValidationException) {
+ return;
+ }
+
+ $body = $event->job->getRawBody();
+ if (empty($body) || !is_string($body)) {
+ return;
+ }
+
+ $payload = json_decode($body);
+ if (empty($payload) || empty($payload->data) || empty($payload->data->command)) {
+ return;
+ }
+
+ $excel_job = unserialize($payload->data->command);
+ if (!$excel_job instanceof \Maatwebsite\Excel\Jobs\ReadChunk) {
+ return;
+ }
+
+ $ref = new \ReflectionProperty($excel_job, 'import');
+ $ref->setAccessible(true);
+
+ // Get import class
+ $class = $ref->getValue($excel_job);
+
+ if (!$class instanceof \App\Abstracts\Import && !$class instanceof \App\Abstracts\ImportMultipleSheets) {
+ return;
+ }
+
+ $errors = [];
+
+ foreach ($event->exception->failures() as $failure) {
+ $message = trans('messages.error.import_column', [
+ 'message' => collect($failure->errors())->first(),
+ 'column' => $failure->attribute(),
+ 'line' => $failure->row(),
+ ]);
+
+ $errors[] = $message;
+ }
+
+ if (!empty($errors)) {
+ $class->user->notify(new ImportFailed($errors));
+ }
+ });
+ }
+}
diff --git a/app/Providers/Route.php b/app/Providers/Route.php
new file mode 100644
index 00000000..ebf2851d
--- /dev/null
+++ b/app/Providers/Route.php
@@ -0,0 +1,330 @@
+ $attrs['middleware'],
+ ];
+
+ if (isset($attrs['namespace'])) {
+ // null means don't add
+ if (!is_null($attrs['namespace'])) {
+ $attributes['namespace'] = $attrs['namespace'];
+ }
+ } else {
+ $attributes['namespace'] = 'Modules\\' . module($alias)->getStudlyName() . '\Http\Controllers';
+ }
+
+ if (isset($attrs['prefix'])) {
+ // null means don't add
+ if (!is_null($attrs['prefix'])) {
+ $attributes['prefix'] = '{company_id}/' . $attrs['prefix'];
+ }
+ } else {
+ $attributes['prefix'] = '{company_id}/' . $alias;
+ }
+
+ if (isset($attrs['as'])) {
+ // null means don't add
+ if (!is_null($attrs['as'])) {
+ $attributes['as'] = $attrs['as'];
+ }
+ } else {
+ $attributes['as'] = $alias . '.';
+ }
+
+ return Facade::group($attributes, $routes);
+ });
+
+ Facade::macro('admin', function ($alias, $routes, $attributes = []) {
+ return Facade::module($alias, $routes, array_merge([
+ 'middleware' => 'admin',
+ ], $attributes));
+ });
+
+ Facade::macro('preview', function ($alias, $routes, $attributes = []) {
+ return Facade::module($alias, $routes, array_merge([
+ 'middleware' => 'preview',
+ 'prefix' => 'preview/' . $alias,
+ 'as' => 'preview.' . $alias . '.',
+ ], $attributes));
+ });
+
+ Facade::macro('portal', function ($alias, $routes, $attributes = []) {
+ return Facade::module($alias, $routes, array_merge([
+ 'middleware' => 'portal',
+ 'prefix' => 'portal/' . $alias,
+ 'as' => 'portal.' . $alias . '.',
+ ], $attributes));
+ });
+
+ Facade::macro('signed', function ($alias, $routes, $attributes = []) {
+ return Facade::module($alias, $routes, array_merge([
+ 'middleware' => 'signed',
+ 'prefix' => 'signed/' . $alias,
+ 'as' => 'signed.' . $alias . '.',
+ ], $attributes));
+ });
+
+ Facade::macro('api', function ($alias, $routes, $attributes = []) {
+ return Facade::module($alias, $routes, array_merge([
+ 'namespace' => 'Modules\\' . module($alias)->getStudlyName() . '\Http\Controllers\Api',
+ 'domain' => config('api.domain'),
+ 'middleware' => config('api.middleware'),
+ 'prefix' => config('api.prefix') ? config('api.prefix') . '/' . $alias : $alias,
+ 'as' => 'api.' . $alias . '.',
+ ], $attributes));
+ });
+ }
+
+ /**
+ * Define the routes for the application.
+ *
+ * @return void
+ */
+ public function map()
+ {
+ $this->configureRateLimiting();
+
+ $this->mapInstallRoutes();
+
+ $this->mapApiRoutes();
+
+ $this->mapCommonRoutes();
+
+ $this->mapGuestRoutes();
+
+ $this->mapWizardRoutes();
+
+ $this->mapAdminRoutes();
+
+ $this->mapPreviewRoutes();
+
+ $this->mapPortalRoutes();
+
+ $this->mapSignedRoutes();
+
+ $this->mapWebRoutes();
+ }
+
+ /**
+ * Define the "install" routes for the application.
+ *
+ * These routes all receive session state, CSRF protection, etc.
+ *
+ * @return void
+ */
+ protected function mapInstallRoutes()
+ {
+ Facade::prefix('install')
+ ->middleware('install')
+ ->namespace($this->namespace)
+ ->group(base_path('routes/install.php'));
+ }
+
+ /**
+ * Define the "api" routes for the application.
+ *
+ * These routes are typically stateless.
+ *
+ * @return void
+ */
+ protected function mapApiRoutes()
+ {
+ Facade::prefix(config('api.prefix'))
+ ->domain(config('api.domain'))
+ ->middleware(config('api.middleware'))
+ ->namespace($this->namespace . '\Api')
+ ->group(base_path('routes/api.php'));
+ }
+
+ /**
+ * Define the "common" routes for the application.
+ *
+ * These routes all receive session state, CSRF protection, etc.
+ *
+ * @return void
+ */
+ protected function mapCommonRoutes()
+ {
+ Facade::prefix('{company_id}')
+ ->middleware('common')
+ ->namespace($this->namespace)
+ ->group(base_path('routes/common.php'));
+ }
+
+ /**
+ * Define the "guest" routes for the application.
+ *
+ * These routes all receive session state, CSRF protection, etc.
+ *
+ * @return void
+ */
+ protected function mapGuestRoutes()
+ {
+ Facade::middleware('guest')
+ ->namespace($this->namespace)
+ ->group(base_path('routes/guest.php'));
+ }
+
+ /**
+ * Define the "wizard" routes for the application.
+ *
+ * These routes all receive session state, CSRF protection, etc.
+ *
+ * @return void
+ */
+ protected function mapWizardRoutes()
+ {
+ Facade::prefix('{company_id}/wizard')
+ ->middleware('wizard')
+ ->namespace($this->namespace)
+ ->group(base_path('routes/wizard.php'));
+ }
+
+ /**
+ * Define the "admin" routes for the application.
+ *
+ * These routes all receive session state, CSRF protection, etc.
+ *
+ * @return void
+ */
+ protected function mapAdminRoutes()
+ {
+ Facade::prefix('{company_id}')
+ ->middleware('admin')
+ ->namespace($this->namespace)
+ ->group(base_path('routes/admin.php'));
+ }
+ /**
+ * Define the "preview" routes for the application.
+ *
+ * These routes all receive session state, CSRF protection, etc.
+ *
+ * @return void
+ */
+ protected function mapPreviewRoutes()
+ {
+ Facade::prefix('{company_id}/preview')
+ ->middleware('preview')
+ ->namespace($this->namespace)
+ ->group(base_path('routes/preview.php'));
+ }
+
+ /**
+ * Define the "portal" routes for the application.
+ *
+ * These routes all receive session state, CSRF protection, etc.
+ *
+ * @return void
+ */
+ protected function mapPortalRoutes()
+ {
+ Facade::prefix('{company_id}/portal')
+ ->middleware('portal')
+ ->namespace($this->namespace)
+ ->group(base_path('routes/portal.php'));
+ }
+
+ /**
+ * Define the "signed" routes for the application.
+ *
+ * These routes all receive session state, CSRF protection, etc.
+ *
+ * @return void
+ */
+ protected function mapSignedRoutes()
+ {
+ Facade::prefix('{company_id}/signed')
+ ->middleware('signed')
+ ->namespace($this->namespace)
+ ->group(base_path('routes/signed.php'));
+ }
+
+ /**
+ * Define the "web" routes for the application.
+ *
+ * These routes all receive session state, CSRF protection, etc.
+ *
+ * @return void
+ */
+ protected function mapWebRoutes()
+ {
+ Facade::middleware('web')
+ ->namespace($this->namespace)
+ ->group(base_path('routes/web.php'));
+ }
+
+ /**
+ * Configure the rate limiters for the application.
+ *
+ * @return void
+ */
+ protected function configureRateLimiting()
+ {
+ RateLimiter::for('api', function (Request $request) {
+ return Limit::perMinute(config('app.throttles.api'))
+ ->by($request->user()?->id ?: $request->ip());
+ });
+
+ RateLimiter::for('oauth', function (Request $request) {
+ return Limit::perMinute(config('app.throttles.oauth'))
+ ->by($request->user()?->id ?: $request->ip());
+ });
+
+ RateLimiter::for('dcr', function (Request $request) {
+ // Dynamic Client Registration: Strict rate limiting to prevent spam
+ return [
+ Limit::perHour(10)->by($request->ip()), // 10 per hour per IP
+ Limit::perDay(50)->by($request->ip()), // 50 per day per IP
+ ];
+ });
+
+ RateLimiter::for('import', function (Request $request) {
+ return Limit::perMinute(config('app.throttles.import'));
+ });
+
+ RateLimiter::for('email', function (Request $request) {
+ return [
+ Limit::perDay(config('app.throttles.email.month'), 30),
+ Limit::perMinute(config('app.throttles.email.minute')),
+ ];
+ });
+ }
+}
diff --git a/app/Providers/Validation.php b/app/Providers/Validation.php
new file mode 100644
index 00000000..0158c6a5
--- /dev/null
+++ b/app/Providers/Validation.php
@@ -0,0 +1,158 @@
+pluck('code')->toArray();
+
+ if (in_array($value, $currencies)) {
+ $status = true;
+ }
+
+ if (strlen($value) != 3) {
+ return $status;
+ }
+
+ $currency_code = $value;
+
+ return $status;
+ });
+
+ // Custom message for currency validation
+ Validator::replacer('currency', function($message, $attribute, $rule, $parameters) {
+ return trans('validation.custom.invalid_currency', ['attribute' => $attribute]);
+ });
+
+ // Check currency code is valid
+ Validator::extend('currency_code', function ($attribute, $value, $parameters, $validator) {
+ $status = false;
+
+ $currency_code = $value;
+
+ $currencies = config('money.currencies');
+
+ if (array_key_exists($value, $currencies)) {
+ $status = true;
+ }
+
+ return $status;
+ });
+
+ // Custom message for currency code validation
+ Validator::replacer('currency_code', function($message, $attribute, $rule, $parameters) {
+ return trans('validation.custom.invalid_currency', ['attribute' => $attribute]);
+ });
+
+ // Check amount is valid
+ Validator::extend('amount', function ($attribute, $value, $parameters, $validator) {
+ $status = false;
+
+ if ($value > 0 || in_array($value, $parameters)) {
+ $status = true;
+ }
+
+ if (! preg_match("/^(?=.*?[0-9])[0-9.,]+$/", $value)) {
+ $status = false;
+ }
+
+ return $status;
+ });
+
+ // Custom message for amount validation
+ Validator::replacer('amount', function($message, $attribute, $rule, $parameters) {
+ return trans('validation.custom.invalid_amount', ['attribute' => $attribute]);
+ });
+
+ // Check extension is valid
+ Validator::extend('extension', function ($attribute, $value, $parameters, $validator) {
+ $extension = $value->getClientOriginalExtension();
+
+ return !empty($extension) && in_array($extension, $parameters);
+ },
+ trans('validation.custom.invalid_extension')
+ );
+
+ // Check colour is valid
+ Validator::extend('colour', function ($attribute, $value, $parameters, $validator) {
+ $status = false;
+
+ $colors = ['gray', 'red', 'yellow', 'green', 'blue', 'indigo', 'purple', 'pink'];
+ $variants = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];
+
+ foreach ($colors as $color) {
+ if (! Str::contains($value, $color)) {
+ continue;
+ }
+
+ foreach ($variants as $variant) {
+ $name = $color . '-' . $variant;
+
+ if (Str::contains($value, $name)) {
+ $status = true;
+
+ break;
+ }
+ }
+
+ if ($status) {
+ break;
+ }
+ }
+
+ if (! $status && Str::contains($value, '#')) {
+ $status = true;
+ }
+
+ return $status;
+ },
+ trans('validation.custom.invalid_colour')
+ );
+
+ // Check payment method is valid
+ Validator::extend('payment_method', function ($attribute, $value, $parameters, $validator) {
+ $status = false;
+
+ $methods = Modules::getPaymentMethods('all');
+
+ if (array_key_exists($value, $methods)) {
+ $status = true;
+ }
+
+ return $status;
+ },
+ trans('validation.custom.invalid_payment_method')
+ );
+ }
+
+ /**
+ * Register any application services.
+ *
+ * @return void
+ */
+ public function register()
+ {
+ //
+ }
+}
diff --git a/app/Providers/ViewComposer.php b/app/Providers/ViewComposer.php
new file mode 100644
index 00000000..7c6304d7
--- /dev/null
+++ b/app/Providers/ViewComposer.php
@@ -0,0 +1,55 @@
+getParentKey()) && $this->related instanceof Model) {
+ return $this->query->getWithoutChildren();
+ }
+
+ return ! is_null($this->getParentKey())
+ ? $this->query->get()
+ : $this->related->newCollection();
+ }
+}
diff --git a/app/Reports/DiscountSummary.php b/app/Reports/DiscountSummary.php
new file mode 100644
index 00000000..cdddf486
--- /dev/null
+++ b/app/Reports/DiscountSummary.php
@@ -0,0 +1,131 @@
+ [
+ 'bar' => [
+ 'colors' => [
+ '#8bb475',
+ ],
+ ],
+ 'donut' => [
+ //
+ ],
+ ],
+ 'expense' => [
+ 'bar' => [
+ 'colors' => [
+ '#fb7185',
+ ],
+ ],
+ 'donut' => [
+ //
+ ],
+ ],
+ ];
+
+ public function setTables()
+ {
+ $this->tables = [
+ 'income' => trans_choice('general.incomes', 1),
+ 'expense' => trans_choice('general.expenses', 2),
+ ];
+ }
+
+ public function setData()
+ {
+ $invoices = $this->applyFilters(
+ model: Document::invoice()->with('totals')->accrued(),
+ args: ['date_field' => 'issued_at', 'model_type' => 'invoice'],
+ )->get();
+ Recurring::reflect($invoices, 'issued_at');
+ $this->setTotals($invoices, 'issued_at', false, 'income');
+
+ // Bills
+ $bills = $this->applyFilters(
+ model: Document::bill()->with('totals')->accrued(),
+ args: ['date_field' => 'issued_at', 'model_type' => 'bill'],
+ )->get();
+ Recurring::reflect($bills, 'issued_at');
+ $this->setTotals($bills, 'issued_at', false, 'expense');
+ }
+
+ public function setTotals($items, $date_field, $check_type = false, $table = 'default', $with_tax = true)
+ {
+ event(new TotalCalculating($this, $items, $date_field, $check_type, $table, $with_tax));
+
+ $group_field = $this->getGroup() . '_id';
+
+ foreach ($items as $item) {
+ // Make groups extensible
+ $item = $this->applyGroups($item);
+
+ $date = $this->getFormattedDate(Date::parse($item->$date_field));
+
+ if (!isset($item->$group_field)) {
+ continue;
+ }
+
+ $group = $item->$group_field;
+
+ $totals = $item->totals;
+
+ foreach ($totals as $total) {
+ if (! in_array($total->code, ['item_discount', 'discount'])) {
+ continue;
+ }
+
+ if (
+ !isset($this->row_values[$table][$group])
+ || !isset($this->row_values[$table][$group][$date])
+ || !isset($this->footer_totals[$table][$date])
+ ) {
+ continue;
+ }
+
+ $amount = $this->convertToDefault($total->amount, $item->currency_code, $item->currency_rate);
+
+ $type = ($item->type === Document::INVOICE_TYPE || $item->type === 'income') ? 'income' : 'expense';
+
+ if (($check_type == false) || ($type == 'income')) {
+ $this->row_values[$table][$group][$date] += $amount;
+
+ $this->footer_totals[$table][$date] += $amount;
+ } else {
+ $this->row_values[$table][$group][$date] -= $amount;
+
+ $this->footer_totals[$table][$date] -= $amount;
+ }
+ }
+ }
+
+ event(new TotalCalculated($this, $items, $date_field, $check_type, $table, $with_tax));
+ }
+
+ public function getFields()
+ {
+ return [
+ $this->getGroupField(),
+ $this->getPeriodField(),
+ ];
+ }
+}
diff --git a/app/Reports/ExpenseSummary.php b/app/Reports/ExpenseSummary.php
new file mode 100644
index 00000000..574518f9
--- /dev/null
+++ b/app/Reports/ExpenseSummary.php
@@ -0,0 +1,67 @@
+ [
+ 'colors' => [
+ '#fb7185',
+ ],
+ ],
+ 'donut' => [
+ //
+ ],
+ ];
+
+ public function setTables()
+ {
+ $this->tables = [
+ 'expense' => trans_choice('general.expenses', 2),
+ ];
+ }
+
+ public function setData()
+ {
+ $transactions = $this->applyFilters(
+ model: Transaction::with('recurring')->expense()->isNotTransfer(),
+ args: ['date_field' => 'paid_at', 'model_type' => 'expense'],
+ );
+
+ switch ($this->getBasis()) {
+ case 'cash':
+ // Expenses
+ $expenses = $transactions->get();
+ $this->setTotals($expenses, 'paid_at', false, 'expense');
+
+ break;
+ default:
+ // Bills
+ $bills = $this->applyFilters(
+ model: Document::bill()->with('recurring', 'transactions', 'items')->accrued(),
+ args: ['date_field' => 'issued_at', 'model_type' => 'bill'],
+ )->get();
+ Recurring::reflect($bills, 'issued_at');
+ $this->setTotals($this->flattenDocumentItems($bills), 'issued_at', false, 'expense');
+
+ // Expenses
+ $expenses = $transactions->isNotDocument()->get();
+ Recurring::reflect($expenses, 'paid_at');
+ $this->setTotals($expenses, 'paid_at', false, 'expense');
+
+ break;
+ }
+ }
+}
diff --git a/app/Reports/IncomeExpenseSummary.php b/app/Reports/IncomeExpenseSummary.php
new file mode 100644
index 00000000..e264259e
--- /dev/null
+++ b/app/Reports/IncomeExpenseSummary.php
@@ -0,0 +1,101 @@
+ [
+ 'bar' => [
+ 'colors' => [
+ '#8bb475',
+ ],
+ ],
+ 'donut' => [
+ //
+ ],
+ ],
+ 'expense' => [
+ 'bar' => [
+ 'colors' => [
+ '#fb7185',
+ ],
+ ],
+ 'donut' => [
+ //
+ ],
+ ],
+ ];
+
+ public function setTables()
+ {
+ $this->tables = [
+ 'income' => trans_choice('general.incomes', 1),
+ 'expense' => trans_choice('general.expenses', 2),
+ ];
+ }
+
+ public function setData()
+ {
+ $income_transactions = $this->applyFilters(
+ model: Transaction::with('recurring')->income()->isNotTransfer(),
+ args: ['date_field' => 'paid_at', 'model_type' => 'income'],
+ );
+ $expense_transactions = $this->applyFilters(
+ model: Transaction::with('recurring')->expense()->isNotTransfer(),
+ args: ['date_field' => 'paid_at', 'model_type' => 'expense'],
+ );
+
+ switch ($this->getBasis()) {
+ case 'cash':
+ // Incomes
+ $incomes = $income_transactions->get();
+ $this->setTotals($incomes, 'paid_at', false, 'income');
+
+ // Expenses
+ $expenses = $expense_transactions->get();
+ $this->setTotals($expenses, 'paid_at', false, 'expense');
+
+ break;
+ default:
+ // Invoices
+ $invoices = $this->applyFilters(
+ model: Document::invoice()->with('recurring', 'transactions', 'items')->accrued(),
+ args: ['date_field' => 'issued_at', 'model_type' => 'invoice'],
+ )->get();
+ Recurring::reflect($invoices, 'issued_at');
+ $this->setTotals($this->flattenDocumentItems($invoices), 'issued_at', false, 'income');
+
+ // Incomes
+ $incomes = $income_transactions->isNotDocument()->get();
+ Recurring::reflect($incomes, 'paid_at');
+ $this->setTotals($incomes, 'paid_at', false, 'income');
+
+ // Bills
+ $bills = $this->applyFilters(
+ model: Document::bill()->with('recurring', 'transactions', 'items')->accrued(),
+ args: ['date_field' => 'issued_at', 'model_type' => 'bill'],
+ )->get();
+ Recurring::reflect($bills, 'issued_at');
+ $this->setTotals($this->flattenDocumentItems($bills), 'issued_at', false, 'expense');
+
+ // Expenses
+ $expenses = $expense_transactions->isNotDocument()->get();
+ Recurring::reflect($expenses, 'paid_at');
+ $this->setTotals($expenses, 'paid_at', false, 'expense');
+
+ break;
+ }
+ }
+}
diff --git a/app/Reports/IncomeSummary.php b/app/Reports/IncomeSummary.php
new file mode 100644
index 00000000..2280a94e
--- /dev/null
+++ b/app/Reports/IncomeSummary.php
@@ -0,0 +1,67 @@
+ [
+ 'colors' => [
+ '#8bb475',
+ ],
+ ],
+ 'donut' => [
+ //
+ ],
+ ];
+
+ public function setTables()
+ {
+ $this->tables = [
+ 'income' => trans_choice('general.incomes', 1),
+ ];
+ }
+
+ public function setData()
+ {
+ $transactions = $this->applyFilters(
+ model: Transaction::with('recurring')->income()->isNotTransfer(),
+ args: ['date_field' => 'paid_at', 'model_type' => 'income'],
+ );
+
+ switch ($this->getBasis()) {
+ case 'cash':
+ // Incomes
+ $incomes = $transactions->get();
+ $this->setTotals($incomes, 'paid_at', false, 'income');
+
+ break;
+ default:
+ // Invoices
+ $invoices = $this->applyFilters(
+ model: Document::invoice()->with('recurring', 'transactions', 'items')->accrued(),
+ args: ['date_field' => 'issued_at', 'model_type' => 'invoice'],
+ )->get();
+ Recurring::reflect($invoices, 'issued_at');
+ $this->setTotals($this->flattenDocumentItems($invoices), 'issued_at', false, 'income');
+
+ // Incomes
+ $incomes = $transactions->isNotDocument()->get();
+ Recurring::reflect($incomes, 'paid_at');
+ $this->setTotals($incomes, 'paid_at', false, 'income');
+
+ break;
+ }
+ }
+}
diff --git a/app/Reports/ProfitLoss.php b/app/Reports/ProfitLoss.php
new file mode 100644
index 00000000..dcad8d53
--- /dev/null
+++ b/app/Reports/ProfitLoss.php
@@ -0,0 +1,316 @@
+views['detail'] = 'reports.profit_loss.detail';
+ $this->views['detail.content.header'] = 'reports.profit_loss.content.header';
+ $this->views['detail.content.footer'] = 'reports.profit_loss.content.footer';
+ $this->views['detail.table.header'] = 'reports.profit_loss.table.header';
+ $this->views['detail.table.row'] = 'reports.profit_loss.table.row';
+ $this->views['detail.table.footer'] = 'reports.profit_loss.table.footer';
+ }
+
+ public function setTables()
+ {
+ $this->tables = [
+ Category::INCOME_TYPE => trans_choice('general.incomes', 1),
+ Category::DIRECT_COST_TYPE => trans_choice('general.cogs', 2),
+ Category::EXPENSE_TYPE => trans_choice('general.expenses', 2),
+ ];
+ }
+
+ public function setData()
+ {
+ switch ($this->getBasis()) {
+ case 'cash':
+ $incomes_query = $this->getTransactionQuery(Transaction::INCOME_TYPE);
+ $expenses_query = $this->getTransactionQuery(Transaction::EXPENSE_TYPE);
+
+ // Incomes
+ $incomes = $incomes_query->get();
+ $this->setTotals($incomes, 'paid_at', false, Category::INCOME_TYPE, false);
+
+ // Direct Costs: Expenses
+ $direct_costs_expenses_query = clone $expenses_query; // To avoid affecting the original query used for non-Direct Costs expenses
+ $direct_costs_expenses = $direct_costs_expenses_query->whereHas('category', fn (Builder $q) => $q->directCost())->get();
+ $this->setTotals($direct_costs_expenses, 'paid_at', false, Category::DIRECT_COST_TYPE, false);
+
+ // Expenses
+ $expenses = $expenses_query->whereDoesntHave('category', fn (Builder $q) => $q->directCost())->get();
+ $this->setTotals($expenses, 'paid_at', false, Category::EXPENSE_TYPE, false);
+
+ break;
+ default:
+ $incomes_query = $this->getTransactionQuery(Transaction::INCOME_TYPE);
+ $expenses_query = $this->getTransactionQuery(Transaction::EXPENSE_TYPE);
+ $invoices_query = $this->getDocumentQuery(Document::INVOICE_TYPE);
+ $bills_query = $this->getDocumentQuery(Document::BILL_TYPE);
+
+ // Invoices
+ $invoices = $invoices_query->get();
+ Recurring::reflect($invoices, 'issued_at');
+ $this->setTotals(
+ items: $this->flattenDocumentItems($invoices),
+ date_field: 'issued_at',
+ check_type: false,
+ table: Category::INCOME_TYPE,
+ with_tax: false,
+ );
+
+ // Incomes
+ $incomes = $incomes_query->isNotDocument()->get();
+ Recurring::reflect($incomes, 'paid_at');
+ $this->setTotals($incomes, 'paid_at', false, Category::INCOME_TYPE, false);
+
+ // Bills: Direct Costs & Expenses
+ $bills = $bills_query->get();
+ Recurring::reflect($bills, 'issued_at');
+ $flat_bill_items = $this->flattenDocumentItems($bills);
+ $this->setTotals(
+ items: $flat_bill_items->filter(fn (DocumentItem $i) => $i->category?->isDirectCostCategory()),
+ date_field: 'issued_at',
+ check_type: false,
+ table: Category::DIRECT_COST_TYPE,
+ with_tax: false,
+ );
+ $this->setTotals(
+ items: $flat_bill_items->filter(fn (DocumentItem $i) => ! $i->category?->isDirectCostCategory()),
+ date_field: 'issued_at',
+ check_type: false,
+ table: Category::EXPENSE_TYPE,
+ with_tax: false,
+ );
+
+ // Direct Costs: Expenses
+ $direct_costs_expenses_query = clone $expenses_query; // To avoid affecting the original query used for non-Direct Costs expenses
+ $direct_costs_expenses = $direct_costs_expenses_query->whereHas('category', fn (Builder $q) => $q->directCost())->get();
+ Recurring::reflect($direct_costs_expenses, 'paid_at');
+ $this->setTotals($direct_costs_expenses, 'paid_at', false, Category::DIRECT_COST_TYPE, false);
+
+ // Expenses
+ $expenses = $expenses_query->whereDoesntHave('category', fn (Builder $q) => $q->directCost())->get();
+ Recurring::reflect($expenses, 'paid_at');
+ $this->setTotals($expenses, 'paid_at', false, Category::EXPENSE_TYPE, false);
+
+ break;
+ }
+
+ $this->setGrossProfit();
+ $this->setNetProfit();
+ }
+
+ public function getTransactionQuery(string $type): Builder
+ {
+ return $this->applyFilters(
+ model: Transaction::with('recurring')->$type()->isNotTransfer(),
+ args: ['date_field' => 'paid_at', 'model_type' => $type],
+ );
+ }
+
+ public function getDocumentQuery(string $type): Builder
+ {
+ return $this->applyFilters(
+ model: Document::with('recurring', 'totals', 'transactions', 'items.category')->$type()->accrued(),
+ args: ['date_field' => 'issued_at', 'model_type' => $type],
+ );
+ }
+
+ public function setGrossProfit(): void
+ {
+ foreach ($this->dates as $date) {
+ $income = $this->footer_totals[Category::INCOME_TYPE][$date] ?? 0;
+ $direct_costs = $this->footer_totals[Category::DIRECT_COST_TYPE][$date] ?? 0;
+
+ $this->gross_profit[$date] = $income - $direct_costs;
+ }
+ }
+
+ public function setNetProfit(): void
+ {
+ foreach ($this->footer_totals as $table => $dates) {
+ if (! in_array($table, [Category::INCOME_TYPE, Category::EXPENSE_TYPE])) {
+ continue;
+ }
+
+ foreach ($dates as $date => $total) {
+ if (! isset($this->net_profit[$date])) {
+ $this->net_profit[$date] = 0;
+ }
+
+ if ($table == Category::INCOME_TYPE) {
+ $this->net_profit[$date] += $total;
+
+ continue;
+ }
+
+ $this->net_profit[$date] -= $total;
+ }
+ }
+ }
+
+ public function array(): array
+ {
+ $data = parent::array();
+
+ $net_profit = $this->net_profit;
+ $gross_profit = $this->gross_profit;
+
+ if ($this->has_money) {
+ $net_profit = array_map(fn ($value) => money($value)->format(), $net_profit);
+ $gross_profit = array_map(fn ($value) => money($value)->format(), $gross_profit);
+ }
+
+ $data['gross_profit'] = $gross_profit;
+ $data['net_profit'] = $net_profit;
+
+ return $data;
+ }
+
+ public function getFields(): array
+ {
+ return [
+ $this->getGroupField(),
+ $this->getPeriodField(),
+ $this->getBasisField(),
+ $this->getPercentageField(),
+ ];
+ }
+
+ public function getPercentageField(): array
+ {
+ return [
+ 'type' => 'select',
+ 'name' => 'show_percentage',
+ 'title' => trans('reports.percentage_of_income'),
+ 'icon' => 'percent',
+ 'values' => [
+ 'yes' => trans('general.yes'),
+ 'no' => trans('general.no'),
+ ],
+ 'selected' => 'no',
+ 'attributes' => [
+ 'required' => 'required',
+ ],
+ ];
+ }
+
+ public function showPercentage(): bool
+ {
+ return $this->getSearchStringValue('show_percentage', $this->getSetting('show_percentage')) === 'yes';
+ }
+
+ public function getPercentageOfIncome(string $date, float|int $cell_value): ?string
+ {
+ if (! $this->showPercentage()) {
+ return null;
+ }
+
+ $income_total = $this->footer_totals['income'][$date] ?? 0;
+
+ if ($income_total == 0) {
+ return null;
+ }
+
+ $pct = round($cell_value / $income_total * 100, 1);
+
+ return setting('localisation.percent_position') == 'after'
+ ? $pct . '%'
+ : '%' . $pct;
+ }
+
+ public function getDrillDownUrl(string $date, int|string $id): string
+ {
+ [$date_start, $date_end] = $this->getDateRangeForDrillDown($date);
+
+ $group = $this->getGroup();
+
+ // category_id:519 paid_at>=2026-03-01 paid_at<=2026-03-29
+ $search = implode(
+ separator: ' ',
+ array: [
+ "{$group}_id:{$id}",
+ "paid_at>={$date_start}",
+ "paid_at<={$date_end}",
+ ],
+ );
+
+ return route('transactions.index') . '?list_records=all&search=' . $search;
+ }
+
+ private function getDateRangeForDrillDown(string $date): array
+ {
+ switch ($this->getPeriod()) {
+ case 'yearly':
+ $range = [
+ trim($date) . '-01-01',
+ trim($date) . '-12-31',
+ ];
+
+ break;
+ case 'quarterly':
+ [$d_start, $d_end] = array_map('trim', explode(' - ', $date, 2));
+
+ $range = [
+ Date::createFromFormat('M Y', $d_start)->startOfMonth()->format('Y-m-d'),
+ Date::createFromFormat('M Y', $d_end)->endOfMonth()->format('Y-m-d'),
+ ];
+
+ break;
+ case 'weekly':
+ [$d_start, $d_end] = array_map('trim', explode(' - ', $date, 2));
+
+ $range = [
+ Date::createFromFormat('d M Y', $d_start)->startOfDay()->format('Y-m-d'),
+ Date::createFromFormat('d M Y', $d_end)->endOfDay()->format('Y-m-d'),
+ ];
+
+ break;
+ default: // monthly
+ $range = [
+ Date::createFromFormat('M Y', $date)->startOfMonth()->format('Y-m-d'),
+ Date::createFromFormat('M Y', $date)->endOfMonth()->format('Y-m-d'),
+ ];
+
+ break;
+ }
+
+ // Clamp to report's start_date/end_date if present
+ $report_start = request('start_date');
+ $report_end = request('end_date');
+
+ if ($report_start && $report_end) {
+ $range[0] = max($range[0], $report_start);
+ $range[1] = min($range[1], $report_end);
+ }
+
+ return $range;
+ }
+}
diff --git a/app/Reports/TaxSummary.php b/app/Reports/TaxSummary.php
new file mode 100644
index 00000000..aa463704
--- /dev/null
+++ b/app/Reports/TaxSummary.php
@@ -0,0 +1,227 @@
+views['detail.content.header'] = 'reports.tax_summary.content.header';
+ $this->views['detail.content.footer'] = 'reports.tax_summary.content.footer';
+ $this->views['detail.table.header'] = 'reports.tax_summary.table.header';
+ $this->views['detail.table.footer'] = 'reports.tax_summary.table.footer';
+ }
+
+ public function setTables()
+ {
+ $withholding = ($this->getSetting('withholding') == 'yes') ? 'withholding' : 'notWithholding';
+
+ $taxes = Tax::enabled()->$withholding()->notRate(0)->orderBy('name')->pluck('name')->toArray();
+
+ $this->tables = array_combine($taxes, $taxes);
+ }
+
+ public function setData()
+ {
+ switch ($this->getBasis()) {
+ case 'cash':
+ // Invoice Payments
+ $invoices = $this->applyFilters(
+ model: Transaction::with('recurring', 'invoice', 'invoice.totals')->income()->isDocument()->isNotTransfer(),
+ args: ['date_field' => 'paid_at', 'model_type' => 'income'],
+ )->get();
+ $this->setTotals($invoices, 'paid_at');
+
+ // Bill Payments
+ $bills = $this->applyFilters(
+ model: Transaction::with('recurring', 'bill', 'bill.totals')->expense()->isDocument()->isNotTransfer(),
+ args: ['date_field' => 'paid_at', 'model_type' => 'expense'],
+ )->get();
+ $this->setTotals($bills, 'paid_at');
+
+ break;
+ default:
+ // Invoices
+ $invoices = $this->applyFilters(
+ model: Document::invoice()->with('recurring', 'totals', 'transactions', 'items')->accrued(),
+ args: ['date_field' => 'issued_at', 'model_type' => 'invoice'],
+ )->get();
+ Recurring::reflect($invoices, 'issued_at');
+ $this->setTotals($invoices, 'issued_at');
+
+ // Bills
+ $bills = $this->applyFilters(
+ model: Document::bill()->with('recurring', 'totals', 'transactions', 'items')->accrued(),
+ args: ['date_field' => 'issued_at', 'model_type' => 'bill'],
+ )->get();
+ Recurring::reflect($bills, 'issued_at');
+ $this->setTotals($bills, 'issued_at');
+
+ break;
+ }
+
+ // Incomes
+ $incomes = $this->applyFilters(
+ model: Transaction::with('taxes')->income()->isNotDocument()->isNotTransfer(),
+ args: ['date_field' => 'paid_at', 'model_type' => 'income'],
+ )->get();
+ $this->setTotals($incomes, 'paid_at');
+
+ // Expenses
+ $expenses = $this->applyFilters(
+ model: Transaction::with('taxes')->expense()->isNotDocument()->isNotTransfer(),
+ args: ['date_field' => 'paid_at', 'model_type' => 'expense'],
+ )->get();
+ $this->setTotals($expenses, 'paid_at');
+ }
+
+ public function setTotals($items, $date_field, $check_type = false, $table = 'default', $with_tax = true)
+ {
+ foreach ($items as $item) {
+ // Make groups extensible
+ $item = $this->applyGroups($item);
+
+ $type = ($item->type === Document::INVOICE_TYPE || $item->type === 'income') ? 'income' : 'expense';
+
+ if ($item instanceof Transaction && empty($item->document_id)) {
+ $this->setTransactionTaxTotal($item, $type, $date_field);
+ continue;
+ }
+
+ $date = $this->getFormattedDate(Date::parse($item->$date_field));
+
+ if ($date_field == 'paid_at') {
+ $document_type = ($item->type == 'income') ? 'invoice' : 'bill';
+ $item_totals = $item->$document_type->totals;
+ $type_item = $item->$document_type;
+ } else {
+ $item_totals = $item->totals;
+ }
+
+ foreach ($item_totals as $item_total) {
+ if ($item_total->code != 'tax') {
+ continue;
+ }
+
+ $item_total_name = $item_total->name;
+
+ if (
+ !isset($this->row_values[$item_total_name][$type][$date])
+ || !isset($this->footer_totals[$item_total_name][$date])
+ ) {
+ $item_total_name = Str::lcfirst($item_total_name); // #Clickup@86c3nzq4r
+
+ if (!isset($this->row_values[$item_total_name][$type][$date])
+ || !isset($this->footer_totals[$item_total_name][$date])
+ ) {
+ continue;
+ }
+ }
+
+ if ($date_field == 'paid_at') {
+ if ($type_item->amount != 0) {
+ $rate = ($item->amount * 100) / $type_item->amount;
+ $item_amount = ($item_total->amount / 100) * $rate;
+ } else {
+ $item_amount = $item_total->amount;
+ }
+ } else {
+ $item_amount = $item_total->amount;
+ }
+
+ $amount = $this->convertToDefault($item_amount, $item->currency_code, $item->currency_rate);
+
+ if ($type == 'income') {
+ $this->row_values[$item_total_name][$type][$date] += $amount;
+
+ $this->footer_totals[$item_total_name][$date] += $amount;
+ } else {
+ $this->row_values[$item_total_name][$type][$date] -= $amount;
+
+ $this->footer_totals[$item_total_name][$date] -= $amount;
+ }
+ }
+ }
+ }
+
+ public function setTransactionTaxTotal($item, $type, $date_field)
+ {
+ if (empty($item->taxes)) {
+ return;
+ }
+
+ $date = $this->getFormattedDate(Date::parse($item->$date_field));
+
+ foreach ($item->taxes as $tax) {
+ if (
+ !isset($this->row_values[$tax->name][$type][$date])
+ || !isset($this->footer_totals[$tax->name][$date])
+ ) {
+ continue;
+ }
+
+ $amount = $this->convertToDefault($tax->amount, $item->currency_code, $item->currency_rate);
+
+ if ($type == 'income') {
+ $this->row_values[$tax->name][$type][$date] += $amount;
+
+ $this->footer_totals[$tax->name][$date] += $amount;
+ } else {
+ $this->row_values[$tax->name][$type][$date] -= $amount;
+
+ $this->footer_totals[$tax->name][$date] -= $amount;
+ }
+ }
+ }
+
+ public function getFields()
+ {
+ return [
+ $this->getPeriodField(),
+ $this->getBasisField(),
+ $this->getWithholdingField(),
+ ];
+ }
+
+ public function getWithholdingField()
+ {
+ return [
+ 'type' => 'selectGroup',
+ 'name' => 'withholding',
+ 'title' => trans('taxes.withholding'),
+ 'icon' => 'hand-holding-usd',
+ 'values' => [
+ 'yes' => trans('general.yes'),
+ 'no' => trans('general.no'),
+ ],
+ 'selected' => 'no',
+ 'attributes' => [
+ 'required' => 'required',
+ ],
+ ];
+ }
+}
diff --git a/app/Scopes/Category.php b/app/Scopes/Category.php
new file mode 100644
index 00000000..c9e7ae61
--- /dev/null
+++ b/app/Scopes/Category.php
@@ -0,0 +1,26 @@
+expectsJson()) {
+ return;
+ }*/
+
+ $builder->whereNull('parent_id');
+ }
+}
diff --git a/app/Scopes/Company.php b/app/Scopes/Company.php
new file mode 100644
index 00000000..592ec4c9
--- /dev/null
+++ b/app/Scopes/Company.php
@@ -0,0 +1,51 @@
+isNotTenantable()) {
+ return;
+ }
+
+ $table = $model->getTable();
+
+ // Skip for specific tables
+ // Note: 'notifications' is excluded because Laravel's notification system
+ // links to users (not companies). Queries against notifications should always
+ // be scoped to the authenticated user (e.g. auth()->user()->notifications)
+ // to avoid cross-company data exposure.
+ $skip_tables = [
+ 'jobs', 'firewall_ips', 'firewall_logs', 'migrations', 'notifications', 'role_companies',
+ 'role_permissions', 'sessions', 'user_companies', 'user_dashboards', 'user_permissions', 'user_roles',
+ ];
+
+ if (in_array($table, $skip_tables)) {
+ return;
+ }
+
+ // Skip if already exists
+ if ($this->scopeColumnExists($builder, '', 'company_id')) {
+ return;
+ }
+
+ // Apply company scope
+ $builder->where($table . '.company_id', '=', company_id());
+ }
+}
diff --git a/app/Scopes/Contact.php b/app/Scopes/Contact.php
new file mode 100644
index 00000000..a8f6eb05
--- /dev/null
+++ b/app/Scopes/Contact.php
@@ -0,0 +1,28 @@
+applyNotRecurringScope($builder, $model);
+ }
+}
diff --git a/app/Scopes/Transaction.php b/app/Scopes/Transaction.php
new file mode 100644
index 00000000..0fea4970
--- /dev/null
+++ b/app/Scopes/Transaction.php
@@ -0,0 +1,27 @@
+applyNotRecurringScope($builder, $model);
+
+ $this->applyNotSplitScope($builder, $model);
+ }
+}
diff --git a/app/Traits/Categories.php b/app/Traits/Categories.php
new file mode 100644
index 00000000..734b0e04
--- /dev/null
+++ b/app/Traits/Categories.php
@@ -0,0 +1,342 @@
+type ?? $this->category->type ?? $this->model->type ?? Category::INCOME_TYPE;
+
+ return in_array($type, $this->getIncomeCategoryTypes());
+ }
+
+ public function isExpenseCategory(): bool
+ {
+ $type = $this->type ?? $this->category->type ?? $this->model->type ?? Category::EXPENSE_TYPE;
+
+ return in_array($type, $this->getExpenseCategoryTypes());
+ }
+
+ public function isDirectCostCategory(): bool
+ {
+ $type = $this->type ?? $this->category->type ?? $this->model->type ?? Category::DIRECT_COST_TYPE;
+
+ return in_array($type, $this->getDirectCostCategoryTypes());
+ }
+
+ public function isItemCategory(): bool
+ {
+ $type = $this->type ?? $this->category->type ?? $this->model->type ?? Category::ITEM_TYPE;
+
+ return in_array($type, $this->getItemCategoryTypes());
+ }
+
+ public function isOtherCategory(): bool
+ {
+ $type = $this->type ?? $this->category->type ?? $this->model->type ?? Category::OTHER_TYPE;
+
+ return in_array($type, $this->getOtherCategoryTypes());
+ }
+
+ public function getTypeCategoryTypes(string $type, string $return = 'array'): string|array
+ {
+ switch ($type) {
+ case Category::INCOME_TYPE:
+ $types = $this->getIncomeCategoryTypes($return);
+ break;
+ case Category::EXPENSE_TYPE:
+ $types = $this->getExpenseAndDirectCostCategoryTypes($return);
+ break;
+ case Category::DIRECT_COST_TYPE:
+ $types = $this->getDirectCostCategoryTypes($return);
+ break;
+ case Category::ITEM_TYPE:
+ $types = $this->getItemCategoryTypes($return);
+ break;
+ case Category::OTHER_TYPE:
+ $types = $this->getOtherCategoryTypes($return);
+ break;
+ default:
+ $types = ($return == 'array') ? [$type] : $type;
+ }
+
+ return $types;
+ }
+
+ public function getIncomeCategoryTypes(string $return = 'array'): string|array
+ {
+ return $this->getCategoryTypesByIndex(Category::INCOME_TYPE, $return);
+ }
+
+ public function getExpenseCategoryTypes(string $return = 'array'): string|array
+ {
+ return $this->getCategoryTypesByIndex(Category::EXPENSE_TYPE, $return);
+ }
+
+ public function getDirectCostCategoryTypes(string $return = 'array'): string|array
+ {
+ return $this->getCategoryTypesByIndex(Category::DIRECT_COST_TYPE, $return);
+ }
+
+ public function getExpenseAndDirectCostCategoryTypes(string $return = 'array'): string|array
+ {
+ $types = array_merge($this->getExpenseCategoryTypes(), $this->getDirectCostCategoryTypes());
+
+ return ($return == 'array') ? $types : implode(',', $types);
+ }
+
+ public function getItemCategoryTypes(string $return = 'array'): string|array
+ {
+ return $this->getCategoryTypesByIndex(Category::ITEM_TYPE, $return);
+ }
+
+ public function getOtherCategoryTypes(string $return = 'array'): string|array
+ {
+ return $this->getCategoryTypesByIndex(Category::OTHER_TYPE, $return);
+ }
+
+ public function getCategoryTypesByIndex(string $index, string $return = 'array'): string|array
+ {
+ $types = (string) setting('category.type.' . $index);
+
+ return ($return == 'array') ? explode(',', $types) : $types;
+ }
+
+ public function addIncomeCategoryType(string $new_type): void
+ {
+ $this->addCategoryType($new_type, Category::INCOME_TYPE);
+ }
+
+ public function addExpenseCategoryType(string $new_type): void
+ {
+ $this->addCategoryType($new_type, Category::EXPENSE_TYPE);
+ }
+
+ public function addDirectCostCategoryType(string $new_type): void
+ {
+ $this->addCategoryType($new_type, Category::DIRECT_COST_TYPE);
+ }
+
+ public function addItemCategoryType(string $new_type): void
+ {
+ $this->addCategoryType($new_type, Category::ITEM_TYPE);
+ }
+
+ public function addOtherCategoryType(string $new_type): void
+ {
+ $this->addCategoryType($new_type, Category::OTHER_TYPE);
+ }
+
+ public function addCategoryType(string $new_type, string $index): void
+ {
+ $types = !empty(setting('category.type.' . $index)) ? explode(',', setting('category.type.' . $index)) : [];
+
+ if (in_array($new_type, $types)) {
+ return;
+ }
+
+ $types[] = $new_type;
+
+ setting([
+ 'category.type.' . $index => implode(',', $types),
+ ])->save();
+ }
+
+ public function isGroupCategoryType(): bool
+ {
+ $setting_category_types = setting('category.type');
+
+ foreach ($setting_category_types as $type => $category) {
+ $categories = explode(',', $category);
+
+ if (count($categories) > 1) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function hideCodeCategoryType(string $type, bool $default = true): bool
+ {
+ return $this->hideCodeCategoryTypes($type)[$type] ?? $default;
+ }
+
+ public function hideCodeCategoryTypes(string|array $types): array
+ {
+ $types = is_string($types) ? explode(',', $types) : $types;
+
+ $type_codes = [];
+
+ foreach ($types as $type) {
+ $config_type = config('type.category.' . $type, []);
+
+ $type_codes[$type] = ! empty($config_type['hide']) && in_array('code', $config_type['hide']) ? true : false;
+ }
+
+ return $type_codes;
+ }
+
+ public function getCategoryTypes(bool $translate = true, bool $group = false, array $types = []): array
+ {
+ $category_types = [];
+
+ $configs = empty($types) ? config('type.category') : array_intersect_key(config('type.category'), array_flip($types));
+
+ foreach ($configs as $type => $attr) {
+ if (! is_array($attr)) {
+ continue;
+ }
+
+ if (is_callable($attr['translation']['type'] ?? null)) {
+ $name = $attr['translation']['type']();
+ } else {
+ $plural_type = Str::plural($type);
+
+ $name = $attr['translation']['prefix'] . '.' . $plural_type;
+
+ if (!empty($attr['alias'])) {
+ $name = $attr['alias'] . '::' . $name;
+ }
+
+ $name = $translate ? trans_choice($name, 1) : $name;
+ }
+
+ if ($group) {
+ $group_value = $attr['group'] ?? $type;
+
+ if (is_callable($attr['translation']['group'] ?? null)) {
+ $group_value = $attr['translation']['group']();
+ }
+
+ $category_types[$group_value][$type] = $name;
+ } else {
+ $category_types[$type] = $name;
+ }
+ }
+
+ return $category_types;
+ }
+
+ public function getCategoryTypeLabel(string $type, int $count = 1): string
+ {
+ $config = config('type.category.' . $type, []);
+
+ if (empty($config)) {
+ return $type;
+ }
+
+ if (is_callable($config['translation']['type'] ?? null)) {
+ $name = $config['translation']['type']();
+ } else {
+ $group = $config['group'] ?? $type;
+ $plural_type = $count === 1 ? Str::plural($group, 1) : Str::plural($group);
+ $name = ($config['translation']['prefix'] ?? 'general') . '.' . $plural_type;
+
+ if (! empty($config['alias'])) {
+ $name = $config['alias'] . '::' . $name;
+ }
+ }
+
+ return trans_choice($name, $count);
+ }
+
+ public function getCategoryTabs(): array
+ {
+ $tabs = [];
+ $configs = config('type.category');
+
+ foreach ($configs as $type => $attr) {
+ if (! is_array($attr)) {
+ continue;
+ }
+
+ $tab_key = 'categories-' . ($attr['group'] ?? $type);
+
+ if (isset($tabs[$tab_key])) {
+ $tabs[$tab_key]['key'] .= ',' . $type;
+ continue;
+ }
+
+ if (! empty($attr['translation']['group'])) {
+ if (is_callable($attr['translation']['group'])) {
+ $name = $attr['translation']['group']();
+ } elseif (is_array($attr['translation']['group'])) {
+ $name = trans_choice($attr['translation']['group'][0], $attr['translation']['group'][1]);
+ } else {
+ $name = trans($attr['translation']['group']);
+ }
+ } else {
+ $plural_type = Str::plural($attr['group'] ?? $type);
+
+ $name = $attr['translation']['prefix'] . '.' . $plural_type;
+
+ if (! empty($attr['alias'])) {
+ $name = $attr['alias'] . '::' . $name;
+ }
+
+ $name = trans_choice($name, 2);
+ }
+
+ $tabs[$tab_key] = [
+ 'key' => $type,
+ 'name' => $name,
+ 'show_code' => $attr['show_code'] ?? false,
+ ];
+ }
+
+ return $tabs;
+ }
+
+ public function getCategoryWithoutChildren(int $id): mixed
+ {
+ return Category::getWithoutChildren()->find($id);
+ }
+
+ public function getTransferCategoryId(): mixed
+ {
+ // 1 hour set cache for same query
+ return Cache::remember('transferCategoryId.' . company_id(), 60, function () {
+ return Category::other()->pluck('id')->first();
+ });
+ }
+
+ public function isTransferCategory(): bool
+ {
+ $id = $this->id ?? $this->category->id ?? $this->model->id ?? 0;
+
+ return $id == $this->getTransferCategoryId();
+ }
+
+ public function getChildrenCategoryIds($category)
+ {
+ $ids = [];
+
+ foreach ($category->sub_categories as $sub_category) {
+ $ids[] = $sub_category->id;
+
+ if ($sub_category->sub_categories) {
+ $ids = array_merge($ids, $this->getChildrenCategoryIds($sub_category));
+ }
+ }
+
+ return $ids;
+ }
+
+ /**
+ * Finds existing maximum code and increase it
+ *
+ * @return mixed
+ */
+ public function getNextCategoryCode()
+ {
+ return Category::isNotSubCategory()->get(['code'])->reject(function ($category) {
+ return !preg_match('/^[0-9]*$/', $category->code);
+ })->max('code') + 1;
+ }
+}
diff --git a/app/Traits/Charts.php b/app/Traits/Charts.php
new file mode 100644
index 00000000..77e71282
--- /dev/null
+++ b/app/Traits/Charts.php
@@ -0,0 +1,235 @@
+ [],
+ 'labels' => [],
+ 'values' => [],
+ ];
+
+ public $donut = [
+ 'colors' => [],
+ 'labels' => [],
+ 'values' => [],
+ ];
+
+ public function addToDonutChart($color, $label, $value)
+ {
+ $this->donut['colors'][] = $color;
+ $this->donut['labels'][] = $label;
+ $this->donut['values'][] = (int) $value;
+ }
+
+ public function addMoneyToDonutChart($color, $amount, $description = '')
+ {
+ $label = money($amount)->formatForHumans();
+
+ if (!empty($description)) {
+ $label .= ' - ' . $description;
+ }
+
+ $this->addToDonutChart($color, $label, $amount);
+ }
+
+ public function getDonutChart($name, $width = '100%', $height = 300, $limit = 10)
+ {
+ // Show donut prorated if there is no value
+ if (array_sum($this->donut['values']) == 0) {
+ foreach ($this->donut['values'] as $key => $value) {
+ $this->donut['values'][$key] = 1;
+ }
+ }
+
+ // Get 6 categories by amount
+ $colors = $labels = [];
+ $values = collect($this->donut['values'])->sort()->reverse()->take($limit)->all();
+
+ foreach ($values as $id => $val) {
+ $colors[$id] = $this->donut['colors'][$id];
+ $labels[$id] = $this->donut['labels'][$id];
+ }
+
+ $chart = new Chart();
+
+ $chart->setType('donut')
+ ->setWidth($width)
+ ->setHeight($height)
+ ->setDefaultLocale($this->getDefaultLocaleOfChart())
+ ->setLocales($this->getLocaleTranslationOfChart())
+ ->setLabels(array_values($labels))
+ ->setColors(array_values($colors))
+ ->setDataset($name, 'donut', array_values($values));
+
+ return $chart;
+ }
+
+ public function addToBarChart($color, $label, $value)
+ {
+ $this->bar['colors'][] = $color;
+ $this->bar['labels'][] = $label;
+ $this->bar['values'][] = (int) $value;
+ }
+
+ public function getBarChart($name, $width = '100%', $height = 160)
+ {
+ $chart = new Chart();
+
+ $chart->setType('bar')
+ ->setWidth($width)
+ ->setHeight($height)
+ ->setDefaultLocale($this->getDefaultLocaleOfChart())
+ ->setLocales($this->getLocaleTranslationOfChart())
+ ->setLabels(array_values($this->bar['labels']))
+ ->setColors($this->bar['colors']);
+
+ foreach ($this->bar['values'] as $key => $value) {
+ $chart->setDataset($this->bar['labels'][$key], 'bar', $value);
+ }
+
+ return $chart;
+ }
+
+ public function getChartLabelFormatter($type = 'money', $position = null)
+ {
+ $label = '';
+ $decimal_mark = str_replace("'", "\\'", currency()->getDecimalMark());
+ $thousands_separator = str_replace("'", "\\'", currency()->getThousandsSeparator());
+ $symbol = str_replace("'", "\\'", currency()->getSymbol());
+ $symbol_first = str_replace("'", "\\'", currency()->isSymbolFirst());
+ $precision = str_replace("'", "\\'", currency()->getPrecision());
+ $percent_position = $position ?: setting('localisation.percent_position');
+
+ switch ($type) {
+ case 'integer':
+ $label = new Raw("function(value) {
+ return value
+ }");
+ break;
+ case 'percent':
+ $label = new Raw("function(value) {
+ " . ($percent_position == 'right' ? "return value + '%';" : "return '%' + value;") . "
+ }");
+ break;
+ default:
+ $label = new Raw("function(value) {
+ const moneySettings = {
+ decimal: '" . $decimal_mark . "',
+ thousands: '". $thousands_separator . "',
+ symbol: '" . $symbol . "',
+ isPrefix: '" . $symbol_first . "',
+ precision: '" . $precision . "',
+ };
+
+ const formattedCurrency = function (input, opt = moneySettings) {
+ if (typeof input === 'number') {
+ input = input.toFixed(fixed(opt.precision))
+ }
+
+ function fixed (precision) {
+ return Math.max(0, Math.min(precision, 20));
+ };
+
+ function toStr(value) {
+ return value ? value.toString() : '';
+ };
+
+ function numbersToCurrency(numbers, precision) {
+ var exp = Math.pow(10, precision);
+ var float = parseFloat(numbers) / exp;
+
+ return float.toFixed(fixed(precision));
+ };
+
+ function joinIntegerAndDecimal (integer, decimal, separator) {
+ return decimal ? integer + separator + decimal : integer;
+ };
+
+ if (typeof input === 'number') {
+ input = input.toFixed(fixed(opt.precision));
+ };
+
+ var negative = input.indexOf('-') >= 0 ? '-' : '';
+ var numbers = toStr(input).replace(/\D+/g, '') || '0';
+ var currency = numbersToCurrency(numbers, opt.precision);
+ var parts = toStr(currency).split('.');
+ var integer = parts[0].replace(/(\d)(?=(?:\d{3})+\b)/gm, ('$1' + opt.thousands));
+ var decimal = parts[1];
+
+ if (opt.isPrefix == 1) {
+ return opt.symbol + negative + joinIntegerAndDecimal(integer, decimal, opt.decimal);
+ }
+
+ return negative + joinIntegerAndDecimal(integer, decimal, opt.decimal) + opt.symbol;
+ };
+
+ return formattedCurrency(value, moneySettings);
+ }");
+ }
+
+ return $label;
+ }
+
+ public function getDefaultLocaleOfChart(): string
+ {
+ $default = 'en';
+
+ $short = language()->getShortCode();
+ $long = language()->getLongCode();
+
+ $short_path = public_path('vendor/apexcharts/locales/' . $short . '.json');
+ $long_path = public_path('vendor/apexcharts/locales/' . $long . '.json');
+
+ if (is_file($short_path)) {
+ return $short;
+ }
+
+ if (is_file($long_path)) {
+ return $long;
+ }
+
+ return $default;
+ }
+
+ public function getLocaleTranslationOfChart(?string $locale = null): array
+ {
+ $translations = [];
+
+ $user_locale = $locale ?: $this->getDefaultLocaleOfChart();
+
+ $locales = ($user_locale == 'en') ? [$user_locale] : [$user_locale, 'en'];
+
+ foreach ($locales as $loc) {
+ $translations[] = json_decode(
+ file_get_contents(
+ public_path('vendor/apexcharts/locales/' . $loc . '.json')
+ )
+ );
+ }
+
+ return $translations;
+ }
+
+ // @deprecated version 3.0.0
+ public function addToDonut($color, $label, $value)
+ {
+ $this->addToDonutChart($color, $label, $value);
+ }
+
+ // @deprecated version 3.0.0
+ public function addMoneyToDonut($color, $amount, $description = '')
+ {
+ $this->addMoneyToDonutChart($color, $amount, $description);
+ }
+
+ // @deprecated version 3.0.0
+ public function addToBar($color, $label, $value)
+ {
+ $this->addToBarChart($color, $label, $value);
+ }
+}
diff --git a/app/Traits/Cloud.php b/app/Traits/Cloud.php
new file mode 100644
index 00000000..ea839b76
--- /dev/null
+++ b/app/Traits/Cloud.php
@@ -0,0 +1,47 @@
+moduleIsEnabled('roles')) {
+ return route('roles.roles.index');
+ }
+
+ return route('apps.app.show', [
+ 'alias' => 'roles',
+ 'utm_source' => $location,
+ 'utm_medium' => 'app',
+ 'utm_campaign' => 'roles',
+ ]);
+ }
+
+ public function getCloudBankFeedsUrl($location = 'widget')
+ {
+ if ($this->moduleIsEnabled('bank-feeds')) {
+ return route('bank-feeds.bank-connections.index');
+ }
+
+ return route('apps.app.show', [
+ 'alias' => 'bank-feeds',
+ 'utm_source' => $location,
+ 'utm_medium' => 'app',
+ 'utm_campaign' => 'bank_feeds',
+ ]);
+ }
+
+ public function isCloud()
+ {
+ if (config('app.offline')) {
+ return false;
+ }
+
+ return request()->getHost() == config('cloud.host', 'app.akaunting.com');
+ }
+}
diff --git a/app/Traits/Companies.php b/app/Traits/Companies.php
new file mode 100644
index 00000000..dc593102
--- /dev/null
+++ b/app/Traits/Companies.php
@@ -0,0 +1,208 @@
+request ?: request();
+
+ if (request_is_api($request)) {
+ return $this->getCompanyIdFromApi($request);
+ }
+
+ if (request_is_mcp($request)) {
+ return $this->getCompanyIdFromMcp($request);
+ }
+
+ return $this->getCompanyIdFromWeb($request);
+ }
+
+ public function getCompanyIdFromWeb($request): ?int
+ {
+ return $this->getCompanyIdFromRoute($request) ?: ($this->getCompanyIdFromQuery($request) ?: $this->getCompanyIdFromHeader($request));
+ }
+
+ public function getCompanyIdFromApi($request): ?int
+ {
+ // Priority 0: OAuth token — when the request carries a Bearer token, the token's company_id is the authoritative source.
+ // This ensures an OAuth client can only access the company the token was issued for, regardless of URL or headers.
+ if (config('oauth.enabled', false) && $request->bearerToken()) {
+ $company_id = $this->getCompanyIdFromToken($request);
+
+ if ($company_id) {
+ return $company_id;
+ }
+ }
+
+ // Priority 1: Query string (?company_id=2)
+ $company_id = $this->getCompanyIdFromQuery($request);
+
+ // Priority 2: Header (X-Company: 2)
+ if (! $company_id) {
+ $company_id = $this->getCompanyIdFromHeader($request);
+ }
+
+ // Priority 3: First company of the user (fallback)
+ if (! $company_id) {
+ $company_id = $this->getFirstCompanyOfUser()?->id;
+ }
+
+ return $company_id;
+ }
+
+ public function getCompanyIdFromMcp($request): ?int
+ {
+ // Priority 0: OAuth Token (must be first since it doesn't rely on session or route)
+ $company_id = $this->getCompanyIdFromToken($request);
+
+ // Priority 1: Query string (?company_id=2)
+ if (! $company_id) {
+ $company_id = $this->getCompanyIdFromQuery($request);
+ }
+
+ // Priority 2: Header (X-Company: 2)
+ if (! $company_id) {
+ $company_id = $this->getCompanyIdFromHeader($request);
+ }
+
+ // Priority 3: First company of the user (fallback)
+ if (! $company_id) {
+ $user = null;
+
+ try {
+ $user = auth()->guard('passport')->user();
+ } catch (\Throwable $e) {
+ logger()->debug('OAuth: Passport guard unavailable during MCP company resolution', [
+ 'error' => $e->getMessage(),
+ ]);
+ }
+
+ if (! $user) {
+ $user = auth()->user();
+ }
+
+ if (! $user) {
+ logger()->debug('OAuth: No authenticated user found when trying to get first company');
+
+ return null;
+ }
+
+ $company = $user->withoutEvents(fn () => $user->companies()->enabled()->first());
+
+ $company_id = $company?->id;
+ }
+
+ return $company_id;
+ }
+
+ /**
+ * Get company ID from OAuth access token.
+ *
+ * When a user is authenticated via OAuth (Passport), extract the company_id
+ * that was assigned to the token during authorization approval.
+ *
+ * @param \Illuminate\Http\Request $request
+ * @return int|null
+ */
+ protected function getCompanyIdFromToken($request): ?int
+ {
+ // Check if OAuth is enabled and user is authenticated via Passport
+ if (! config('oauth.enabled')) {
+ logger()->debug('OAuth: Disabled, skipping token company_id extraction');
+
+ return null;
+ }
+
+ try {
+ // Try passport guard first
+ if (auth()->guard('passport')->check()) {
+ $user = auth()->guard('passport')->user();
+
+ if ($user && method_exists($user, 'token')) {
+ $token = $user->token();
+
+ if ($token && isset($token->company_id) && $token->company_id) {
+ logger()->debug('OAuth: Extracted company_id from token', [
+ 'company_id' => $token->company_id,
+ 'user_id' => $user->id,
+ ]);
+
+ return (int) $token->company_id;
+ }
+
+ // Token exists but no company_id stored — use the passport user's first company
+ logger()->debug('OAuth: No company_id on token, falling back to passport user\'s first company');
+ $company = $user->withoutEvents(fn () => $user->companies()->enabled()->first());
+ if ($company) {
+ return (int) $company->id;
+ }
+
+ logger()->debug('OAuth: No companies found for authenticated passport user');
+ }
+
+ logger()->debug('OAuth: No company_id found on authenticated user\'s token');
+ }
+
+ // Fallback: Try to get token from request directly
+ if ($request->bearerToken()) {
+ $tokenId = $request->bearerToken();
+
+ // Try to find the token in database
+ $tokenModel = config('oauth.company_aware', true)
+ ? \Modules\Oauth\Models\AccessToken::withoutGlobalScope('company')->where('id', $tokenId)->first()
+ : null;
+
+ if ($tokenModel && isset($tokenModel->company_id)) {
+ logger()->debug('OAuth: Extracted company_id from token model', [
+ 'company_id' => $tokenModel->company_id,
+ 'token_id' => $tokenModel->id,
+ ]);
+
+ return (int) $tokenModel->company_id;
+ }
+
+ logger()->debug('OAuth: No company_id found on token model for bearer token');
+ }
+ } catch (\Exception $e) {
+ // Silently fail if OAuth token checking fails
+ // This allows fallback to other methods
+ logger()->debug('OAuth: company_id extraction failed', [
+ 'error' => $e->getMessage(),
+ ]);
+ }
+
+ logger()->debug('OAuth: No company_id found in token');
+
+ return null;
+ }
+
+ public function getCompanyIdFromRoute($request): ?int
+ {
+ $route_id = (int) $request->route('company_id');
+ $segment_id = (int) $request->segment(1);
+
+ return $route_id ?: $segment_id;
+ }
+
+ public function getCompanyIdFromQuery($request): ?int
+ {
+ return (int) $request->query('company_id');
+ }
+
+ public function getCompanyIdFromHeader($request): ?int
+ {
+ return (int) $request->header('X-Company');
+ }
+}
diff --git a/app/Traits/Contacts.php b/app/Traits/Contacts.php
new file mode 100644
index 00000000..06cb108c
--- /dev/null
+++ b/app/Traits/Contacts.php
@@ -0,0 +1,103 @@
+type ?? $this->contact->type ?? $this->model->type ?? 'customer';
+
+ return in_array($type, $this->getCustomerTypes());
+ }
+
+ public function isVendor()
+ {
+ $type = $this->type ?? $this->contact->type ?? $this->model->type ?? 'vendor';
+
+ return in_array($type, $this->getVendorTypes());
+ }
+
+ public function isEmployee()
+ {
+ $type = $this->type ?? $this->contact->type ?? $this->model->type ?? 'employee';
+
+ return in_array($type, $this->getEmployeeTypes());
+ }
+
+ public function getCustomerTypes($return = 'array')
+ {
+ return $this->getContactTypes('customer', $return);
+ }
+
+ public function getVendorTypes($return = 'array')
+ {
+ return $this->getContactTypes('vendor', $return);
+ }
+
+ public function getEmployeeTypes($return = 'array')
+ {
+ return $this->getContactTypes('employee', $return);
+ }
+
+ public function getContactTypes($index, $return = 'array')
+ {
+ $types = (string) setting('contact.type.' . $index);
+
+ return ($return == 'array') ? explode(',', $types) : $types;
+ }
+
+ public function addCustomerType($new_type)
+ {
+ $this->addContactType($new_type, 'customer');
+ }
+
+ public function addVendorType($new_type)
+ {
+ $this->addContactType($new_type, 'vendor');
+ }
+
+ public function addEmployeeType($new_type)
+ {
+ $this->addContactType($new_type, 'employee');
+ }
+
+ public function addContactType($new_type, $index)
+ {
+ $types = explode(',', setting('contact.type.' . $index));
+
+ if (in_array($new_type, $types)) {
+ return;
+ }
+
+ $types[] = $new_type;
+
+ setting([
+ 'contact.type.' . $index => implode(',', $types),
+ ])->save();
+ }
+
+ public function getFormattedAddress($city = null, $country = null, $state = null, $zip_code = null)
+ {
+ if (is_null($city)
+ && is_null($country)
+ && is_null($state)
+ && is_null($zip_code)
+ ) {
+ return null;
+ }
+
+ $address_format = (string) setting('default.address_format');
+
+ // Keep formatted address as plain text. Views convert line breaks to
safely.
+ $address_format = str_ireplace(['
', '
', '
'], "\n", $address_format);
+
+ $formatted_address = str_replace(
+ ["{city}", "{country}", "{state}", "{zip_code}", "\n"],
+ [$city, $country, $state, $zip_code, "\n"],
+ $address_format
+ );
+
+ return $formatted_address;
+ }
+}
diff --git a/app/Traits/Currencies.php b/app/Traits/Currencies.php
new file mode 100644
index 00000000..d84aec71
--- /dev/null
+++ b/app/Traits/Currencies.php
@@ -0,0 +1,85 @@
+format() : $money->getAmount();
+ }
+
+ try {
+ $money = $money->$method((double) $rate);
+ } catch (\Throwable $e) {
+ report($e);
+
+ return 0;
+ }
+
+ return $format ? $money->format() : $money->getAmount();
+ }
+
+ public function convertToDefault($amount, $from, $rate, $format = false, $default = null)
+ {
+ $default_currency = $default ?? $this->getDefaultCurrency();
+
+ return $this->convert('divide', $amount, $from, $default_currency, $rate, $format);
+ }
+
+ public function convertFromDefault($amount, $to, $rate, $format = false, $default = null)
+ {
+ $default_currency = $default ?? $this->getDefaultCurrency();
+
+ return $this->convert('multiply', $amount, $default_currency, $to, $rate, $format);
+ }
+
+ public function convertBetween($amount, $from_code, $from_rate, $to_code, $to_rate)
+ {
+ $default_amount = $amount;
+
+ if ($from_code != default_currency()) {
+ $default_amount = $this->convertToDefault($amount, $from_code, $from_rate);
+ }
+
+ $converted_amount = $this->convertFromDefault($default_amount, $to_code, $to_rate);
+
+ return $converted_amount;
+ }
+
+ public function getAmountConvertedToDefault($format = false, $with_tax = true)
+ {
+ return $this->convertToDefault($this->getAmount($with_tax), $this->currency_code, $this->currency_rate, $format);
+ }
+
+ public function getAmountConvertedFromDefault($format = false, $with_tax = true)
+ {
+ return $this->convertFromDefault($this->getAmount($with_tax), $this->currency_code, $this->currency_rate, $format);
+ }
+
+ public function getAmount($with_tax = true)
+ {
+ return $with_tax ? $this->amount : (isset($this->amount_without_tax) ? $this->amount_without_tax : $this->amount);
+ }
+
+ public function getDefaultCurrency()
+ {
+ return !empty($this->default_currency_code) ? $this->default_currency_code : default_currency();
+ }
+
+ public function setDefaultCurrency($code)
+ {
+ $this->default_currency_code = $code;
+ }
+
+ public function unsetDefaultCurrency()
+ {
+ unset($this->default_currency_code);
+ }
+}
diff --git a/app/Traits/Database.php b/app/Traits/Database.php
new file mode 100644
index 00000000..f193323f
--- /dev/null
+++ b/app/Traits/Database.php
@@ -0,0 +1,13 @@
+ '-', 'slash' => '/', 'dot' => '.', 'comma' => ',', 'space' => ' '];
+
+ $date_format = setting('localisation.date_format', $default) ?? $default;
+
+ $char = setting('localisation.date_separator', 'space') ?? 'space';
+ $date_separator = $chars[$char];
+
+ return str_replace(' ', $date_separator, $date_format);
+ }
+
+ public function scopeDateFilter($query, string $field)
+ {
+ [$start, $end] = $this->getStartAndEndDates();
+
+ return $query->whereBetween($field, [$start->toDateTimeString(), $end->toDateTimeString()]);
+ }
+
+ public function getStartAndEndDates($year = null): array
+ {
+ if (request()->filled('start_date') && request()->filled('end_date')) {
+ $start = Date::parse(request('start_date'))->startOfDay();
+ $end = Date::parse(request('end_date'))->endOfDay();
+ } else {
+ $financial_year = $this->getFinancialYear($year);
+
+ $start = $financial_year->copy()->getStartDate()->startOfDay();
+ $end = $financial_year->copy()->getEndDate()->endOfDay();
+ }
+
+ return [$start, $end];
+ }
+
+ public function getFinancialStart($year = null, $date = null): Date
+ {
+ $start_of_year = Date::now()->startOfYear();
+
+ $setting = explode('-', setting('localisation.financial_start'));
+
+ $day = ! empty($setting[0]) ? $setting[0] : $start_of_year->day;
+ $month = ! empty($setting[1]) ? $setting[1] : $start_of_year->month;
+ $year = $year ?? $start_of_year->year;
+
+ $financial_start = Date::create($year, $month, $day);
+ if ((setting('localisation.financial_denote') == 'ends') && ($financial_start->dayOfYear != 1) ||
+ $financial_start->greaterThan($date ?? Date::now())
+ ) {
+ $financial_start->subYear();
+ }
+
+ return $financial_start;
+ }
+
+ public function getFinancialWeek($year = null, $date = null): CarbonPeriod
+ {
+ $today = Date::today();
+ $financial_weeks = $this->getFinancialWeeks($year, $date);
+
+ foreach ($financial_weeks as $week) {
+ if ($today->lessThan($week->getStartDate()) || $today->greaterThan($week->getEndDate())) {
+ continue;
+ }
+
+ $this_week = $week;
+
+ break;
+ }
+
+ if (! isset($this_week)) {
+ $this_week = $financial_weeks[0];
+ }
+
+ return $this_week;
+ }
+
+ public function getFinancialMonth($year = null, $date = null): CarbonPeriod
+ {
+ $today = Date::today();
+ $financial_months = $this->getFinancialMonths($year, $date);
+
+ foreach ($financial_months as $month) {
+ if ($today->lessThan($month->getStartDate()) || $today->greaterThan($month->getEndDate())) {
+ continue;
+ }
+
+ $this_month = $month;
+
+ break;
+ }
+
+ if (! isset($this_month)) {
+ $this_month = $financial_months[0];
+ }
+
+ return $this_month;
+ }
+
+ public function getFinancialQuarter($year = null, $date = null): CarbonPeriod
+ {
+ $today = Date::today();
+ $financial_quarters = $this->getFinancialQuarters($year, $date);
+
+ foreach ($financial_quarters as $quarter) {
+ if ($today->lessThan($quarter->getStartDate()) || $today->greaterThan($quarter->getEndDate())) {
+ continue;
+ }
+
+ $this_quarter = $quarter;
+
+ break;
+ }
+
+ if (! isset($this_quarter)) {
+ $this_quarter = $financial_quarters[0];
+ }
+
+ return $this_quarter;
+ }
+
+ public function getFinancialYear($year = null, $date = null): CarbonPeriod
+ {
+ $financial_start = $this->getFinancialStart($year, $date);
+
+ return CarbonPeriod::create($financial_start, $financial_start->copy()->addYear()->subDay()->endOfDay());
+ }
+
+ public function getFinancialWeeks($year = null, $date = null): array
+ {
+ $weeks = [];
+
+ $start = $this->getFinancialStart($year, $date);
+
+ $w = 52;
+
+ if (request()->filled('start_date') && request()->filled('end_date')) {
+ $w = Date::parse($start->copy())->diffInWeeks(Date::parse(request('end_date'))) + 1;
+ }
+
+ for ($i = 0; $i < $w; $i++) {
+ $weeks[] = CarbonPeriod::create($start->copy()->addWeeks($i), $start->copy()->addWeeks($i + 1)->subDay()->endOfDay());
+ }
+
+ return $weeks;
+ }
+
+ public function getFinancialMonths($year = null, $date = null): array
+ {
+ $months = [];
+
+ $start = $this->getFinancialStart($year, $date);
+
+ $m = 12;
+
+ if (request()->filled('start_date') && request()->filled('end_date')) {
+ $m = Date::parse($start->copy())->diffInMonths(Date::parse(request('end_date'))) + 1;
+ }
+
+ for ($i = 0; $i < $m; $i++) {
+ $months[] = CarbonPeriod::create($start->copy()->addMonths($i), $start->copy()->addMonths($i + 1)->subDay()->endOfDay());
+ }
+
+ return $months;
+ }
+
+ public function getFinancialQuarters($year = null, $date = null): array
+ {
+ $quarters = [];
+
+ $start = $this->getFinancialStart($year, $date);
+
+ $q = 4;
+
+ /*
+ Previously, diffInQuarters was calculated from start_date, which caused errors
+ when the financial start value differed from the default value.
+ Therefore, this change has been made. Ticket: #8106
+ */
+ if (request()->filled('start_date') && request()->filled('end_date')) {
+ $q = Date::parse($start->copy())->diffInQuarters(Date::parse(request('end_date'))) + 1;
+ }
+
+ for ($i = 0; $i < $q; $i++) {
+ $quarters[] = CarbonPeriod::create($start->copy()->addQuarters($i), $start->copy()->addQuarters($i + 1)->subDay()->endOfDay());
+ }
+
+ return $quarters;
+ }
+
+ public function getDatePickerShortcuts(): array
+ {
+ $today = Date::today();
+ $financial_week = $this->getFinancialWeek();
+ $financial_month = $this->getFinancialMonth();
+ $financial_quarter = $this->getFinancialQuarter();
+ $financial_year = $this->getFinancialYear();
+
+ $date_picker_shortcuts = [
+ trans('general.date_range.this_week') => [
+ 'start' => $financial_week->copy()->getStartDate()->toDateString(),
+ 'end' => $financial_week->copy()->getEndDate()->toDateString(),
+ ],
+ trans('general.date_range.this_month') => [
+ 'start' => $financial_month->copy()->getStartDate()->toDateString(),
+ 'end' => $financial_month->copy()->getEndDate()->toDateString(),
+ ],
+ trans('general.date_range.this_quarter') => [
+ 'start' => $financial_quarter->copy()->getStartDate()->toDateString(),
+ 'end' => $financial_quarter->copy()->getEndDate()->toDateString(),
+ ],
+ trans('general.date_range.this_year') => [
+ 'start' => $financial_year->copy()->getStartDate()->toDateString(),
+ 'end' => $financial_year->copy()->getEndDate()->toDateString(),
+ ],
+ trans('general.date_range.year_to_date') => [
+ 'start' => $financial_year->copy()->getStartDate()->toDateString(),
+ 'end' => $today->copy()->toDateString(),
+ ],
+ trans('general.date_range.previous_week') => [
+ 'start' => $financial_week->copy()->getStartDate()->subWeek()->toDateString(),
+ 'end' => $financial_week->copy()->getEndDate()->subWeek()->toDateString(),
+ ],
+ trans('general.date_range.previous_month') => [
+ 'start' => $financial_month->copy()->getStartDate()->subMonth()->toDateString(),
+ 'end' => $financial_month->copy()->getEndDate()->subMonth()->endOfMonth()->toDateString(),
+ ],
+ trans('general.date_range.previous_quarter') => [
+ 'start' => $financial_quarter->copy()->getStartDate()->subQuarter()->toDateString(),
+ 'end' => $financial_quarter->copy()->getEndDate()->subQuarter()->toDateString(),
+ ],
+ trans('general.date_range.previous_year') => [
+ 'start' => $financial_year->copy()->getStartDate()->subYear()->toDateString(),
+ 'end' => $financial_year->copy()->getEndDate()->subYear()->toDateString(),
+ ],
+ ];
+
+ return $date_picker_shortcuts;
+ }
+
+ public function getDailyDateFormat(): string
+ {
+ return 'd M Y';
+ }
+
+ public function getWeeklyDateFormat(): string
+ {
+ return 'W - M Y';
+ }
+
+ public function getMonthlyDateFormat(): string
+ {
+ return 'M Y';
+ }
+
+ public function getQuarterlyDateFormat(): string
+ {
+ return 'M Y';
+ }
+
+ public function getYearlyDateFormat(): string
+ {
+ return 'Y';
+ }
+
+ public function getPeriodicDate(Date $date, string $period, string $year): string
+ {
+ $formatted_date = '';
+
+ switch ($period) {
+ case 'yearly':
+ $financial_year = $this->getFinancialYear($year, $date);
+
+ if ($date->greaterThanOrEqualTo($financial_year->getStartDate()) && $date->lessThanOrEqualTo($financial_year->getEndDate())) {
+ if (setting('localisation.financial_denote') == 'begins') {
+ $formatted_date = $financial_year->copy()->getStartDate()->format($this->getYearlyDateFormat());
+ } else {
+ $formatted_date = $financial_year->copy()->getEndDate()->format($this->getYearlyDateFormat());
+ }
+ }
+
+ break;
+ case 'quarterly':
+ $quarters = $this->getFinancialQuarters($year, $date);
+
+ foreach ($quarters as $quarter) {
+ if ($date->lessThan($quarter->getStartDate()) || $date->greaterThan($quarter->getEndDate())) {
+ continue;
+ }
+
+ $start = $quarter->copy()->getStartDate()->format($this->getQuarterlyDateFormat($year));
+ $end = $quarter->copy()->getEndDate()->format($this->getQuarterlyDateFormat($year));
+
+ $formatted_date = $start . ' - ' . $end;
+
+ break;
+ }
+
+ break;
+ case 'weekly':
+ $weeks = $this->getFinancialWeeks($year, $date);
+
+ foreach ($weeks as $week) {
+ if ($date->lessThan($week->getStartDate()) || $date->greaterThan($week->getEndDate())) {
+ continue;
+ }
+
+ $start = $week->copy()->getStartDate()->format($this->getDailyDateFormat($year));
+ $end = $week->copy()->getEndDate()->format($this->getDailyDateFormat($year));
+
+ $formatted_date = $start . ' - ' . $end;
+
+ break;
+ }
+
+ break;
+ default:
+ $formatted_date = $date->copy()->format($this->getMonthlyDateFormat($year));
+
+ break;
+ }
+
+ return $formatted_date;
+ }
+
+ public function getTimezones(): array
+ {
+ // The list of available timezone groups to use.
+ $use_zones = ['Africa', 'America', 'Antarctica', 'Arctic', 'Asia', 'Atlantic', 'Australia', 'Europe', 'Indian', 'Pacific'];
+
+ // Get the list of time zones from the server.
+ $zones = \DateTimeZone::listIdentifiers();
+
+ return collect($zones)
+ ->mapWithKeys(function ($timezone) use ($use_zones) {
+ if (! in_array(str($timezone)->before('/'), $use_zones)) {
+ return [];
+ }
+
+ return [$timezone => str($timezone)->after('/')->value()];
+ })
+ ->groupBy(function ($item, $key) {
+ return str($key)->before('/')->value();
+ }, preserveKeys: true)
+ ->toArray();
+ }
+
+ // @deprecated 3.1
+ public function scopeMonthsOfYear($query, string $field)
+ {
+ return $this->scopeDateFilter($query, $field);
+ }
+}
diff --git a/app/Traits/Documents.php b/app/Traits/Documents.php
new file mode 100644
index 00000000..21934977
--- /dev/null
+++ b/app/Traits/Documents.php
@@ -0,0 +1,316 @@
+type ?? $this->document->type ?? $this->model->type ?? 'invoice';
+
+ return Str::endsWith($type, '-recurring');
+ }
+
+ public function isNotRecurringDocument(): bool
+ {
+ return ! $this->isRecurringDocument();
+ }
+
+ public function getRecurringDocumentTypes() : array
+ {
+ $types = array_keys(config('type.document'));
+
+ $recurring_types = [];
+
+ foreach ($types as $type) {
+ if (Str::endsWith($type, '-recurring')) {
+ $recurring_types[] = $type;
+ }
+ }
+
+ return $recurring_types;
+ }
+
+ /**
+ * Deprecated. Use the DocumentNumber::getNextNumber() method instead.
+ *
+ * @deprecated This method is deprecated and will be removed in future versions.
+ */
+ public function getNextDocumentNumber(string $type): string
+ {
+ return app(DocumentNumber::class)->getNextNumber($type, null);
+ }
+
+ /**
+ * Deprecated. Use the DocumentNumber::increaseNextNumber() method instead.
+ *
+ * @deprecated This method is deprecated and will be removed in future versions.
+ */
+ public function increaseNextDocumentNumber(string $type): void
+ {
+ app(DocumentNumber::class)->increaseNextNumber($type, null);
+ }
+
+ public function getDocumentStatuses(string $type): Collection
+ {
+ $list = [
+ 'invoice' => [
+ 'draft',
+ 'sent',
+ 'viewed',
+ 'approved',
+ 'partial',
+ 'paid',
+ 'overdue',
+ 'unpaid',
+ 'cancelled',
+ ],
+ 'bill' => [
+ 'draft',
+ 'received',
+ 'partial',
+ 'paid',
+ 'overdue',
+ 'unpaid',
+ 'cancelled',
+ ],
+ ];
+
+ // @todo get dynamic path
+ //$trans_key = $this->getTextDocumentStatuses($type);
+ $trans_key = 'documents.statuses.';
+
+ $statuses = collect($list[$type])->each(function ($code) use ($type, $trans_key) {
+ $item = new \stdClass();
+ $item->code = $code;
+ $item->name = trans($trans_key . $code);
+
+ return $item;
+ });
+
+ return $statuses;
+ }
+
+ public function getDocumentStatusesForFuture()
+ {
+ return [
+ 'draft',
+ 'sent',
+ 'received',
+ 'viewed',
+ 'partial',
+ ];
+ }
+
+ public function getDocumentFileName(Document $document, string $separator = '-', string $extension = 'pdf'): string
+ {
+ return $this->getSafeDocumentNumber($document, $separator) . $separator . time() . '.' . $extension;
+ }
+
+ public function getSafeDocumentNumber(Document $document, string $separator = '-'): string
+ {
+ return Str::slug($document->document_number, $separator, language()->getShortCode());
+ }
+
+ protected function getTextDocumentStatuses($type)
+ {
+ $default_key = config('type.document.' . $type . '.translation.prefix') . '.statuses.';
+
+ $translation = DocumentComponent::getTextFromConfig($type, 'document_status', $default_key);
+
+ if (!empty($translation)) {
+ return $translation;
+ }
+
+ $alias = config('type.document.' . $type . '.alias');
+
+ if (!empty($alias)) {
+ $translation = $alias . '::' . config('type.document.' . $type . '.translation.prefix') . '.statuses';
+
+ if (is_array(trans($translation))) {
+ return $translation . '.';
+ }
+ }
+
+ return 'documents.statuses.';
+ }
+
+ // This function will be remoed in the future
+ protected function getSettingKey($type, $setting_key)
+ {
+ return $this->getDocumentSettingKey($type, $setting_key);
+ }
+
+ protected function getDocumentSettingKey($type, $setting_key)
+ {
+ $key = '';
+ $alias = config('type.document.' . $type . '.alias');
+
+ if (! empty($alias)) {
+ $key .= $alias . '.';
+ }
+
+ $prefix = config('type.document.' . $type . '.setting.prefix');
+
+ if (! empty($prefix)) {
+ $key .= $prefix . '.' . $setting_key;
+ } else {
+ $key .= $setting_key;
+ }
+
+ return $key;
+ }
+
+ public function storeDocumentPdfAndGetPath($document)
+ {
+ event(new \App\Events\Document\DocumentPrinting($document));
+
+ $view = view($document->template_path, ['invoice' => $document, 'document' => $document])->render();
+ $html = mb_convert_encoding($view, 'HTML-ENTITIES', 'UTF-8');
+
+ $pdf = app('dompdf.wrapper');
+ $pdf->loadHTML($html);
+
+ $file_name = $this->getDocumentFileName($document);
+
+ $pdf_path = get_storage_path('app/temp/' . $file_name);
+
+ // Save the PDF file into temp folder
+ $pdf->save($pdf_path);
+
+ return $pdf_path;
+ }
+
+ public function getTotalsForFutureDocuments($type = 'invoice', $documents = null)
+ {
+ $totals = [
+ 'overdue' => 0,
+ 'open' => 0,
+ 'draft' => 0,
+ ];
+
+ $today = Date::today()->toDateString();
+
+ // Eager load transactions with currency to prevent N+1 queries when calling getAmountConvertedToDefault()
+ $documents = $documents ?: Document::type($type)->with(['transactions', 'transactions.currency'])->future();
+
+ $documents->each(function ($document) use (&$totals, $today) {
+ if (! in_array($document->status, $this->getDocumentStatusesForFuture())) {
+ return;
+ }
+
+ $payments = 0;
+
+ if ($document->status == 'draft') {
+ $totals['draft'] += $document->getAmountConvertedToDefault();
+
+ return;
+ }
+
+ if ($document->status == 'partial') {
+ foreach ($document->transactions as $transaction) {
+ $payments += $transaction->getAmountConvertedToDefault();
+ }
+ }
+
+ // Check if the document is open or overdue
+ if ($document->due_at > $today) {
+ $totals['open'] += $document->getAmountConvertedToDefault() - $payments;
+ } else {
+ $totals['overdue'] += $document->getAmountConvertedToDefault() - $payments;
+ }
+ });
+
+ return $totals;
+ }
+
+ public function canNotifyTheContactOfDocument(Document $document): bool
+ {
+ $config = config('type.document.' . $document->type . '.notification');
+
+ if (! $config['notify_contact']) {
+ return false;
+ }
+
+ if (! $document->contact || ($document->contact->enabled == 0)) {
+ return false;
+ }
+
+ if (empty($document->contact_email)) {
+ return false;
+ }
+
+ // Check if ietf.org has MX records signaling a server with email capabilites
+ $validator = new EmailValidator();
+ $validations = new MultipleValidationWithAnd([
+ new RFCValidation(),
+ new DNSCheckValidation(),
+ ]);
+ if (! $validator->isValid($document->contact_email, $validations)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ public function getRealTypeOfDocument(string $type): string
+ {
+ $type = $this->getRealTypeOfRecurringDocument($type);
+
+ return $type;
+ }
+
+ public function getRealTypeOfRecurringDocument(string $recurring_type): string
+ {
+ return Str::replace('-recurring', '', $recurring_type);
+ }
+
+ public function getDocumentTemplates($type)
+ {
+ $templates = new \stdClass();
+ $templates->templates = collect();
+ $config_templates = config('type.document.' . $type . '.templates', [
+ [
+ 'id' => 'default',
+ 'name' => trans('settings.invoice.default'),
+ 'image' => asset('public/img/invoice_templates/default.png'),
+ 'template' => 'default',
+ ],
+ [
+ 'id' => 'classic',
+ 'name' => trans('settings.invoice.classic'),
+ 'image' => asset('public/img/invoice_templates/classic.png'),
+ 'template' => 'classic',
+ ],
+ [
+ 'id' => 'modern',
+ 'name' => trans('settings.invoice.modern'),
+ 'image' => asset('public/img/invoice_templates/modern.png'),
+ 'template' => 'modern',
+ ],
+ ]);
+
+ foreach ($config_templates as $config_template) {
+ $templates->templates->push($config_template);
+ }
+
+ event(new DocumentTemplates($type, $templates));
+
+ return $templates->templates;
+ }
+}
diff --git a/app/Traits/Emails.php b/app/Traits/Emails.php
new file mode 100644
index 00000000..fd30fe44
--- /dev/null
+++ b/app/Traits/Emails.php
@@ -0,0 +1,51 @@
+ null, $decay_per_month);
+
+ if ($can_send) {
+ // Check if the user has reached the limit of emails per minute
+ $key_per_minute = 'email-minute:' . user_id();
+ $limit_per_minute = config('app.throttles.email.minute');
+
+ $can_send = RateLimiter::attempt($key_per_minute, $limit_per_minute, fn() => null);
+ }
+
+ if ($can_send) {
+ $this->dispatch($job);
+
+ return [
+ 'success' => true,
+ 'error' => false,
+ 'data' => '',
+ 'message' => '',
+ ];
+ }
+
+ event(new TooManyEmailsSent(user_id()));
+
+ return [
+ 'success' => false,
+ 'error' => true,
+ 'data' => null,
+ 'message' => 'Too many emails sent!',
+ ];
+ }
+}
diff --git a/app/Traits/HasApiTokens.php b/app/Traits/HasApiTokens.php
new file mode 100644
index 00000000..fc8b5afb
--- /dev/null
+++ b/app/Traits/HasApiTokens.php
@@ -0,0 +1,83 @@
+hasMany(\Laravel\Passport\Passport::clientModel(), 'user_id');
+ }
+
+ /**
+ * Get all of the access tokens for the user.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function tokens()
+ {
+ return $this->hasMany(\Laravel\Passport\Passport::tokenModel(), 'user_id')->orderBy('created_at', 'desc');
+ }
+
+ /**
+ * Get the current access token being used by the user.
+ *
+ * @return \Laravel\Passport\Token|\Laravel\Passport\TransientToken|null
+ */
+ public function token()
+ {
+ return $this->accessToken;
+ }
+
+ /**
+ * Determine if the current API token has a given scope.
+ *
+ * @param string $scope
+ * @return bool
+ */
+ public function tokenCan($scope)
+ {
+ return $this->accessToken ? $this->accessToken->can($scope) : false;
+ }
+
+ /**
+ * Create a new personal access token for the user.
+ *
+ * @param string $name
+ * @param array $scopes
+ * @return \Laravel\Passport\PersonalAccessTokenResult
+ */
+ public function createToken($name, array $scopes = [])
+ {
+ return Container::getInstance()->make(\Laravel\Passport\PersonalAccessTokenFactory::class)->make(
+ $this->getKey(), $name, $scopes
+ );
+ }
+
+ /**
+ * Set the current access token for the user.
+ *
+ * @param \Laravel\Passport\Token|\Laravel\Passport\TransientToken|null $accessToken
+ * @return $this
+ */
+ public function withAccessToken($accessToken)
+ {
+ $this->accessToken = $accessToken;
+
+ return $this;
+ }
+}
diff --git a/app/Traits/Import.php b/app/Traits/Import.php
new file mode 100644
index 00000000..bd71ff90
--- /dev/null
+++ b/app/Traits/Import.php
@@ -0,0 +1,583 @@
+getImportViewPath($group, $type);
+
+ // Get import blade variables
+ $path = $this->getImportPath($group, $type);
+ $title_type = $this->getImportTitleType($group, $type);
+ $sample_file = $this->getImportSampleFile($group, $type);
+ $form_params = $this->getImportFormParams($group, $type, $path, $route);
+ $document_link = $this->getImportDocumentLink($group, $type);
+
+ // Create the import view
+ $import = new \stdClass();
+ $import->view = $view;
+ $import->data = compact('group', 'type', 'route', 'path', 'title_type', 'sample_file', 'form_params', 'document_link');
+
+ event(new ImportViewCreating($import));
+
+ event(new ImportViewCreated($import));
+
+ return [
+ $import->view,
+ $import->data
+ ];
+ }
+
+ public function getAccountId($row)
+ {
+ $id = isset($row['account_id']) ? $row['account_id'] : null;
+
+ if (empty($id) && !empty($row['account_name'])) {
+ $id = $this->getAccountIdFromName($row);
+ }
+
+ if (empty($id) && !empty($row['account_number'])) {
+ $id = $this->getAccountIdFromNumber($row);
+ }
+
+ if (empty($id) && !empty($row['currency_code'])) {
+ $id = $this->getAccountIdFromCurrency($row);
+ }
+
+ return is_null($id) ? $id : (int) $id;
+ }
+
+ public function getCategoryId($row, $type = null)
+ {
+ $id = isset($row['category_id']) ? $row['category_id'] : null;
+
+ $type = !empty($type) ? $type : (!empty($row['type']) ? $row['type'] : Category::INCOME_TYPE);
+
+ if (empty($id) && !empty($row['category_name'])) {
+ $id = $this->getCategoryIdFromName($row, $type);
+ }
+
+ return is_null($id) ? $id : (int) $id;
+ }
+
+ public function getCategoryType($type)
+ {
+ return array_key_exists($type, config('type.category')) ? $type : Category::OTHER_TYPE;
+ }
+
+ public function getContactId($row, $type = null)
+ {
+ $id = isset($row['contact_id']) ? $row['contact_id'] : null;
+
+ $type = !empty($type) ? $type : (!empty($row['type']) ? (($row['type'] == Transaction::INCOME_TYPE) ? Contact::CUSTOMER_TYPE : Contact::VENDOR_TYPE) : Contact::CUSTOMER_TYPE);
+
+ if (empty($row['contact_id']) && !empty($row['contact_email'])) {
+ $id = $this->getContactIdFromEmail($row, $type);
+ }
+
+ if (empty($id) && !empty($row['contact_name'])) {
+ $id = $this->getContactIdFromName($row, $type);
+ }
+
+ return is_null($id) ? $id : (int) $id;
+ }
+
+ public function getCurrencyCode($row)
+ {
+ // Uploaded file may not include a currency_code column — fall back to the
+ // company default instead of crashing on an undefined array key.
+ if (empty($row['currency_code'])) {
+ return default_currency();
+ }
+
+ $currency = Currency::where('code', $row['currency_code'])->first();
+
+ if (!empty($currency)) {
+ return $currency->code;
+ }
+
+ try {
+ $data = [
+ 'company_id' => company_id(),
+ 'code' => $row['currency_code'],
+ 'name' => isset($row['currency_name']) ? $row['currency_name'] : currency($row['currency_code'])->getName(),
+ 'rate' => isset($row['currency_rate']) ? $row['currency_rate'] : 1,
+ 'symbol' => isset($row['currency_symbol']) ? $row['currency_symbol'] : currency($row['currency_code'])->getSymbol(),
+ 'precision' => isset($row['currency_precision']) ? $row['currency_precision'] : currency($row['currency_code'])->getPrecision(),
+ 'decimal_mark' => isset($row['currency_decimal_mark']) ? $row['currency_decimal_mark'] : currency($row['currency_code'])->getDecimalMark(),
+ 'thousands_separator' => isset($row['currency_thousands_separator']) ? $row['currency_thousands_separator'] : currency($row['currency_code'])->getThousandsSeparator(),
+ 'created_from' => !empty($row['created_from']) ? $row['created_from'] : $this->getSourcePrefix() . 'import',
+ 'created_by' => !empty($row['created_by']) ? $row['created_by'] : user()?->id,
+ ];
+ } catch (\OutOfBoundsException $e) {
+ return default_currency();
+ }
+
+ Validator::validate($data, (new CurrencyRequest)->rules());
+
+ $currency = $this->dispatch(new CreateCurrency($data));
+
+ return $currency->code;
+ }
+
+ public function getCreatedById($row)
+ {
+ if (empty($row['created_by'])) {
+ return $this->user?->id;
+ }
+
+ $user = user_model_class()::where('email', $row['created_by'])->first();
+
+ if (! empty($user)) {
+ return $user->id;
+ }
+
+ return $this->user->id;
+ }
+
+ public function getDocumentId($row)
+ {
+ $id = isset($row['document_id']) ? $row['document_id'] : null;
+
+ if (empty($id) && !empty($row['document_number'])) {
+ $id = Document::number($row['document_number'])->pluck('id')->first();
+ }
+
+ if (empty($id) && !empty($row['invoice_number'])) {
+ $id = Document::invoice()->number($row['invoice_number'])->pluck('id')->first();
+ }
+
+ if (empty($id) && !empty($row['bill_number'])) {
+ $id = Document::bill()->number($row['bill_number'])->pluck('id')->first();
+ }
+
+ if (empty($id) && !empty($row['invoice_bill_number'])) {
+ if ($row['type'] == Transaction::INCOME_TYPE) {
+ $id = Document::invoice()->number($row['invoice_bill_number'])->pluck('id')->first();
+ } else {
+ $id = Document::bill()->number($row['invoice_bill_number'])->pluck('id')->first();
+ }
+ }
+
+ return is_null($id) ? $id : (int) $id;
+ }
+
+ public function getParentId($row)
+ {
+ $id = isset($row['parent_id']) ? $row['parent_id'] : null;
+
+ if (empty($row['parent_number']) && empty($row['parent_name'])){
+ return null;
+ }
+
+ if (empty($id) && (!empty($row['document_number']) || !empty($row['invoice_number']) || !empty($row['bill_number']))) {
+ $id = Document::number($row['parent_number'])->pluck('id')->first();
+ }
+
+ if (empty($id) && isset($row['number'])) {
+ $id = Transaction::number($row['parent_number'])->pluck('id')->first();
+ }
+
+ if (empty($id) && isset($row['parent_name'])) {
+ $id = Category::type($row['type'])->withSubCategory()->where('name', $row['parent_name'])->pluck('id')->first();
+ }
+
+ return is_null($id) ? $id : (int) $id;
+ }
+
+ public function getPaymentMethod($row)
+ {
+ Modules::clearPaymentMethodsCache();
+
+ $methods = Modules::getPaymentMethods('all');
+
+ $payment_method = isset($row['payment_method']) ? $row['payment_method'] : null;
+
+ if (array_key_exists($payment_method, $methods)) {
+ return $payment_method;
+ }
+
+ if (module_is_enabled('offline-payments')) {
+ $offline_payment = $this->dispatch(new \Modules\OfflinePayments\Jobs\CreatePaymentMethod([
+ 'name' => $payment_method,
+ 'customer' => 1,
+ 'order' => count($methods) + 1,
+ 'description' => '',
+ ]));
+
+ $payment_method = $offline_payment['code'];
+ }
+
+ return $payment_method;
+ }
+
+ public function getItemId($row, $type = null)
+ {
+ $id = isset($row['item_id']) ? $row['item_id'] : null;
+
+ $type = !empty($type) ? $type : (!empty($row['item_type']) ? $row['item_type'] : 'product');
+
+ if (empty($id) && !empty($row['item_name'])) {
+ $id = $this->getItemIdFromName($row, $type);
+ }
+
+ return is_null($id) ? $id : (int) $id;
+ }
+
+ public function getTaxId($row)
+ {
+ $id = isset($row['tax_id']) ? $row['tax_id'] : null;
+
+ if (empty($id) && !empty($row['tax_name'])) {
+ $id = Tax::name($row['tax_name'])->pluck('id')->first();
+ }
+
+ if (empty($id) && !empty($row['tax_rate'])) {
+ $id = $this->getTaxIdFromRate($row);
+ }
+
+ return is_null($id) ? $id : (int) $id;
+ }
+
+ public function getAccountIdFromCurrency($row)
+ {
+ $account_id = Account::where('currency_code', $row['currency_code'])->pluck('id')->first();
+
+ if (!empty($account_id)) {
+ return $account_id;
+ }
+
+ $data = [
+ 'company_id' => company_id(),
+ 'type' => !empty($row['account_type']) ? $row['account_type'] : 'bank',
+ 'currency_code' => $row['currency_code'],
+ 'name' => !empty($row['account_name']) ? $row['account_name'] : $row['currency_code'],
+ 'number' => !empty($row['account_number']) ? $row['account_number'] : (string) rand(1, 10000),
+ 'opening_balance' => !empty($row['opening_balance']) ? $row['opening_balance'] : 0,
+ 'enabled' => 1,
+ 'created_from' => !empty($row['created_from']) ? $row['created_from'] : $this->getSourcePrefix() . 'import',
+ 'created_by' => !empty($row['created_by']) ? $row['created_by'] : user()?->id,
+ ];
+
+ Validator::validate($data, (new AccountRequest)->rules());
+
+ $account = $this->dispatch(new CreateAccount($data));
+
+ return $account->id;
+ }
+
+ public function getAccountIdFromName($row)
+ {
+ $account_id = Account::where('name', $row['account_name'])->pluck('id')->first();
+
+ if (!empty($account_id)) {
+ return $account_id;
+ }
+
+ $data = [
+ 'company_id' => company_id(),
+ 'type' => !empty($row['account_type']) ? $row['account_type'] : 'bank',
+ 'name' => $row['account_name'],
+ 'number' => !empty($row['account_number']) ? $row['account_number'] : (string) rand(1, 10000),
+ 'currency_code' => !empty($row['currency_code']) ? $row['currency_code'] : default_currency(),
+ 'opening_balance' => !empty($row['opening_balance']) ? $row['opening_balance'] : 0,
+ 'enabled' => 1,
+ 'created_from' => !empty($row['created_from']) ? $row['created_from'] : $this->getSourcePrefix() . 'import',
+ 'created_by' => !empty($row['created_by']) ? $row['created_by'] : user()?->id,
+ ];
+
+ Validator::validate($data, (new AccountRequest)->rules());
+
+ $account = $this->dispatch(new CreateAccount($data));
+
+ return $account->id;
+ }
+
+ public function getAccountIdFromNumber($row)
+ {
+ $account_id = Account::where('account_number', $row['account_number'])->pluck('id')->first();
+
+ if (!empty($account_id)) {
+ return $account_id;
+ }
+
+ $data = [
+ 'company_id' => company_id(),
+ 'type' => !empty($row['account_type']) ? $row['account_type'] : 'bank',
+ 'number' => $row['account_number'],
+ 'name' => !empty($row['account_name']) ? $row['account_name'] : $row['account_number'],
+ 'currency_code' => !empty($row['currency_code']) ? $row['currency_code'] : default_currency(),
+ 'opening_balance' => !empty($row['opening_balance']) ? $row['opening_balance'] : 0,
+ 'enabled' => 1,
+ 'created_from' => !empty($row['created_from']) ? $row['created_from'] : $this->getSourcePrefix() . 'import',
+ 'created_by' => !empty($row['created_by']) ? $row['created_by'] : user()?->id,
+ ];
+
+ Validator::validate($data, (new AccountRequest)->rules());
+
+ $account = $this->dispatch(new CreateAccount($data));
+
+ return $account->id;
+ }
+
+ public function getCategoryIdFromName($row, $type)
+ {
+ $category_id = Category::type($type)->withSubCategory()->where('name', $row['category_name'])->pluck('id')->first();
+
+ if (!empty($category_id)) {
+ return $category_id;
+ }
+
+ $data = [
+ 'company_id' => company_id(),
+ 'name' => $row['category_name'],
+ 'type' => $type,
+ 'color' => !empty($row['category_color']) ? $row['category_color'] : '#' . dechex(rand(0x000000, 0xFFFFFF)),
+ 'enabled' => 1,
+ 'created_from' => !empty($row['created_from']) ? $row['created_from'] : $this->getSourcePrefix() . 'import',
+ 'created_by' => !empty($row['created_by']) ? $row['created_by'] : user()?->id,
+ ];
+
+ Validator::validate($data, (new CategoryRequest)->rules());
+
+ $category = $this->dispatch(new CreateCategory($data));
+
+ return $category->id;
+ }
+
+ public function getContactIdFromEmail($row, $type)
+ {
+ $contact_id = Contact::type($type)->where('email', $row['contact_email'])->pluck('id')->first();
+
+ if (!empty($contact_id)) {
+ return $contact_id;
+ }
+
+ $data = [
+ 'company_id' => company_id(),
+ 'email' => $row['contact_email'],
+ 'type' => $type,
+ 'name' => !empty($row['contact_name']) ? $row['contact_name'] : $row['contact_email'],
+ 'currency_code' => !empty($row['contact_currency']) ? $row['contact_currency'] : default_currency(),
+ 'enabled' => 1,
+ 'created_from' => !empty($row['created_from']) ? $row['created_from'] : $this->getSourcePrefix() . 'import',
+ 'created_by' => !empty($row['created_by']) ? $row['created_by'] : user()?->id,
+ ];
+
+ Validator::validate($data, (new ContactRequest)->rules());
+
+ $contact = $this->dispatch(new CreateContact($data));
+
+ return $contact->id;
+ }
+
+ public function getContactIdFromName($row, $type)
+ {
+ $contact_id = Contact::type($type)->where('name', $row['contact_name'])->pluck('id')->first();
+
+ if (!empty($contact_id)) {
+ return $contact_id;
+ }
+
+ $data = [
+ 'company_id' => company_id(),
+ 'name' => $row['contact_name'],
+ 'type' => $type,
+ 'email' => !empty($row['contact_email']) ? $row['contact_email'] : null,
+ 'currency_code' => !empty($row['contact_currency']) ? $row['contact_currency'] : default_currency(),
+ 'enabled' => 1,
+ 'created_from' => !empty($row['created_from']) ? $row['created_from'] : $this->getSourcePrefix() . 'import',
+ 'created_by' => !empty($row['created_by']) ? $row['created_by'] : user()?->id,
+ ];
+
+ Validator::validate($data, (new ContactRequest)->rules());
+
+ $contact = $this->dispatch(new CreateContact($data));
+
+ return $contact->id;
+ }
+
+ public function getItemIdFromName($row, $type = null)
+ {
+ $type = !empty($type) ? $type : (!empty($row['item_type']) ? $row['item_type'] : 'product');
+
+ $item_id = Item::type($type)->where('name', $row['item_name'])->pluck('id')->first();
+
+ if (!empty($item_id)) {
+ return $item_id;
+ }
+
+ $data = [
+ 'company_id' => company_id(),
+ 'type' => $type,
+ 'name' => $row['item_name'],
+ 'description' => !empty($row['item_description']) ? $row['item_description'] : null,
+ 'sale_price' => !empty($row['sale_price']) ? $row['sale_price'] : (!empty($row['price']) ? $row['price'] : 0),
+ 'purchase_price' => !empty($row['purchase_price']) ? $row['purchase_price'] : (!empty($row['price']) ? $row['price'] : 0),
+ 'enabled' => 1,
+ 'created_from' => !empty($row['created_from']) ? $row['created_from'] : $this->getSourcePrefix() . 'import',
+ 'created_by' => !empty($row['created_by']) ? $row['created_by'] : user()?->id,
+ ];
+
+ Validator::validate($data, (new ItemRequest())->rules());
+
+ $item = $this->dispatch(new CreateItem($data));
+
+ return $item->id;
+ }
+
+ public function getTaxIdFromRate($row, $type = 'normal')
+ {
+ $tax_id = Tax::type($type)->where('rate', $row['tax_rate'])->pluck('id')->first();
+
+ if (!empty($tax_id)) {
+ return $tax_id;
+ }
+
+ $data = [
+ 'company_id' => company_id(),
+ 'rate' => $row['tax_rate'],
+ 'type' => $type,
+ 'name' => !empty($row['tax_name']) ? $row['tax_name'] : (string) $row['tax_rate'],
+ 'enabled' => 1,
+ 'created_from' => !empty($row['created_from']) ? $row['created_from'] : $this->getSourcePrefix() . 'import',
+ 'created_by' => !empty($row['created_by']) ? $row['created_by'] : user()?->id,
+ ];
+
+ Validator::validate($data, (new TaxRequest())->rules());
+
+ $tax = $this->dispatch(new CreateTax($data));
+
+ return $tax->id;
+ }
+
+ protected function getImportPath($group, $type)
+ {
+ $path = config('import.' . $group . '.' . $type . '.path');
+
+ if (! empty($path)) {
+ return str_replace('company_id', company_id(), $path);
+ }
+
+ return company_id() . '/' . $group . '/' . $type;
+ }
+
+ protected function getImportTitleType($group, $type)
+ {
+ $title_type = config('import.' . $group . '.' . $type . '.title_type');
+
+ if (! empty($title_type)) {
+ return $this->findTranslation($title_type);
+ }
+
+ $module = module($group);
+
+ $title_type = trans_choice('general.' . str_replace('-', '_', $type), 2);
+
+ if ($module instanceof Module) {
+ $title_type = trans_choice($group . '::general.' . str_replace('-', '_', $type), 2);
+ }
+
+ return $title_type;
+ }
+
+ protected function getImportSampleFile($group, $type)
+ {
+ $sample_file = config('import.' . $group . '.' . $type . '.sample_file');
+
+ if (! empty($sample_file)) {
+ return url($sample_file);
+ }
+
+ $module = module($group);
+
+ $sample_file = url('public/files/import/' . $type . '.xlsx');
+
+ if ($module instanceof Module) {
+ $sample_file = url('modules/' . $module->getStudlyName() . '/Resources/assets/' . $type . '.xlsx');
+ }
+
+ return $sample_file;
+ }
+
+ protected function getImportFormParams($group, $type, $path = null, $route = null)
+ {
+ $form_params = config('import.' . $group . '.' . $type . '.form_params');
+
+ if (! empty($form_params)) {
+ return $form_params;
+ }
+
+ $form_params = [
+ 'id' => 'import',
+ '@submit.prevent' => 'onSubmit',
+ '@keydown' => 'form.errors.clear($event.target.name)',
+ 'files' => true,
+ 'role' => 'form',
+ 'class' => 'form-loading-button',
+ 'novalidate' => true,
+ 'route' => '',
+ 'url' => '',
+ ];
+
+ if (! empty($route)) {
+ $form_params['route'] = $route;
+ } else {
+ $form_params['url'] = $path . '/import';
+ }
+
+ return $form_params;
+ }
+
+ protected function getImportDocumentLink($group, $type)
+ {
+ return null;
+ }
+
+ protected function getImportViewPath($group, $type)
+ {
+ $view = config('import.' . $group . '.' . $type . '.view');
+
+ if (! empty($view)) {
+ return $view;
+ }
+
+ $view = 'common.import.create';
+
+ return $view;
+ }
+}
diff --git a/app/Traits/Jobs.php b/app/Traits/Jobs.php
new file mode 100644
index 00000000..b254d198
--- /dev/null
+++ b/app/Traits/Jobs.php
@@ -0,0 +1,83 @@
+getDispatchFunction();
+
+ return $this->$function($job);
+ }
+
+ /**
+ * Dispatch a job to its appropriate handler.
+ *
+ * @param mixed $command
+ * @return mixed
+ */
+ public function dispatchQueue($job)
+ {
+ return app(Dispatcher::class)->dispatch($job);
+ }
+
+ /**
+ * Dispatch a command to its appropriate handler in the current process.
+ *
+ * Queuable jobs will be dispatched to the "sync" queue.
+ *
+ * @param mixed $command
+ * @param mixed $handler
+ * @return mixed
+ */
+ public function dispatchSync($job, $handler = null)
+ {
+ return app(Dispatcher::class)->dispatchSync($job, $handler);
+ }
+
+ /**
+ * Dispatch a job to its appropriate handler and return a response array for ajax calls.
+ *
+ * @param mixed $job
+ * @return mixed
+ */
+ public function ajaxDispatch($job)
+ {
+ try {
+ $data = $this->dispatch($job);
+
+ $response = [
+ 'success' => true,
+ 'error' => false,
+ 'data' => $data,
+ 'message' => '',
+ ];
+ } catch (Exception | Throwable $e) {
+ $response = [
+ 'success' => false,
+ 'error' => true,
+ 'data' => null,
+ 'code' => $e->getCode(),
+ 'message' => $e->getMessage(),
+ ];
+ }
+
+ return $response;
+ }
+
+ public function getDispatchFunction()
+ {
+ return should_queue() ? 'dispatchQueue' : 'dispatchSync';
+ }
+}
diff --git a/app/Traits/Media.php b/app/Traits/Media.php
new file mode 100644
index 00000000..968cd70f
--- /dev/null
+++ b/app/Traits/Media.php
@@ -0,0 +1,63 @@
+
+ *
+ * Whether the model should automatically reload its media relationship after modification.
+ */
+trait Media
+{
+ use Mediable;
+
+ /**
+ * Relationship for all attached media.
+ * @return \Illuminate\Database\Eloquent\Relations\MorphToMany
+ */
+ public function media()
+ {
+ $media = $this->morphToMany(config('mediable.model'), 'mediable')
+ ->withPivot('tag', 'order')
+ ->orderBy('order');
+
+ // Skip deleted media if not detached
+ if (config('mediable.detach_on_soft_delete') == false) {
+ $media->whereNull('deleted_at');
+ }
+
+ return $media;
+ }
+
+ public function attachMedia($media, $tags): void
+ {
+ $tags = (array)$tags;
+ $increments = $this->getOrderValueForTags($tags);
+
+ $ids = $this->extractPrimaryIds($media);
+
+ foreach ($tags as $tag) {
+ $attach = [];
+
+ foreach ($ids as $id) {
+ $attach[$id] = [
+ 'company_id' => company_id(),
+ 'created_from' => source_name(),
+ 'created_by' => user_id(),
+ 'tag' => $tag,
+ 'order' => ++$increments[$tag],
+ ];
+ }
+
+ $this->media()->attach($attach);
+ }
+
+ $this->markMediaDirty($tags);
+ }
+}
diff --git a/app/Traits/Modules.php b/app/Traits/Modules.php
new file mode 100644
index 00000000..58bc8602
--- /dev/null
+++ b/app/Traits/Modules.php
@@ -0,0 +1,659 @@
+ [
+ 'token' => $apiKey,
+ ]
+ ];
+
+ if (! $response = static::getResponse('POST', 'token/check', $data)) {
+ return false;
+ }
+
+ $result = json_decode($response->getBody());
+
+ return $result->success ? true : false;
+ }
+
+ // Get All Modules
+ public function getModules($data = [])
+ {
+ $key = 'apps.app.' . $this->getDataKeyOfModules($data);
+
+ $items = Cache::get($key);
+
+ if (! empty($items)) {
+ return $items;
+ }
+
+ $items = static::getResponseData('GET', 'apps/items');
+
+ Cache::put($key, $items, Date::now()->addHour());
+
+ return $items;
+ }
+
+ // Get Module
+ public function getModule($alias)
+ {
+ $item = static::getResponseData('GET', 'apps/' . $alias);
+
+ return $item;
+ }
+
+ public function getModuleIsubscribe($alias)
+ {
+ if (! $response = static::getResponse('GET', 'apps/' . $alias . '/isubscribe')) {
+ return [];
+ }
+
+ $body = json_decode($response->getBody());
+
+ if (! is_object($body)) {
+ return [];
+ }
+
+ return $body;
+ }
+
+ public function getModuleDocumentation($alias, $data = [])
+ {
+ $key = 'apps.' . $alias . '.docs.' . $this->getDataKeyOfModules($data);
+
+ $documentation = Cache::get($key);
+
+ if (! empty($documentation)) {
+ return $documentation;
+ }
+
+ $documentation = static::getResponseData('GET', 'apps/docs/' . $alias);
+
+ Cache::put($key, $documentation, Date::now()->addHour());
+
+ return $documentation;
+ }
+
+ public function getModuleReleases($alias, $data = [])
+ {
+ $key = 'apps.' . $alias . '.releases.' . $this->getDataKeyOfModules($data);
+
+ $releases = Cache::get($key);
+
+ if (! empty($releases)) {
+ return $releases;
+ }
+
+ $releases = static::getResponseData('GET', 'apps/' . $alias . '/releases', $data);
+
+ Cache::put($key, $releases, Date::now()->addHour());
+
+ return $releases;
+ }
+
+ public function getModuleReviews($alias, $data = [])
+ {
+ $key = 'apps.' . $alias . '.reviews.' . $this->getDataKeyOfModules($data);
+
+ $reviews = Cache::get($key);
+
+ if (! empty($reviews)) {
+ return $reviews;
+ }
+
+ $reviews = static::getResponseData('GET', 'apps/' . $alias . '/reviews', $data);
+
+ Cache::put($key, $reviews, Date::now()->addHour());
+
+ return $reviews;
+ }
+
+ public function getModuleTestimonials($alias, $data = [])
+ {
+ $key = 'apps.' . $alias . '.testimonials.' . $this->getDataKeyOfModules($data);
+
+ $testimonials = Cache::get($key);
+
+ if (! empty($testimonials)) {
+ return $testimonials;
+ }
+
+ $testimonials = static::getResponseData('GET', 'apps/' . $alias . '/testimonials', $data);
+
+ Cache::put($key, $testimonials, Date::now()->addHour());
+
+ return $testimonials;
+ }
+
+ public function getBannersOfModules($data = [])
+ {
+ $key = 'apps.banners.' . $this->getDataKeyOfModules($data);
+
+ $banners = Cache::get($key);
+
+ if (! empty($banners)) {
+ return $banners;
+ }
+
+ $banners = static::getResponseData('GET', 'apps/banners');
+
+ Cache::put($key, $banners, Date::now()->addHour());
+
+ return $banners;
+ }
+
+ public function getCategoriesOfModules($data = [])
+ {
+ $key = 'apps.categories.' . $this->getDataKeyOfModules($data);
+
+ $categories = Cache::get($key);
+
+ if (! empty($categories)) {
+ return $categories;
+ }
+
+ $categories = static::getResponseData('GET', 'apps/categories');
+
+ Cache::put($key, $categories, Date::now()->addHour());
+
+ return $categories;
+ }
+
+ public function getModulesByCategory($alias, $data = [])
+ {
+ $key = 'apps.categories.' . $alias . '.' . $this->getDataKeyOfModules($data);
+
+ $category = Cache::get($key);
+
+ if (! empty($category)) {
+ return $category;
+ }
+
+ $category = static::getResponseData('GET', 'apps/categories/' . $alias, $data);
+
+ Cache::put($key, $category, Date::now()->addHour());
+
+ return $category;
+ }
+
+ public function getVendorsOfModules($data = [])
+ {
+ $key = 'apps.vendors.' . $this->getDataKeyOfModules($data);
+
+ $vendors = Cache::get($key);
+
+ if (! empty($vendors)) {
+ return $vendors;
+ }
+
+ $vendors = static::getResponseData('GET', 'apps/vendors');
+
+ Cache::put($key, $vendors, Date::now()->addHour());
+
+ return $vendors;
+ }
+
+ public function getModulesByVendor($alias, $data = [])
+ {
+ $key = 'apps.vendors.' . $alias . '.' . $this->getDataKeyOfModules($data);
+
+ $vendor = Cache::get($key);
+
+ if (! empty($vendor)) {
+ return $vendor;
+ }
+
+ $vendor = static::getResponseData('GET', 'apps/vendors/' . $alias, $data);
+
+ Cache::put($key, $vendor, Date::now()->addHour());
+
+ return $vendor;
+ }
+
+ public function getMyModules($data = [])
+ {
+ return static::getResponseData('GET', 'apps/my', $data);
+ }
+
+ public function getInstalledModules()
+ {
+ $key = 'apps.installed.' . company_id();
+
+ if ($installed = Cache::get($key)) {
+ return $installed;
+ }
+
+ $installed = [];
+
+ Module::all()->each(function($module) use (&$installed) {
+ if (! $this->moduleExists($module->alias)) {
+ return;
+ }
+
+ if (! $result = $this->getModule($module->alias)) {
+ return;
+ }
+
+ $installed[] = $result;
+ });
+
+ Cache::put($key, $installed, Date::now()->addHours(6));
+
+ return $installed;
+ }
+
+ public function getPreSaleModules($data = [])
+ {
+ $key = 'apps.pre_sale.' . $this->getDataKeyOfModules($data);
+
+ $pre_sale = Cache::get($key);
+
+ if (! empty($pre_sale)) {
+ return $pre_sale;
+ }
+
+ $pre_sale = static::getResponseData('GET', 'apps/pre_sale', $data);
+
+ Cache::put($key, $pre_sale, Date::now()->addHour());
+
+ return $pre_sale;
+ }
+
+ public function getPaidModules($data = [])
+ {
+ $key = 'apps.paid.' . $this->getDataKeyOfModules($data);
+
+ $paid = Cache::get($key);
+
+ if (! empty($paid)) {
+ return $paid;
+ }
+
+ $paid = static::getResponseData('GET', 'apps/paid', $data);
+
+ Cache::put($key, $paid, Date::now()->addHour());
+
+ return $paid;
+ }
+
+ public function getNewModules($data = [])
+ {
+ $key = 'apps.new.' . $this->getDataKeyOfModules($data);
+
+ $new = Cache::get($key);
+
+ if (! empty($new)) {
+ return $new;
+ }
+
+ $new = static::getResponseData('GET', 'apps/new', $data);
+
+ Cache::put($key, $new, Date::now()->addHour());
+
+ return $new;
+ }
+
+ public function getFreeModules($data = [])
+ {
+ $key = 'apps.free.' . $this->getDataKeyOfModules($data);
+
+ $free = Cache::get($key);
+
+ if (! empty($free)) {
+ return $free;
+ }
+
+ $free = static::getResponseData('GET', 'apps/free', $data);
+
+ Cache::put($key, $free, Date::now()->addHour());
+
+ return $free;
+ }
+
+ public function getFeaturedModules($data = [])
+ {
+ $key = 'apps.featured.' . $this->getDataKeyOfModules($data);
+
+ $featured = Cache::get($key);
+
+ if (! empty($featured)) {
+ return $featured;
+ }
+
+ $featured = static::getResponseData('GET', 'apps/featured', $data);
+
+ Cache::put($key, $featured, Date::now()->addHour());
+
+ return $featured;
+ }
+
+ public function getPopularModules($data = [])
+ {
+ $key = 'apps.popular.' . $this->getDataKeyOfModules($data);
+
+ $popular = Cache::get($key);
+
+ if (! empty($popular)) {
+ return $popular;
+ }
+
+ $popular = static::getResponseData('GET', 'apps/popular', $data);
+
+ Cache::put($key, $popular, Date::now()->addHour());
+
+ return $popular;
+ }
+
+ public function getSearchModules($data = [])
+ {
+ return static::getResponseData('GET', 'apps/search', $data);
+ }
+
+ public function getTestimonialModules($data = [])
+ {
+ $key = 'apps.testimonials.' . $this->getDataKeyOfModules($data);
+
+ return Cache::remember($key, Date::now()->addHours(6), function () use ($data) {
+ return static::getResponseData('GET', 'apps/testimonials', $data);
+ });
+ }
+
+ public function getWidgetsOfModules($data = [])
+ {
+ $key = 'apps.widgets.' . $this->getDataKeyOfModules($data);
+
+ return Cache::remember($key, Date::now()->addHours(6), function () {
+ return static::getResponseData('GET', 'apps/widgets');
+ });
+ }
+
+ public function getModulesByWidget($alias, $data = [])
+ {
+ $key = 'apps.widgets.' . $alias . '.' . $this->getDataKeyOfModules($data);
+
+ return Cache::remember($key, Date::now()->addHours(6), function () use ($alias, $data) {
+ return static::getResponseData('GET', 'apps/widgets/' . $alias, $data);
+ });
+ }
+
+ public function getCoreVersion()
+ {
+ $data['query'] = Info::all();
+
+ if (! $response = static::getResponse('GET', 'core/version', $data)) {
+ return [];
+ }
+
+ return $response->json();
+ }
+
+ public function moduleExists($alias)
+ {
+ if (! module($alias) instanceof \Akaunting\Module\Module) {
+ return false;
+ }
+
+ return true;
+ }
+
+ public function moduleIsEnabled($alias): bool
+ {
+ if (! $this->moduleExists($alias)) {
+ return false;
+ }
+
+ return module($alias)->enabled();
+ }
+
+ public function moduleIsDisabled($alias): bool
+ {
+ return ! $this->moduleIsEnabled($alias);
+ }
+
+ public function getModulesLimitOfSubscription()
+ {
+ $limit = new \stdClass();
+
+ $limit->action_status = true;
+ $limit->view_status = true;
+ $limit->message = "Success";
+
+ if (! config('app.installed') || running_in_test()) {
+ return $limit;
+ }
+
+ if (is_cloud()) {
+ return $limit;
+ }
+
+ $modules = module()->all();
+
+ $versions = Versions::all($modules);
+
+ foreach ($versions as $alias => $version) {
+ if ($alias == 'core') {
+ continue;
+ }
+
+ $module_limit = $this->getModuleLimitOfSubscription($alias, $version);
+
+ if ($module_limit->action_status === false) {
+ $limit->action_status = false;
+ $limit->view_status = false;
+ $limit->message = $module_limit->message;
+
+ // Clear cache to reflect changes
+ Cache::forget('updates');
+ Cache::forget('versions');
+ }
+ }
+
+ return $limit;
+ }
+
+ public function getModuleLimitOfSubscription($alias, $version = null)
+ {
+ $limit = new \stdClass();
+
+ $limit->action_status = true;
+ $limit->view_status = true;
+ $limit->message = "Success";
+
+ if (empty($version)) {
+ $version = Versions::getVersionByAlias($alias);
+ }
+
+ if (! $version->subscription) {
+ return $limit;
+ }
+
+ if (! in_array($version->subscription->action_status, ['disabled', 'uninstalled'])) {
+ return $limit;
+ }
+
+ $module_companies = Module::allCompanies()->enabled()->alias($alias)->get();
+
+ if (! $module_companies->count()) {
+ return $limit;
+ }
+
+ $limit->action_status = false;
+ $limit->view_status = false;
+ $limit->message = "Not able to app $alias.";
+
+ foreach ($module_companies as $module) {
+ switch ($version->subscription->action_status) {
+ case 'disabled':
+ dispatch(new DisableModule($alias, $module->company_id));
+ break;
+ case 'uninstalled':
+ dispatch(new UninstallModule($alias, $module->company_id));
+ break;
+ default:
+ // Do nothing
+ break;
+ }
+ }
+
+ return $limit;
+ }
+
+ public function loadSubscriptions()
+ {
+ $key = 'apps.subscriptions';
+
+ return Cache::remember($key, Date::now()->addHours(6), function () {
+ $data = [];
+
+ if (! is_cloud()) {
+ $data['headers'] = [
+ 'X-Akaunting-Modules' => implode(',', module()->getAvailable()),
+ ];
+ }
+
+ return (array) static::getResponseData('GET', 'apps/subscriptions', $data);
+ });
+ }
+
+ public function loadSuggestions()
+ {
+ $key = 'apps.suggestions';
+
+ return Cache::remember($key, Date::now()->addHours(6), function () {
+ $data = [];
+
+ $suggestions = (array) static::getResponseData('GET', 'apps/suggestions');
+
+ foreach ($suggestions as $suggestion) {
+ $data[$suggestion->path] = $suggestion;
+ }
+
+ return $data;
+ });
+ }
+
+ public function loadNotifications()
+ {
+ $key = 'apps.notifications';
+
+ return Cache::remember($key, Date::now()->addHours(6), function () {
+ $data = [];
+
+ $notifications = (array) static::getResponseData('GET', 'apps/notifications');
+
+ foreach ($notifications as $notification) {
+ $data[$notification->path][] = $notification;
+ }
+
+ return $data;
+ });
+ }
+
+ public function loadTips()
+ {
+ $key = 'apps.tips';
+
+ return Cache::remember($key, Date::now()->addHours(6), function () {
+ $data = [];
+
+ $tips = (array) static::getResponseData('GET', 'apps/tips');
+
+ foreach ($tips as $tip) {
+ $data[$tip->path][] = $tip;
+ }
+
+ return $data;
+ });
+ }
+
+ public function getSubscription($alias)
+ {
+ $data = $this->loadSubscriptions();
+
+ if (! empty($data) && array_key_exists($alias, $data)) {
+ return $data[$alias];
+ }
+
+ return false;
+ }
+
+ public function getSuggestions($path)
+ {
+ $data = $this->loadSuggestions();
+
+ if (! empty($data) && array_key_exists($path, $data)) {
+ return $data[$path];
+ }
+
+ return false;
+ }
+
+ public function getNotifications($path): array
+ {
+ $data = $this->loadNotifications();
+
+ if (! empty($data) && array_key_exists($path, $data)) {
+ return (array) $data[$path];
+ }
+
+ return [];
+ }
+
+ public function getTips($path): array
+ {
+ $data = $this->loadTips();
+
+ if (! empty($data) && array_key_exists($path, $data)) {
+ return (array) $data[$path];
+ }
+
+ return [];
+ }
+
+ public function getPageNumberOfModules($data = [])
+ {
+ if (empty($data['query']) || empty($data['query']['page'])) {
+ return 1;
+ }
+
+ return $data['query']['page'];
+ }
+
+ public function getDataKeyOfModules($data = [])
+ {
+ $result = 'language.' . language()->getShortCode() . '.page.' . $this->getPageNumberOfModules($data);
+
+ if (isset($data['query']['page'])) {
+ unset($data['query']['page']);
+ }
+
+ if (isset($data['query'])){
+ foreach($data['query'] as $key => $value) {
+ $result .= '.' . $key . '.' . $value;
+ }
+ }
+
+ return $result;
+ }
+
+ public function registerModules(): void
+ {
+ app(\Akaunting\Module\Contracts\ActivatorInterface::class)->register();
+ }
+}
diff --git a/app/Traits/Omnipay.php b/app/Traits/Omnipay.php
new file mode 100644
index 00000000..d2b8fbc3
--- /dev/null
+++ b/app/Traits/Omnipay.php
@@ -0,0 +1,210 @@
+ $invoice->amount,
+ 'currency' => $invoice->currency_code,
+ 'transactionId' => $invoice->id,
+ 'returnUrl' => $this->getReturnUrl($invoice),
+ 'cancelUrl' => $this->getCancelUrl($invoice),
+ ];
+
+ $options = array_merge($default_options, $extra_options);
+
+ try {
+ $response = $this->gateway->authorize($options)->send();
+ } catch (\Exception $e) {
+ $this->logger->info($this->module->getName() . ':: Invoice: ' . $invoice->id . ' - Error: '. $e->getMessage());
+
+ $message = $e->getMessage();
+
+ return response()->json([
+ 'error' => $message,
+ 'redirect' => false,
+ 'success' => false,
+ 'data' => false,
+ ]);
+ }
+
+ if ($response->isSuccessful()) {
+ $this->setReference($invoice, $response->$reference());
+
+ $options[lcfirst(str_replace('get', '', $reference))] = $response->$reference();
+
+ $response = $this->gateway->capture($options)->send();
+
+ return $this->finish($invoice, $request);
+ }
+
+ if ($response->isRedirect()) {
+ $this->setReference($invoice, $response->$reference());
+
+ return response()->json([
+ 'error' => false,
+ 'redirect' => $response->getRedirectUrl(),
+ 'success' => false,
+ 'data' => $response->getRedirectData(),
+ ]);
+ }
+
+ return $this->failure($invoice, $response);
+ }
+
+ public function purchase($invoice, $request, $extra_options = [])
+ {
+ $default_options = [
+ 'amount' => $invoice->amount - $invoice->paid,
+ 'currency' => $invoice->currency_code,
+ 'transactionId' => $invoice->id,
+ 'returnUrl' => $this->getReturnUrl($invoice),
+ 'cancelUrl' => $this->getCancelUrl($invoice),
+ ];
+
+ $options = array_merge($default_options, $extra_options);
+
+ try {
+ $response = $this->gateway->purchase($options)->send();
+ } catch (\Exception $e) {
+ $this->logger->info($this->module->getName() . ':: Invoice: ' . $invoice->id . ' - Error: '. $e->getMessage());
+
+ $message = $e->getMessage();
+
+ return response()->json([
+ 'error' => $message,
+ 'redirect' => false,
+ 'success' => false,
+ 'data' => false,
+ ]);
+ }
+
+ if ($response->isSuccessful()) {
+ $this->setReference($invoice, $response->getTransactionReference());
+
+ return $this->finish($invoice, $request);
+ }
+
+ if ($response->isRedirect()) {
+ $this->setReference($invoice, $response->getTransactionReference());
+
+ return response()->json([
+ 'error' => false,
+ 'redirect' => $response->getRedirectUrl(),
+ 'success' => false,
+ 'data' => $response->getRedirectData(),
+ ]);
+ }
+
+ return $this->failure($invoice, $response);
+ }
+
+ public function completePurchase($invoice, $request, $extra_options = [])
+ {
+ $default_options = [
+ 'amount' => $invoice->amount,
+ 'currency' => $invoice->currency_code,
+ 'transactionId' => $invoice->id,
+ 'transactionReference' => $this->getReference($invoice),
+ //'returnUrl' => $this->getReturnUrl($invoice),
+ //'cancelUrl' => $this->getCancelUrl($invoice),
+ ];
+
+ $options = array_merge($default_options, $extra_options);
+
+ $response = $this->gateway->completePurchase($options)->send();
+
+ if ($response->isSuccessful()) {
+ return $this->finish($invoice, $request);
+ }
+
+ if ($response->isCancelled()) {
+ return $this->cancel($invoice);
+ }
+
+ return $this->failure($invoice, $response, true);
+ }
+
+ public function failure($invoice, $response, $force_redirect = false)
+ {
+ $data = $response->getData();
+ $message = $response->getMessage();
+
+ if (isset($data['error'])) {
+ $this->logger->info($this->module->getName() . ':: Invoice: ' . $invoice->id . ' - Error Type: ' . $data['error']['type'] . ' - Error Message: ' . $message);
+ } else {
+ $this->logger->info($this->module->getName() . ':: Invoice: ' . $invoice->id . ' - Error Message: ' . $message);
+ }
+
+ $invoice_url = $this->getInvoiceUrl($invoice);
+
+ flash($message)->error()->important();
+
+ if ($force_redirect) {
+ return redirect($invoice_url);
+ }
+
+ return response()->json([
+ 'error' => $message,
+ 'redirect' => $invoice_url,
+ 'success' => false,
+ 'data' => false,
+ ]);
+ }
+
+ public function all()
+ {
+ return $this->callFactory('all');
+ }
+
+ public function replace($gateways)
+ {
+ return $this->callFactory('replace', [$gateways]);
+ }
+
+ public function register($class_name)
+ {
+ return $this->callFactory('register', [$class_name]);
+ }
+
+ public function create($class, $http_client = null, $http_request = null)
+ {
+ $this->gateway = $this->callFactory('create', [$class, $http_client, $http_request]);
+
+ return $this->gateway;
+ }
+
+ public function callFactory($method, $parameters = [])
+ {
+ $factory = $this->getFactory();
+
+ return call_user_func_array(array($factory, $method), (array) $parameters);
+ }
+
+ public function getFactory()
+ {
+ if (is_null($this->factory)) {
+ $this->factory = new \Omnipay\Common\GatewayFactory();
+ }
+
+ return $this->factory;
+ }
+
+ public function setCardFirstLastName(&$request)
+ {
+ $contact = explode(" ", $request['cardName']);
+
+ $last_name = array_pop($contact);
+ $first_name = implode(" ", $contact);
+
+ $request['cardFirstName'] = $first_name;
+ $request['cardLastName'] = $last_name;
+ }
+}
diff --git a/app/Traits/Owners.php b/app/Traits/Owners.php
new file mode 100644
index 00000000..24df9558
--- /dev/null
+++ b/app/Traits/Owners.php
@@ -0,0 +1,18 @@
+ownable ?: true;
+
+ return ($ownable === true) && in_array('created_by', $this->getFillable());
+ }
+
+ public function isNotOwnable()
+ {
+ return !$this->isOwnable();
+ }
+}
diff --git a/app/Traits/Permissions.php b/app/Traits/Permissions.php
new file mode 100644
index 00000000..142205de
--- /dev/null
+++ b/app/Traits/Permissions.php
@@ -0,0 +1,514 @@
+ 'create',
+ 'r' => 'read',
+ 'u' => 'update',
+ 'd' => 'delete',
+ ];
+ }
+
+ public function attachPermissionsByRoleNames($roles)
+ {
+ foreach ($roles as $role_name => $permissions) {
+ $role = $this->createRole($role_name);
+
+ foreach ($permissions as $id => $permission) {
+ $this->attachPermissionsByAction($role, $id, $permission);
+ }
+ }
+ }
+
+ public function attachPermissionsToAdminRoles($permissions)
+ {
+ $this->applyPermissionsToRoles($this->getDefaultAdminRoles(), 'attach', $permissions);
+ }
+
+ public function attachPermissionsToPortalRoles($permissions)
+ {
+ $this->applyPermissionsToRoles($this->getDefaultPortalRoles(), 'attach', $permissions);
+ }
+
+ public function attachPermissionsToAllRoles($permissions, $require = 'read-admin-panel')
+ {
+ $this->applyPermissionsToRoles($this->getRoles($require), 'attach', $permissions);
+ }
+
+ public function detachPermissionsByRoleNames($roles)
+ {
+ foreach ($roles as $role_name => $permissions) {
+ foreach ($permissions as $permission_name) {
+ $this->detachPermission($role_name, $permission_name);
+ }
+ }
+ }
+
+ public function detachPermissionsFromAdminRoles($permissions)
+ {
+ $this->applyPermissionsToRoles($this->getDefaultAdminRoles(), 'detach', $permissions);
+ }
+
+ public function detachPermissionsFromPortalRoles($permissions)
+ {
+ $this->applyPermissionsToRoles($this->getDefaultPortalRoles(), 'detach', $permissions);
+ }
+
+ public function detachPermissionsFromAllRoles($permissions, $require = 'read-admin-panel')
+ {
+ $this->applyPermissionsToRoles($this->getRoles($require), 'detach', $permissions, $require);
+ }
+
+ public function applyPermissionsToRoles($roles, $apply, $permissions)
+ {
+ $roles->each(function ($role) use ($apply, $permissions) {
+ $f1 = $apply . 'PermissionsByAction';
+ $f2 = $apply . 'Permission';
+
+ foreach ($permissions as $id => $permission) {
+ if ($this->isActionList($permission)) {
+ $this->$f1($role, $id, $permission);
+
+ continue;
+ }
+
+ $this->$f2($role, $permission);
+ }
+ });
+ }
+
+ public function updatePermissionNames($permissions)
+ {
+ $actions = $this->getActionsMap();
+
+ foreach ($permissions as $old => $new) {
+ foreach ($actions as $action) {
+ $old_name = $action . '-' . $old;
+
+ $permission = Permission::where('name', $old_name)->first();
+
+ if (empty($permission)) {
+ continue;
+ }
+
+ $new_name = $action . '-' . $new;
+ $new_display_name = $this->getPermissionDisplayName($new_name);
+
+ $permission->update([
+ 'name' => $new_name,
+ 'display_name' => $new_display_name,
+ ]);
+ }
+ }
+ }
+
+ public function attachDefaultModulePermissions($module, $require = null)
+ {
+ $this->attachModuleReportPermissions($module, $require);
+
+ $this->attachModuleWidgetPermissions($module, $require);
+
+ $this->attachModuleSettingPermissions($module, $require);
+ }
+
+ public function attachModuleReportPermissions($module, $require = null)
+ {
+ if (is_string($module)) {
+ $module = module($module);
+ }
+
+ if (empty($module->get('reports'))) {
+ return;
+ }
+
+ $permissions = [];
+
+ foreach ($module->get('reports') as $class) {
+ if (!class_exists($class)) {
+ continue;
+ }
+
+ $permissions[] = $this->createModuleReportPermission($module, $class);
+ }
+
+ $require
+ ? $this->attachPermissionsToAllRoles($permissions, $require)
+ : $this->attachPermissionsToAdminRoles($permissions);
+ }
+
+ public function attachModuleWidgetPermissions($module, $require = null)
+ {
+ if (is_string($module)) {
+ $module = module($module);
+ }
+
+ if (empty($module->get('widgets'))) {
+ return;
+ }
+
+ $permissions = [];
+
+ foreach ($module->get('widgets') as $class) {
+ if (!class_exists($class)) {
+ continue;
+ }
+
+ $permissions[] = $this->createModuleWidgetPermission($module, $class);
+ }
+
+ $require
+ ? $this->attachPermissionsToAllRoles($permissions, $require)
+ : $this->attachPermissionsToAdminRoles($permissions);
+ }
+
+ public function attachModuleSettingPermissions($module, $require = null)
+ {
+ if (is_string($module)) {
+ $module = module($module);
+ }
+
+ if (empty($module->get('settings'))) {
+ return;
+ }
+
+ $permissions = [];
+
+ $permissions[] = $this->createModuleSettingPermission($module, 'read');
+ $permissions[] = $this->createModuleSettingPermission($module, 'update');
+
+ $require
+ ? $this->attachPermissionsToAllRoles($permissions, $require)
+ : $this->attachPermissionsToAdminRoles($permissions);
+ }
+
+ public function createModuleReportPermission($module, $class)
+ {
+ if (is_string($module)) {
+ $module = module($module);
+ }
+
+ if (!class_exists($class)) {
+ return;
+ }
+
+ $name = Reports::getPermission($class);
+ $display_name = 'Read ' . $module->getName() . ' Reports ' . Reports::getDefaultName($class);
+
+ return $this->createPermission($name, $display_name);
+ }
+
+ public function createModuleWidgetPermission($module, $class)
+ {
+ if (is_string($module)) {
+ $module = module($module);
+ }
+
+ if (!class_exists($class)) {
+ return;
+ }
+
+ $name = Widgets::getPermission($class);
+ $display_name = 'Read ' . $module->getName() . ' Widgets ' . Widgets::getDefaultName($class);
+
+ return $this->createPermission($name, $display_name);
+ }
+
+ public function createModuleSettingPermission($module, $action)
+ {
+ return $this->createModuleControllerPermission($module, $action, 'settings');
+ }
+
+ public function createModuleControllerPermission($module, $action, $controller)
+ {
+ if (is_string($module)) {
+ $module = module($module);
+ }
+
+ $name = $action . '-' . $module->getAlias() . '-' . $controller;
+ $display_name = Str::title($action) . ' ' . $module->getName() . ' ' . Str::title($controller);
+
+ return $this->createPermission($name, $display_name);
+ }
+
+ public function createRole($name, $display_name = null, $description = null)
+ {
+ $alias = !empty($this->alias) ? $this->alias : $name;
+
+ if (empty($display_name)) {
+ $display_name = $this->findTranslation([
+ 'auth.roles.' . Str::replace('-', '_', $name) . '.name',
+ $alias . '::permissions.roles.' . Str::replace('-', '_', $name) . '.name',
+ $alias . '::auth.roles.' . Str::replace('-', '_', $name) . '.name',
+ ]);
+
+ if (empty($display_name)) {
+ $display_name = Str::title(Str::replace('-', ' ', $name));
+ }
+ }
+
+ if (empty($description)) {
+ $description = $this->findTranslation([
+ 'auth.roles.' . Str::replace('-', '_', $name) . '.description',
+ $alias . '::permissions.roles.' . Str::replace('-', '_', $name) . '.description',
+ $alias . '::auth.roles.' . Str::replace('-', '_', $name) . '.description',
+ ]);
+
+ if (empty($description)) {
+ $description = $display_name;
+ }
+ }
+
+ return role_model_class()::firstOrCreate([
+ 'name' => $name,
+ ], [
+ 'display_name' => $display_name,
+ 'description' => $description,
+ ]);
+ }
+
+ public function createPermission($name, $display_name = null, $description = null)
+ {
+ $display_name = $display_name ?? $this->getPermissionDisplayName($name);
+
+ return Permission::firstOrCreate([
+ 'name' => $name,
+ ], [
+ 'display_name' => $display_name,
+ 'description' => $description ?? $display_name,
+ ]);
+ }
+
+ public function attachPermission($role, $permission)
+ {
+ if (is_string($permission)) {
+ $permission = $this->createPermission($permission);
+ }
+
+ if ($role->hasPermission($permission->name)) {
+ return;
+ }
+
+ $role->attachPermission($permission);
+ }
+
+ public function detachPermission($role, $permission, $delete = true)
+ {
+ if (is_string($role)) {
+ $role = role_model_class()::where('name', $role)->first();
+ }
+
+ if (empty($role)) {
+ return;
+ }
+
+ if (is_string($permission)) {
+ $permission = Permission::where('name', $permission)->first();
+ }
+
+ if (empty($permission)) {
+ return;
+ }
+
+ if ($role->hasPermission($permission->name)) {
+ $role->detachPermission($permission);
+ }
+
+ if ($delete === false) {
+ return;
+ }
+
+ $permission->delete();
+ }
+
+ public function isActionList($permission)
+ {
+ if (!is_string($permission)) {
+ return false;
+ }
+
+ // c || c,r,u,d
+ return (Str::length($permission) == 1) || Str::contains($permission, ',');
+ }
+
+ public function attachPermissionsByAction($role, $page, $action_list)
+ {
+ $this->applyPermissionsByAction('attach', $role, $page, $action_list);
+ }
+
+ public function detachPermissionsByAction($role, $page, $action_list)
+ {
+ $this->applyPermissionsByAction('detach', $role, $page, $action_list);
+ }
+
+ public function applyPermissionsByAction($apply, $role, $page, $action_list)
+ {
+ $function = $apply . 'Permission';
+
+ $actions_map = collect($this->getActionsMap());
+
+ $actions = explode(',', $action_list);
+
+ foreach ($actions as $short_action) {
+ $action = $actions_map->get($short_action);
+
+ $name = $action . '-' . $page;
+
+ $this->$function($role, $name);
+ }
+ }
+
+ public function getPermissionDisplayName($name)
+ {
+ if (!empty($this->alias)) {
+ $name = str_replace($this->alias, '{Module Placeholder}', $name);
+ }
+
+ $name = Str::title(str_replace('-', ' ', $name));
+
+ if (!empty($this->alias)) {
+ $name = str_replace('{Module Placeholder}', module($this->alias)->getName(), $name);
+ }
+
+ return $name;
+ }
+
+ public function getRoles($require = 'read-admin-panel')
+ {
+ return role_model_class()::all()->filter(function ($role) use ($require) {
+ return $require ? $role->hasPermission($require) : true;
+ });
+ }
+
+ public function getDefaultAdminRoles($custom = null)
+ {
+ $roles = role_model_class()::whereIn('name', $custom ?? ['super_admin', 'admin'])->get();
+
+ if ($roles->isNotEmpty()) {
+ return $roles;
+ }
+
+ return $this->getRoles('read-admin-panel');
+ }
+
+ public function getDefaultPortalRoles($custom = null)
+ {
+ $roles = role_model_class()::whereIn('name', $custom ?? ['customer'])->get();
+
+ if ($roles->isNotEmpty()) {
+ return $roles;
+ }
+
+ return $this->getRoles('read-client-portal');
+ }
+
+ /**
+ * Assign permissions middleware to default controller methods.
+ *
+ * @return void
+ */
+ public function assignPermissionsToController()
+ {
+ // No need to check for permission in console
+ if (app()->runningInConsole()) {
+ return;
+ }
+
+ $table = request_is_api() ? request()->segment(2) : '';
+
+ // Find the proper controller for common API endpoints
+ if (in_array($table, ['contacts', 'documents'])) {
+ $controller = '';
+
+ // Look for type in search variable like api/contacts?search=type:customer
+ $type = $this->getSearchStringValue('type');
+
+ if (! empty($type)) {
+ $alias = config('type.' . Str::singular($table) . '.' . $type . '.alias');
+ $group = config('type.' . Str::singular($table) . '.' . $type . '.group');
+ $prefix = config('type.' . Str::singular($table) . '.' . $type . '.permission.prefix');
+
+ // if use module set module alias
+ if (! empty($alias)) {
+ $controller .= $alias . '-';
+ }
+
+ // if controller in folder it must
+ if (! empty($group)) {
+ $controller .= $group . '-';
+ }
+
+ $controller .= $prefix;
+ }
+ } else {
+ $route = app(Route::class);
+
+ // Get the controller array
+ $arr = array_reverse(explode('\\', explode('@', $route->getAction()['uses'])[0]));
+
+ $controller = '';
+
+ // Add module
+ if (isset($arr[3]) && isset($arr[4])) {
+ if (strtolower($arr[4]) == 'modules') {
+ $controller .= Str::kebab($arr[3]) . '-';
+ } elseif (isset($arr[5]) && (strtolower($arr[5]) == 'modules')) {
+ $controller .= Str::kebab($arr[4]) . '-';
+ }
+ }
+
+ // Add folder
+ if (! in_array(strtolower($arr[1]), ['api', 'controllers'])) {
+ $controller .= Str::kebab($arr[1]) . '-';
+ }
+
+ // Add file
+ $controller .= Str::kebab($arr[0]);
+
+ // Skip ACL
+ $skip = ['portal-dashboard'];
+ if (in_array($controller, $skip)) {
+ return;
+ }
+
+ // App\Http\Controllers\FooBar -->> foo-bar
+ // App\Http\Controllers\FooBar\Main -->> foo-bar-main
+ // Modules\Blog\Http\Controllers\Posts -->> blog-posts
+ // Modules\Blog\Http\Controllers\Portal\Posts -->> blog-portal-posts
+ }
+
+ // Add CRUD permission check
+ $this->middleware('permission:create-' . $controller)->only('create', 'store', 'duplicate', 'import');
+ $this->middleware('permission:read-' . $controller)->only('index', 'show', 'edit', 'export');
+ $this->middleware('permission:update-' . $controller)->only('update', 'enable', 'disable', 'markSent', 'markCancelled', 'markReceived', 'markApproved', 'markRefused', 'restoreInvoice', 'restoreBill', 'end');
+ $this->middleware('permission:delete-' . $controller)->only('destroy');
+ }
+
+ public function canAccessMenuItem($title, $permissions)
+ {
+ $permissions = Arr::wrap($permissions);
+
+ $item = new \stdClass();
+ $item->title = $title;
+ $item->permissions = $permissions;
+
+ event(new \App\Events\Menu\ItemAuthorizing($item));
+
+ return user()->canAny($item->permissions);
+ }
+}
diff --git a/app/Traits/Plans.php b/app/Traits/Plans.php
new file mode 100644
index 00000000..f7f85ad6
--- /dev/null
+++ b/app/Traits/Plans.php
@@ -0,0 +1,104 @@
+getPlanLimitByType('user');
+ }
+
+ public function getCompanyLimitOfPlan(): object
+ {
+ return $this->getPlanLimitByType('company');
+ }
+
+ public function getInvoiceLimitOfPlan(): object
+ {
+ return $this->getPlanLimitByType('invoice');
+ }
+
+ public function getAnyActionLimitOfPlan(): object
+ {
+ $user_limit = $this->getUserLimitOfPlan();
+ $company_limit = $this->getCompanyLimitOfPlan();
+ $invoice_limit = $this->getInvoiceLimitOfPlan();
+
+ if (! $user_limit->action_status) {
+ return $user_limit;
+ }
+
+ if (! $company_limit->action_status) {
+ return $company_limit;
+ }
+
+ if (! $invoice_limit->action_status) {
+ return $invoice_limit;
+ }
+
+ $limit = new \stdClass();
+ $limit->action_status = true;
+ $limit->view_status = true;
+ $limit->message = "Success";
+
+ return $limit;
+ }
+
+ public function getPlanLimitByType($type): object
+ {
+ // A self-hosted installation must not depend on Akaunting Cloud to
+ // create its own users, companies, or invoices.
+ if (! config('app.installed') || running_in_test() || config('app.offline')) {
+ $limit = new \stdClass();
+
+ $limit->action_status = true;
+ $limit->view_status = true;
+ $limit->message = "Success";
+
+ return $limit;
+ }
+
+ if (! $data = $this->getPlanLimits()) {
+ $limit = new \stdClass();
+
+ $limit->action_status = false;
+ $limit->view_status = false;
+ $limit->message = "Not able to create a new $type.";
+
+ return $limit;
+ }
+
+ $limit = $data->$type;
+
+ $limit->message = str_replace('{company_id}', company_id(), $limit->message);
+
+ return $limit;
+ }
+
+ public function getPlanLimits(): bool|object
+ {
+ $key = 'plans.limits';
+
+ return Cache::remember($key, Date::now()->addHour(), function () {
+ $url = 'plans/limits';
+
+ if (! $data = static::getResponseData('GET', $url, ['timeout' => 10])) {
+ return false;
+ }
+
+ return $data;
+ });
+ }
+}
diff --git a/app/Traits/Recurring.php b/app/Traits/Recurring.php
new file mode 100644
index 00000000..edaf5f3a
--- /dev/null
+++ b/app/Traits/Recurring.php
@@ -0,0 +1,233 @@
+recurring()->create([
+ 'company_id' => $this->company_id,
+ 'frequency' => $frequency,
+ 'interval' => $interval,
+ 'started_at' => $started_at,
+ 'status' => $status,
+ 'limit_by' => $limit_by,
+ 'limit_count' => $limit_count,
+ 'limit_date' => $limit_date,
+ 'auto_send' => $auto_send,
+ 'created_from' => $source,
+ 'created_by' => $owner,
+ ]);
+ }
+
+ public function updateRecurring($request)
+ {
+ if (empty($request['recurring_frequency']) || ($request['recurring_frequency'] == 'no')) {
+ $this->recurring()->delete();
+
+ return;
+ }
+
+ $frequency = ($request['recurring_frequency'] != 'custom') ? $request['recurring_frequency'] : $request['recurring_custom_frequency'];
+ $interval = (($request['recurring_frequency'] != 'custom') || ($request['recurring_interval'] < 1)) ? 1 : (int) $request['recurring_interval'];
+ $started_at = !empty($request['recurring_started_at']) ? $request['recurring_started_at'] : Date::now();
+ $limit_by = !empty($request['recurring_limit']) ? $request['recurring_limit'] : 'count';
+ $limit_count = isset($request['recurring_limit_count']) ? (int) $request['recurring_limit_count'] : 0;
+ $limit_date = !empty($request['recurring_limit_date']) ? $request['recurring_limit_date'] : null;
+ $auto_send = !empty($request['recurring_send_email']) ? $request['recurring_send_email'] : 0;
+
+ $recurring = $this->recurring();
+ $model_exists = $recurring->count();
+
+ $data = [
+ 'company_id' => $this->company_id,
+ 'frequency' => $frequency,
+ 'interval' => $interval,
+ 'started_at' => $started_at,
+ 'limit_by' => $limit_by,
+ 'limit_count' => $limit_count,
+ 'limit_date' => $limit_date,
+ 'auto_send' => $auto_send,
+ ];
+
+ if (! empty($request['recurring_status'])) {
+ $data['status'] = $request['recurring_status'];
+ }
+
+ if ($model_exists) {
+ $recurring->update($data);
+ } else {
+ $source = !empty($request['created_from']) ? $request['created_from'] : source_name();
+ $owner = !empty($request['created_by']) ? (int) $request['created_by'] : user_id();
+
+ $recurring->create(array_merge($data, [
+ 'status' => Model::ACTIVE_STATUS,
+ 'created_from' => $source,
+ 'created_by' => $owner,
+ ]));
+ }
+ }
+
+ public function getRecurringSchedule()
+ {
+ $config = new ArrayTransformerConfig();
+ $config->enableLastDayOfMonthFix();
+ $config->setVirtualLimit($this->getRecurringVirtualLimit());
+
+ $transformer = new ArrayTransformer();
+ $transformer->setConfig($config);
+
+ return $transformer->transform($this->getRecurringRule());
+ }
+
+ public function getRecurringRule()
+ {
+ $rule = (new Rule())
+ ->setStartDate($this->getRecurringRuleStartDate())
+ ->setTimezone($this->getRecurringRuleTimeZone())
+ ->setFreq($this->getRecurringRuleFrequency())
+ ->setInterval($this->getRecurringRuleInterval());
+
+ if ($this->limit_by == 'date') {
+ $rule->setUntil($this->getRecurringRuleUntilDate());
+ } elseif ($this->limit_count != 0) {
+ // 0 means infinite
+ $rule->setCount($this->getRecurringRuleCount());
+ }
+
+ return $rule;
+ }
+
+ public function getRecurringRuleStartDate()
+ {
+ return $this->getRecurringRuleDate($this->started_at);
+ }
+
+ public function getRecurringRuleUntilDate()
+ {
+ return $this->getRecurringRuleDate($this->limit_date);
+ }
+
+ public function getRecurringRuleTodayDate()
+ {
+ return $this->getRecurringRuleDate(Date::today()->toDateTimeString());
+ }
+
+ public function getRecurringRuleTomorrowDate()
+ {
+ return $this->getRecurringRuleDate(Date::tomorrow()->toDateTimeString());
+ }
+
+ public function getRecurringRuleDate($date)
+ {
+ return new \DateTime($date, new \DateTimeZone($this->getRecurringRuleTimeZone()));
+ }
+
+ public function getRecurringRuleTimeZone()
+ {
+ return setting('localisation.timezone');
+ }
+
+ public function getRecurringRuleCount()
+ {
+ return $this->limit_count;
+ }
+
+ public function getRecurringRuleFrequency()
+ {
+ return strtoupper($this->frequency);
+ }
+
+ public function getRecurringRuleInterval()
+ {
+ return $this->interval;
+ }
+
+ public function getRecurringVirtualLimit()
+ {
+ switch ($this->frequency) {
+ case 'yearly':
+ $limit = '2';
+ break;
+ case 'monthly':
+ $limit = '24';
+ break;
+ case 'weekly':
+ $limit = '104';
+ break;
+ case 'daily':
+ default;
+ $limit = '732';
+ break;
+ }
+
+ return $limit;
+ }
+
+ public function getNextRecurring()
+ {
+ if (! $schedule = $this->getRecurringSchedule()) {
+ return false;
+ }
+
+ $schedule = $schedule->startsAfter($this->getRecurringRuleTodayDate());
+
+ if ($schedule->count() == 0) {
+ return false;
+ }
+
+ if (! $next = $schedule->current()) {
+ return false;
+ }
+
+ return $next->getStart();
+ }
+
+ public function getFirstRecurring()
+ {
+ if (! $schedule = $this->getRecurringSchedule()) {
+ return false;
+ }
+
+ if (! $first = $schedule->first()) {
+ return false;
+ }
+
+ return $first->getStart();
+ }
+
+ public function getLastRecurring()
+ {
+ if (! $schedule = $this->getRecurringSchedule()) {
+ return false;
+ }
+
+ if (! $last = $schedule->last()) {
+ return false;
+ }
+
+ return $last->getStart();
+ }
+}
diff --git a/app/Traits/Relationships.php b/app/Traits/Relationships.php
new file mode 100644
index 00000000..cac38556
--- /dev/null
+++ b/app/Traits/Relationships.php
@@ -0,0 +1,70 @@
+model = $model;
+ $record->relationships = $relationships;
+
+ event(new RelationshipCounting($record));
+
+ $counter = [];
+
+ foreach ((array) $record->relationships as $relationship => $text) {
+ if (!$c = $model->$relationship()->count()) {
+ continue;
+ }
+
+ $text = Str::contains($text, '::') ? $text : 'general.' . $text;
+ $counter[] = (($c > 1) ? $c . ' ' : null ) . strtolower(trans_choice($text, ($c > 1) ? 2 : 1));
+ }
+
+ return $counter;
+ }
+
+ /**
+ * Mass delete relationships with events being fired.
+ *
+ * @param $model
+ * @param $relationships
+ * @param $permanently
+ */
+ public function deleteRelationships($model, $relationships, $permanently = false): void
+ {
+ $record = new \stdClass();
+ $record->model = $model;
+ $record->relationships = $relationships;
+
+ event(new RelationshipDeleting($record));
+
+ foreach ((array) $record->relationships as $relationship) {
+ if (empty($model->$relationship)) {
+ continue;
+ }
+
+ $items = [];
+ $relation = $model->$relationship;
+
+ if ($relation instanceof Collection) {
+ $items = $relation->all();
+ } else {
+ $items[] = $relation;
+ }
+
+ $function = $permanently ? 'forceDelete' : 'delete';
+
+ foreach ((array) $items as $item) {
+ $item->$function();
+ }
+ }
+ }
+}
diff --git a/app/Traits/Scopes.php b/app/Traits/Scopes.php
new file mode 100644
index 00000000..a051c079
--- /dev/null
+++ b/app/Traits/Scopes.php
@@ -0,0 +1,114 @@
+scopeColumnExists($builder, $model->getTable(), 'type')) {
+ return;
+ }
+
+ // Apply not recurring scope
+ $builder->isNotRecurring();
+ }
+
+ public function applyNotSplitScope(Builder $builder, Model $model): void
+ {
+ // Skip if type already set
+ if ($this->scopeColumnExists($builder, $model->getTable(), 'type')) {
+ return;
+ }
+
+ // Apply not split scope
+ $builder->isNotSplit();
+ }
+
+ public function scopeColumnExists(Builder $builder, string $table, string $column): bool
+ {
+ $query = $builder->getQuery();
+
+ foreach ((array) $query->wheres as $key => $where) {
+ if (empty($where) || empty($where['column'])) {
+ continue;
+ }
+
+ // Skip Expression objects (e.g. DB::raw()) — they are not
+ // simple "table.column" strings and cannot be parsed here.
+ // Fixes CLOUD-PROD-LARAVEL-1TKA
+ if (! is_string($where['column'])) {
+ continue;
+ }
+
+ if (strstr($where['column'], '.')) {
+ $whr = explode('.', $where['column']);
+
+ $where['table'] = $whr[0];
+ $where['column'] = $whr[1];
+ }
+
+ if (! empty($where['table']) && ! empty($table) && ($where['table'] != $table)) {
+ continue;
+ }
+
+ if ($where['column'] != $column) {
+ continue;
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ public function scopeValueExists(Builder $builder, string $table, string $column, string $value): bool
+ {
+ $query = $builder->getQuery();
+
+ foreach ((array) $query->wheres as $key => $where) {
+ if (empty($where) || empty($where['column']) || empty($where['value'])) {
+ continue;
+ }
+
+ // Skip Expression objects — same guard as scopeColumnExists
+ if (! is_string($where['column'])) {
+ continue;
+ }
+
+ if (strstr($where['column'], '.')) {
+ $whr = explode('.', $where['column']);
+
+ $where['table'] = $whr[0];
+ $where['column'] = $whr[1];
+ }
+
+ if (! empty($where['table']) && ! empty($table) && ($where['table'] != $table)) {
+ continue;
+ }
+
+ if ($where['column'] != $column) {
+ continue;
+ }
+
+ if (! Str::endsWith($where['value'], $value)) {
+ continue;
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ // @deprecated version 3.0.0
+ public function scopeExists($builder, $column): bool
+ {
+ return $this->scopeColumnExists($builder, '', $column);
+ }
+}
diff --git a/app/Traits/SearchString.php b/app/Traits/SearchString.php
new file mode 100644
index 00000000..a121aca3
--- /dev/null
+++ b/app/Traits/SearchString.php
@@ -0,0 +1,196 @@
+=2021-02-01 issued_at<=2021-02-10
+ *
+ * Examples (spaced):
+ * search=age > 30
+ * search=title : "Hello World"
+ *
+ * Returns a string for single-value operators (:, =) and an array when the
+ * same column appears multiple times (e.g. range queries with > / <).
+ * Surrounding quotes are stripped from returned values.
+ */
+ public function getSearchStringValue(
+ string $name,
+ string $default = '',
+ string $input = '',
+ ): string|array {
+ $value = $default;
+
+ $input = $input ?: request('search', '');
+
+ // Normalise spaced operators (e.g. "age > 30") into compact form ("age>30")
+ // so the rest of the parsing logic only needs to handle one format.
+ $input = preg_replace('/\b(' . preg_quote($name, '/') . ')\s*(>=|<=|>|<|=|:)\s*/', '$1$2', $input);
+
+ $columns = explode(' ', $input);
+
+ foreach ($columns as $column) {
+ // Operators ordered longest-first to avoid partial matches (>= before >).
+ $variable = preg_split('/(>=|<=|>|<|=|:)/', $column, 2);
+
+ if ($name === 'searchable' && count($variable) === 1 && preg_match('/^".*"$/', $variable[0])) {
+ return trim($variable[0], '"');
+ }
+
+ if (empty($variable[0]) || $variable[0] !== $name || ! isset($variable[1]) || $variable[1] === '') {
+ continue;
+ }
+
+ $extracted = trim($variable[1], '"\'');
+
+ if (str_contains($column, ':')) {
+ return $extracted;
+ }
+
+ if (! is_array($value)) {
+ $value = [];
+ }
+
+ $value[] = $extracted;
+ }
+
+ return $value;
+ }
+
+ /**
+ * Get the operator for a named column in the search string, mapped to an Eloquent-compatible operator.
+ * The `:` operator maps to `=` (exact match).
+ * The `not` prefix inverts the operator (e.g. `not amount>100` → `<=`).
+ *
+ * Examples without `not`:
+ * search=amount>=100 → '>='
+ * search=amount<=100 → '<='
+ * search=amount>100 → '>'
+ * search=amount<100 → '<'
+ * search=amount:100 → '='
+ * search=amount=100 → '='
+ *
+ * Examples with `not`:
+ * search=not amount>=100 → '<'
+ * search=not amount<=100 → '>'
+ * search=not amount>100 → '<='
+ * search=not amount<100 → '>='
+ * search=not amount:100 → '!='
+ * search=not amount=100 → '!='
+ */
+ public function getSearchStringOperator(
+ string $name,
+ string $default = '=',
+ string $input = '',
+ ): string {
+ $input = $input ?: request('search', '');
+
+ $columns = explode(' ', $input);
+
+ foreach ($columns as $index => $column) {
+ if (! str_starts_with($column, $name)) {
+ continue;
+ }
+
+ $rest = substr($column, strlen($name));
+
+ if (! preg_match('/^(>=|<=|>|<|=|:)/', $rest, $matches)) {
+ continue;
+ }
+
+ $operator = $matches[1] === ':' ? '=' : $matches[1];
+ $negated = isset($columns[$index - 1]) && $columns[$index - 1] === 'not';
+
+ if (! $negated) {
+ return $operator;
+ }
+
+ return match ($operator) {
+ '>=' => '<',
+ '>' => '<=',
+ '<=' => '>',
+ '<' => '>=',
+ default => '!=',
+ };
+ }
+
+ return $default;
+ }
+
+ /**
+ * Strip tokens from a search string whose keys are not recognised columns
+ * for the given model class. Tokens whose keys appear in neither the model's
+ * search-string config nor the default keywords / columns are removed before
+ * the string is passed to usingSearchString().
+ *
+ * Falls back to returning $input unchanged when the model has no config entry.
+ */
+ public function stripUnknownSearchStringTokens(
+ string $input,
+ string $model_class,
+ ): string {
+ $model_config_columns = config('search-string.' . $model_class . '.columns', null);
+
+ if ($model_config_columns === null) {
+ return $input;
+ }
+
+ $valid_columns = array_unique(array_merge(
+ array_keys($model_config_columns),
+ array_values(config('search-string.default.keywords', [])),
+ array_keys(config('search-string.default.columns', [])),
+ ));
+
+ // Collect key aliases from column configs.
+ // A 'key' may be a plain string alias (e.g. 'invoiced_at') or a regex
+ // pattern (e.g. '/^(invoiced_at|billed_at)$/'). Plain aliases are
+ // merged into $valid_columns; regex patterns are kept separately and
+ // tested with preg_match inside the callback below.
+ $key_patterns = [];
+
+ foreach ($model_config_columns as $column_config) {
+ if (! is_array($column_config) || ! isset($column_config['key'])) {
+ continue;
+ }
+
+ $key = $column_config['key'];
+
+ if (is_string($key) && strlen($key) >= 2 && $key[0] === '/' && $key[-1] === '/') {
+ $key_patterns[] = $key;
+ } elseif (is_string($key)) {
+ $valid_columns[] = $key;
+ }
+ }
+
+ $valid_columns = array_unique($valid_columns);
+
+ // Match optional "not " prefix + key + operator + value (quoted or unquoted)
+ $input = preg_replace_callback(
+ pattern: '/\b(?:not\s+)?(\w+)\s*(?:>=|<=|>|<|=|:)\s*(?:"[^"]*"|\S+)/',
+ callback: function (array $matches) use ($valid_columns, $key_patterns): string {
+ $token = $matches[1];
+
+ if (in_array($token, $valid_columns, strict: true)) {
+ return $matches[0];
+ }
+
+ foreach ($key_patterns as $pattern) {
+ if (preg_match($pattern, $token)) {
+ return $matches[0];
+ }
+ }
+
+ return '';
+ },
+ subject: $input,
+ );
+
+ return trim(preg_replace('/\s+/', ' ', $input));
+ }
+}
diff --git a/app/Traits/Settings.php b/app/Traits/Settings.php
new file mode 100644
index 00000000..04b1d9e3
--- /dev/null
+++ b/app/Traits/Settings.php
@@ -0,0 +1,30 @@
+all();
+
+ if (! empty($settings)) {
+ return setting($key, $default);
+ }
+
+ $company_id = $this->getCompanyId();
+
+ if (empty($company_id)) {
+ return $default;
+ }
+
+ $value = Setting::companyId($company_id)->where('key', $key)->pluck('value')->first();
+
+ return $value ?: $default;
+ }
+}
diff --git a/app/Traits/SiteApi.php b/app/Traits/SiteApi.php
new file mode 100644
index 00000000..c7073245
--- /dev/null
+++ b/app/Traits/SiteApi.php
@@ -0,0 +1,86 @@
+ false, 'base_uri' => static::$base_uri]);
+
+ $headers['headers'] = [
+ 'Authorization' => 'Bearer ' . Info::getApiKey(),
+ 'Accept' => 'application/json',
+ 'Referer' => app()->runningInConsole() ? config('app.url') : url('/'),
+ 'Akaunting' => Version::short(),
+ 'Language' => language()->getShortCode(),
+ 'Information' => json_encode(Info::all()),
+ ];
+
+ $data = array_merge([
+ 'timeout' => 30,
+ 'referer' => true,
+ 'http_errors' => false,
+ ], $extra_data);
+
+ $options = array_merge_recursive($data, $headers);
+
+ try {
+ $response = $client->request($method, $path, $options);
+ } catch (ConnectException | Exception | RequestException $e) {
+ $response = $e;
+ }
+
+ return $response;
+ }
+
+ public static function getResponse($method, $path, $data = [], $status_code = 200)
+ {
+ $response = static::siteApiRequest($method, $path, $data);
+
+ $is_exception = (($response instanceof ConnectException) || ($response instanceof Exception) || ($response instanceof RequestException));
+
+ if (!$response || $is_exception || ($response->getStatusCode() != $status_code)) {
+ return false;
+ }
+
+ return $response;
+ }
+
+ public static function getResponseBody($method, $path, $data = [], $status_code = 200)
+ {
+ if (! $response = static::getResponse($method, $path, $data, $status_code)) {
+ return [];
+ }
+
+ $body = json_decode($response->getBody());
+
+ return $body;
+ }
+
+ public static function getResponseData($method, $path, $data = [], $status_code = 200)
+ {
+ if (! $body = static::getResponseBody($method, $path, $data, $status_code)) {
+ return [];
+ }
+
+ if (! is_object($body)) {
+ return [];
+ }
+
+ return $body->data;
+ }
+}
diff --git a/app/Traits/Sources.php b/app/Traits/Sources.php
new file mode 100644
index 00000000..810f9191
--- /dev/null
+++ b/app/Traits/Sources.php
@@ -0,0 +1,72 @@
+sourcable ?: true;
+
+ return ($sourcable === true) && in_array('created_from', $this->getFillable());
+ }
+
+ public function isNotSourcable(): bool
+ {
+ return ! $this->isSourcable();
+ }
+
+ public function getSourceName($request = null, $alias = null): string
+ {
+ $prefix = $this->getSourcePrefix($alias);
+
+ if (app()->runningInConsole()) {
+ $source = $prefix . 'console';
+ }
+
+ if (empty($source)) {
+ $request = $request ?: request();
+
+ if ($request instanceof QueueCollection || running_in_queue()) {
+ $source = $prefix . 'queue';
+ } else {
+ $source = $request->isApi() ? $prefix . 'api' : null;
+ }
+ }
+
+ if (empty($source)) {
+ $source = $prefix . 'ui';
+ }
+
+ return $source;
+ }
+
+ public function getSourcePrefix($alias = null)
+ {
+ $alias = is_null($alias) ? $this->getSourceAlias() : $alias;
+
+ return $alias . '::';
+ }
+
+ public function getSourceAlias()
+ {
+ $prefix = '';
+
+ $namespaces = explode('\\', get_class($this));
+
+ if (empty($namespaces[0]) || (empty($namespaces[1]))) {
+ return $prefix;
+ }
+
+ if ($namespaces[0] != 'Modules') {
+ return 'core';
+ }
+
+ $prefix = Str::kebab($namespaces[1]);
+
+ return $prefix;
+ }
+}
diff --git a/app/Traits/Tailwind.php b/app/Traits/Tailwind.php
new file mode 100644
index 00000000..156627a5
--- /dev/null
+++ b/app/Traits/Tailwind.php
@@ -0,0 +1,113 @@
+ '#6b7280',
+ 'gray-50' => '#f9fafb',
+ 'gray-100' => '#f3f4f6',
+ 'gray-200' => '#e5e7eb',
+ 'gray-300' => '#d1d5db',
+ 'gray-400' => '#9ca3af',
+ 'gray-500' => '#6b7280',
+ 'gray-600' => '#4b5563',
+ 'gray-700' => '#374151',
+ 'gray-800' => '#1f2937',
+ 'gray-900' => '#111827',
+
+ 'red' => '#cc0000',
+ 'red-50' => '#fcf2f2',
+ 'red-100' => '#fae6e6',
+ 'red-200' => '#f2bfbf',
+ 'red-300' => '#eb9999',
+ 'red-400' => '#db4d4d',
+ 'red-500' => '#cc0000',
+ 'red-600' => '#b80000',
+ 'red-700' => '#990000',
+ 'red-800' => '#7a0000',
+ 'red-900' => '#640000',
+
+ 'yellow' => '#eab308',
+ 'yellow-50' => '#fefce8',
+ 'yellow-100' => '#fef9c3',
+ 'yellow-200' => '#fef08a',
+ 'yellow-300' => '#fde047',
+ 'yellow-400' => '#facc15',
+ 'yellow-500' => '#eab308',
+ 'yellow-600' => '#ca8a04',
+ 'yellow-700' => '#a16207',
+ 'yellow-800' => '#854d0e',
+ 'yellow-900' => '#713f12',
+
+ 'green' => '#6ea152',
+ 'green-50' => '#f8faf6',
+ 'green-100' => '#f1f6ee',
+ 'green-200' => '#dbe8d4',
+ 'green-300' => '#c5d9ba',
+ 'green-400' => '#9abd86',
+ 'green-500' => '#6ea152',
+ 'green-600' => '#63914a',
+ 'green-700' => '#53793e',
+ 'green-800' => '#426131',
+ 'green-900' => '#364f28',
+
+ 'blue' => '#006ea6',
+ 'blue-50' => '#f2f8fb',
+ 'blue-100' => '#e6f1f6',
+ 'blue-200' => '#bfdbe9',
+ 'blue-300' => '#99c5db',
+ 'blue-400' => '#4d9ac1',
+ 'blue-500' => '#006ea6',
+ 'blue-600' => '#006395',
+ 'blue-700' => '#00537d',
+ 'blue-800' => '#004264',
+ 'blue-900' => '#003651',
+
+ 'indigo' => '#6366f1',
+ 'indigo-50' => '#eef2ff',
+ 'indigo-100' => '#e0e7ff',
+ 'indigo-200' => '#c7d2fe',
+ 'indigo-300' => '#a5b4fc',
+ 'indigo-400' => '#818cf8',
+ 'indigo-500' => '#6366f1',
+ 'indigo-600' => '#4f46e5',
+ 'indigo-700' => '#4338ca',
+ 'indigo-800' => '#3730a3',
+ 'indigo-900' => '#312e81',
+
+ 'purple' => '#55588b',
+ 'purple-50' => '#f7f7f9',
+ 'purple-100' => '#eeeef3',
+ 'purple-200' => '#d5d5e2',
+ 'purple-300' => '#bbbcd1',
+ 'purple-400' => '#888aae',
+ 'purple-500' => '#55588b',
+ 'purple-600' => '#4d4f7d',
+ 'purple-700' => '#404268',
+ 'purple-800' => '#333553',
+ 'purple-900' => '#2a2b44',
+
+ 'pink' => '#ec4899',
+ 'pink-50' => '#fdf2f8',
+ 'pink-100' => '#fce7f3',
+ 'pink-200' => '#fbcfe8',
+ 'pink-300' => '#f9a8d4',
+ 'pink-400' => '#f472b6',
+ 'pink-500' => '#ec4899',
+ 'pink-600' => '#db2777',
+ 'pink-700' => '#be185d',
+ 'pink-800' => '#9d174d',
+ 'pink-900' => '#831843',
+ ];
+
+ return Arr::exists($colors, $class)
+ ? $colors[$class]
+ : $class;
+ }
+}
diff --git a/app/Traits/Tenants.php b/app/Traits/Tenants.php
new file mode 100644
index 00000000..f02095eb
--- /dev/null
+++ b/app/Traits/Tenants.php
@@ -0,0 +1,30 @@
+tenantable ?? true;
+
+ return ($tenantable === true) && in_array('company_id', $this->getFillable());
+ }
+
+ public function isNotTenantable()
+ {
+ return !$this->isTenantable();
+ }
+}
diff --git a/app/Traits/Trackers.php b/app/Traits/Trackers.php
new file mode 100644
index 00000000..689db009
--- /dev/null
+++ b/app/Traits/Trackers.php
@@ -0,0 +1,43 @@
+ (string) company_id(),
+ 'locale' => (string) app()->getLocale(),
+ 'timezone' => (string) config('app.timezone'),
+ 'app_type' => (string) static::getAppType(),
+ 'route_name' => (string) static::getRouteName(),
+ ];
+ }
+
+ public static function getAppType(): string
+ {
+ $hostname = gethostname();
+
+ if (Str::contains($hostname, '-queue-')) {
+ $app_type = 'queue';
+ } elseif (Str::contains($hostname, '-cron-')) {
+ $app_type = 'cron';
+ } elseif (request_is_api()) {
+ $app_type = 'api';
+ } elseif (app()->runningInConsole()) {
+ $app_type = 'console';
+ } else {
+ $app_type = 'ui';
+ }
+
+ return $app_type;
+ }
+
+ public static function getRouteName(): ?string
+ {
+ return request()->route()?->getName();
+ }
+}
diff --git a/app/Traits/Transactions.php b/app/Traits/Transactions.php
new file mode 100644
index 00000000..3fcb2780
--- /dev/null
+++ b/app/Traits/Transactions.php
@@ -0,0 +1,262 @@
+type ?? $this->transaction->type ?? $this->model->type ?? Transaction::INCOME_TYPE;
+
+ return in_array($type, $this->getIncomeTypes());
+ }
+
+ public function isNotIncome(): bool
+ {
+ return ! $this->isIncome();
+ }
+
+ public function isExpense(): bool
+ {
+ $type = $this->type ?? $this->transaction->type ?? $this->model->type ?? Transaction::EXPENSE_TYPE;
+
+ return in_array($type, $this->getExpenseTypes());
+ }
+
+ public function isNotExpense(): bool
+ {
+ return ! $this->isExpense();
+ }
+
+ public function isRecurringTransaction(): bool
+ {
+ $type = $this->type ?? $this->transaction->type ?? $this->model->type ?? Transaction::INCOME_TYPE;
+
+ return Str::endsWith($type, '-recurring');
+ }
+
+ public function isNotRecurringTransaction(): bool
+ {
+ return ! $this->isRecurringTransaction();
+ }
+
+ public function isTransferTransaction(): bool
+ {
+ $type = $this->type ?? $this->transaction->type ?? $this->model->type ?? Transaction::INCOME_TYPE;
+
+ return Str::endsWith($type, '-transfer');
+ }
+
+ public function isNotTransferTransaction(): bool
+ {
+ return ! $this->isTransferTransaction();
+ }
+
+ public function isSplitTransaction(): bool
+ {
+ $type = $this->type ?? $this->transaction->type ?? $this->model->type ?? Transaction::INCOME_TYPE;
+
+ return Str::endsWith($type, '-split');
+ }
+
+ public function isNotSplitTransaction(): bool
+ {
+ return ! $this->isSplitTransaction();
+ }
+
+ public function isDocumentTransaction(): bool
+ {
+ $document_id = $this->document_id ?? $this->transaction->document_id ?? $this->model->document_id ?? null;
+
+ return ! empty($document_id);
+ }
+
+ public function isNotDocumentTransaction(): bool
+ {
+ return ! $this->isDocumentTransaction();
+ }
+
+ public function getIncomeTypes(string $return = 'array'): string|array
+ {
+ return $this->getTransactionTypes(Transaction::INCOME_TYPE, $return);
+ }
+
+ public function getExpenseTypes(string $return = 'array'): string|array
+ {
+ return $this->getTransactionTypes(Transaction::EXPENSE_TYPE, $return);
+ }
+
+ public function getTransactionTypes(string $index, string $return = 'array'): string|array
+ {
+ $types = (string) setting('transaction.type.' . $index);
+
+ if ($return == 'array') {
+ return $types === '' ? [] : explode(',', $types);
+ }
+
+ return $types;
+ }
+
+ public function addIncomeType(string $new_type): void
+ {
+ $this->addTransactionType($new_type, 'income');
+ }
+
+ public function addExpenseType(string $new_type): void
+ {
+ $this->addTransactionType($new_type, 'expense');
+ }
+
+ public function addTransactionType(string $new_type, string $index): void
+ {
+ $types = explode(',', setting('transaction.type.' . $index));
+
+ if (in_array($new_type, $types)) {
+ return;
+ }
+
+ $types[] = $new_type;
+
+ setting([
+ 'transaction.type.' . $index => implode(',', $types),
+ ])->save();
+ }
+
+ public function getTransactionFileName(Transaction $transaction, string $separator = '-', string $extension = 'pdf'): string
+ {
+ return $this->getSafeTransactionNumber($transaction, $separator) . $separator . time() . '.' . $extension;
+ }
+
+ public function getSafeTransactionNumber(Transaction $transaction, string $separator = '-'): string
+ {
+ return Str::slug($transaction->id, $separator, language()->getShortCode());
+ }
+
+ protected function getTransactionSettingKey(string $type, string $setting_key): string
+ {
+ $key = '';
+ $alias = config('type.transaction.' . $type . '.alias');
+
+ if (!empty($alias)) {
+ $key .= $alias . '.';
+ }
+
+ $prefix = config('type.transaction.' . $type . '.setting.prefix');
+
+ $key .= $prefix . '.' . $setting_key;
+
+ return $key;
+ }
+
+ public function storeTransactionPdfAndGetPath(Transaction $transaction): string
+ {
+ event(new TransactionPrinting($transaction));
+
+ $real_type = $this->getRealTypeTransaction($transaction->type);
+
+ $view = view('banking.transactions.print_default', compact('transaction', 'real_type'))->render();
+ $html = mb_convert_encoding($view, 'HTML-ENTITIES', 'UTF-8');
+
+ $pdf = app('dompdf.wrapper');
+ $pdf->loadHTML($html);
+
+ $file_name = $this->getTransactionFileName($transaction);
+
+ $pdf_path = get_storage_path('app/temp/' . $file_name);
+
+ // Save the PDF file into temp folder
+ $pdf->save($pdf_path);
+
+ return $pdf_path;
+ }
+
+ public function getTranslationsForConnect(string $type = Transaction::INCOME_TYPE): array
+ {
+ $document_type = config('type.transaction.' . $type . '.document_type');
+ $contact_type = config('type.transaction.' . $type . '.contact_type');
+
+ return [
+ 'title' => trans('general.connect') . ' ' . trans_choice('general.' . Str::plural($document_type), 1),
+ 'cancel' => trans('general.cancel'),
+ 'save' => trans('general.save'),
+ 'action' => trans('general.actions'),
+ 'document' => trans_choice('general.' . Str::plural($document_type), 1),
+ 'total' => trans('invoices.total'),
+ 'category' => trans_choice('general.categories', 1),
+ 'account' => trans_choice('general.accounts', 1),
+ 'amount' => trans('general.amount'),
+ 'number' => trans_choice('general.numbers', 1),
+ 'notes' => trans_choice('general.notes', 2),
+ 'contact' => trans_choice('general.' . Str::plural($contact_type), 1),
+ 'no_data' => trans('general.no_data'),
+ 'placeholder_search' => trans('general.placeholder.search'),
+ 'add_an' => trans('general.form.add_an', ['field' => trans_choice('general.' . Str::plural($document_type), 1)]),
+ 'transaction' => trans_choice('general.' . Str::plural($type), 1),
+ 'difference' => trans('general.difference'),
+ 'connect_tax' => trans('messages.warning.connect_tax', ['type' => $type]),
+ ];
+ }
+
+ public function getTransactionFormRoutesOfType(string $type): array
+ {
+ return [
+ 'contact_index' => route(Str::plural(config('type.transaction.' . $type . '.contact_type')) . '.index'),
+ 'contact_modal' => route('modals.' . Str::plural(config('type.transaction.' . $type . '.contact_type')) . '.create'),
+ 'category_index' => route('modals.categories.create', ['type' => $type]),
+ 'category_modal' => route('categories.index', ['search' => 'type:' . $type]),
+ ];
+ }
+
+ public function getTypeTransaction(string $type = Transaction::INCOME_TYPE): string
+ {
+ return array_key_exists($type, config('type.transaction')) ? $type : Transaction::INCOME_TYPE;
+ }
+
+ public function getRealTypeTransaction(string $type): string
+ {
+ $type = $this->getRealTypeOfRecurringTransaction($type);
+ $type = $this->getRealTypeOfTransferTransaction($type);
+ $type = $this->getRealTypeOfSplitTransaction($type);
+
+ return $type;
+ }
+
+ public function getTypeRecurringTransaction(string $type = Transaction::INCOME_RECURRING_TYPE): string
+ {
+ if (! Str::contains($type, '-recurring')) {
+ return Transaction::INCOME_RECURRING_TYPE;
+ }
+
+ return array_key_exists($type, config('type.transaction')) ? $type : Transaction::INCOME_RECURRING_TYPE;
+ }
+
+ public function getRealTypeOfRecurringTransaction(string $recurring_type): string
+ {
+ return Str::replace('-recurring', '', $recurring_type);
+ }
+
+ public function getRealTypeOfTransferTransaction(string $transfer_type): string
+ {
+ return Str::replace('-transfer', '', $transfer_type);
+ }
+
+ public function getRealTypeOfSplitTransaction(string $transfer_type): string
+ {
+ return Str::replace('-split', '', $transfer_type);
+ }
+
+ public function getNextTransactionNumber(string $type = 'income', string $suffix = ''): string
+ {
+ return app(TransactionNumber::class)->getNextNumber($type, $suffix, null);
+ }
+
+ public function increaseNextTransactionNumber(string $type = 'income', string $suffix = ''): void
+ {
+ app(TransactionNumber::class)->increaseNextNumber($type, $suffix, null);
+ }
+}
diff --git a/app/Traits/Translations.php b/app/Traits/Translations.php
new file mode 100644
index 00000000..3655473b
--- /dev/null
+++ b/app/Traits/Translations.php
@@ -0,0 +1,41 @@
+getAlias() : $alias;
+
+ return $alias === 'core' ? base_path() : module_path($alias);
+ }
+
+ /**
+ * delete the files.
+ */
+ public function deleteFiles(array $files, $alias = ''): void
+ {
+ $path = $this->getPathByAlias($alias);
+
+ foreach ($files as $file) {
+ File::delete($path . '/' . $file);
+ }
+ }
+
+ /**
+ * delete the folders.
+ */
+ public function deleteFolders(array $folders, $alias = ''): void
+ {
+ $path = $this->getPathByAlias($alias);
+
+ foreach ($folders as $folder) {
+ File::deleteDirectory($path . '/' . $folder);
+ }
+ }
+
+ /**
+ * Run single migration.
+ */
+ public function runMigration(string $file_name, $alias = ''): void
+ {
+ $path = $this->getPathByAlias($alias);
+
+ $semi_path = str($path)->contains('modules') ? '/Database/Migrations/' : '/database/migrations/';
+
+ $migration_file = $path . $semi_path . $file_name . '.php';
+
+ if (File::missing($migration_file)) {
+ return;
+ }
+
+ $migration_file = str($migration_file)->replace(base_path(), '');
+
+ Artisan::call('migrate', ['--force' => true, '--path' => $migration_file]);
+ }
+
+ /**
+ * Run multiple migrations.
+ */
+ public function runMigrations(array $file_names, $alias = ''): void
+ {
+ foreach ($file_names as $file_name) {
+ $this->runMigration($file_name, $alias);
+ }
+ }
+}
diff --git a/app/Traits/Uploads.php b/app/Traits/Uploads.php
new file mode 100644
index 00000000..d75ca972
--- /dev/null
+++ b/app/Traits/Uploads.php
@@ -0,0 +1,216 @@
+isValid()) {
+ return $path;
+ }
+
+ $this->validateUploadedFile($file);
+
+ $path = $this->getMediaFolder($folder, $company_id);
+
+ $file_name = $this->getMediaFileName($file);
+
+ return MediaUploader::makePrivate()
+ ->beforeSave(function(MediaModel $media) {
+ $media->company_id = company_id();
+ $media->created_from = source_name();
+ $media->created_by = user_id();
+ })
+ ->fromSource($file)
+ ->toDirectory($path)
+ ->useFilename($file_name)
+ ->upload();
+ }
+
+ public function importMedia($file, $folder = 'settings', $company_id = null, $disk = null)
+ {
+ $path = '';
+
+ if (! $disk) {
+ $disk = config('mediable.default_disk');
+ }
+
+ $this->validateImportedFilePath($file);
+
+ $path = $this->getMediaFolder($folder, $company_id) . '/' . basename($file);
+
+ return MediaUploader::makePrivate()
+ ->beforeSave(function(MediaModel $media) {
+ $media->company_id = company_id();
+ $media->created_from = source_name();
+ $media->created_by = user_id();
+ })
+ ->importPath($disk, $path);
+ }
+
+ public function deleteMediaModel($model, $parameter, $request = null)
+ {
+ $medias = $model->$parameter;
+
+ if (! $medias) {
+ return;
+ }
+
+ $multiple = true;
+
+ if ($medias instanceof \App\Models\Common\Media) {
+ $multiple = false;
+
+ $medias = [$medias];
+ }
+
+ $already_uploaded = [];
+
+ if ($request && isset($request['uploaded_' . $parameter])) {
+ $uploaded = ($multiple) ? $request['uploaded_' . $parameter] : [$request['uploaded_' . $parameter]];
+
+ if (count($medias) == count($uploaded)) {
+ return;
+ }
+
+ foreach ($uploaded as $old_media) {
+ $already_uploaded[] = $old_media['id'];
+ }
+ }
+
+ foreach ((array) $medias as $media) {
+ if (in_array($media->id, $already_uploaded)) {
+ continue;
+ }
+
+ MediaModel::where('id', $media->id)->delete();
+ }
+ }
+
+ public function getMediaFolder($folder, $company_id = null)
+ {
+ if (! $company_id) {
+ $company_id = company_id();
+ }
+
+ $date = Date::now()->format('Y/m/d');
+
+ // 2021/04/09/34235/invoices
+ return $date . '/' . $company_id . '/' . $folder;
+ }
+
+ public function getMediaPathOnStorage($media)
+ {
+ if (! is_object($media)) {
+ return false;
+ }
+
+ $path = $media->getDiskPath();
+
+ if (! $media->fileExists()) {
+ return false;
+ }
+
+ return $path;
+ }
+
+ public function streamMedia($media, $disposition = 'attachment')
+ {
+ return response()->streamDownload(
+ function() use ($media) {
+ $stream = $media->stream();
+
+ while ($bytes = $stream->read(1024)) {
+ echo $bytes;
+ }
+ },
+ $media->basename,
+ [
+ 'Content-Type' => $media->mime_type,
+ 'Content-Length' => $media->size,
+ ],
+ $disposition,
+ );
+ }
+
+ public function isLocalStorage()
+ {
+ return config('filesystems.disks.' . config('filesystems.default') . '.driver') == 'local';
+ }
+
+ public function getMediaFileName($file): string
+ {
+ $file_name = $this->filename($file);
+
+ if (Str::length($file_name) > '110') {
+ $file_name = Str::limit($file_name, 110);
+ }
+
+ return $file_name;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function filename($file): string
+ {
+ return pathinfo((string)$file->getClientOriginalName(), PATHINFO_FILENAME);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function extension($file): string
+ {
+ $extension = $file->getClientOriginalExtension();
+
+ if ($extension) {
+ return $extension;
+ }
+
+ return (string)$file->guessExtension();
+ }
+
+ protected function validateUploadedFile($file): void
+ {
+ $mimes = (string) config('filesystems.mimes');
+ $maxSize = (int) config('filesystems.max_size') * 1024;
+
+ $validator = validator(
+ ['file' => $file],
+ ['file' => 'file|mimes:' . $mimes . '|max:' . $maxSize]
+ );
+
+ if ($validator->fails()) {
+ throw ValidationException::withMessages([
+ 'file' => $validator->errors()->first('file'),
+ ]);
+ }
+ }
+
+ protected function validateImportedFilePath($file): void
+ {
+ $extension = Str::lower(pathinfo((string) $file, PATHINFO_EXTENSION));
+
+ $allowed = collect(explode(',', (string) config('filesystems.mimes')))
+ ->map(fn ($mime) => Str::lower(trim($mime)))
+ ->filter()
+ ->values()
+ ->all();
+
+ if (empty($extension) || ! in_array($extension, $allowed, true)) {
+ throw ValidationException::withMessages([
+ 'file' => trans('validation.mimes', ['values' => implode(', ', $allowed)]),
+ ]);
+ }
+ }
+}
diff --git a/app/Traits/Users.php b/app/Traits/Users.php
new file mode 100644
index 00000000..8db6c27b
--- /dev/null
+++ b/app/Traits/Users.php
@@ -0,0 +1,138 @@
+runningInConsole() ? true : false;
+ }
+
+ $company = $user->withoutEvents(function () use ($user, $id) {
+ return $user->companies()->where('id', $id)->first();
+ });
+
+ return !empty($company);
+ }
+
+ public function isNotUserCompany($id)
+ {
+ return !$this->isUserCompany($id);
+ }
+
+ /**
+ * Check user dashboard assignment
+ *
+ * @param $id
+ *
+ * @return boolean
+ */
+ public function isUserDashboard($id)
+ {
+ $user = user();
+
+ if (empty($user)) {
+ return app()->runningInConsole() ? true : false;
+ }
+
+ $dashboard = $user->withoutEvents(function () use ($user, $id) {
+ return $user->dashboards()->where('id', $id)->first();
+ });
+
+ return !empty($dashboard);
+ }
+
+ public function isNotUserDashboard($id)
+ {
+ return !$this->isUserDashboard($id);
+ }
+
+ /**
+ * Get the fist company of active user
+ *
+ * @return null|\App\Models\Common\Company
+ */
+ public function getFirstCompanyOfUser()
+ {
+ $user = user();
+
+ if (empty($user)) {
+ return null;
+ }
+
+ $company = $user->withoutEvents(function () use ($user) {
+ return $user->companies()->enabled()->first();
+ });
+
+ if (empty($company)) {
+ return null;
+ }
+
+ return $company;
+ }
+
+ public function getLandingPageOfUser()
+ {
+ $user = user();
+
+ if (empty($user)) {
+ return route('login');
+ }
+
+ $route_name = $user->isCustomer()
+ ? 'portal.dashboard'
+ : (Route::has($user->landing_page) ? $user->landing_page : 'dashboard');
+
+ $company_id = company_id() ?: $this->getFirstCompanyOfUser()?->id;
+
+ if (empty($company_id)) {
+ return route('login');
+ }
+
+ return route($route_name, ['company_id' => $company_id]);
+ }
+
+ /**
+ * Checks if the given user has a pending invitation.
+ *
+ * @return bool
+ */
+ public function hasPendingInvitation()
+ {
+ return $this->getPendingInvitation() ? true : false;
+ }
+
+ /**
+ * Returns if the given user has a pending invitation.
+ *
+ * @return null|UserInvitation
+ */
+ public function getPendingInvitation()
+ {
+ return UserInvitation::where('user_id', $this->id)->first();
+ }
+}
diff --git a/app/Traits/ViewComponents.php b/app/Traits/ViewComponents.php
new file mode 100644
index 00000000..3f9e4e4a
--- /dev/null
+++ b/app/Traits/ViewComponents.php
@@ -0,0 +1,746 @@
+ $value) {
+ if (in_array($key, $excludes)) {
+ continue;
+ }
+
+ $this->{$key} = $this->getParentData($key, $value);
+ }
+ }
+
+ public function getTextFromConfig($type, $config_key, $default_key = '', $trans_type = 'trans')
+ {
+ $translation = '';
+
+ // if set config translation config_key
+ if ($translation = config('type.' . static::OBJECT_TYPE . '.' . $type . '.translation.' . $config_key)) {
+ return $translation;
+ }
+
+ $alias = config('type.' . static::OBJECT_TYPE . '.' . $type . '.alias');
+ $prefix = config('type.' . static::OBJECT_TYPE . '.' . $type . '.translation.prefix');
+
+ if (! empty($alias)) {
+ $alias .= '::';
+ }
+
+ // This magic trans key..
+ $translations = [
+ 'general' => $alias . 'general.' . $default_key,
+ 'prefix' => $alias . $prefix . '.' . $default_key,
+ 'config_general' => $alias . 'general.' . $config_key,
+ 'config_prefix' => $alias . $prefix . '.' . $config_key,
+ ];
+
+ switch ($trans_type) {
+ case 'trans':
+ foreach ($translations as $trans) {
+ if (is_string(trans($trans)) && trans($trans) !== $trans) {
+ return $trans;
+ }
+ }
+
+ break;
+ case 'trans_choice':
+ foreach ($translations as $trans_choice) {
+ if (is_string(trans($trans_choice)) && trans_choice($trans_choice, 1) !== $trans_choice) {
+ return $trans_choice;
+ }
+ }
+
+ break;
+ }
+
+ return $translation;
+ }
+
+ public function getRouteFromConfig($type, $config_key, $config_parameters = [], $modal = false)
+ {
+ $route = '';
+
+ // if set config translation config_key
+ if ($route = config('type.' . static::OBJECT_TYPE . '.' . $type . '.route.' . $config_key)) {
+ return $route;
+ }
+
+ $alias = config('type.' . static::OBJECT_TYPE . '.' . $type . '.alias');
+ $prefix = config('type.' . static::OBJECT_TYPE . '.' . $type . '.route.prefix');
+
+ // if use module set module alias
+ if (! empty($alias)) {
+ $route .= $alias . '.';
+ }
+
+ if ($modal == true) {
+ $route .= 'modals.';
+ }
+
+ if (! empty($prefix)) {
+ $route .= $prefix . '.';
+ }
+
+ $route .= $config_key;
+
+ try {
+ route($route, $config_parameters);
+ } catch (\Exception $e) {
+ try {
+ $route = Str::plural($type, 2) . '.' . $config_key;
+
+ route($route, $config_parameters);
+ } catch (\Exception $e) {
+ $route = '';
+ }
+ }
+
+ return $route;
+ }
+
+ public function getRouteParamsFromConfig($type, $config_key)
+ {
+ $params = config('type.' . static::OBJECT_TYPE . '.' . $type . '.route.params.' . $config_key, []);
+
+ return $params;
+ }
+
+ public function getPermissionFromConfig($type, $config_key)
+ {
+ $permission = '';
+
+ // if set config translation config_key
+ if ($permission = config('type.' . static::OBJECT_TYPE . '.' . $type . '.permission.' . $config_key)) {
+ return $permission;
+ }
+
+ $alias = config('type.' . static::OBJECT_TYPE . '.' . $type . '.alias');
+ $group = config('type.' . static::OBJECT_TYPE . '.' . $type . '.group');
+ $prefix = config('type.' . static::OBJECT_TYPE . '.' . $type . '.permission.prefix');
+
+ $permission = $config_key . '-';
+
+ // if use module set module alias
+ if (! empty($alias)) {
+ $permission .= $alias . '-';
+ }
+
+ // if controller in folder it must
+ if (! empty($group)) {
+ $permission .= $group . '-';
+ }
+
+ $permission .= $prefix;
+
+ return $permission;
+ }
+
+ public function getHideFromConfig($type, $config_key)
+ {
+ $hide = false;
+
+ $hides = config('type.' . static::OBJECT_TYPE . '.' . $type . '.hide');
+
+ if (! empty($hides) && (in_array($config_key, $hides))) {
+ $hide = true;
+ }
+
+ return $hide;
+ }
+
+ public function getClassFromConfig($type, $config_key)
+ {
+ $class_key = 'type.' . $type . '.class.' . $config_key;
+
+ return config($class_key, '');
+ }
+
+ public function getCategoryFromConfig($type)
+ {
+ $category_type = '';
+
+ // if set config translation config_key
+ if ($category_type = config('type.' . static::OBJECT_TYPE . '.' . $type . '.category_type')) {
+ return $category_type;
+ }
+
+ switch ($type) {
+ case 'bill':
+ case 'expense':
+ case 'purchase':
+ $category_type = Category::EXPENSE_TYPE;
+ break;
+ case 'item':
+ $category_type = Category::ITEM_TYPE;
+ break;
+ case 'other':
+ $category_type = Category::OTHER_TYPE;
+ break;
+ case 'transfer':
+ $category_type = 'transfer';
+ break;
+ default:
+ $category_type = Category::INCOME_TYPE;
+ break;
+ }
+
+ return $category_type;
+ }
+
+ public function getScriptFromConfig($type, $config_key)
+ {
+ $script_key = config('type.' . static::OBJECT_TYPE . '.' . $type . '.script.' . $config_key, '');
+
+ return $script_key;
+ }
+
+ public function getTabActiveFromSetting($type)
+ {
+ $tabs = setting('favorites.tab.' . user()->id, []);
+
+ if (empty($tabs)) {
+ return false;
+ }
+
+ $tabs = json_decode($tabs, true);
+
+ return $tabs[$type] ?? false;
+ }
+
+ protected function getTextPage($type, $textPage)
+ {
+ if (! empty($textPage)) {
+ return $textPage;
+ }
+
+ $config_route_prefix = config('type.' . static::OBJECT_TYPE . '.' . $type . '.route.prefix', static::DEFAULT_PLURAL_TYPE);
+
+ $page = str_replace('-', '_', $config_route_prefix);
+
+ $translation = $this->getTextFromConfig($type, 'page', $page);
+
+ if (! empty($translation)) {
+ return $translation;
+ }
+
+ return 'general.' . $page;
+ }
+
+ protected function getGroup($type, $group)
+ {
+ if (! empty($group)) {
+ return $group;
+ }
+
+ return config('type.' . static::OBJECT_TYPE . '.' . $type . '.group', static::DEFAULT_PLURAL_TYPE);
+ }
+
+ protected function getPage($type, $page)
+ {
+ if (! empty($page)) {
+ return $page;
+ }
+
+ return Str::plural($type);
+ }
+
+ protected function getPermissionCreate($type, $permissionCreate)
+ {
+ if (! empty($permissionCreate)) {
+ return $permissionCreate;
+ }
+
+ $permissionCreate = $this->getPermissionFromConfig($type, 'create');
+
+ return $permissionCreate;
+ }
+
+ protected function getPermissionUpdate($type, $permissionUpdate)
+ {
+ if (! empty($permissionUpdate)) {
+ return $permissionUpdate;
+ }
+
+ $permissionUpdate = $this->getPermissionFromConfig($type, 'update');
+
+ return $permissionUpdate;
+ }
+
+ protected function getPermissionDelete($type, $permissionDelete)
+ {
+ if (! empty($permissionDelete)) {
+ return $permissionDelete;
+ }
+
+ $permissionDelete = $this->getPermissionFromConfig($type, 'delete');
+
+ return $permissionDelete;
+ }
+
+ protected function getIndexRoute($type, $indexRoute)
+ {
+ if (! empty($indexRoute)) {
+ return $indexRoute;
+ }
+
+ $route = $this->getRouteFromConfig($type, 'index');
+
+ if (!empty($route)) {
+ return $route;
+ }
+
+ return static::DEFAULT_PLURAL_TYPE . '.index';
+ }
+
+ protected function getShowRoute($type, $showRoute)
+ {
+ if (! empty($showRoute)) {
+ return $showRoute;
+ }
+
+ $route = $this->getRouteFromConfig($type, 'show', 1);
+
+ if (!empty($route)) {
+ return $route;
+ }
+
+ return static::DEFAULT_PLURAL_TYPE . '.show';
+ }
+
+ protected function getCreateRoute($type, $createRoute)
+ {
+ if (! empty($createRoute)) {
+ return $createRoute;
+ }
+
+ $route = $this->getRouteFromConfig($type, 'create');
+
+ if (! empty($route)) {
+ return $route;
+ }
+
+ return static::DEFAULT_PLURAL_TYPE . '.create';
+ }
+
+ protected function getEditRoute($type, $editRoute)
+ {
+ if (! empty($editRoute)) {
+ return $editRoute;
+ }
+
+ $route = $this->getRouteFromConfig($type, 'edit', 1);
+
+ if (! empty($route)) {
+ return $route;
+ }
+
+ return static::DEFAULT_PLURAL_TYPE . '.edit';
+ }
+
+ protected function getDuplicateRoute($type, $duplicateRoute)
+ {
+ if (! empty($duplicateRoute)) {
+ return $duplicateRoute;
+ }
+
+ $route = $this->getRouteFromConfig($type, 'duplicate', 1);
+
+ if (! empty($route)) {
+ return $route;
+ }
+
+ return static::DEFAULT_PLURAL_TYPE . '.duplicate';
+ }
+
+ protected function getDeleteRoute($type, $deleteRoute)
+ {
+ if (! empty($deleteRoute)) {
+ return $deleteRoute;
+ }
+
+ $route = $this->getRouteFromConfig($type, 'destroy', 1);
+
+ if (! empty($route)) {
+ return $route;
+ }
+
+ return static::DEFAULT_PLURAL_TYPE . '.destroy';
+ }
+
+ protected function getCancelRoute($type, $cancelRoute)
+ {
+ if (! empty($cancelRoute)) {
+ return $cancelRoute;
+ }
+
+ $route = $this->getRouteFromConfig($type, 'index');
+
+ if (! empty($route)) {
+ return $route;
+ }
+
+ return static::DEFAULT_PLURAL_TYPE . '.index';
+ }
+
+ protected function getImportRoute($importRoute)
+ {
+ if (! empty($importRoute)) {
+ return $importRoute;
+ }
+
+ $route = 'import.create';
+
+ return $route;
+ }
+
+ protected function getImportRouteParameters($type, $importRouteParameters)
+ {
+ if (! empty($importRouteParameters)) {
+ return $importRouteParameters;
+ }
+
+ $alias = config('type.' . static::OBJECT_TYPE . '.' . $type . '.alias');
+ $group = config('type.' . static::OBJECT_TYPE . '.' . $type . '.group');
+ $prefix = config('type.' . static::OBJECT_TYPE . '.' . $type . '.route.prefix');
+
+ if (empty($group) && ! empty($alias)){
+ $group = $alias;
+ } else if (empty($group) && empty($alias)) {
+ $group = 'sales';
+ }
+
+ $importRouteParameters = [
+ 'group' => $group,
+ 'type' => $prefix,
+ ];
+
+ return $importRouteParameters;
+ }
+
+ protected function getExportRoute($type, $exportRoute)
+ {
+ if (! empty($exportRoute)) {
+ return $exportRoute;
+ }
+
+ $route = $this->getRouteFromConfig($type, 'export');
+
+ if (! empty($route)) {
+ return $route;
+ }
+
+ return static::DEFAULT_PLURAL_TYPE . '.export';
+ }
+
+ protected function getSearchStringModel($type, $searchStringModel)
+ {
+ if (! empty($searchStringModel)) {
+ return $searchStringModel;
+ }
+
+ $search_string_model = config('type.' . static::OBJECT_TYPE . '.' . $type . '.search_string_model');
+
+ if (! empty($search_string_model)) {
+ return $search_string_model;
+ }
+
+ if ($group = config('type.' . static::OBJECT_TYPE . '.' . $type . '.group')) {
+ $group = Str::studly(Str::singular($group)) . '\\';
+ }
+
+ $prefix = Str::studly(Str::singular(config('type.' . static::OBJECT_TYPE . '.' . $type . '.route.prefix')));
+
+ if ($alias = config('type.' . static::OBJECT_TYPE . '.' . $type . '.alias')) {
+ $searchStringModel = 'Modules\\' . Str::studly($alias) .'\Models\\' . $group . $prefix;
+ } else {
+ $searchStringModel = 'App\Models\\' . $group . $prefix;
+ }
+
+ return $searchStringModel;
+ }
+
+ protected function getBulkActionClass($type, $bulkActionClass)
+ {
+ if (! empty($bulkActionClass)) {
+ return $bulkActionClass;
+ }
+
+ $bulk_actions = config('type.' . static::OBJECT_TYPE . '.' . $type . '.bulk_actions');
+
+ if (! empty($bulk_actions)) {
+ return $bulk_actions;
+ }
+
+ $file_name = '';
+
+ if ($group = config('type.' . static::OBJECT_TYPE . '.' . $type . '.group')) {
+ $file_name .= Str::studly($group) . '\\';
+ }
+
+ if ($prefix = config('type.' . static::OBJECT_TYPE . '.' . $type . '.route.prefix')) {
+ $file_name .= Str::studly($prefix);
+ }
+
+ if ($alias = config('type.' . static::OBJECT_TYPE . '.' . $type . '.alias')) {
+ $module = module($alias);
+
+ if (! $module instanceof Module) {
+ $b = new \stdClass();
+ $b->actions = [];
+
+ event(new BulkActionsAdding($b));
+
+ return $b->actions;
+ }
+
+ $bulkActionClass = 'Modules\\' . $module->getStudlyName() . '\BulkActions\\' . $file_name;
+ } else {
+ $bulkActionClass = 'App\BulkActions\\' . $file_name;
+ }
+
+ return $bulkActionClass;
+ }
+
+ protected function getBulkActionRouteParameters($type, $bulkActionRouteParameters)
+ {
+ if (! empty($bulkActionRouteParameters)) {
+ return $bulkActionRouteParameters;
+ }
+
+ $group = config('type.' . static::OBJECT_TYPE . '.' . $type . '.group');
+
+ if (! empty(config('type.' . static::OBJECT_TYPE . '.' . $type . '.alias'))) {
+ $group = config('type.' . static::OBJECT_TYPE . '.' . $type . '.alias');
+ }
+
+ $bulkActionRouteParameters = [
+ 'group' => $group,
+ 'type' => config('type.' . static::OBJECT_TYPE . '.' . $type . '.route.prefix')
+ ];
+
+ return $bulkActionRouteParameters;
+ }
+
+ protected function getClassBulkAction($type, $classBulkAction)
+ {
+ if (! empty($classBulkAction)) {
+ return $classBulkAction;
+ }
+
+ $class = $this->getClassFromConfig($type, 'bulk_action');
+
+ if (! empty($class)) {
+ return $class;
+ }
+
+ return 'ltr:pr-6 rtl:pl-6 hidden sm:table-cell';
+ }
+
+ protected function getImageEmptyPage($type, $imageEmptyPage)
+ {
+ if (! empty($imageEmptyPage)) {
+ return $imageEmptyPage;
+ }
+
+ $image_empty_page = config('type.' . static::OBJECT_TYPE . '.' . $type . '.image_empty_page');
+
+ if (! empty($image_empty_page)) {
+ return $image_empty_page;
+ }
+
+ $page = str_replace('-', '_', config('type.' . static::OBJECT_TYPE . '.' . $type . '.route.prefix', 'invoices'));
+ $image_path = 'public/img/empty_pages/' . $page . '.png';
+
+ if ($alias = config('type.' . static::OBJECT_TYPE . '.' . $type . '.alias')) {
+ $image_path = 'modules/' . Str::studly($alias) . '/Resources/assets/img/empty_pages/' . $page . '.png';
+ }
+
+ return $image_path;
+ }
+
+ protected function getTextEmptyPage($type, $textEmptyPage)
+ {
+ if (! empty($textEmptyPage)) {
+ return $textEmptyPage;
+ }
+
+ $page = str_replace('-', '_', config('type.' . static::OBJECT_TYPE . '.' . $type . '.route.prefix', 'invoices'));
+
+ $translation = $this->getTextFromConfig($type, 'empty_page', 'empty.' . $page);
+
+ if (! empty($translation)) {
+ return $translation;
+ }
+
+ return 'general.empty.' . $page;
+ }
+
+ protected function getTextSectionTitle($type, $key, $default_key = '')
+ {
+ $translation = $this->getTextFromConfig($type, 'section_'. $key . '_title', $key);
+
+ if (! empty($translation)) {
+ return $translation;
+ }
+
+ if ($default_key) {
+ return $default_key;
+ }
+
+ return 'general.' . $key;
+ }
+
+ protected function getTextSectionDescription($type, $key, $default_key = '')
+ {
+ $translation = $this->getTextFromConfig($type, 'section_'. $key . '_description', 'form_description.' . $key);
+
+ if (! empty($translation)) {
+ return $translation;
+ }
+
+ if ($default_key) {
+ return $default_key;
+ }
+
+ return 'general.form_description.' . $key;
+ }
+
+ protected function getUrlDocsPath($type, $urlDocsPath)
+ {
+ return null;
+ }
+
+ public function getSuggestionModule()
+ {
+ return !empty($this->suggestions) ? Arr::random($this->suggestions) : false;
+ }
+
+ public function getSuggestionModules()
+ {
+ if ((! $user = user()) || $user->cannot('read-modules-home')) {
+ return [];
+ }
+
+ if (! $path = Route::current()->uri()) {
+ return [];
+ }
+
+ $path = str_replace('{company_id}/', '', $path);
+
+ if (! $suggestions = $this->getSuggestions($path)) {
+ return [];
+ }
+
+ $modules = [];
+
+ foreach ($suggestions->modules as $s_module) {
+ if ($this->moduleIsEnabled($s_module->alias)) {
+ continue;
+ }
+
+ $s_module->action_url = company_id() . '/' . $s_module->action_url;
+
+ $modules[] = $s_module;
+ }
+
+ if (empty($modules)) {
+ return [];
+ }
+
+ return $modules;
+ }
+
+ protected function getFormRoute($type, $formRoute, $model = false)
+ {
+ if (! empty($formRoute)) {
+ return $formRoute;
+ }
+
+ $prefix = 'store';
+ $parameters = [];
+
+ if (! empty($model)) {
+ $prefix = 'update';
+ $parameters = [$model->id];
+ }
+
+ $route = $this->getRouteFromConfig($type, $prefix, $parameters);
+
+ return (! empty($model)) ? [$route, $model->id] : $route;
+ }
+
+ protected function getFormMethod($type, $formMethod, $model = false)
+ {
+ if (! empty($formMethod)) {
+ return $formMethod;
+ }
+
+ $method = 'POST';
+
+ if (! empty($model)) {
+ $method = 'PATCH';
+ }
+
+ return $method;
+ }
+
+ protected function getAlias($type, $alias)
+ {
+ if (!empty($alias)) {
+ return $alias;
+ }
+
+ if ($alias = config('type.' . static::OBJECT_TYPE . '.' . $type . '.alias')) {
+ return $alias;
+ }
+
+ return 'core';
+ }
+
+ protected function getScriptFolder($type, $folder)
+ {
+ if (!empty($folder)) {
+ return $folder;
+ }
+
+ if ($folder = config('type.' . static::OBJECT_TYPE . '.' . $type . '.script.folder')) {
+ return $folder;
+ }
+
+ return '';
+ }
+
+ protected function getScriptFile($type, $file)
+ {
+ if (!empty($file)) {
+ return $file;
+ }
+
+ if ($file = config('type.' . static::OBJECT_TYPE . '.' . $type . '.script.file')) {
+ return $file;
+ }
+
+ return '';
+ }
+}
diff --git a/app/Utilities/BusinessNavigation.php b/app/Utilities/BusinessNavigation.php
new file mode 100644
index 00000000..6aeb952a
--- /dev/null
+++ b/app/Utilities/BusinessNavigation.php
@@ -0,0 +1,39 @@
+ $page) {
+ $section['pages'][$pageIndex]['title'] = self::title(
+ $pageTitles[$sectionSlug][$page['slug']] ?? null,
+ $page['title']
+ );
+ }
+ }
+ unset($section);
+
+ return $navigation;
+ }
+
+ private static function title($value, string $default): string
+ {
+ $value = trim((string) $value);
+
+ return $value === '' ? $default : $value;
+ }
+}
diff --git a/app/Utilities/Console.php b/app/Utilities/Console.php
new file mode 100644
index 00000000..b98392f7
--- /dev/null
+++ b/app/Utilities/Console.php
@@ -0,0 +1,56 @@
+setTimeout($timeout);
+
+ $process->mustRun();
+
+ $output = $process->getOutput();
+
+ if (static::isValidOutput($output)) {
+ return true;
+ }
+ } catch (Throwable $e) {
+ $output = $e->getMessage();
+ }
+
+ logger('Console output:: ' . $output);
+
+ return static::formatOutput($output);
+ }
+
+ public static function formatOutput($output)
+ {
+ $output = nl2br($output);
+ $output = str_replace(['"', "'"], '', $output);
+ $output = preg_replace('/[\x00-\x1F\x80-\xFF]/', '', $output);
+
+ return $output;
+ }
+
+ public static function isValidOutput($output)
+ {
+ $errors = [
+ 'Content-Type: application/json',
+ 'CSRF token mismatch',
+ ];
+
+ return !Str::contains($output, $errors);
+ }
+}
diff --git a/app/Utilities/Date.php b/app/Utilities/Date.php
new file mode 100644
index 00000000..059821fa
--- /dev/null
+++ b/app/Utilities/Date.php
@@ -0,0 +1,140 @@
+bound('translator')) {
+ $appLocale = app()->getLocale();
+ }
+
+ if (app()->bound('config')) {
+ $fallbackLocale = app('config')->get('app.fallback_locale');
+ }
+ } catch (\Throwable $e) {
+ }
+ }
+
+ $candidates = array_merge(
+ $locales,
+ [static::getLocale()],
+ [$appLocale],
+ [$fallbackLocale],
+ ['en']
+ );
+
+ $normalized = [];
+
+ foreach ($candidates as $locale) {
+ if (empty($locale)) {
+ continue;
+ }
+
+ $normalized[] = $locale;
+
+ $language = static::getLanguageFromLocale($locale);
+
+ if ($language !== $locale) {
+ $normalized[] = $language;
+ }
+ }
+
+ return array_values(array_unique($normalized));
+ }
+}
diff --git a/app/Utilities/DocumentNumber.php b/app/Utilities/DocumentNumber.php
new file mode 100644
index 00000000..7275028e
--- /dev/null
+++ b/app/Utilities/DocumentNumber.php
@@ -0,0 +1,39 @@
+resolveTypeAlias($type);
+
+ $prefix = setting($type . '.number_prefix');
+ $next = (string) setting($type . '.number_next');
+ $digit = (int) setting($type . '.number_digit');
+
+ return $prefix . str_pad($next, $digit, '0', STR_PAD_LEFT);
+ }
+
+ public function increaseNextNumber(string $type, ?Contact $contact): void
+ {
+ $type = $this->resolveTypeAlias($type);
+
+ $next = setting($type . '.number_next', 1) + 1;
+
+ setting([$type . '.number_next' => $next]);
+ setting()->save();
+ }
+
+ protected function resolveTypeAlias(string $type): string
+ {
+ if ($alias = config('type.document.' . $type . '.alias')) {
+ return $alias . '.' . str_replace('-', '_', $type);
+ }
+
+ return $type;
+ }
+}
diff --git a/app/Utilities/Export.php b/app/Utilities/Export.php
new file mode 100644
index 00000000..cf4ff8d0
--- /dev/null
+++ b/app/Utilities/Export.php
@@ -0,0 +1,57 @@
+ids) && method_exists($class, 'sheets') && is_array($sheets = $class->sheets())) {
+ $class->ids = ! empty($ids = (new $sheets[array_key_first($sheets)])->collection()->pluck('id')->toArray()) ? $ids : [0];
+ }
+
+ if (should_queue()) {
+ $disk = 'temp';
+
+ if (config('excel.temporary_files.remote_disk') !== null) {
+ $disk = config('excel.temporary_files.remote_disk');
+ $file_name = config('excel.temporary_files.remote_prefix') . $file_name;
+ }
+
+ $class->queue($file_name, $disk)->onQueue('exports')->chain([
+ new CreateMediableForExport(user(), $file_name, $translation),
+ ]);
+
+ $message = trans('messages.success.export_queued', ['type' => $translation]);
+
+ flash($message)->success();
+
+ return back();
+ } else {
+ return $class->download($file_name);
+ }
+ } catch (Throwable $e) {
+ report($e);
+ flash($e->getMessage())->error()->important();
+
+ return back();
+ }
+ }
+}
diff --git a/app/Utilities/FundAccountSpreadsheet.php b/app/Utilities/FundAccountSpreadsheet.php
new file mode 100644
index 00000000..18860ebb
--- /dev/null
+++ b/app/Utilities/FundAccountSpreadsheet.php
@@ -0,0 +1,297 @@
+ 'company_name',
+ '银行名称' => 'bank_name',
+ '银行类型' => 'bank_type',
+ '账户账号' => 'account_number',
+ '预留手机号' => 'reserved_phone',
+ '登录密码' => 'login_password',
+ '备注' => 'notes',
+ ];
+
+ private const STORE_COLUMNS = [
+ '公司名称' => 'company_name',
+ '账户名称' => 'account_name',
+ '账户账号' => 'account_number',
+ '预留手机号' => 'reserved_phone',
+ '登录密码' => 'login_password',
+ '备注' => 'notes',
+ ];
+
+ public function downloadTemplate(string $platform): StreamedResponse
+ {
+ $spreadsheet = $this->makeSpreadsheet($platform, collect());
+ $filename = $this->title($platform) . '-导入模板.xlsx';
+
+ return $this->download($spreadsheet, $filename);
+ }
+
+ public function downloadAccounts(string $platform, Collection $accounts): StreamedResponse
+ {
+ $spreadsheet = $this->makeSpreadsheet($platform, $accounts);
+ $filename = $this->title($platform) . '-批量数据-' . now()->format('Ymd-His') . '.xlsx';
+
+ return $this->download($spreadsheet, $filename);
+ }
+
+ public function import(string $platform, UploadedFile $file): int
+ {
+ try {
+ return $this->readAndStore($platform, $file);
+ } catch (ValidationException $exception) {
+ throw $exception;
+ } catch (Throwable $exception) {
+ report($exception);
+
+ throw ValidationException::withMessages([
+ 'import' => trans('fund_accounts.import_invalid_file'),
+ ]);
+ }
+ }
+
+ private function makeSpreadsheet(string $platform, Collection $accounts): Spreadsheet
+ {
+ $spreadsheet = new Spreadsheet();
+ $sheet = $spreadsheet->getActiveSheet();
+ $columns = $this->columns($platform);
+ $columnCount = count($columns);
+ $lastColumn = Coordinate::stringFromColumnIndex($columnCount);
+
+ $sheet->setTitle(mb_substr($this->title($platform), 0, 31));
+ $sheet->freezePane('A2');
+ $sheet->setAutoFilter("A1:{$lastColumn}1");
+ $sheet->getRowDimension(1)->setRowHeight(26);
+
+ foreach (array_keys($columns) as $index => $heading) {
+ $column = Coordinate::stringFromColumnIndex($index + 1);
+ $sheet->setCellValueExplicit("{$column}1", $heading, DataType::TYPE_STRING);
+ }
+
+ $header = $sheet->getStyle("A1:{$lastColumn}1");
+ $header->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF');
+ $header->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('FF4F7D3B');
+ $header->getAlignment()->setVertical('center');
+
+ foreach ($accounts->values() as $rowIndex => $account) {
+ $excelRow = $rowIndex + 2;
+
+ foreach (array_values($columns) as $columnIndex => $field) {
+ $column = Coordinate::stringFromColumnIndex($columnIndex + 1);
+ $value = $account->{$field};
+
+ $sheet->setCellValueExplicit(
+ "{$column}{$excelRow}",
+ is_null($value) ? '' : (string) $value,
+ DataType::TYPE_STRING
+ );
+ }
+ }
+
+ foreach (array_values($columns) as $index => $field) {
+ $column = Coordinate::stringFromColumnIndex($index + 1);
+ $sheet->getColumnDimension($column)->setWidth($this->columnWidth($field));
+ $sheet->getStyle("{$column}2:{$column}" . (self::MAX_ROWS + 1))
+ ->getNumberFormat()
+ ->setFormatCode(NumberFormat::FORMAT_TEXT);
+ }
+
+ return $spreadsheet;
+ }
+
+ private function readAndStore(string $platform, UploadedFile $file): int
+ {
+ $reader = IOFactory::createReaderForFile($file->getRealPath());
+ $reader->setReadDataOnly(true);
+
+ $spreadsheet = $reader->load($file->getRealPath());
+
+ try {
+ $sheet = $spreadsheet->getActiveSheet();
+ $columns = $this->columns($platform);
+ $expectedHeadings = array_keys($columns);
+ $actualHeadings = [];
+
+ foreach ($expectedHeadings as $index => $heading) {
+ $column = Coordinate::stringFromColumnIndex($index + 1);
+ $actualHeadings[] = trim($this->cellString($sheet->getCell("{$column}1")->getValue()));
+ }
+
+ if ($actualHeadings !== $expectedHeadings) {
+ throw ValidationException::withMessages([
+ 'import' => trans('fund_accounts.import_header_mismatch', [
+ 'headers' => implode('、', $expectedHeadings),
+ ]),
+ ]);
+ }
+
+ $highestRow = $sheet->getHighestDataRow();
+
+ if ($highestRow > self::MAX_ROWS + 1) {
+ throw ValidationException::withMessages([
+ 'import' => trans('fund_accounts.import_too_many_rows', ['count' => self::MAX_ROWS]),
+ ]);
+ }
+
+ $rows = [];
+ $errors = [];
+ $fields = array_values($columns);
+ $attributes = array_flip($columns);
+
+ for ($excelRow = 2; $excelRow <= $highestRow; $excelRow++) {
+ $row = [];
+
+ foreach ($fields as $index => $field) {
+ $column = Coordinate::stringFromColumnIndex($index + 1);
+ $value = $this->cellString($sheet->getCell("{$column}{$excelRow}")->getValue());
+ $row[$field] = $field === 'login_password' ? $value : trim($value);
+ }
+
+ if ($this->rowIsEmpty($row)) {
+ continue;
+ }
+
+ $validator = Validator::make($row, $this->rules($platform), [], $attributes);
+
+ if ($validator->fails()) {
+ $errors[] = trans('fund_accounts.import_row_error', [
+ 'row' => $excelRow,
+ 'message' => $validator->errors()->first(),
+ ]);
+
+ continue;
+ }
+
+ $rows[] = $row;
+ }
+
+ if (! empty($errors)) {
+ throw ValidationException::withMessages(['import' => $errors]);
+ }
+
+ if (empty($rows)) {
+ throw ValidationException::withMessages([
+ 'import' => trans('fund_accounts.import_empty'),
+ ]);
+ }
+
+ DB::transaction(function () use ($platform, $rows) {
+ foreach ($rows as $row) {
+ if ($platform === 'bank') {
+ $row['account_name'] = $row['bank_name'];
+ } else {
+ $row['bank_name'] = null;
+ $row['bank_type'] = null;
+ }
+
+ FundAccount::create(array_merge($row, [
+ 'company_id' => company_id(),
+ 'platform' => $platform,
+ 'created_from' => 'core::import',
+ 'created_by' => user()->id,
+ ]));
+ }
+ });
+
+ return count($rows);
+ } finally {
+ $spreadsheet->disconnectWorksheets();
+ }
+ }
+
+ private function download(Spreadsheet $spreadsheet, string $filename): StreamedResponse
+ {
+ return response()->streamDownload(function () use ($spreadsheet) {
+ $writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
+ $writer->setPreCalculateFormulas(false);
+ $writer->save('php://output');
+ $spreadsheet->disconnectWorksheets();
+ }, $filename, [
+ 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'Cache-Control' => 'no-store, no-cache, must-revalidate',
+ ]);
+ }
+
+ private function columns(string $platform): array
+ {
+ return $platform === 'bank' ? self::BANK_COLUMNS : self::STORE_COLUMNS;
+ }
+
+ private function rules(string $platform): array
+ {
+ return [
+ 'company_name' => ['required', 'string', 'max:191'],
+ 'account_name' => [$platform === 'bank' ? 'nullable' : 'required', 'nullable', 'string', 'max:191'],
+ 'account_number' => ['required', 'string', 'max:191'],
+ 'bank_name' => [$platform === 'bank' ? 'required' : 'nullable', 'nullable', 'string', 'max:191'],
+ 'bank_type' => [$platform === 'bank' ? 'required' : 'nullable', 'nullable', 'string', 'max:191'],
+ 'login_password' => ['nullable', 'string', 'max:191'],
+ 'reserved_phone' => ['nullable', 'string', 'max:191'],
+ 'notes' => ['nullable', 'string', 'max:2000'],
+ ];
+ }
+
+ private function rowIsEmpty(array $row): bool
+ {
+ foreach ($row as $value) {
+ if ($value !== '') {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private function cellString(mixed $value): string
+ {
+ if ($value instanceof RichText) {
+ return $value->getPlainText();
+ }
+
+ if (is_null($value)) {
+ return '';
+ }
+
+ return (string) $value;
+ }
+
+ private function columnWidth(string $field): int
+ {
+ return match ($field) {
+ 'company_name', 'account_name', 'account_number', 'login_password' => 24,
+ 'bank_name' => 20,
+ 'bank_type', 'reserved_phone' => 18,
+ 'notes' => 36,
+ default => 16,
+ };
+ }
+
+ private function title(string $platform): string
+ {
+ $key = $platform === 'bank' ? 'bank_accounts' : $platform . '_accounts';
+
+ return trans('fund_accounts.' . $key);
+ }
+}
diff --git a/app/Utilities/Import.php b/app/Utilities/Import.php
new file mode 100644
index 00000000..ab7e9f2b
--- /dev/null
+++ b/app/Utilities/Import.php
@@ -0,0 +1,104 @@
+file('import');
+
+ if ($should_queue) {
+ self::importQueue($class, $file, $translation);
+ } else {
+ $class->import($file);
+ }
+
+ $message = trans(
+ 'messages.success.' . ($should_queue ? 'import_queued' : 'imported'),
+ ['type' => $translation]
+ );
+ } catch (Throwable $e) {
+ if (! $e instanceof SheetNotFoundException) {
+ report($e);
+ }
+
+ $message = self::flashFailures($e);
+
+ $success = false;
+ }
+
+ return [
+ 'success' => $success,
+ 'error' => ! $success,
+ 'data' => null,
+ 'message' => $message,
+ ];
+ }
+
+ /**
+ * Import the excel file
+ *
+ * @param AbstractsImport|ImportMultipleSheets $class
+ */
+ protected static function importQueue($class, $file, string $translation): void
+ {
+ $rows = $class->toArray($file);
+
+ $total_rows = 0;
+
+ if (! empty($rows[0])) {
+ $total_rows = count($rows[0]);
+ } else if (! empty($sheets = $class->sheets())) {
+ // The uploaded file's sheet names may not match the expected keys
+ // (e.g. a single-sheet file, or differently named sheets), so the
+ // expected key may be absent from $rows — default to 0 instead of
+ // crashing on an undefined array key.
+ $total_rows = count($rows[array_keys($sheets)[0]] ?? []);
+ }
+
+ $class->queue($file)->onQueue('imports')->chain([
+ new NotifyUser(user(), new ImportCompleted($translation, $total_rows))
+ ]);
+ }
+
+ protected static function flashFailures(Throwable $e): string
+ {
+ if (! $e instanceof ValidationException) {
+ return $e->getMessage();
+ }
+
+ foreach ($e->failures() as $failure) {
+ $message = trans('messages.error.import_column', [
+ 'message' => collect($failure->errors())->first(),
+ 'column' => $failure->attribute(),
+ 'line' => $failure->row(),
+ ]);
+
+ flash($message)->error()->important();
+ }
+
+ return '';
+ }
+}
diff --git a/app/Utilities/Info.php b/app/Utilities/Info.php
new file mode 100644
index 00000000..b93ec6f9
--- /dev/null
+++ b/app/Utilities/Info.php
@@ -0,0 +1,104 @@
+ static::getApiKey(),
+ 'ip' => static::ip(),
+ ];
+
+ if (! empty($info) || is_cloud()) {
+ if (is_cloud()) {
+ $basic['companies'] = 0;
+ }
+
+ return array_merge(static::versions(), $info, $basic);
+ }
+
+ $users_count = user_model_class()::query()->isNotCustomer()->count();
+
+ $info = array_merge(static::versions(), $basic, [
+ 'companies' => Company::count(),
+ 'users' => $users_count,
+ 'invoices' => Document::allCompanies()->invoice()->count(),
+ 'customers' => Contact::allCompanies()->customer()->count(),
+ 'php_extensions' => static::phpExtensions(),
+ ]);
+
+ return $info;
+ }
+
+ public static function versions()
+ {
+ static $versions = [];
+
+ if (! empty($versions)) {
+ return $versions;
+ }
+
+ $versions = [
+ 'akaunting' => Version::short(),
+ 'laravel' => InstalledVersions::getPrettyVersion('laravel/framework'),
+ 'php' => static::phpVersion(),
+ 'mysql' => static::mysqlVersion(),
+ 'guzzle' => InstalledVersions::getPrettyVersion('guzzlehttp/guzzle'),
+ 'livewire' => InstalledVersions::getPrettyVersion('livewire/livewire'),
+ 'omnipay' => InstalledVersions::getPrettyVersion('league/omnipay'),
+ ];
+
+ return $versions;
+ }
+
+ public static function phpVersion()
+ {
+ return phpversion();
+ }
+
+ public static function phpExtensions()
+ {
+ return get_loaded_extensions();
+ }
+
+ public static function mysqlVersion()
+ {
+ static $version;
+
+ if (empty($version) && (config('database.default') === 'mysql')) {
+ $version = DB::selectOne('select version() as mversion')->mversion;
+ }
+
+ if (isset($version)) {
+ return $version;
+ }
+
+ return 'N/A';
+ }
+
+ public static function ip()
+ {
+ return request()->header('CF_CONNECTING_IP')
+ ? request()->header('CF_CONNECTING_IP')
+ : request()->ip();
+ }
+
+ public static function getApiKey(): string
+ {
+ $setting = new class() { use Settings; };
+
+ return (string) ($setting->getSettingValue('apps.api_key', '') ?? '');
+ }
+}
diff --git a/app/Utilities/Installer.php b/app/Utilities/Installer.php
new file mode 100644
index 00000000..85331462
--- /dev/null
+++ b/app/Utilities/Installer.php
@@ -0,0 +1,324 @@
+ 'Safe Mode']);
+ }
+
+ if (ini_get('register_globals')) {
+ $requirements[] = trans('install.requirements.disabled', ['feature' => 'Register Globals']);
+ }
+
+ if (ini_get('magic_quotes_gpc')) {
+ $requirements[] = trans('install.requirements.disabled', ['feature' => 'Magic Quotes']);
+ }
+
+ if (!ini_get('file_uploads')) {
+ $requirements[] = trans('install.requirements.enabled', ['feature' => 'File Uploads']);
+ }
+
+ if (!function_exists('proc_open')) {
+ $requirements[] = trans('install.requirements.enabled', ['feature' => 'proc_open']);
+ }
+
+ if (!function_exists('proc_close')) {
+ $requirements[] = trans('install.requirements.enabled', ['feature' => 'proc_close']);
+ }
+
+ if (!class_exists('PDO')) {
+ $requirements[] = trans('install.requirements.extension', ['extension' => 'MySQL PDO']);
+ }
+
+ if (!extension_loaded('bcmath')) {
+ $requirements[] = trans('install.requirements.extension', ['extension' => 'BCMath']);
+ }
+
+ if (!extension_loaded('ctype')) {
+ $requirements[] = trans('install.requirements.extension', ['extension' => 'Ctype']);
+ }
+
+ if (!extension_loaded('curl')) {
+ $requirements[] = trans('install.requirements.extension', ['extension' => 'cURL']);
+ }
+
+ if (!extension_loaded('dom')) {
+ $requirements[] = trans('install.requirements.extension', ['extension' => 'DOM']);
+ }
+
+ if (!extension_loaded('fileinfo')) {
+ $requirements[] = trans('install.requirements.extension', ['extension' => 'FileInfo']);
+ }
+
+ if (!extension_loaded('intl')) {
+ $requirements[] = trans('install.requirements.extension', ['extension' => 'Intl']);
+ }
+
+ if (!extension_loaded('gd')) {
+ $requirements[] = trans('install.requirements.extension', ['extension' => 'GD']);
+ }
+
+ if (!extension_loaded('json')) {
+ $requirements[] = trans('install.requirements.extension', ['extension' => 'JSON']);
+ }
+
+ if (!extension_loaded('mbstring')) {
+ $requirements[] = trans('install.requirements.extension', ['extension' => 'Mbstring']);
+ }
+
+ if (!extension_loaded('openssl')) {
+ $requirements[] = trans('install.requirements.extension', ['extension' => 'OpenSSL']);
+ }
+
+ if (!extension_loaded('tokenizer')) {
+ $requirements[] = trans('install.requirements.extension', ['extension' => 'Tokenizer']);
+ }
+
+ if (!extension_loaded('xml')) {
+ $requirements[] = trans('install.requirements.extension', ['extension' => 'XML']);
+ }
+
+ if (!extension_loaded('zip')) {
+ $requirements[] = trans('install.requirements.extension', ['extension' => 'ZIP']);
+ }
+
+ if (!is_writable(base_path('storage/app'))) {
+ $requirements[] = trans('install.requirements.directory', ['directory' => 'storage/app']);
+ }
+
+ if (!is_writable(base_path('storage/app/uploads'))) {
+ $requirements[] = trans('install.requirements.directory', ['directory' => 'storage/app/uploads']);
+ }
+
+ if (!is_writable(base_path('storage/framework'))) {
+ $requirements[] = trans('install.requirements.directory', ['directory' => 'storage/framework']);
+ }
+
+ if (!is_writable(base_path('storage/logs'))) {
+ $requirements[] = trans('install.requirements.directory', ['directory' => 'storage/logs']);
+ }
+
+ if (Console::run('help') !== true) {
+ $requirements[] = trans('install.error.php_version', ['php_version' => AKAUNTING_PHP]);
+ }
+
+ return $requirements;
+ }
+
+ /**
+ * Create a default .env file.
+ *
+ * @return void
+ */
+ public static function createDefaultEnvFile()
+ {
+ // Rename file
+ if (!is_file(base_path('.env')) && is_file(base_path('.env.example'))) {
+ File::move(base_path('.env.example'), base_path('.env'));
+ }
+
+ // Update .env file
+ static::updateEnv([
+ 'APP_KEY' => 'base64:'.base64_encode(random_bytes(32)),
+ ]);
+ }
+
+ public static function createDbTables($host, $port, $database, $username, $password, $prefix = null)
+ {
+ if (!static::isDbValid($host, $port, $database, $username, $password)) {
+ return false;
+ }
+
+ // Set database details
+ static::saveDbVariables($host, $port, $database, $username, $password, $prefix);
+
+ // Try to increase the maximum execution time
+ set_time_limit(300); // 5 minutes
+
+ // Create tables
+ Artisan::call('migrate', ['--force' => true]);
+
+ // Create Permissions
+ Artisan::call('db:seed', ['--class' => 'Database\Seeds\Permissions', '--force' => true]);
+
+ return true;
+ }
+
+ /**
+ * Check if the database exists and is accessible.
+ *
+ * @param $host
+ * @param $port
+ * @param $database
+ * @param $host
+ * @param $database
+ * @param $username
+ * @param $password
+ *
+ * @return bool
+ */
+ public static function isDbValid($host, $port, $database, $username, $password)
+ {
+ Config::set('database.connections.install_test', [
+ 'host' => $host,
+ 'port' => $port,
+ 'database' => $database,
+ 'username' => $username,
+ 'password' => $password,
+ 'driver' => $connection = config('database.default', 'mysql'),
+ 'charset' => config("database.connections.$connection.charset", 'utf8mb4'),
+ ]);
+
+ try {
+ DB::connection('install_test')->getPdo();
+ } catch (\Exception $e) {
+ return false;
+ }
+
+ // Purge test connection
+ DB::purge('install_test');
+
+ return true;
+ }
+
+ public static function saveDbVariables($host, $port, $database, $username, $password, $prefix = null)
+ {
+ $prefix = !empty($prefix) ? $prefix : strtolower(Str::random(3) . '_');
+
+ // Update .env file
+ static::updateEnv([
+ 'DB_HOST' => $host,
+ 'DB_PORT' => $port,
+ 'DB_DATABASE' => $database,
+ 'DB_USERNAME' => $username,
+ 'DB_PASSWORD' => '"' . $password . '"',
+ 'DB_PREFIX' => $prefix,
+ ]);
+
+ $con = config('database.default', 'mysql');
+
+ // Change current connection
+ $db = Config::get('database.connections.' . $con);
+
+ $db['host'] = $host;
+ $db['database'] = $database;
+ $db['username'] = $username;
+ $db['password'] = $password;
+ $db['prefix'] = $prefix;
+
+ Config::set('database.connections.' . $con, $db);
+
+ DB::purge($con);
+ DB::reconnect($con);
+ }
+
+ public static function createCompany($name, $email, $locale)
+ {
+ dispatch_sync(new CreateCompany([
+ 'name' => $name,
+ 'domain' => '',
+ 'email' => $email,
+ 'currency' => 'USD',
+ 'locale' => $locale,
+ 'enabled' => '1',
+ ]));
+ }
+
+ public static function createUser($email, $password, $locale)
+ {
+ dispatch_sync(new CreateUser([
+ 'name' => '',
+ 'email' => $email,
+ 'password' => $password,
+ 'locale' => $locale,
+ 'companies' => ['1'],
+ 'roles' => ['1'],
+ 'enabled' => '1',
+ ]));
+ }
+
+ public static function finalTouches()
+ {
+ // Update .env file
+ $env = [
+ 'APP_LOCALE' => session('locale'),
+ 'APP_INSTALLED' => 'true',
+ 'APP_DEBUG' => 'false',
+ 'FIREWALL_ENABLED' => 'true',
+ 'MODEL_CACHE_ENABLED' => 'true',
+ ];
+
+ if (!app()->runningInConsole()) {
+ $env['APP_URL'] = request()->getUriForPath('');
+ }
+
+ static::updateEnv($env);
+
+ // Rename the robots.txt file
+ try {
+ File::move(base_path('robots.txt.dist'), base_path('robots.txt'));
+ } catch (\Exception $e) {
+ // nothing to do
+ }
+ }
+
+ public static function updateEnv($data)
+ {
+ if (empty($data) || !is_array($data) || !is_file(base_path('.env'))) {
+ return false;
+ }
+
+ $env = file_get_contents(base_path('.env'));
+
+ $env = explode("\n", $env);
+
+ foreach ($data as $data_key => $data_value) {
+ $updated = false;
+
+ foreach ($env as $env_key => $env_value) {
+ $entry = explode('=', $env_value, 2);
+
+ // Check if new or old key
+ if ($entry[0] == $data_key) {
+ $env[$env_key] = $data_key . '=' . $data_value;
+ $updated = true;
+ } else {
+ $env[$env_key] = $env_value;
+ }
+ }
+
+ // Lets create if not available
+ if (!$updated) {
+ $env[] = $data_key . '=' . $data_value;
+ }
+ }
+
+ $env = implode("\n", $env);
+
+ file_put_contents(base_path('.env'), $env);
+
+ return true;
+ }
+}
diff --git a/app/Utilities/ModuleActivator.php b/app/Utilities/ModuleActivator.php
new file mode 100644
index 00000000..4060288c
--- /dev/null
+++ b/app/Utilities/ModuleActivator.php
@@ -0,0 +1,179 @@
+cache = $app['cache'];
+ $this->config = $app['config'];
+
+ $this->load();
+ }
+
+ public function is(Module $module, bool $active): bool
+ {
+ if (app()->runningInConsole()) {
+ return true;
+ }
+
+ if (running_in_install() && in_array($module->getAlias(), ['offline-payments', 'paypal-standard'])) {
+ return true;
+ }
+
+ if (! isset($this->statuses[$module->getAlias()])) {
+ return false;
+ }
+
+ return $this->statuses[$module->getAlias()] === $active;
+ }
+
+ public function enable(Module $module): void
+ {
+ $this->setActive($module, true);
+ }
+
+ public function disable(Module $module): void
+ {
+ $this->setActive($module, false);
+ }
+
+ public function setActive(Module $module, bool $active): void
+ {
+ $this->statuses[$module->getAlias()] = $active;
+
+ $this->flushCache();
+
+ if (empty($this->company_id)) {
+ $company_id = $this->getCompanyId();
+
+ if (empty($company_id)) {
+ return;
+ }
+
+ $this->company_id = $company_id;
+ }
+
+ $model = Model::companyId($this->company_id)->alias($module->getAlias())->first();
+
+ if (! empty($model)) {
+ $model->enabled = $active;
+ $model->save();
+
+ return;
+ }
+
+ Model::create([
+ 'company_id' => $this->company_id,
+ 'alias' => $module->getAlias(),
+ 'enabled' => $active,
+ 'created_from' => 'core::activator',
+ ]);
+ }
+
+ public function delete(Module $module): void
+ {
+ if (! isset($this->statuses[$module->getAlias()])) {
+ return;
+ }
+
+ unset($this->statuses[$module->getAlias()]);
+
+ Model::companyId($this->company_id)->alias($module->getAlias())->delete();
+
+ $this->flushCache();
+ }
+
+ public function reset(): void
+ {
+ $this->statuses = [];
+
+ $this->flushCache();
+ }
+
+ public function load(): void
+ {
+ $this->statuses = $this->getStatusesByCompany();
+ }
+
+ public function getStatusesByCompany(): array
+ {
+ if (! $this->config->get('module.cache.enabled')) {
+ return $this->readDatabase();
+ }
+
+ $key = $this->config->get('module.cache.key') . '.statuses';
+ $lifetime = $this->config->get('module.cache.lifetime');
+
+ return $this->cache->remember($key, $lifetime, function () {
+ return $this->readDatabase();
+ });
+ }
+
+ public function readDatabase(): array
+ {
+ if (app()->runningInConsole()) {
+ return [];
+ }
+
+ $company_id = $this->getCompanyId();
+
+ if (empty($company_id)) {
+ return [];
+ }
+
+ $this->company_id = $company_id;
+
+ $modules = Model::companyId($this->company_id)->pluck('enabled', 'alias')->toArray();
+
+ foreach ($modules as $alias => $enabled) {
+ if (in_array($alias, ['offline-payments', 'paypal-standard'])) {
+ continue;
+ }
+
+ $subscription = $this->getSubscription($alias);
+
+ if (! is_object($subscription)) {
+ continue;
+ }
+
+ $modules[$alias] = $subscription->status;
+ }
+
+ return $modules;
+ }
+
+ public function flushCache(): void
+ {
+ $key = $this->config->get('module.cache.key') . '.statuses';
+
+ $this->cache->forget($key);
+ }
+
+ public function register(): void
+ {
+ $this->load();
+
+ app()->register(\Akaunting\Module\Providers\Bootstrap::class, true);
+ }
+}
diff --git a/app/Utilities/Modules.php b/app/Utilities/Modules.php
new file mode 100644
index 00000000..c839029b
--- /dev/null
+++ b/app/Utilities/Modules.php
@@ -0,0 +1,92 @@
+contact;
+ }
+
+ if ($contact && ($type != 'all')) {
+ $payment_methods = Cache::get($cache_customer);
+ }
+
+ if (!empty($payment_methods)) {
+ return $payment_methods;
+ }
+
+ $list = [];
+
+ $modules = new \stdClass();
+ $modules->payment_methods = [];
+
+ // Fire the event to get the list of payment methods
+ event(new PaymentMethodShowing($modules));
+
+ foreach ((array) $modules->payment_methods as $method) {
+ if (!isset($method['name']) || !isset($method['code'])) {
+ continue;
+ }
+
+ if (($contact && empty($method['customer'])) && ($type != 'all')) {
+ continue;
+ }
+
+ $list[] = $method;
+ }
+
+ static::sortPaymentMethods($list);
+
+ foreach ($list as $method) {
+ $payment_methods[$method['code']] = $method['name'];
+ }
+
+ if ($contact && ($type != 'all')) {
+ Cache::put($cache_customer, $payment_methods, Date::now()->addHour(6));
+ } else {
+ Cache::put($cache_admin, $payment_methods, Date::now()->addHour(6));
+ }
+
+ return ($payment_methods) ? $payment_methods : [];
+ }
+
+ public static function clearPaymentMethodsCache()
+ {
+ Cache::forget(static::getPaymentMethodsCacheKey('admin'));
+ Cache::forget(static::getPaymentMethodsCacheKey('customer'));
+ }
+
+ public static function getPaymentMethodsCacheKey($type)
+ {
+ return 'payment_methods.' . company_id() . '.' . $type;
+ }
+
+ protected static function sortPaymentMethods(&$list)
+ {
+ $sort_order = [];
+
+ foreach ($list as $key => $value) {
+ $sort_order[$key] = !empty($value['order']) ? $value['order'] : 0;
+ }
+
+ if (empty($sort_order)) {
+ return;
+ }
+
+ array_multisort($sort_order, SORT_ASC, $list);
+ }
+}
diff --git a/app/Utilities/Overrider.php b/app/Utilities/Overrider.php
new file mode 100644
index 00000000..1ee26999
--- /dev/null
+++ b/app/Utilities/Overrider.php
@@ -0,0 +1,140 @@
+ $timezone]);
+ date_default_timezone_set(config('app.timezone'));
+
+ // Email
+ $email_protocol = setting('email.protocol', 'mail');
+ config(['mail.default' => $email_protocol]);
+ config(['mail.from.name' => setting('company.name')]);
+ config(['mail.from.address' => setting('company.email')]);
+
+ if ($email_protocol == 'sendmail') {
+ config(['mail.mailers.sendmail.path' => setting('email.sendmail_path')]);
+ } elseif ($email_protocol == 'smtp') {
+ config(['mail.mailers.smtp.host' => setting('email.smtp_host')]);
+ config(['mail.mailers.smtp.port' => setting('email.smtp_port')]);
+ config(['mail.mailers.smtp.username' => setting('email.smtp_username')]);
+ config(['mail.mailers.smtp.password' => setting('email.smtp_password')]);
+ config(['mail.mailers.smtp.encryption' => setting('email.smtp_encryption')]);
+ }
+
+ // Locale
+ if (! session('locale')) {
+ $locale = user()->locale ?? setting('default.locale');
+
+ app()->setLocale($locale);
+ }
+
+ // Set locale for Money package
+ Money::setLocale(app()->getLocale());
+
+ // Money
+ config(['money.defaults.currency' => setting('default.currency')]);
+
+ // Set app url dynamically if empty
+ if (! config('app.url')) {
+ config(['app.url' => url('/')]);
+ }
+ }
+
+ protected static function loadCategoryTypes()
+ {
+ $category = new Category;
+
+ $income_types = $category->getIncomeCategoryTypes('string');
+ $expense_types = $category->getExpenseCategoryTypes('string');
+ $item_types = $category->getItemCategoryTypes('string');
+ $other_types = $category->getOtherCategoryTypes('string');
+
+ $search_string = config('search-string');
+
+ foreach ($search_string as $model => &$model_config) {
+ $route = $model_config['columns']['category_id']['route'] ?? null;
+
+ // Only update category_id routes that point to categories.index
+ if (!is_array($route) || ($route[0] ?? '') !== 'categories.index' || !isset($route[1])) {
+ continue;
+ }
+
+ // Longest match first (income,expense must come before income)
+ $replacements = [
+ 'type:' . Category::INCOME_TYPE . ',' . Category::EXPENSE_TYPE => 'type:' . $income_types . ',' . $expense_types,
+ 'type:' . Category::INCOME_TYPE => 'type:' . $income_types,
+ 'type:' . Category::EXPENSE_TYPE => 'type:' . $expense_types,
+ 'type:' . Category::ITEM_TYPE => 'type:' . $item_types,
+ 'type:' . Category::OTHER_TYPE => 'type:' . $other_types,
+ ];
+
+ foreach ($replacements as $search => $replace) {
+ if (Str::contains($route[1], $search)) {
+ $model_config['columns']['category_id']['route'][1] = Str::replace($search, $replace, $route[1]);
+ break;
+ }
+ }
+ }
+
+ config(['search-string' => $search_string]);
+ }
+
+ protected static function loadCurrencies()
+ {
+ $currencies = Currency::all();
+
+ foreach ($currencies as $currency) {
+ // If currency is not set in config, add it
+ if (! config("money.currencies.{$currency->code}")) {
+ config(['money.currencies.' . $currency->code => [
+ 'code' => $currency->code,
+ 'subunit' => 100,
+ ]]);
+ }
+
+ config(['money.currencies.' . $currency->code . '.name' => $currency->name]);
+ config(['money.currencies.' . $currency->code . '.rate' => $currency->rate]);
+ config(['money.currencies.' . $currency->code . '.precision' => $currency->precision]);
+ config(['money.currencies.' . $currency->code . '.symbol' => $currency->symbol]);
+ config(['money.currencies.' . $currency->code . '.symbol_first' => $currency->symbol_first]);
+ config(['money.currencies.' . $currency->code . '.decimal_mark' => $currency->decimal_mark]);
+ config(['money.currencies.' . $currency->code . '.thousands_separator' => $currency->thousands_separator]);
+ }
+
+ // Set currencies with new settings
+ \Akaunting\Money\Currency::setCurrencies(config('money.currencies'));
+ }
+}
diff --git a/app/Utilities/QueueCollection.php b/app/Utilities/QueueCollection.php
new file mode 100644
index 00000000..660db5d0
--- /dev/null
+++ b/app/Utilities/QueueCollection.php
@@ -0,0 +1,10 @@
+getFinancialYear();
+
+ foreach ($items as $key => $item) {
+ // @todo cache recurrings
+ if (! $item->recurring || !empty($item->parent_id)) {
+ continue;
+ }
+
+ foreach ($item->recurring->getRecurringSchedule() as $schedule) {
+ $issued = Date::parse($item->$issued_date_field);
+ $start = $schedule->getStart();
+ $start_date = Date::parse($start->format('Y-m-d'));
+
+ if (($issued->format('Y-m') == $start->format('Y-m')) && ($issued->format('d') >= $start->format('d'))) {
+ continue;
+ }
+
+ if ($start_date->lessThan($financial_year->getStartDate()) || $start_date->greaterThan($financial_year->getEndDate())) {
+ continue;
+ }
+
+ $clone = clone $item;
+
+ if ($clone instanceof Document) {
+ // Days between invoiced/billed and due date
+ $diff_days = Date::parse($clone->due_at)->diffInDays(Date::parse($clone->$issued_date_field));
+
+ $clone->due_at = $start_date->copy()->addDays($diff_days)->format('Y-m-d');
+ }
+
+ $clone->parent_id = $item->id;
+ $clone->created_at = $start_date->format('Y-m-d');
+ $clone->$issued_date_field = $start_date->format('Y-m-d');
+
+ $items->push($clone);
+ }
+ }
+ }
+}
diff --git a/app/Utilities/Reports.php b/app/Utilities/Reports.php
new file mode 100644
index 00000000..5590d82d
--- /dev/null
+++ b/app/Utilities/Reports.php
@@ -0,0 +1,164 @@
+each(function ($module) use (&$list) {
+ $m = module($module->alias);
+
+ if (! $m || $m->disabled() || empty($m->get('reports'))) {
+ return;
+ }
+
+ $list = array_merge($list, (array) $m->get('reports'));
+ });
+
+ // Added New Event
+ $report_classes = collect($list);
+
+ event(new ReportClassesCreated($report_classes));
+
+ $list = $report_classes->all();
+
+ foreach ($list as $class) {
+ if (! class_exists($class) || ($check_permission && static::cannotRead($class))) {
+ continue;
+ }
+
+ $classes[$class] = static::getDefaultName($class);
+ }
+
+ return $classes;
+ }
+
+ public static function getClassInstance($model, $load_data = true)
+ {
+ if (is_string($model)) {
+ $model = Report::where('class', $model)->first();
+ }
+
+ if ((! $model instanceof Report) || ! class_exists($model->class)) {
+ return false;
+ }
+
+ // Use the static cache from isModuleEnabled() instead of creating
+ // a new instance and re-reading module JSON from disk each time.
+ if (! empty($model) && ($model->alias != 'core') && ! static::isModuleEnabled($model->class)) {
+ return false;
+ }
+
+ $class = $model->class;
+
+ return new $class($model, $load_data);
+ }
+
+ public static function canShow($class)
+ {
+ return (static::isModuleEnabled($class) && static::canRead($class));
+ }
+
+ public static function cannotShow($class)
+ {
+ return ! static::canShow($class);
+ }
+
+ public static function canRead($class)
+ {
+ return user()->can(static::getPermission($class));
+ }
+
+ public static function cannotRead($class)
+ {
+ return ! static::canRead($class);
+ }
+
+ public static function getPermission($class)
+ {
+ $arr = explode('\\', $class);
+
+ $prefix = 'read-';
+
+ // Add module
+ if ($alias = static::getModuleAlias($arr)) {
+ $prefix .= $alias . '-';
+ }
+
+ $prefix .= 'reports-';
+
+ $class_name = end($arr);
+
+ $permission = $prefix . Str::kebab($class_name);
+
+ return str_replace('--', '-', $permission);
+ }
+
+ public static function getDefaultName($class)
+ {
+ return (new $class())->getDefaultName();
+ }
+
+ public static function isModuleEnabled($class)
+ {
+ if (! $alias = static::getModuleAlias($class)) {
+ return true;
+ }
+
+ static $cache = [];
+
+ if (! array_key_exists($alias, $cache)) {
+ $cache[$alias] = module_is_enabled($alias);
+ }
+
+ return $cache[$alias];
+ }
+
+ public static function isModuleDisabled($class)
+ {
+ return ! static::isModuleEnabled($class);
+ }
+
+ public static function isModule($class)
+ {
+ $arr = is_array($class) ? $class : explode('\\', $class);
+
+ return (strtolower($arr[0]) == 'modules');
+ }
+
+ public static function isNotModule($class)
+ {
+ return ! static::isModule($class);
+ }
+
+ public static function getModuleAlias($class)
+ {
+ if (static::isNotModule($class)) {
+ return false;
+ }
+
+ $arr = is_array($class) ? $class : explode('\\', $class);
+
+ return Str::kebab($arr[1]);
+ }
+}
diff --git a/app/Utilities/SidebarPermissions.php b/app/Utilities/SidebarPermissions.php
new file mode 100644
index 00000000..d526ab6e
--- /dev/null
+++ b/app/Utilities/SidebarPermissions.php
@@ -0,0 +1,88 @@
+id;
+ $settings = self::settings();
+
+ if (in_array($userKey, $settings['configured'] ?? [], true)) {
+ return $userKey;
+ }
+
+ return 'role_' . SystemAccess::roleName($user);
+ }
+
+ public static function allowedKeys(User $user): ?array
+ {
+ $settings = self::settings();
+ $subject = self::subjectKey($user);
+
+ if (! in_array($subject, $settings['configured'] ?? [], true)) {
+ return null;
+ }
+
+ return array_values(array_unique((array) ($settings['subjects'][$subject] ?? [])));
+ }
+
+ public static function navigationFor(User $user): array
+ {
+ $navigation = BusinessNavigation::all();
+ $allowed = self::allowedKeys($user);
+
+ if ($allowed === null) {
+ return $navigation;
+ }
+
+ return array_values(array_filter(array_map(function (array $section) use ($allowed) {
+ $sectionKey = $section['slug'];
+
+ if (in_array($sectionKey, $allowed, true)) {
+ return $section;
+ }
+
+ $pages = array_values(array_filter($section['pages'] ?? [], function (array $page) use ($allowed, $sectionKey) {
+ return in_array($sectionKey . '/' . $page['slug'], $allowed, true);
+ }));
+
+ if (! empty($pages)) {
+ $section['pages'] = $pages;
+
+ return $section;
+ }
+
+ return null;
+ }, $navigation)));
+ }
+
+ public static function allKeys(): array
+ {
+ $keys = [];
+
+ foreach (BusinessNavigation::all() as $section) {
+ $keys[] = $section['slug'];
+
+ foreach ($section['pages'] ?? [] as $page) {
+ $keys[] = $section['slug'] . '/' . $page['slug'];
+ }
+ }
+
+ return $keys;
+ }
+}
diff --git a/app/Utilities/Str.php b/app/Utilities/Str.php
new file mode 100644
index 00000000..44d4c0bc
--- /dev/null
+++ b/app/Utilities/Str.php
@@ -0,0 +1,57 @@
+count() === 1) {
+ $initial = static::getInitialFromOneWord($value, $words, $length);
+ } else {
+ $initial = static::getInitialFromMultipleWords($words, $length);
+ }
+
+ $initial = strtoupper($initial);
+
+ if (language()->direction() == 'rtl') {
+ $initial = collect(mb_str_split($initial))->reverse()->implode('');
+ }
+
+ return $initial;
+ }
+
+ public static function getInitialFromOneWord($value, $words, $length)
+ {
+ $initial = (string) $words->first();
+
+ if (strlen($value) >= $length) {
+ $initial = IStr::substr($value, 0, $length);
+ }
+
+ return $initial;
+ }
+
+ public static function getInitialFromMultipleWords($words, $length)
+ {
+ // otherwise, use initial char from each word
+ $initials = new Collection();
+
+ $words->each(function ($word) use ($initials) {
+ $initials->push(IStr::substr($word, 0, 1));
+ });
+
+ return static::selectInitialFromMultipleInitials($initials, $length);
+ }
+
+ public static function selectInitialFromMultipleInitials($initials, $length)
+ {
+ return $initials->slice(0, $length)->implode('');
+ }
+}
diff --git a/app/Utilities/SystemAccess.php b/app/Utilities/SystemAccess.php
new file mode 100644
index 00000000..4632792b
--- /dev/null
+++ b/app/Utilities/SystemAccess.php
@@ -0,0 +1,73 @@
+hasRole($role)) {
+ return $role;
+ }
+ }
+
+ return '';
+ }
+
+ public static function canOpenSettings(?User $user = null): bool
+ {
+ return in_array(self::roleName($user), [self::SUPER_ADMIN, self::ADMIN], true);
+ }
+
+ public static function isSuperAdmin(?User $user = null): bool
+ {
+ return self::roleName($user) === self::SUPER_ADMIN;
+ }
+
+ public static function canManageUser(User $actor, User $target): bool
+ {
+ if (self::isSuperAdmin($actor)) {
+ return true;
+ }
+
+ return self::roleName($actor) === self::ADMIN
+ && self::roleName($target) === self::USER;
+ }
+
+ public static function assignableRoles(?User $actor = null): array
+ {
+ $actor ??= user();
+
+ if (self::isSuperAdmin($actor)) {
+ return [
+ self::SUPER_ADMIN => '超级管理员',
+ self::ADMIN => '普通管理员',
+ self::USER => '普通用户',
+ ];
+ }
+
+ return [self::USER => '普通用户'];
+ }
+
+ public static function roleLabels(): array
+ {
+ return [
+ self::SUPER_ADMIN => '超级管理员',
+ self::ADMIN => '普通管理员',
+ self::USER => '普通用户',
+ ];
+ }
+}
diff --git a/app/Utilities/TransactionNumber.php b/app/Utilities/TransactionNumber.php
new file mode 100644
index 00000000..80884b75
--- /dev/null
+++ b/app/Utilities/TransactionNumber.php
@@ -0,0 +1,62 @@
+ $prefix . str_pad($next, $digit, '0', STR_PAD_LEFT);
+ $number_exists = fn($type, $number) => $this->numberExists($type, $number);
+
+ $transaction_number = $get_number($prefix, $next, $digit);
+
+ if ($number_exists($type, $transaction_number)) {
+ do {
+ $next++;
+
+ $transaction_number = $get_number($prefix, $next, $digit);
+ } while ($number_exists($type, $transaction_number));
+
+ setting(['transaction' . $suffix . '.number_next' => $next]);
+ setting()->save();
+ }
+
+ return $transaction_number;
+
+ }
+
+ public function numberExists($type, $number)
+ {
+ $number_exists = new Transaction;
+
+ if (Str::endsWith($type, '-recurring')) {
+ $number_exists = $number_exists->isRecurring();
+ }
+
+ if (Str::endsWith($type, '-split')) {
+ $number_exists = $number_exists->isSplit();
+ }
+
+ $number_exists = $number_exists->where('number', $number);
+
+ return $number_exists->exists();
+ }
+
+ public function increaseNextNumber($type, $suffix = '', ?Contact $contact): void
+ {
+ $next = setting('transaction' . $suffix . '.number_next', 1) + 1;
+
+ setting(['transaction' . $suffix . '.number_next' => $next]);
+ setting()->save();
+ }
+}
diff --git a/app/Utilities/Versions.php b/app/Utilities/Versions.php
new file mode 100644
index 00000000..eef8f25d
--- /dev/null
+++ b/app/Utilities/Versions.php
@@ -0,0 +1,248 @@
+addHours(6), function () use ($modules) {
+ $info = Info::all();
+
+ $versions = [];
+
+ // Check core first
+ try {
+ $url = 'core/version/' . $info['akaunting'] . '/' . $info['php'] . '/' . $info['mysql'] . '/' . $info['companies'];
+ } catch (\Exception $e) {
+ // Handle exception
+ report('Error fetching core version: ( $info = ' . json_encode($info) . ')' . $e->getMessage());
+
+ return $versions;
+ }
+
+ # Installed modules start
+ $modules = Arr::wrap($modules);
+
+ $installed_modules = [];
+ $module_version = '?modules=';
+
+ foreach ($modules as $module) {
+ if (is_string($module)) {
+ $module = module($module);
+ }
+
+ if (! $module instanceof \Akaunting\Module\Module) {
+ continue;
+ }
+
+ $alias = $module->get('alias');
+
+ $installed_modules[] = $alias;
+ }
+
+ $module_version .= implode(',', $installed_modules);
+
+ $url .= $module_version;
+ # Installed modules end
+
+ $versions['core'] = static::getLatestVersion($url, $info['akaunting']);
+
+ // Then modules
+ foreach ($modules as $module) {
+ if (is_string($module)) {
+ $module = module($module);
+ }
+
+ if (! $module instanceof \Akaunting\Module\Module) {
+ continue;
+ }
+
+ $alias = $module->get('alias');
+ $version = $module->get('version');
+
+ $url = 'apps/' . $alias . '/version/' . $version . '/' . $info['akaunting'];
+
+ $versions[$alias] = static::getLatestVersion($url, $version);
+ }
+
+ return $versions;
+ });
+ }
+
+ public static function getVersionByAlias($alias)
+ {
+ $info = Info::all();
+
+ // Check core first
+ $url = 'core/version/' . $info['akaunting'] . '/' . $info['php'] . '/' . $info['mysql'] . '/' . $info['companies'];
+ $version = $info['akaunting'];
+
+ if ($alias != 'core') {
+ $version = module($alias) ? module($alias)->get('version') : '1.0.0';
+
+ $url = 'apps/' . $alias . '/version/' . $version . '/' . $info['akaunting'];
+ }
+
+ // Get data from cache
+ $versions = Cache::get('versions', []);
+
+ $versions[$alias] = static::getLatestVersion($url, $version);
+
+ Cache::put('versions', $versions, Date::now()->addHours(6));
+
+ return $versions[$alias];
+ }
+
+ public static function getLatestVersion($url, $latest)
+ {
+ $version = new \stdClass();
+
+ $version->can_update = true;
+ $version->latest = $latest;
+ $version->errors = false;
+ $version->message = '';
+ $version->subscription = null;
+
+ if (! $body = static::getResponseBody('GET', $url, ['timeout' => 10])) {
+ return $version;
+ }
+
+ if (! is_object($body)) {
+ return $version;
+ }
+
+ $version->can_update = $body->success;
+ $version->latest = $body->data->latest;
+ $version->errors = $body->errors;
+ $version->message = $body->message;
+ $version->subscription = $body->data?->subscription ?? null;
+
+ return $version;
+ }
+
+ public static function enforceSubscriptionStatus($alias, $version): void
+ {
+ if (! $version->subscription) {
+ return;
+ }
+
+ if ($version->subscription->expired_at > Date::now()->startOfDay()) {
+ return;
+ }
+
+ $status = 'active';
+
+ switch ($version->subscription->status) {
+ case 'expired':
+ case 'canceled':
+ $status = 'disabled';
+ break;
+ case 'chargeback':
+ case 'not_paid':
+ case 'none':
+ case 'refund':
+ $status = 'uninstalled';
+ break;
+ default:
+ // Do nothing
+ break;
+ }
+
+ if ($status == 'active') {
+ return;
+ }
+
+ $module_companies = Module::allCompanies()->alias($alias)->get();
+
+ foreach ($module_companies as $module) {
+ switch ($status) {
+ case 'disabled':
+ dispatch(new DisableModule($alias, $module->company_id));
+ break;
+ case 'uninstalled':
+ dispatch(new UninstallModule($alias, $module->company_id));
+ break;
+ default:
+ // Do nothing
+ break;
+ }
+ }
+ }
+
+ public static function getUpdates()
+ {
+ return Cache::remember('updates', Date::now()->addHours(6), function () {
+ $updates = [];
+
+ $modules = module()->all();
+
+ $versions = static::all($modules);
+
+ foreach ($versions as $alias => $latest_version) {
+ if ($alias == 'core') {
+ $installed_version = version('short');
+ } else {
+ $module = module($alias);
+
+ if (! $module instanceof \Akaunting\Module\Module) {
+ continue;
+ }
+
+ $installed_version = $module->get('version');
+ }
+
+ if (version_compare($installed_version, $latest_version->latest, '>=')) {
+ continue;
+ }
+
+ $updates[$alias] = $latest_version;
+ }
+
+ return $updates;
+ });
+ }
+
+ public static function shouldUpdate($listener_version, $old_version, $new_version): bool
+ {
+ // Don't update if "listener" is same or lower than "old" version
+ if (version_compare($listener_version, $old_version, '<=')) {
+ return false;
+ }
+
+ // Don't update if "listener" is higher than "new" version
+ if (version_compare($listener_version, $new_version, '>')) {
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/app/Utilities/Widgets.php b/app/Utilities/Widgets.php
new file mode 100644
index 00000000..b5c5c8df
--- /dev/null
+++ b/app/Utilities/Widgets.php
@@ -0,0 +1,222 @@
+each(function ($module) use (&$list, $alias) {
+ if (! in_array($alias, [$module->alias, 'all'])) {
+ return;
+ }
+
+ $m = module($module->alias);
+
+ if (! $m || $m->disabled() || empty($m->get('widgets'))) {
+ return;
+ }
+
+ $list = array_merge($list, (array) $m->get('widgets'));
+ });
+
+ // Added New Event
+ $widget_classes = collect($list);
+
+ event(new WidgetClassesCreated($widget_classes));
+
+ $list = $widget_classes->all();
+
+ foreach ($list as $class) {
+ if (! class_exists($class) || ($check_permission && ! static::canRead($class))) {
+ continue;
+ }
+
+ $classes[$class] = static::getDefaultName($class);
+ }
+
+ return $classes;
+ }
+
+ public static function getClassInstance($model)
+ {
+ if (is_string($model)) {
+ $class_name = $model;
+
+ if (! class_exists($class_name)) {
+ return false;
+ }
+
+ $model = Widget::where('dashboard_id', session('dashboard_id'))->where('class', $class_name)->first();
+
+ if (! empty($model) && ($model->alias != 'core') && (new static)->moduleIsDisabled($model->alias)) {
+ return false;
+ }
+
+ if (! $model instanceof Widget) {
+ $class = (new $class_name());
+
+ $model = new Widget();
+ $model->id = 0;
+ $model->company_id = company_id();
+ $model->dashboard_id = session('dashboard_id');
+ $model->class = $class_name;
+ $model->name = $class->getDefaultName();
+ $model->sort = 99;
+ $model->settings = $class->getDefaultSettings();
+ }
+ } else {
+ if ((! $model instanceof Widget) || ! class_exists($model->class)) {
+ return false;
+ }
+
+ if (($model->alias != 'core') && (new static)->moduleIsDisabled($model->alias)) {
+ return false;
+ }
+
+ $class_name = $model->class;
+ }
+
+ return new $class_name($model);
+ }
+
+ public static function show($model, ...$arguments)
+ {
+ if (! $class = static::getClassInstance($model)) {
+ return '';
+ }
+
+ return $class->show(...$arguments);
+ }
+
+ public static function canShow($class)
+ {
+ return (static::isModuleEnabled($class) && static::canRead($class));
+ }
+
+ public static function cannotShow($class)
+ {
+ return ! static::canShow($class);
+ }
+
+ public static function canRead($class)
+ {
+ return user()->can(static::getPermission($class));
+ }
+
+ public static function cannotRead($class)
+ {
+ return ! static::canRead($class);
+ }
+
+ public static function getPermission($class)
+ {
+ $arr = explode('\\', $class);
+
+ $prefix = 'read-';
+
+ // Add module
+ if ($alias = static::getModuleAlias($arr)) {
+ $prefix .= $alias . '-';
+ }
+
+ $prefix .= 'widgets-';
+
+ $class_name = end($arr);
+
+ $permission = $prefix . Str::kebab($class_name);
+
+ return str_replace('--', '-', $permission);
+ }
+
+ public static function getDefaultName($class)
+ {
+ return (new $class())->getDefaultName();
+ }
+
+ public static function isModuleEnabled($class)
+ {
+ if (! $alias = static::getModuleAlias($class)) {
+ return true;
+ }
+
+ if (module_is_enabled($alias)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ public static function isModuleDisabled($class)
+ {
+ return ! static::isModuleEnabled($class);
+ }
+
+ public static function isModule($class)
+ {
+ $arr = is_array($class) ? $class : explode('\\', $class);
+
+ return (strtolower($arr[0]) == 'modules');
+ }
+
+ public static function isNotModule($class)
+ {
+ return ! static::isModule($class);
+ }
+
+ public static function getModuleAlias($class)
+ {
+ if (static::isNotModule($class)) {
+ return false;
+ }
+
+ $arr = is_array($class) ? $class : explode('\\', $class);
+
+ return Str::kebab($arr[1]);
+ }
+
+ public static function getCoreWidgets()
+ {
+ return static::$core_widgets;
+ }
+
+ public static function setCoreWidgets($widgets)
+ {
+ static::$core_widgets = $widgets;
+ }
+
+ public static function optimizeCoreWidgets()
+ {
+ $core_widgets = collect(static::getCoreWidgets());
+
+ $core_widgets->pop();
+
+ $core_widgets->push('App\Widgets\BankFeeds');
+
+ static::setCoreWidgets($core_widgets->all());
+ }
+}
diff --git a/app/Utilities/helpers.php b/app/Utilities/helpers.php
new file mode 100644
index 00000000..10525554
--- /dev/null
+++ b/app/Utilities/helpers.php
@@ -0,0 +1,483 @@
+user();
+ }
+}
+
+if (! function_exists('user_id')) {
+ /**
+ * Get id of current user.
+ */
+ function user_id(): int|null
+ {
+ return user()?->id;
+ }
+}
+
+if (! function_exists('company_date_format')) {
+ /**
+ * Get the date format of company.
+ */
+ function company_date_format(): string
+ {
+ $date_time = new class() { use DateTime; };
+
+ return $date_time->getCompanyDateFormat();
+ }
+}
+
+if (! function_exists('company_date')) {
+ /**
+ * Format the given date based on company settings.
+ */
+ function company_date($date): string
+ {
+ return Date::parse($date)->format(company_date_format());
+ }
+}
+
+if (! function_exists('show_widget')) {
+ /**
+ * Show a widget.
+ *
+ * @return string
+ */
+ function show_widget()
+ {
+ $arguments = func_get_args();
+
+ $model = array_shift($arguments);
+
+ return Widgets::show($model, ...$arguments);
+ }
+}
+
+if (! function_exists('company')) {
+ /**
+ * Get current/any company model.
+ */
+ function company(int|null $id = null): Company|null
+ {
+ $company = null;
+
+ if (is_null($id)) {
+ $company = Company::getCurrent();
+ }
+
+ if (is_numeric($id)) {
+ $company = Company::find($id);
+ }
+
+ return $company;
+ }
+}
+
+if (! function_exists('module_is_enabled')) {
+ /**
+ * Check if a module is enabled.
+ */
+ function module_is_enabled(string $alias): bool
+ {
+ $module = new class() { use Modules; };
+
+ return $module->moduleIsEnabled($alias);
+ }
+}
+
+if (! function_exists('company_id')) {
+ /**
+ * Get id of current company.
+ */
+ function company_id(): int|null
+ {
+ return company()?->id;
+ }
+}
+
+if (! function_exists('team')) {
+ /**
+ * Get team of current company.
+ */
+ function team()
+ {
+ return company()?->team() !== null ? company()?->team() : company()?->owner?->team();
+ }
+}
+
+if (! function_exists('team_id')) {
+ /**
+ * Get id of current company team.
+ */
+ function team_id()
+ {
+ return team()?->id;
+ }
+}
+
+if (! function_exists('should_queue')) {
+ /**
+ * Check if queue is enabled.
+ */
+ function should_queue(): bool
+ {
+ return config('queue.default') != 'sync';
+ }
+}
+
+if (! function_exists('source_name')) {
+ /**
+ * Get the current source.
+ */
+ function source_name(string|null $alias = null): string
+ {
+ $tmp = new class() { use Sources; };
+
+ return $tmp->getSourceName(null, $alias);
+ }
+}
+
+if (! function_exists('cache_prefix')) {
+ /**
+ * Cache system added company_id prefix.
+ */
+ function cache_prefix(): string
+ {
+ return company_id() . '_';
+ }
+}
+
+if (! function_exists('array_values_recursive')) {
+ /**
+ * Get array values recursively.
+ */
+ function array_values_recursive(array $array): array
+ {
+ $flat = [];
+
+ foreach($array as $value) {
+ if (is_array($value)) {
+ $flat = array_merge($flat, array_values_recursive($value));
+ } else {
+ $flat[] = $value;
+ }
+ }
+
+ return $flat;
+ }
+}
+
+if (! function_exists('running_in_install')) {
+ /**
+ * Detect if application is running in queue.
+ */
+ function running_in_install(): bool
+ {
+ return request_is_install() && env('APP_INSTALLED', false) == false;
+ }
+}
+
+if (! function_exists('running_in_queue')) {
+ /**
+ * Detect if application is running in queue.
+ */
+ function running_in_queue(): bool
+ {
+ return app()->runningConsoleCommand([
+ 'queue:work',
+ 'queue:listen',
+ 'horizon',
+ ]);
+ }
+}
+
+if (! function_exists('running_in_schedule')) {
+ /**
+ * Detect if application is running in schedule.
+ */
+ function running_in_schedule(): bool
+ {
+ return app()->runningConsoleCommand([
+ 'schedule:run',
+ 'schedule:work',
+ ]);
+ }
+}
+
+if (! function_exists('running_in_test')) {
+ /**
+ * Detect if application is running in test.
+ */
+ function running_in_test(): bool
+ {
+ return env_is_testing() && app()->runningInConsole();
+ }
+}
+
+if (! function_exists('simple_icons')) {
+ /**
+ * Get the simple icon content
+ */
+ function simple_icons(string $name): string
+ {
+ $path = base_path('vendor/simple-icons/simple-icons/icons/' . $name . '.svg');
+
+ return file_get_contents($path);
+ }
+}
+
+if (! function_exists('default_currency')) {
+ /**
+ * Get the default currency code
+ */
+ function default_currency(): string
+ {
+ return setting('default.currency');
+ }
+}
+
+if (! function_exists('env_is_production')) {
+ /**
+ * Determine if the application is in the production environment
+ */
+ function env_is_production(): bool
+ {
+ return config('app.env') === 'production';
+ }
+}
+
+if (! function_exists('env_is_development')) {
+ /**
+ * Determine if the application is in the development environment
+ */
+ function env_is_development(): bool
+ {
+ return config('app.env') === 'development';
+ }
+}
+
+if (! function_exists('env_is_build')) {
+ /**
+ * Determine if the application is in the build environment
+ */
+ function env_is_build(): bool
+ {
+ return config('app.env') === 'build';
+ }
+}
+
+if (! function_exists('env_is_local')) {
+ /**
+ * Determine if the application is in the local environment
+ */
+ function env_is_local(): bool
+ {
+ return config('app.env') === 'local';
+ }
+}
+
+if (! function_exists('env_is_testing')) {
+ /**
+ * Determine if the application is in the testing environment
+ */
+ function env_is_testing(): bool
+ {
+ return config('app.env') === 'testing';
+ }
+}
+
+if (! function_exists('is_local_storage')) {
+ /**
+ * Determine if the storage is local.
+ */
+ function is_local_storage(): bool
+ {
+ $driver = config('filesystems.disks.' . config('filesystems.default') . '.driver');
+
+ return $driver == 'local';
+ }
+}
+
+if (! function_exists('is_cloud_storage')) {
+ /**
+ * Determine if the storage is cloud.
+ */
+ function is_cloud_storage(): bool
+ {
+ return ! is_local_storage();
+ }
+}
+
+if (! function_exists('get_storage_path')) {
+ /**
+ * Get the path from the storage.
+ */
+ function get_storage_path(string $path = ''): string
+ {
+ return is_local_storage()
+ ? storage_path($path)
+ : Storage::path($path);
+ }
+}
+
+if (! function_exists('user_model_class')) {
+ function user_model_class(): string
+ {
+ return config('auth.providers.users.model');
+ }
+}
+
+if (! function_exists('role_model_class')) {
+ function role_model_class(): string
+ {
+ return config('laratrust.models.role');
+ }
+}
+
+if (! function_exists('team_model_class')) {
+ function team_model_class(): string
+ {
+ return config('laratrust.models.team');
+ }
+}
+
+if (! function_exists('search_string_value')) {
+ function search_string_value(string $name, string $default = '', string $input = ''): string|array
+ {
+ $search = new class() { use SearchString; };
+
+ return $search->getSearchStringValue($name, $default, $input);
+ }
+}
+
+if (! function_exists('is_cloud')) {
+ function is_cloud(): bool
+ {
+ $cloud = new class() { use Cloud; };
+
+ return $cloud->isCloud();
+ }
+}
+
+if (! function_exists('request_is_install')) {
+ function request_is_install(Request|null $request = null): bool
+ {
+ $r = $request ?: request();
+
+ return $r->is('install/*');
+ }
+}
+
+if (! function_exists('request_is_api')) {
+ function request_is_api(Request|null $request = null): bool
+ {
+ $r = $request ?: request();
+
+ return $r->is(config('api.prefix') . '/*');
+ }
+}
+
+if (! function_exists('request_is_mcp')) {
+ function request_is_mcp(Request|null $request = null): bool
+ {
+ $r = $request ?: request();
+
+ return $r->is('mcp') || $r->is('mcp/*');
+ }
+}
+
+if (! function_exists('request_is_oauth_api')) {
+ /**
+ * Returns true when the current request is an API call authenticated
+ * via an OAuth Bearer token (as set by AuthenticateOnceWithDynamicApi middleware).
+ */
+ function request_is_oauth_api(Request|null $request = null): bool
+ {
+ $r = $request ?: request();
+
+ return request_is_api($r) && $r->attributes->get('auth_method') === 'oauth';
+ }
+}
+
+if (! function_exists('request_is_auth')) {
+ function request_is_auth(Request|null $request = null): bool
+ {
+ $r = $request ?: request();
+
+ return $r->is('auth/*');
+ }
+}
+
+if (! function_exists('request_is_signed')) {
+ function request_is_signed(Request|null $request = null, int $company_id = null): bool
+ {
+ if (is_null($company_id)) {
+ return false;
+ }
+
+ $r = $request ?: request();
+
+ return $r->is($company_id . '/signed/*');
+ }
+}
+
+if (! function_exists('request_is_portal')) {
+ function request_is_portal(Request|null $request = null, int $company_id = null): bool
+ {
+ if (is_null($company_id)) {
+ return false;
+ }
+
+ $r = $request ?: request();
+
+ return $r->is($company_id . '/portal') || $r->is($company_id . '/portal/*');
+ }
+}
+
+if (! function_exists('calculation_to_quantity')) {
+ function calculation_to_quantity($quantity)
+ {
+ $quantity = trim((string) $quantity);
+
+ if ($quantity === '') {
+ throw new \InvalidArgumentException('Invalid mathematical expression.');
+ }
+
+ if (! preg_match('/^[0-9,+\-x*\/().\s]+$/', $quantity)) {
+ throw new \InvalidArgumentException('Invalid mathematical expression.');
+ }
+
+ $quantity = str_replace(',', '.', $quantity);
+ $quantity = str_ireplace('x', '*', $quantity);
+
+ try {
+ $result = eval('return ' . $quantity . ';');
+ } catch (\Throwable $e) {
+ throw new \InvalidArgumentException('Error evaluating the expression: ' . $e->getMessage());
+ }
+
+ if (! is_numeric($result)) {
+ throw new \InvalidArgumentException('Invalid mathematical expression.');
+ }
+
+ return $result;
+ }
+}
diff --git a/app/View/Components/Alert.php b/app/View/Components/Alert.php
new file mode 100644
index 00000000..943bccdd
--- /dev/null
+++ b/app/View/Components/Alert.php
@@ -0,0 +1,142 @@
+rounded = $rounded;
+ $this->border = $border;
+
+ $this->type = $type;
+
+ $this->color = $this->getColor($color);
+ $this->icon = $this->getIcon($icon);
+
+ $this->title = $title;
+ $this->description = $description;
+
+ $this->message = $message;
+
+ $this->list = $list;
+ $this->actions = $actions;
+
+ $this->dismiss = $dismiss;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.alert.index');
+ }
+
+ protected function getColor($color)
+ {
+ if (! empty($color)) {
+ return $color;
+ }
+
+ switch ($this->type) {
+ case 'success':
+ $color = 'green';
+ break;
+ case 'info':
+ $color = 'blue';
+ break;
+ case 'warning':
+ $color = 'orange';
+ break;
+ case 'danger':
+ case 'error':
+ $color = 'red';
+ break;
+ default:
+ $color = 'green';
+ break;
+ }
+
+ return $color;
+ }
+
+ protected function getIcon($icon)
+ {
+ if (! empty($icon)) {
+ return $icon;
+ }
+
+ switch ($this->type) {
+ case 'success':
+ $icon = 'check_circle';
+ break;
+ case 'info':
+ $icon = 'info';
+ break;
+ case 'warning':
+ $icon = 'warning';
+ break;
+ case 'danger':
+ case 'error':
+ $icon = 'error';
+ break;
+ default:
+ $icon = 'check_circle';
+ break;
+ }
+
+ return $icon;
+ }
+}
diff --git a/app/View/Components/Alert/Border.php b/app/View/Components/Alert/Border.php
new file mode 100644
index 00000000..ad00dde7
--- /dev/null
+++ b/app/View/Components/Alert/Border.php
@@ -0,0 +1,18 @@
+type = !empty($type) ? $type : 'button';
+ $this->override = $this->getOverride($override);
+
+ $this->kind = $kind;
+ $this->class = $this->getClass($class);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.button.index');
+ }
+
+ protected function getOverride($override)
+ {
+ return explode(',', $override);
+ }
+
+ protected function getClass($class)
+ {
+ $default = 'px-3 py-1.5 mb-3 sm:mb-0 rounded-xl text-sm font-medium leading-6';
+
+ switch ($this->kind) {
+ case 'primary':
+ $default .= ' bg-green hover:bg-green-700 text-white disabled:bg-green-100';
+ break;
+ case 'secondary':
+ $default .= ' bg-purple hover:bg-purple-700 text-white disabled:bg-purple-100';
+ break;
+ case 'disabled':
+ $default .= ' bg-gray-100 disabled:bg-gray-100';
+ break;
+ default:
+ $default .= ' bg-gray-100 hover:bg-gray-200 disabled:bg-gray-50';
+ }
+
+ if (in_array('class', $this->override)) {
+ return $class;
+ }
+
+ return $default;
+ }
+}
diff --git a/app/View/Components/Button/Hover.php b/app/View/Components/Button/Hover.php
new file mode 100644
index 00000000..d51010f1
--- /dev/null
+++ b/app/View/Components/Button/Hover.php
@@ -0,0 +1,36 @@
+color = $color;
+ $this->groupHover = $groupHover;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.button.hover');
+ }
+}
diff --git a/app/View/Components/Contacts/Form/Address.php b/app/View/Components/Contacts/Form/Address.php
new file mode 100644
index 00000000..3507926c
--- /dev/null
+++ b/app/View/Components/Contacts/Form/Address.php
@@ -0,0 +1,18 @@
+type = $type;
+ $this->contact = $contact;
+ $this->contact_persons = ($contact) ? $contact->contact_persons : [];
+ $this->currencies = $this->getCurrencies($currencies);
+ $this->currency_code = ($contact) ? $contact->currency_code : default_currency();
+
+ $this->alias = $this->getAlias($type, $alias);
+ $this->folder = $this->getScriptFolder($type, $folder);
+ $this->file = $this->getScriptFile($type, $file);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.contacts.script');
+ }
+
+ protected function getCurrencies($currencies)
+ {
+ if (!empty($currencies)) {
+ return $currencies;
+ }
+
+ return Currency::enabled()->orderBy('name')->get()->makeHidden(['id', 'company_id', 'created_at', 'updated_at', 'deleted_at']);
+ }
+}
diff --git a/app/View/Components/Contacts/Show/Buttons.php b/app/View/Components/Contacts/Show/Buttons.php
new file mode 100644
index 00000000..eb9c8788
--- /dev/null
+++ b/app/View/Components/Contacts/Show/Buttons.php
@@ -0,0 +1,18 @@
+ 0,
+ 'open' => 0,
+ 'overdue' => 0,
+ ];
+
+ $this->counts = [];
+
+ // Handle documents
+ $docs = $this->contact->isCustomer() ? 'invoices' : 'bills';
+
+ // Eager load documents with necessary relations to prevent lazy loading
+ $this->documents = $this->contact->$docs()
+ ->with(['transactions', 'transactions.currency', 'contact', 'last_history', 'items', 'totals'])
+ ->get();
+
+ $this->counts['documents'] = $this->documents->count();
+
+ $today = Date::today()->toDateString();
+
+ foreach ($this->documents as $item) {
+ // Already in transactions
+ if ($item->status == 'paid' || $item->status == 'cancelled') {
+ continue;
+ }
+
+ $transactions = 0;
+
+ foreach ($item->transactions as $transaction) {
+ $transactions += $transaction->getAmountConvertedToDefault();
+ }
+
+ // Check if it's open or overdue invoice
+ if ($item->due_at > $today) {
+ $totals['open'] += $item->getAmountConvertedToDefault() - $transactions;
+ } else {
+ $totals['overdue'] += $item->getAmountConvertedToDefault() - $transactions;
+ }
+ }
+
+ // Handle payments - eager load necessary relations to prevent lazy loading
+ $this->transactions = $this->contact->transactions()
+ ->with(['account', 'category', 'currency', 'contact', 'document', 'recurring'])
+ ->get();
+
+ $this->counts['transactions'] = $this->transactions->count();
+
+ // Prepare data
+ $this->transactions->each(function ($item) use (&$totals) {
+ $totals['paid'] += $item->getAmountConvertedToDefault();
+ });
+
+ $open_amount = money($totals['open']);
+ $overdue_amount = money($totals['overdue']);
+ $paid_amount = money($totals['paid']);
+
+ $summary_amounts = [
+ 'open_exact' => $open_amount->format(),
+ 'open_for_humans' => $open_amount->formatForHumans(),
+ 'overdue_exact' => $overdue_amount->format(),
+ 'overdue_for_humans' => $overdue_amount->formatForHumans(),
+ 'paid_exact' => $paid_amount->format(),
+ 'paid_for_humans' => $paid_amount->formatForHumans(),
+ ];
+
+ $this->summary_amounts = $summary_amounts;
+
+ $this->transactions = $this->paginate($this->transactions->sortByDesc('paid_at'));
+ $this->documents = $this->paginate($this->documents->sortByDesc('issued_at'));
+
+ return view('components.contacts.show.content');
+ }
+
+ /**
+ * Generate a pagination collection.
+ *
+ * @param array|Collection $items
+ * @param int $perPage
+ * @param int $page
+ * @param array $options
+ *
+ * @return LengthAwarePaginator
+ */
+ public function paginate($items, $perPage = null, $page = null, $options = [])
+ {
+ $perPage = $perPage ?: (int) request('limit', setting('default.list_limit', '25'));
+
+ $page = $page ?: (Paginator::resolveCurrentPage() ?: 1);
+
+ $items = $items instanceof Collection ? $items : Collection::make($items);
+
+ return new LengthAwarePaginator($items->forPage($page, $perPage), $items->count(), $perPage, $page, $options);
+ }
+}
diff --git a/app/View/Components/Contacts/Show/MoreButtons.php b/app/View/Components/Contacts/Show/MoreButtons.php
new file mode 100644
index 00000000..c5db328d
--- /dev/null
+++ b/app/View/Components/Contacts/Show/MoreButtons.php
@@ -0,0 +1,18 @@
+rawDate = $date;
+ $this->format = $this->getFormat($format);
+ $this->function = $function;
+
+ $this->date = $this->getFormatDate($date);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.date');
+ }
+
+ protected function getFormat($format)
+ {
+ if (! empty($format)) {
+ return $format;
+ }
+
+ $date_time = new class() {
+ use DateTime;
+ };
+
+ return $date_time->getCompanyDateFormat();
+ }
+
+ protected function getFormatDate($date)
+ {
+ if (! empty($this->function)) {
+ $date = UDate::parse($date)->{$this->function}();
+
+ return Str::ucfirst($date);
+ }
+
+ return UDate::parse($date)->format($this->format);
+ }
+}
diff --git a/app/View/Components/DeleteButton.php b/app/View/Components/DeleteButton.php
new file mode 100644
index 00000000..a0e57ef8
--- /dev/null
+++ b/app/View/Components/DeleteButton.php
@@ -0,0 +1,288 @@
+label = $this->getLabel($label);
+
+ $this->model = $model;
+ $this->modelId = $modelId;
+ $this->modelName = $modelName;
+ $this->modelTitle = $modelTitle;
+ $this->modelTable = $model->getTable();
+
+ $this->text = $text;
+ $this->type = $type;
+
+ $this->action = $this->getAction($action, $route, $url);
+ $this->route = $route;
+ $this->url = $url;
+
+ $this->title = $this->getTitle($title);
+ $this->message = $this->getMessage($message);
+
+ $this->cancelText = $this->getCancelText($cancelText);
+ $this->deleteText = $this->getDeleteText($deleteText);
+
+ $this->id = $this->getId();
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.delete-button');
+ }
+
+ protected function getId()
+ {
+ if (! empty($this->model)) {
+ return $this->model->{$this->modelId};
+ }
+
+ if (! empty($this->route) && is_array($this->route)) {
+ return $this->route[1];
+ }
+
+ return mt_rand();
+ }
+
+ protected function getLabel($label)
+ {
+ if (! empty($label)) {
+ return $label;
+ }
+
+ return trans('general.delete');
+ }
+
+ protected function getAction($action, $route, $url)
+ {
+ if (! empty($action)) {
+ return $action;
+ }
+
+ if (! empty($route)) {
+ return $this->getRouteAction($route);
+ }
+
+ if (! empty($url)) {
+ return $this->getUrlAction($url);
+ }
+
+ return '';
+ }
+
+ /**
+ * Get the action for a "url" option.
+ *
+ * @param array|string $options
+ *
+ * @return string
+ */
+ protected function getUrlAction($options)
+ {
+ if (is_array($options)) {
+ return url($options[0], array_slice($options, 1));
+ }
+
+ if (! empty($this->model)) {
+ return url($options, $this->model->{$this->modelId});
+ }
+
+ return url($options);
+ }
+
+ /**
+ * Get the action for a "route" option.
+ *
+ * @param array|string $options
+ *
+ * @return string
+ */
+ protected function getRouteAction($options)
+ {
+ if (is_array($options)) {
+ $parameters = array_slice($options, 1);
+
+ if (array_keys($options) === [0, 1]) {
+ $parameters = head($parameters);
+ }
+
+ return route($options[0], $parameters);
+ }
+
+ if (! empty($this->model)) {
+ return route($options, $this->model->{$this->modelId});
+ }
+
+ return route($options);
+ }
+
+ protected function getTitle($title)
+ {
+ if (! empty($title)) {
+ return $title;
+ }
+
+ $type = '';
+
+ if (! empty($this->model)) {
+ $type = $this->getModelTitle();
+ }
+
+ return ! empty($type) ? trans('general.title.delete', ['type' => $type]) : trans('general.delete');
+ }
+
+ protected function getMessage($cancelText)
+ {
+ if (! empty($cancelText)) {
+ return $cancelText;
+ }
+
+ $name = '';
+ $type = '';
+
+ if (! empty($this->model)) {
+ $page = '';
+
+ if (! empty($this->route)) {
+ if (! is_array($this->route)) {
+ $string = $this->route;
+ }
+
+ if (is_array($this->route)) {
+ $string = $this->route[0];
+ }
+
+ $page = explode('.', $string)[0];
+ } elseif (! empty($this->url)) {
+ $page = explode('/', $this->url)[1];
+ }
+
+ $text = $this->text ? $this->text : $page;
+ $name = e($this->model->{$this->modelName});
+ $name = addslashes($name);
+ $name = Str::replace(['\"', '"'], '"', $name);
+
+ $type = mb_strtolower($this->getModelTitle());
+
+ $message = trans('general.delete_confirm', ['name' => '' . $name . '', 'type' => $type]);
+
+ return $message;
+ }
+
+ return trans('general.delete_confirm', ['name' => '' . $name . '', 'type' => $type]);
+ }
+
+ protected function getModelTitle()
+ {
+ if (! empty($this->modelTitle)) {
+ return $this->modelTitle;
+ }
+
+ $group = 'core';
+ $page = '';
+
+ if (! empty($this->route)) {
+ if (is_array($this->route)) {
+ $string = $this->route[0];
+ } else {
+ $string = $this->route;
+ }
+
+ $paths = explode('.', $string);
+
+ $page = $paths[0];
+ } elseif (! empty($this->url)) {
+ $paths = explode('/', $this->url);
+
+ $page = $paths[1];
+ }
+
+ $title = trans_choice('general.' . $page, 1);
+
+ if (module($page) != null) {
+ $group = $page;
+ $page = (! empty($this->route)) ? $paths[1] : $paths[2];
+
+ $title = trans_choice($group . '::general.' . str_replace('-', '_', $page), 1);
+ }
+
+ return $title;
+ }
+
+ protected function getCancelText($cancelText)
+ {
+ if (! empty($cancelText)) {
+ return $cancelText;
+ }
+
+ return trans('general.cancel');
+ }
+
+ protected function getDeleteText($deleteText)
+ {
+ if (! empty($deleteText)) {
+ return $deleteText;
+ }
+
+ return trans('general.delete');
+ }
+}
diff --git a/app/View/Components/DeleteLink.php b/app/View/Components/DeleteLink.php
new file mode 100644
index 00000000..65dd9ee7
--- /dev/null
+++ b/app/View/Components/DeleteLink.php
@@ -0,0 +1,301 @@
+label = $this->getLabel($label);
+
+ $this->model = $model;
+ $this->modelId = $modelId;
+ $this->modelName = $modelName;
+ $this->modelTitle = $modelTitle;
+ $this->modelTable = $model->getTable();
+
+ $this->text = $text;
+ $this->type = $type;
+
+ $this->action = $this->getAction($action, $route, $url);
+ $this->route = $route;
+ $this->url = $url;
+
+ $this->title = $this->getTitle($title);
+ $this->message = $this->getMessage($message);
+
+ $this->cancelText = $this->getCancelText($cancelText);
+ $this->deleteText = $this->getDeleteText($deleteText);
+
+ $this->id = $this->getId();
+
+ $this->override = $override;
+
+ $this->class = $this->getClass($class);
+ $this->textClass = ! empty($textClass) ? $textClass : 'w-full h-full flex items-center rounded-md px-2 text-sm hover:bg-lilac-100';
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.delete-link');
+ }
+
+ protected function getId()
+ {
+ if (! empty($this->model)) {
+ return $this->model->{$this->modelId};
+ }
+
+ if (! empty($this->route) && is_array($this->route)) {
+ return $this->route[1];
+ }
+
+ return mt_rand();
+ }
+
+ protected function getLabel($label)
+ {
+ if (! empty($label)) {
+ return $label;
+ }
+
+ return trans('general.delete');
+ }
+
+ protected function getAction($action, $route, $url)
+ {
+ if (! empty($action)) {
+ return $action;
+ }
+
+ if (! empty($route)) {
+ return $this->getRouteAction($route);
+ }
+
+ if (! empty($url)) {
+ return $this->getUrlAction($url);
+ }
+
+ return '';
+ }
+
+ /**
+ * Get the action for a "url" option.
+ *
+ * @param array|string $options
+ *
+ * @return string
+ */
+ protected function getUrlAction($options)
+ {
+ if (is_array($options)) {
+ return url($options[0], array_slice($options, 1));
+ }
+
+ if (! empty($this->model)) {
+ return url($options, $this->model->{$this->modelId});
+ }
+
+ return url($options);
+ }
+
+ /**
+ * Get the action for a "route" option.
+ *
+ * @param array|string $options
+ *
+ * @return string
+ */
+ protected function getRouteAction($options)
+ {
+ if (is_array($options)) {
+ $parameters = array_slice($options, 1);
+
+ if (array_keys($options) === [0, 1]) {
+ $parameters = head($parameters);
+ }
+
+ return route($options[0], $parameters);
+ }
+
+ if (! empty($this->model)) {
+ return route($options, $this->model->{$this->modelId});
+ }
+
+ return route($options);
+ }
+
+ protected function getTitle($title)
+ {
+ if (! empty($title)) {
+ return $title;
+ }
+
+ $type = '';
+
+ if (! empty($this->model)) {
+ $type = $this->getModelTitle();
+ }
+
+ return ! empty($type) ? trans('general.title.delete', ['type' => $type]) : trans('general.delete');
+ }
+
+ protected function getMessage($cancelText)
+ {
+ if (!empty($cancelText)) {
+ return $cancelText;
+ }
+
+ $name = '';
+ $type = '';
+
+ if (! empty($this->model)) {
+ $page = '';
+
+ if (! empty($this->route)) {
+ $page = explode('.', $this->route)[0];
+ } elseif (! empty($this->url)) {
+ $page = explode('/', $this->url)[1];
+ }
+
+ $text = $this->text ? $this->text : $page;
+ $name = e($this->model->{$this->modelName});
+ $name = addslashes($name);
+ $name = Str::replace(['\"', '"'], '"', $name);
+
+ $type = mb_strtolower($this->getModelTitle());
+
+ $message = trans('general.delete_confirm', ['name' => '' . $name . '', 'type' => $type]);
+
+ return $message;
+ }
+
+ return trans('general.delete_confirm', ['name' => '' . $name . '', 'type' => $type]);
+ }
+
+ protected function getModelTitle()
+ {
+ if (! empty($this->modelTitle)) {
+ return $this->modelTitle;
+ }
+
+ $group = 'core';
+ $page = '';
+
+ if (! empty($this->route)) {
+ $paths = explode('.', $this->route);
+
+ $page = $paths[0];
+ } elseif (! empty($this->url)) {
+ $paths = explode('/', $this->url);
+
+ $page = $paths[1];
+ }
+
+ $title = trans_choice('general.' . $page, 1);
+
+ if (module($page) != null) {
+ $group = $page;
+ $page = (! empty($this->route)) ? $paths[1] : $paths[2];
+
+ $title = trans_choice($group . '::general.' . $page, 1);
+ }
+
+ return $title;
+ }
+
+ protected function getCancelText($cancelText)
+ {
+ if (! empty($cancelText)) {
+ return $cancelText;
+ }
+
+ return trans('general.cancel');
+ }
+
+ protected function getDeleteText($deleteText)
+ {
+ if (! empty($deleteText)) {
+ return $deleteText;
+ }
+
+ return trans('general.delete');
+ }
+
+ protected function getClass($class)
+ {
+ $default_class = 'w-full flex items-center text-red sm:text-purple px-2 h-9 leading-9 whitespace-nowrap';
+
+ $explode = explode(',', $this->override);
+
+ if (count($explode) && in_array('class', $explode)) {
+ $default_class = $class;
+ } else {
+ $default_class .= ' ' . $class;
+ }
+
+ return $default_class;
+ }
+}
diff --git a/app/View/Components/Documents/Form/Advanced.php b/app/View/Components/Documents/Form/Advanced.php
new file mode 100644
index 00000000..86c119fd
--- /dev/null
+++ b/app/View/Components/Documents/Form/Advanced.php
@@ -0,0 +1,18 @@
+type . '.route.parameter');
+
+ return view('components.documents.form.company', compact('company', 'inputNameType'));
+ }
+}
diff --git a/app/View/Components/Documents/Form/Contact.php b/app/View/Components/Documents/Form/Contact.php
new file mode 100644
index 00000000..01ffbfb2
--- /dev/null
+++ b/app/View/Components/Documents/Form/Contact.php
@@ -0,0 +1,230 @@
+type = $type;
+ $this->contact = $contact;
+ $this->contacts = $contacts;
+ $this->searchRoute = $searchRoute;
+ $this->createRoute = $createRoute;
+ $this->error = ($error) ?: "form.errors.get('contact_id')" ;
+
+ $this->textAddContact = $this->getTextAddContact($type, $textAddContact);
+ $this->textCreateNewContact = $this->getTextCreateNewContact($type, $textCreateNewContact);
+ $this->textEditContact = $this->getTextEditContact($type, $textEditContact);
+ $this->textContactInfo = $this->getTextContactInfo($type, $textContactInfo);
+ $this->textChooseDifferentContact = $this->getTextChooseDifferentContact($type, $textChooseDifferentContact);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ if (empty($this->contacts)) {
+ $this->contacts = Model::{$this->type}()->enabled()->orderBy('name')->take(setting('default.select_limit'))->get();
+
+ if (!empty($this->contact) && (!$this->contacts->contains('id', $contact->id))) {
+ $this->contacts->push($this->contact);
+ }
+ }
+
+ if (empty($this->searchRoute)) {
+ switch ($this->type) {
+ case 'customer':
+ $this->searchRoute = route('customers.index');
+ break;
+ case 'vendor':
+ $this->searchRoute = route('vendors.index');
+ break;
+ }
+ }
+
+ if (empty($this->createRoute)) {
+ switch ($this->type) {
+ case 'customer':
+ $this->createRoute = route('modals.customers.create');
+ break;
+ case 'vendor':
+ $this->createRoute = route('modals.vendors.create');
+ break;
+ }
+ }
+
+ #todo 3rd part apps
+ $this->placeholder = trans('general.placeholder.contact_search', ['type' => trans_choice('general.' . Str::plural($this->type, 2), 1)]);
+
+ return view('components.documents.form.contact');
+ }
+
+ protected function getTextAddContact($type, $textAddContact)
+ {
+ if (!empty($textAddContact)) {
+ return $textAddContact;
+ }
+
+ switch ($type) {
+ case 'bill':
+ case 'expense':
+ case 'purchase':
+ $textAddContact = [
+ 'general.form.add',
+ 'general.vendors'
+ ];
+ break;
+ default:
+ $textAddContact = [
+ 'general.form.add',
+ 'general.customers'
+ ];
+ break;
+ }
+
+ return $textAddContact;
+ }
+
+ protected function getTextCreateNewContact($type, $textCreateNewContact)
+ {
+ if (!empty($textCreateNewContact) && is_array($textCreateNewContact)) {
+ return trans($textCreateNewContact[0], ['type' => trans_choice($textCreateNewContact[1], 1)]);
+ }
+
+ switch ($type) {
+ case 'bill':
+ case 'expense':
+ case 'purchase':
+ $textCreateNewContact = [
+ 'general.form.add_new',
+ 'general.vendors'
+ ];
+ break;
+ default:
+ $textCreateNewContact = [
+ 'general.form.add_new',
+ 'general.customers'
+ ];
+ break;
+ }
+
+ return $textCreateNewContact;
+ }
+
+ protected function getTextEditContact($type, $textEditContact)
+ {
+ if (!empty($textEditContact)) {
+ return $textEditContact;
+ }
+
+ switch ($type) {
+ case 'bill':
+ case 'expense':
+ case 'purchase':
+ $textEditContact = [
+ 'general.form.contact_edit',
+ 'general.vendors'
+ ];
+ break;
+ default:
+ $textEditContact = [
+ 'general.form.contact_edit',
+ 'general.customers'
+ ];
+ break;
+ }
+
+ return $textEditContact;
+ }
+
+ protected function getTextContactInfo($type, $textContactInfo)
+ {
+ if (!empty($textContactInfo)) {
+ return $textContactInfo;
+ }
+
+ switch ($type) {
+ case 'bill':
+ case 'expense':
+ case 'purchase':
+ $textContactInfo = 'bills.bill_from';
+ break;
+ default:
+ $textContactInfo = 'invoices.bill_to';
+ break;
+ }
+
+ return $textContactInfo;
+ }
+
+ protected function getTextChooseDifferentContact($type, $textChooseDifferentContact)
+ {
+ if (!empty($textChooseDifferentContact)) {
+ return $textChooseDifferentContact;
+ }
+
+ switch ($type) {
+ case 'bill':
+ case 'expense':
+ case 'purchase':
+ $textChooseDifferentContact = [
+ 'general.form.choose_different',
+ 'general.vendors'
+ ];
+ break;
+ default:
+ $textChooseDifferentContact = [
+ 'general.form.choose_different',
+ 'general.customers'
+ ];
+ break;
+ }
+
+ return $textChooseDifferentContact;
+ }
+}
diff --git a/app/View/Components/Documents/Form/Content.php b/app/View/Components/Documents/Form/Content.php
new file mode 100644
index 00000000..b7c40415
--- /dev/null
+++ b/app/View/Components/Documents/Form/Content.php
@@ -0,0 +1,24 @@
+document)) {
+ $status = $this->document->status;
+ }
+
+ return view('components.documents.form.content', compact('status'));
+ }
+}
diff --git a/app/View/Components/Documents/Form/Footer.php b/app/View/Components/Documents/Form/Footer.php
new file mode 100644
index 00000000..e45bfe60
--- /dev/null
+++ b/app/View/Components/Documents/Form/Footer.php
@@ -0,0 +1,18 @@
+type = $type;
+ $this->isSale = $isSale;
+ $this->isPurchase = $isPurchase;
+ $this->searchUrl = $this->getSearchUrl($searchUrl);
+ $this->searchCharLimit = $searchCharLimit;
+ $this->searchListKey = $searchListKey;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ $price_type = $this->getPriceType($this->type, $this->isSale, $this->isPurchase);
+
+ $items = Item::priceType($price_type)->enabled()->orderBy('name')->take(setting('default.select_limit'))->get();
+
+ foreach ($items as $item) {
+ $price = $item->{$price_type . '_price'};
+
+ $item->price = $price;
+ }
+
+ $price = $price_type . '_price';
+
+ return view('components.documents.form.item-button', compact('items', 'price'));
+ }
+
+ protected function getSearchUrl($url)
+ {
+ if (empty($url)) {
+ return route('items.index');
+ }
+
+ return $url;
+ }
+
+ protected function getPriceType($type, $is_sale, $is_purchase)
+ {
+ if (!empty($is_sale)) {
+ return 'sale';
+ }
+
+ if (!empty($is_purchase)) {
+ return 'purchase';
+ }
+
+ switch ($type) {
+ case 'bill':
+ case 'expense':
+ case 'purchase':
+ $type = 'purchase';
+ break;
+ case 'sale':
+ case 'income':
+ case 'invoice':
+ default:
+ $type = 'sale';
+ }
+
+ return $type;
+ }
+}
diff --git a/app/View/Components/Documents/Form/ItemColumns.php b/app/View/Components/Documents/Form/ItemColumns.php
new file mode 100644
index 00000000..6a62101e
--- /dev/null
+++ b/app/View/Components/Documents/Form/ItemColumns.php
@@ -0,0 +1,31 @@
+type = $type;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.documents.form.item-columns');
+ }
+}
diff --git a/app/View/Components/Documents/Form/Items.php b/app/View/Components/Documents/Form/Items.php
new file mode 100644
index 00000000..63187725
--- /dev/null
+++ b/app/View/Components/Documents/Form/Items.php
@@ -0,0 +1,21 @@
+orderBy('name')->get();
+
+ return view('components.documents.form.items', compact('taxes'));
+ }
+}
diff --git a/app/View/Components/Documents/Form/LineItem.php b/app/View/Components/Documents/Form/LineItem.php
new file mode 100644
index 00000000..9ba17e85
--- /dev/null
+++ b/app/View/Components/Documents/Form/LineItem.php
@@ -0,0 +1,18 @@
+document = $document;
+ $this->hideShow = $hideShow;
+
+ // Load relations if not loaded to prevent lazy loading
+ $relations = ['contact', 'last_history', 'items', 'totals'];
+
+ foreach ($relations as $relation) {
+ if (! $document->relationLoaded($relation)) {
+ $document->load($relation);
+ }
+ }
+
+ $this->showRoute = $this->getShowRoute($document->contact->type, $showRoute);
+ $this->showDocumentRoute = $this->getShowRoute($document->type, $showDocumentRoute);
+ $this->placement = (! empty($placement)) ? $placement : 'left';
+ $this->id = (! empty($id)) ? $id : 'tooltip-information-' . $document->id;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.documents.index.information');
+ }
+}
diff --git a/app/View/Components/Documents/Index/MoreButtons.php b/app/View/Components/Documents/Index/MoreButtons.php
new file mode 100644
index 00000000..58276c5f
--- /dev/null
+++ b/app/View/Components/Documents/Index/MoreButtons.php
@@ -0,0 +1,18 @@
+type = $type;
+ $this->document = $document;
+ $this->items = $items;
+ $this->currencies = $this->getCurrencies($currencies);
+ $this->currency_code = ($document) ? $document->currency_code : default_currency();
+ $this->taxes = $this->getTaxes($taxes);
+
+ $this->alias = $this->getAlias($type, $alias);
+ $this->folder = $this->getScriptFolder($type, $folder);
+ $this->file = $this->getScriptFile($type, $file);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.documents.script');
+ }
+
+ protected function getCurrencies($currencies)
+ {
+ if (!empty($currencies)) {
+ return $currencies;
+ }
+
+ return Currency::enabled()->orderBy('name')->get()->makeHidden(['id', 'company_id', 'created_at', 'updated_at', 'deleted_at']);
+ }
+
+ protected function getTaxes($taxes)
+ {
+ if (!empty($taxes)) {
+ return $taxes;
+ }
+
+ return Tax::enabled()->orderBy('name')->get()->makeHidden(['company_id', 'created_at', 'updated_at', 'deleted_at']);
+ }
+}
diff --git a/app/View/Components/Documents/Show/Attachment.php b/app/View/Components/Documents/Show/Attachment.php
new file mode 100644
index 00000000..8bb1e027
--- /dev/null
+++ b/app/View/Components/Documents/Show/Attachment.php
@@ -0,0 +1,39 @@
+transaction_attachment = collect();
+
+ // Eager load transactions with their media/attachments to prevent N+1 queries
+ if (!$this->document->relationLoaded('transactions')) {
+ $this->document->load(['transactions.media', 'transactions']);
+ }
+
+ if ($this->document->transactions->count()) {
+ foreach ($this->document->transactions as $transaction) {
+ if (! $transaction->attachment) {
+ continue;
+ }
+
+ foreach ($transaction->attachment as $file) {
+ $this->transaction_attachment->push($file);
+ }
+ }
+ }
+
+ return view('components.documents.show.attachment');
+ }
+}
diff --git a/app/View/Components/Documents/Show/Buttons.php b/app/View/Components/Documents/Show/Buttons.php
new file mode 100644
index 00000000..9d774cf3
--- /dev/null
+++ b/app/View/Components/Documents/Show/Buttons.php
@@ -0,0 +1,18 @@
+where('recurable_id', $this->document->id)
+ ->first();
+
+ $this->type_lowercase = Str::lower(trans_choice($this->textPage, 2));
+
+ return view('components.documents.show.children', compact('recurring'));
+ }
+}
diff --git a/app/View/Components/Documents/Show/Content.php b/app/View/Components/Documents/Show/Content.php
new file mode 100644
index 00000000..d8f790f1
--- /dev/null
+++ b/app/View/Components/Documents/Show/Content.php
@@ -0,0 +1,18 @@
+description = ($this->document->isRecurringDocument()) ? 'documents.slider.create_recurring' : 'documents.slider.create';
+
+ $this->user_name = $this->document->owner->name;
+
+ $this->type_lowercase = Str::lower(trans_choice($this->textPage, 1));
+
+ $this->created_date = '' . company_date($this->document->created_at) . '';
+
+ return view('components.documents.show.create');
+ }
+}
diff --git a/app/View/Components/Documents/Show/GetPaid.php b/app/View/Components/Documents/Show/GetPaid.php
new file mode 100644
index 00000000..41d476ec
--- /dev/null
+++ b/app/View/Components/Documents/Show/GetPaid.php
@@ -0,0 +1,32 @@
+description = trans('general.amount_due') . ': ' . '' . money($this->document->amount_due, $this->document->currency_code) . '';
+
+ if (
+ request()->isSigned($this->document->company_id)
+ || request()->isPreview($this->document->company_id)
+ || request()->isPortal($this->document->company_id)
+ ) {
+ $this->text_document_transaction = 'documents.portal_transaction';
+ }
+
+ return view('components.documents.show.get-paid');
+ }
+}
diff --git a/app/View/Components/Documents/Show/MakePayment.php b/app/View/Components/Documents/Show/MakePayment.php
new file mode 100644
index 00000000..3eeeb0cf
--- /dev/null
+++ b/app/View/Components/Documents/Show/MakePayment.php
@@ -0,0 +1,32 @@
+description = trans('general.amount_due') . ': ' . '' . money($this->document->amount_due, $this->document->currency_code) . '';
+
+ if (
+ request()->isSigned($this->document->company_id)
+ || request()->isPreview($this->document->company_id)
+ || request()->isPortal($this->document->company_id)
+ ) {
+ $this->text_document_transaction = 'documents.portal_transaction';
+ }
+
+ return view('components.documents.show.make-payment');
+ }
+}
diff --git a/app/View/Components/Documents/Show/Message.php b/app/View/Components/Documents/Show/Message.php
new file mode 100644
index 00000000..c9cb274c
--- /dev/null
+++ b/app/View/Components/Documents/Show/Message.php
@@ -0,0 +1,44 @@
+type = $type;
+ $this->backgroundColor = $backgroundColor;
+ $this->textColor = $textColor;
+ $this->message = $message;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.documents.show.message');
+ }
+}
diff --git a/app/View/Components/Documents/Show/MoreButtons.php b/app/View/Components/Documents/Show/MoreButtons.php
new file mode 100644
index 00000000..7a087db6
--- /dev/null
+++ b/app/View/Components/Documents/Show/MoreButtons.php
@@ -0,0 +1,18 @@
+description = $this->document->isRecurringDocument() ? 'documents.slider.create_recurring' : 'general.last_received';
+
+ $this->last_received = $this->document->histories()->status('received')->latest()->first();
+
+ $this->user_name = ($this->last_received) ? $this->last_received->owner->name : trans('general.na');
+
+ $this->type_lowercase = Str::lower(trans_choice($this->textPage, 1));
+
+ $date = ($this->last_received) ? company_date($this->last_received->created_at) : trans('general.na');
+
+ $this->last_received_date = '' . $date . '';
+
+ return view('components.documents.show.receive');
+ }
+}
diff --git a/app/View/Components/Documents/Show/Restore.php b/app/View/Components/Documents/Show/Restore.php
new file mode 100644
index 00000000..ee087c1b
--- /dev/null
+++ b/app/View/Components/Documents/Show/Restore.php
@@ -0,0 +1,39 @@
+description = 'documents.slider.cancel';
+
+ $this->last_cancelled = $this->document->histories()->status('cancelled')->latest()->first();
+
+ $this->user_name = ($this->last_cancelled) ? $this->last_cancelled->owner->name : trans('general.na');
+
+ $this->type_lowercase = Str::lower(trans_choice($this->textPage, 1));
+
+ $this->last_cancelled_date = '' . company_date($this->last_cancelled->created_at) . '';
+
+ return view('components.documents.show.restore');
+ }
+}
diff --git a/app/View/Components/Documents/Show/Schedule.php b/app/View/Components/Documents/Show/Schedule.php
new file mode 100644
index 00000000..381f38ce
--- /dev/null
+++ b/app/View/Components/Documents/Show/Schedule.php
@@ -0,0 +1,36 @@
+where('recurable_id', $this->document->id)
+ ->first();
+
+ $started_date = '' . company_date($recurring->started_at) . '';
+ $frequency = Str::lower(trans('recurring.' . str_replace(['daily', 'ly'], ['days', 's'], $recurring->frequency)));
+ $invertal = $recurring->interval;
+
+ $this->description = trans('transactions.slider.schedule', [
+ 'frequency' => $frequency,
+ 'interval' => $invertal,
+ 'date' => $started_date
+ ]);
+
+ return view('components.documents.show.schedule', compact('recurring'));
+ }
+}
diff --git a/app/View/Components/Documents/Show/Send.php b/app/View/Components/Documents/Show/Send.php
new file mode 100644
index 00000000..14f7dede
--- /dev/null
+++ b/app/View/Components/Documents/Show/Send.php
@@ -0,0 +1,45 @@
+description = $this->document->isRecurringDocument() ? 'documents.slider.create_recurring' : 'general.last_sent';
+
+ $this->histories = $this->document->histories()->status('sent')->latest()->get();
+
+ $this->last_sent = $this->histories->first();
+
+ $this->user_name = ($this->last_sent) ? $this->last_sent->owner->name : trans('general.na');
+
+ $this->type_lowercase = Str::lower(trans_choice($this->textPage, 1));
+
+ $date = ($this->last_sent) ? company_date($this->last_sent->created_at) : trans('general.na');
+
+ $this->last_sent_date = '' . $date . '';
+
+ return view('components.documents.show.send');
+ }
+}
diff --git a/app/View/Components/Documents/Show/Template.php b/app/View/Components/Documents/Show/Template.php
new file mode 100644
index 00000000..15ef793c
--- /dev/null
+++ b/app/View/Components/Documents/Show/Template.php
@@ -0,0 +1,18 @@
+id = $id;
+ $this->override = explode(',', $override);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.dropdown.index');
+ }
+}
diff --git a/app/View/Components/Dropdown/Button.php b/app/View/Components/Dropdown/Button.php
new file mode 100644
index 00000000..85157875
--- /dev/null
+++ b/app/View/Components/Dropdown/Button.php
@@ -0,0 +1,18 @@
+href = $href;
+ $this->target = $target;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.dropdown.link');
+ }
+}
diff --git a/app/View/Components/EmptyPage.php b/app/View/Components/EmptyPage.php
new file mode 100644
index 00000000..5f595bfa
--- /dev/null
+++ b/app/View/Components/EmptyPage.php
@@ -0,0 +1,370 @@
+alias = (module($alias) === null) ? 'core': $alias;
+ $this->group = $group;
+ $this->page = $page;
+ $this->docsCategory = $docsCategory;
+
+ $this->title = $this->getTitle($title);
+ $this->createButtonTitle = $createButtonTitle;
+ $this->importButtonTitle = $importButtonTitle;
+
+ $this->description = $this->getDescription($description);
+
+ $this->imageEmptyPage = $imageEmptyPage;
+ $this->image = $this->getImage($page, $image);
+
+ $this->checkPermissionCreate = $checkPermissionCreate;
+ $this->permissionCreate = $this->getPermissionCreate($page, $group, $permissionCreate);
+
+ $this->hideButtonCreate = $hideButtonCreate;
+ $this->hideButtonImport = $hideButtonImport;
+ $this->hideDocsDescription = $hideDocsDescription;
+
+ $this->buttons = $this->getButtons($page, $group, $buttons);
+
+ $this->suggestions = $this->getSuggestionModules();
+ $this->suggestion = $this->getSuggestionModule();
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.empty-page');
+ }
+
+ protected function getTitle($title = null, $number = 2)
+ {
+ if (! empty($title)) {
+ return $title;
+ }
+
+ switch ($this->alias) {
+ case 'core':
+ $text = 'general.' . $this->page;
+ $text2 = 'general.' . Str::replace('-', '_', $this->page);
+ break;
+ default:
+ $text = $this->alias . '::general.' . $this->page;
+ $text2 = $this->alias . '::general.' . Str::replace('-', '_', $this->page);
+ }
+
+ $title = trans_choice($text, $number);
+
+ if ($title == $text) {
+ $title = trans_choice($text2, $number);
+ }
+
+ return $title;
+ }
+
+ protected function getDescription($description)
+ {
+ if (! empty($description)) {
+ return $description;
+ }
+
+ switch ($this->alias) {
+ case 'core':
+ $text = 'general.empty.' . $this->page;
+ $text2 = 'general.empty.' . Str::replace('-', '_', $this->page);
+ break;
+ default:
+ $text = $this->alias . '::general.empty.' . $this->page;
+ $text2 = $this->alias . '::general.empty.' . Str::replace('-', '_', $this->page);
+ }
+
+ $description = trans($text);
+
+ if ($description == $text) {
+ $description = trans($text2);
+ }
+
+ if ($this->hideDocsDescription) {
+ $docs_url = $this->getDocsUrl();
+
+ if (! empty($docs_url)) {
+ $description .= ' ' . trans('general.empty.documentation', ['url' => $docs_url]);
+ }
+ }
+
+ return $description;
+ }
+
+ protected function getDocsUrl()
+ {
+ return null;
+ }
+
+ protected function getImage($page, $image)
+ {
+ if (! empty($image)) {
+ return $image;
+ }
+
+ if (! empty($this->imageEmptyPage)) {
+ return asset($this->imageEmptyPage);
+ }
+
+ $path = 'public/img/empty_pages/' . $page . '.png';
+
+ if ($this->alias != 'core') {
+ $path = 'modules/' . Str::studly($this->alias) . '/Resources/assets/img/empty-' . $page . '.png';
+
+ if (! file_exists($path)) {
+ $path = 'public/img/empty_pages/default.png';
+ }
+ }
+
+ return asset($path);
+ }
+
+ protected function getPermissionCreate($page, $group, $permissionCreate)
+ {
+ if (! empty($permissionCreate)) {
+ return $permissionCreate;
+ }
+
+ $pages = [
+ 'reconciliations' => 'create-banking-reconciliations',
+ 'transfers' => 'create-banking-transfers',
+ 'vendors' => 'create-purchases-vendors',
+ 'customers' => 'create-sales-customers',
+ 'taxes' => 'create-settings-taxes',
+ 'items' => 'create-common-items',
+ ];
+
+ if (array_key_exists($page, $pages)) {
+ $permissionCreate = $pages[$page];
+ }
+
+ if (empty($permissionCreate) && !empty($group)) {
+ $permissionCreate = 'create-' . $group . '-' . $page;
+ }
+
+ return $permissionCreate;
+ }
+
+ protected function getButtons($page, $group, $buttons)
+ {
+ if (! empty($buttons)) {
+ $suggestion = $this->getSuggestionModule();
+
+ if (! empty($suggestion)) {
+ return array_slice($buttons, 0, 2);
+ } else {
+ return array_slice($buttons, 0, 3);
+ }
+ }
+
+ if (! $this->hideButtonCreate) {
+ $buttons[] = $this->getCreateButton($page, $group);
+ }
+
+ if (! $this->hideButtonImport) {
+ $buttons[] = $this->getImportButton();
+ }
+
+ return $buttons;
+ }
+
+ protected function getCreateButton($page, $group)
+ {
+ try {
+ $route = route($group . '.' . $page . '.create');
+ } catch (\Exception $e) {
+ $route = route($page . '.create');
+ }
+
+ $title = $this->createButtonTitle;
+
+ if (! $title) {
+ $title = $this->getTitle(null, 1);
+ }
+
+ return [
+ 'url' => $route,
+ 'permission' => $this->permissionCreate,
+ 'text' => trans('general.title.new', ['type' => $title]),
+ 'description' => trans('general.empty.actions.new', ['type' => strtolower($title)]),
+ 'active_badge' => true,
+ 'stack' => 'create_button',
+ ];
+ }
+
+ protected function getImportButton()
+ {
+ $importRoute = $this->getImportRoute($this->importRoute);
+ $importRouteParameters = $this->getImportRouteParameters($this->importRouteParameters);
+
+ $title = $this->importButtonTitle;
+
+ if (! $title) {
+ $title = $this->getTitle();
+ }
+
+ return [
+ 'url' => route($importRoute, $importRouteParameters),
+ 'permission' => $this->permissionCreate,
+ 'text' => trans('import.title', ['type' => $title]),
+ 'description' => trans('general.empty.actions.import', ['type' => strtolower($title)]),
+ 'active_badge' => false,
+ 'stack' => 'import_button',
+ ];
+ }
+
+ protected function getImportRoute($importRoute)
+ {
+ if (! empty($importRoute)) {
+ return $importRoute;
+ }
+
+ $route = 'import.create';
+
+ return $route;
+ }
+
+ protected function getImportRouteParameters($importRouteParameters)
+ {
+ if (! empty($importRouteParameters)) {
+ return $importRouteParameters;
+ }
+
+ return array_slice(request()->segments(), -2, 2, true) ;
+ }
+
+ public function getSuggestionModule()
+ {
+ return ! empty($this->suggestions) ? Arr::random($this->suggestions) : false;
+ }
+
+ public function getSuggestionModules()
+ {
+ if ((! $user = user()) || $user->cannot('read-modules-home')) {
+ return [];
+ }
+
+ if (! $path = Route::current()->uri()) {
+ return [];
+ }
+
+ $path = str_replace('{company_id}/', '', $path);
+
+ if (! $suggestions = $this->getSuggestions($path)) {
+ return [];
+ }
+
+ $modules = [];
+
+ foreach ($suggestions->modules as $s_module) {
+ if ($this->moduleIsEnabled($s_module->alias)) {
+ continue;
+ }
+
+ $s_module->action_url = company_id() . '/' . $s_module->action_url;
+
+ $modules[] = $s_module;
+ }
+
+ if (empty($modules)) {
+ return [];
+ }
+
+ return $modules;
+ }
+}
diff --git a/app/View/Components/Form.php b/app/View/Components/Form.php
new file mode 100644
index 00000000..edd48eda
--- /dev/null
+++ b/app/View/Components/Form.php
@@ -0,0 +1,132 @@
+method = Str::upper($method);
+ $this->action = $this->getAction($action, $route, $url);
+ $this->model = $model;
+ $this->class = $class;
+ $this->role = $role;
+ $this->novalidate = $novalidate;
+ $this->enctype = $enctype;
+ $this->acceptCharset = $acceptCharset;
+ $this->submit = $submit;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.form.index');
+ }
+
+ protected function getAction($action, $route, $url)
+ {
+ if (!empty($action)) {
+ return $action;
+ }
+
+ if (!empty($route)) {
+ return $this->getRouteAction($route);
+ }
+
+ if (!empty($url)) {
+ return $this->getUrlAction($url);
+ }
+
+ return '';
+ }
+
+ /**
+ * Get the action for a "url" option.
+ *
+ * @param array|string $options
+ *
+ * @return string
+ */
+ protected function getUrlAction($options)
+ {
+ if (is_array($options)) {
+ return url($options[0], array_slice($options, 1));
+ }
+
+ return url($options);
+ }
+
+ /**
+ * Get the action for a "route" option.
+ *
+ * @param array|string $options
+ *
+ * @return string
+ */
+ protected function getRouteAction($options)
+ {
+ if (is_array($options)) {
+ $parameters = array_slice($options, 1);
+
+ if (array_keys($options) === [0, 1]) {
+ $parameters = head($parameters);
+ }
+
+ return route($options[0], $parameters);
+ }
+
+ return route($options);
+ }
+}
diff --git a/app/View/Components/Form/Accordion.php b/app/View/Components/Form/Accordion.php
new file mode 100644
index 00000000..16519537
--- /dev/null
+++ b/app/View/Components/Form/Accordion.php
@@ -0,0 +1,46 @@
+type = $type;
+ $this->icon = $this->getIcon($icon);
+ $this->open = $open;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.form.accordion.index');
+ }
+
+ protected function getIcon($icon)
+ {
+ if (! empty($icon)) {
+ return $icon;
+ }
+
+ return 'expand_more';
+ }
+}
diff --git a/app/View/Components/Form/Buttons.php b/app/View/Components/Form/Buttons.php
new file mode 100644
index 00000000..a541cc94
--- /dev/null
+++ b/app/View/Components/Form/Buttons.php
@@ -0,0 +1,139 @@
+groupClass = $this->getGroupClass($groupClass);
+
+ $this->cancel = $this->getCancel($cancel, $cancelRoute, $cancelUrl);
+ $this->cancelClass = $this->getCancelClass($cancelClass);
+ $this->cancelText = $this->getCancelText($cancelText);
+ $this->withoutCancel = $withoutCancel;
+
+ $this->saveDisabled = ! empty($saveDisabled) ? $saveDisabled : 'form.loading';
+ $this->saveLoading = ! empty($saveLoading) ? $saveLoading : 'form.loading';
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.form.buttons');
+ }
+
+ protected function getGroupClass($groupClass)
+ {
+ if (! empty($groupClass)) {
+ return $groupClass;
+ }
+
+ return $this->groupClass;
+ }
+
+ protected function getCancel($cancel, $route, $url)
+ {
+ if (! empty($cancel)) {
+ return $cancel;
+ }
+
+ if (! empty($route)) {
+ return $this->getRouteAction($route);
+ }
+
+ if (! empty($url)) {
+ return $this->getUrlAction($url);
+ }
+
+ return '';
+ }
+
+ /**
+ * Get the action for a "url" option.
+ *
+ * @param array|string $options
+ *
+ * @return string
+ */
+ protected function getUrlAction($options)
+ {
+ if (is_array($options)) {
+ return url($options[0], array_slice($options, 1));
+ }
+
+ return url($options);
+ }
+
+ /**
+ * Get the action for a "route" option.
+ *
+ * @param array|string $options
+ *
+ * @return string
+ */
+ protected function getRouteAction($options)
+ {
+ if (is_array($options)) {
+ $parameters = array_slice($options, 1);
+
+ if (array_keys($options) === [0, 1]) {
+ $parameters = head($parameters);
+ }
+
+ return route($options[0], $parameters);
+ }
+
+ return route($options);
+ }
+
+ protected function getCancelClass($cancelClass)
+ {
+ if (! empty($cancelClass)) {
+ return $cancelClass;
+ }
+
+ return $this->cancelClass;
+ }
+
+ protected function getCancelText($cancelText)
+ {
+ if (! empty($cancelText)) {
+ return $cancelText;
+ }
+
+ return trans('general.cancel');
+ }
+}
\ No newline at end of file
diff --git a/app/View/Components/Form/Group/Account.php b/app/View/Components/Form/Group/Account.php
new file mode 100644
index 00000000..3616b164
--- /dev/null
+++ b/app/View/Components/Form/Group/Account.php
@@ -0,0 +1,73 @@
+name)) {
+ $this->name = 'account_id';
+ }
+
+ $this->path = route('modals.accounts.create');
+
+ if (empty($this->accounts) && ! empty($this->options)) {
+ $this->accounts = $this->options;
+ } else if (empty($this->accounts)) {
+ $this->accounts = $this->getAccounts();
+ }
+
+ $model = $this->getParentData('model');
+
+ $account_id = old('account.id', old('account_id', null));
+
+ if (! empty($account_id)) {
+ $this->selected = $account_id;
+
+ if (! $this->accounts->has($account_id)) {
+ $account = Model::with('currency')->find($account_id);
+
+ $this->accounts->push($account);
+ }
+ }
+
+ if (! empty($model) && ! empty($model->{$this->name})) {
+ $this->selected = $model->{$this->name};
+
+ $selected_account = $model->account;
+ }
+
+ if (! empty($selected_account) && ! $this->accounts->contains('id', $selected_account->id)) {
+ $this->accounts->push($selected_account);
+ }
+
+ if ($this->selected === null) {
+ $this->selected = setting('default.account');
+ }
+
+ // Share accounts with all views
+ view()->share('accounts', $this->accounts);
+
+ return view('components.form.group.account');
+ }
+
+ protected function getAccounts()
+ {
+ return Model::with('currency')->enabled()->orderBy('name')->get();
+ }
+}
diff --git a/app/View/Components/Form/Group/Attachment.php b/app/View/Components/Form/Group/Attachment.php
new file mode 100644
index 00000000..50d6da9f
--- /dev/null
+++ b/app/View/Components/Form/Group/Attachment.php
@@ -0,0 +1,80 @@
+ 'form-file dropzone-column w-1/2 h-32.5',
+ ];
+
+ public $file_types;
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ $this->setFileTypes();
+
+ $this->custom_attributes = $this->setCustomAttributes();
+
+ return view('components.form.group.attachment');
+ }
+
+ protected function setFileTypes()
+ {
+ $this->file_types = [];
+
+ $file_type_mimes = explode(',', config('filesystems.mimes'));
+
+ $file_types = [];
+
+ foreach ($file_type_mimes as $mime) {
+ $file_types[] = '.' . $mime;
+ }
+
+ $this->file_types = implode(',', $file_types);
+ }
+
+ protected function setCustomAttributes()
+ {
+ $attributes = [];
+
+ if (! empty($this->required)) {
+ $attributes['required'] = $this->required;
+ }
+
+ if (! empty($this->disabled)) {
+ $attributes['disabled'] = $this->disabled;
+ }
+
+ if (! empty($this->readonly)) {
+ $attributes['readonly'] = $this->readonly;
+ }
+
+ if (! empty($this->options)) {
+ $attributes['options'] = $this->options;
+ }
+
+ if (! empty($this->multiple)) {
+ $attributes['multiple'] = $this->multiple;
+ }
+
+ foreach ($this->custom_attributes as $key => $value) {
+ $attributes[$key] = $value;
+ }
+
+ return $attributes;
+ }
+}
diff --git a/app/View/Components/Form/Group/Category.php b/app/View/Components/Form/Group/Category.php
new file mode 100644
index 00000000..a7ca6ee1
--- /dev/null
+++ b/app/View/Components/Form/Group/Category.php
@@ -0,0 +1,184 @@
+ 'id',
+ 'value' => 'name',
+ ];
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ if (empty($this->name)) {
+ $this->name = 'category_id';
+ }
+
+ $types = $this->getTypeCategoryTypes($this->type);
+ $types_string = implode(',', $types);
+
+ $this->path = route('modals.categories.create', ['type' => $this->type]);
+ $this->remoteAction = route('categories.index', ['search' => 'type:' . $types_string . ' enabled:1']);
+
+ $typeLabels = $this->getCategoryTypes(types: $types);
+
+ $is_code = false;
+
+ foreach (config('type.category', []) as $type => $config) {
+ if (! in_array($type, $types)) {
+ continue;
+ }
+
+ if (empty($config['hide']) || ! in_array('code', $config['hide'])) {
+ $is_code = true;
+ $this->group = true;
+ break;
+ }
+ }
+
+ $order_by = $is_code ? 'code' : 'name';
+
+ if ($this->group) {
+ $this->option_field = [
+ 'key' => 'id',
+ 'value' => 'title',
+ ];
+ }
+
+ $query = Model::type($types);
+
+ $query->enabled()
+ ->orderBy($order_by);
+
+ if (! $this->group) {
+ $query->take(setting('default.select_limit'));
+ }
+
+ $this->categories = $query->get();
+
+ if ($this->group) {
+ $groups = [];
+
+ foreach ($this->categories as $category) {
+ $group = $typeLabels[$category->type] ?? trans_choice('general.others', 1);
+
+ $category->title = ($category->code ? $category->code . ' - ' : '') . $category->name;
+ $category->group = $group;
+
+ $groups[$group][$category->id] = $category;
+ }
+
+ ksort($groups);
+
+ $this->categories = $groups;
+ }
+
+ $model = $this->getParentData('model');
+ $selected_category = null;
+
+ $categoryExists = function ($categoryId): bool {
+ if (! $this->group) {
+ return $this->categories->contains(function ($category) use ($categoryId) {
+ return (int) $category->id === (int) $categoryId;
+ });
+ }
+
+ foreach ($this->categories as $group_categories) {
+ foreach ($group_categories as $category) {
+ if ((int) $category->id === (int) $categoryId) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ };
+
+ $appendCategory = function ($category) use ($typeLabels): void {
+ if (empty($category)) {
+ return;
+ }
+
+ $category->title = ($category->code ? $category->code . ' - ' : '') . $category->name;
+
+ if (! $this->group) {
+ $this->categories->push($category);
+
+ return;
+ }
+
+ $group = $typeLabels[$category->type] ?? trans_choice('general.others', 1);
+
+ if (! isset($this->categories[$group])) {
+ $this->categories[$group] = [];
+ }
+
+ $this->categories[$group][$category->id] = $category;
+
+ ksort($this->categories);
+ };
+
+ $category_id = old('category.id', old('category_id', null));
+
+ if (! empty($category_id)) {
+ $this->selected = $category_id;
+
+ if (! $categoryExists($category_id)) {
+ $category = Model::find($category_id);
+
+ $appendCategory($category);
+ }
+ }
+
+ if (! empty($model) && ! empty($model->category_id)) {
+ $this->selected = $model->category_id;
+
+ $selected_category = $model->category;
+ }
+
+ if ($this->selected === null && in_array($this->type, [Model::INCOME_TYPE, Model::EXPENSE_TYPE])) {
+ $this->selected = setting('default.' . $this->type . '_category');
+
+ $selected_category = Model::find($this->selected);
+ }
+
+ if (empty($selected_category) && ! empty($this->selected)) {
+ $selected_category = Model::find($this->selected);
+ }
+
+ if (! empty($selected_category)) {
+ $selected_category_id = $selected_category->id;
+
+ if (! $categoryExists($selected_category_id)) {
+ $appendCategory($selected_category);
+ }
+ }
+
+ return view('components.form.group.category');
+ }
+}
diff --git a/app/View/Components/Form/Group/Checkbox.php b/app/View/Components/Form/Group/Checkbox.php
new file mode 100644
index 00000000..05b62d45
--- /dev/null
+++ b/app/View/Components/Form/Group/Checkbox.php
@@ -0,0 +1,27 @@
+name)) {
+ $this->name = 'contact_id';
+ }
+
+ if (empty($this->label)) {
+ $this->label = trans_choice('general.' . Str::plural($this->type), 1);
+ }
+
+ if (empty($this->contacts) && ! empty($this->options)) {
+ $this->contacts = $this->options;
+ } else if (empty($this->contacts)) {
+ $this->contacts = Model::type($this->type)->enabled()->orderBy('name')->take(setting('default.select_limit'))->get();
+ }
+
+ $this->setRoutes();
+
+ $model = $this->getParentData('model');
+
+ $contact_id = old('contact.id', old('contact_id', null));
+
+ if (! empty($contact_id)) {
+ $this->selected = $contact_id;
+
+ if (! $this->contacts->has($contact_id)) {
+ $contact = Model::find($contact_id);
+
+ $this->contacts->push($contact);
+ }
+ }
+
+ if (! empty($model) && ! empty($model->contact_id)) {
+ $this->selected = $model->contact_id;
+
+ $selected_contact = $model->contact;
+ }
+
+ if (! empty($selected_contact) && ! $this->contacts->has($selected_contact->id)) {
+ $this->contacts->push($selected_contact);
+ }
+
+ return view($this->view);
+ }
+
+ public function setRoutes(): void
+ {
+ $alias = config('type.contact.' . $this->type . '.alias');
+ $prefix = config('type.contact.' . $this->type . '.route.prefix');
+
+ $parameters = ['search' => 'enabled:1'];
+
+ $this->path = ! empty($alias) ? route("{$alias}.modals.{$prefix}.create") : route("modals.{$prefix}.create");
+ $this->remoteAction = ! empty($alias) ? route("{$alias}.{$prefix}.index", $parameters) : route("{$prefix}.index", $parameters);
+ }
+}
diff --git a/app/View/Components/Form/Group/Country.php b/app/View/Components/Form/Group/Country.php
new file mode 100644
index 00000000..9180693f
--- /dev/null
+++ b/app/View/Components/Form/Group/Country.php
@@ -0,0 +1,28 @@
+name)) {
+ $this->name = 'country';
+ }
+
+ if ($this->selected === null && empty($this->getParentData('model'))) {
+ $this->selected = setting('company.country');
+ }
+
+ return view('components.form.group.country');
+ }
+}
diff --git a/app/View/Components/Form/Group/Currency.php b/app/View/Components/Form/Group/Currency.php
new file mode 100644
index 00000000..1e07611d
--- /dev/null
+++ b/app/View/Components/Form/Group/Currency.php
@@ -0,0 +1,60 @@
+name)) {
+ $this->name = 'currency_code';
+ }
+
+ $this->path = route('modals.currencies.create');
+
+ $this->field = [
+ 'key' => 'code',
+ 'value' => 'name'
+ ];
+
+ $this->currencies = Model::enabled()->orderBy('name')->pluck('name', 'code');
+
+ if (! empty($this->options)) {
+ $this->currencies = $this->options;
+ }
+
+ $currency_id = old('currency.id', old('currency_id', null));
+
+ if (! empty($currency_id)) {
+ $this->selected = $currency_id;
+
+ if (! $this->currencies->has($currency_id)) {
+ $currency = Model::find($currency_id);
+
+ $this->currencies->put($currency->id, $currency->name);
+ }
+ }
+
+ if ($this->selected === null && empty($this->getParentData('model'))) {
+ $this->selected = default_currency();
+ }
+
+ return view('components.form.group.currency');
+ }
+}
diff --git a/app/View/Components/Form/Group/Customer.php b/app/View/Components/Form/Group/Customer.php
new file mode 100644
index 00000000..28d7ec5b
--- /dev/null
+++ b/app/View/Components/Form/Group/Customer.php
@@ -0,0 +1,10 @@
+ 'form-file dropzone-column sm:w-1/2 h-32.5',
+ ];
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ if (! empty($this->options)) {
+ $options = [];
+
+ foreach ($this->options as $option) {
+ $options[$option->id] = $option->name;
+ }
+
+ $this->options = $options;
+ }
+
+ $this->options['maxFilesize'] = config('filesystems.max_size');
+
+ return view('components.form.group.file');
+ }
+}
diff --git a/app/View/Components/Form/Group/Import.php b/app/View/Components/Form/Group/Import.php
new file mode 100644
index 00000000..a5e7ba2b
--- /dev/null
+++ b/app/View/Components/Form/Group/Import.php
@@ -0,0 +1,38 @@
+ 'form-file dropzone-column sm:w-1/2 h-32.5',
+ ];
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ if (! empty($this->options)) {
+ $options = [];
+
+ foreach ($this->options as $option) {
+ $options[$option->id] = $option->name;
+ }
+
+ $this->options = $options;
+ }
+
+ $this->options['maxFilesize'] = config('filesystems.max_size');
+
+ return view('components.form.group.import');
+ }
+}
diff --git a/app/View/Components/Form/Group/InvoiceText.php b/app/View/Components/Form/Group/InvoiceText.php
new file mode 100644
index 00000000..4cb6c6ae
--- /dev/null
+++ b/app/View/Components/Form/Group/InvoiceText.php
@@ -0,0 +1,20 @@
+selected === null && empty($this->getParentData('model'))) {
+ $this->selected = setting('default.locale', config('app.locale', 'en-GB'));
+ }
+
+ return view('components.form.group.locale');
+ }
+}
diff --git a/app/View/Components/Form/Group/Money.php b/app/View/Components/Form/Group/Money.php
new file mode 100644
index 00000000..193ee3c9
--- /dev/null
+++ b/app/View/Components/Form/Group/Money.php
@@ -0,0 +1,20 @@
+name)) {
+ $this->name = 'number_digit';
+ }
+
+ $this->number_digits = [
+ '1' => '1',
+ '2' => '2',
+ '3' => '3',
+ '4' => '4',
+ '5' => '5',
+ '6' => '6',
+ '7' => '7',
+ '8' => '8',
+ '9' => '9',
+ '10' => '10',
+ '11' => '11',
+ '12' => '12',
+ '13' => '13',
+ '14' => '14',
+ '15' => '15',
+ '16' => '16',
+ '17' => '17',
+ '18' => '18',
+ '19' => '19',
+ '20' => '20',
+ ];
+
+ if (empty($this->selected) && empty($this->getParentData('model'))) {
+ $this->selected = setting('invoice.number_digit');
+ }
+
+ return view('components.form.group.number_digit');
+ }
+}
diff --git a/app/View/Components/Form/Group/Password.php b/app/View/Components/Form/Group/Password.php
new file mode 100644
index 00000000..78228b60
--- /dev/null
+++ b/app/View/Components/Form/Group/Password.php
@@ -0,0 +1,20 @@
+name)) {
+ $this->name = 'payment_method';
+ }
+
+ $this->payment_methods = Modules::getPaymentMethods();
+
+ if ($this->selected === null && empty($this->getParentData('model'))) {
+ $this->selected = setting('default.payment_method');
+ }
+
+ return view('components.form.group.payment_method');
+ }
+}
diff --git a/app/View/Components/Form/Group/PaymentTerm.php b/app/View/Components/Form/Group/PaymentTerm.php
new file mode 100644
index 00000000..4629a8b6
--- /dev/null
+++ b/app/View/Components/Form/Group/PaymentTerm.php
@@ -0,0 +1,42 @@
+name = 'temp_payment_term';
+
+ $payment_terms = [
+ '0' => trans('settings.invoice.due_receipt'),
+ '7' => trans('settings.invoice.due_days', ['days' => 7]),
+ '15' => trans('settings.invoice.due_days', ['days' => 15]),
+ '30' => trans('settings.invoice.due_days', ['days' => 30]),
+ '45' => trans('settings.invoice.due_days', ['days' => 45]),
+ '60' => trans('settings.invoice.due_days', ['days' => 60]),
+ '90' => trans('settings.invoice.due_days', ['days' => 90]),
+ 'custom' => trans('settings.invoice.due_custom'),
+ ];
+
+ if (empty($this->options)) {
+ $this->options = $this->getOptions($payment_terms);
+ }
+
+ if (! array_key_exists($this->selected, $payment_terms)) {
+ $this->value = $this->selected;
+ $this->selected = 'custom';
+ }
+
+ return view('components.form.group.payment_term');
+ }
+}
diff --git a/app/View/Components/Form/Group/Radio.php b/app/View/Components/Form/Group/Radio.php
new file mode 100644
index 00000000..e4d30a2a
--- /dev/null
+++ b/app/View/Components/Form/Group/Radio.php
@@ -0,0 +1,30 @@
+type = $this->getType($type);
+
+ $this->interval = $this->getInterval($interval);
+
+ $this->frequency = $this->getFrequency($frequency, $interval);
+ $this->frequencies = $this->getFrequencies($frequencies);
+
+ $this->customFrequency = $this->getCustomFrequency($frequency, $customFrequency, $interval);
+ $this->customFrequencies = $this->getCustomFrequencies($customFrequencies);
+
+ $this->limit = $this->getLimit($limit);
+ $this->limits = $this->getLimits($limits);
+
+ $this->startedValue = $this->getStartedValue($startedValue);
+ $this->limitCount = $this->getLimitCount($limitCount);
+ $this->limitDateValue = $this->getLimitDateValue($limitDateValue);
+
+ $this->sendEmailShow = $this->getSendEmailShow($sendEmailShow);
+ $this->sendEmail = $this->getSendEmail($sendEmail);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.form.group.recurring');
+ }
+
+ protected function getType($type)
+ {
+ if (! empty($type)) {
+ return $type;
+ }
+
+ return 'invoice';
+ }
+
+ protected function getFrequency($frequency, $interval = 0)
+ {
+ if ($interval > 1) {
+ return 'custom';
+ }
+
+ if (! empty($frequency)) {
+ return $frequency;
+ }
+
+ return 'monthly';
+ }
+
+ protected function getFrequencies($frequencies)
+ {
+ if (! empty($frequencies)) {
+ return $frequencies;
+ }
+
+ return [
+ 'daily' => trans('recurring.daily'),
+ 'weekly' => trans('recurring.weekly'),
+ 'monthly' => trans('recurring.monthly'),
+ 'yearly' => trans('recurring.yearly'),
+ 'custom' => trans('recurring.custom'),
+ ];
+ }
+
+ protected function getInterval($interval)
+ {
+ if (! empty($interval)) {
+ return $interval;
+ }
+
+ return '';
+ }
+
+ protected function getCustomFrequency($frequency, $customFrequency, $interval = 0)
+ {
+ if ($interval > 1) {
+ return $frequency;
+ }
+
+ if (! empty($customFrequency)) {
+ return $customFrequency;
+ }
+
+ return 'monthly';
+ }
+
+ protected function getCustomFrequencies($customFrequencies)
+ {
+ if (! empty($customFrequencies)) {
+ return $customFrequencies;
+ }
+
+ return [
+ 'daily' => trans('recurring.days'),
+ 'weekly' => trans('recurring.weeks'),
+ 'monthly' => trans('recurring.months'),
+ 'yearly' => trans('recurring.years'),
+ ];
+ }
+
+ protected function getLimit($limit)
+ {
+ if (! empty($limit)) {
+ return $limit;
+ }
+
+ return 'never';
+ }
+
+ protected function getLimits($limits)
+ {
+ if (! empty($limits)) {
+ return $limits;
+ }
+
+ return [
+ 'after' => trans('recurring.after'),
+ 'on' => trans('recurring.on'),
+ 'never' => trans('recurring.never'),
+ ];
+ }
+
+ protected function getStartedValue($startedValue)
+ {
+ if (! empty($startedValue)) {
+ return $startedValue;
+ }
+
+ return Date::now()->toDateString();
+ }
+
+ protected function getLimitCount($limitCount)
+ {
+ if (! empty($limitCount)) {
+ return $limitCount;
+ }
+
+ return 0;
+ }
+
+ protected function getLimitDateValue($limitDateValue)
+ {
+ if (! empty($limitDateValue)) {
+ return $limitDateValue;
+ }
+
+ return Date::now()->toDateString();
+ }
+
+ protected function getSendEmailShow($sendEmailShow)
+ {
+ if (! empty($sendEmailShow)) {
+ return $sendEmailShow;
+ }
+
+ return false;
+ }
+
+ protected function getSendEmail($sendEmail)
+ {
+ if (! empty($sendEmail)) {
+ return $sendEmail;
+ }
+
+ return false;
+ }
+}
diff --git a/app/View/Components/Form/Group/Select.php b/app/View/Components/Form/Group/Select.php
new file mode 100644
index 00000000..797b6062
--- /dev/null
+++ b/app/View/Components/Form/Group/Select.php
@@ -0,0 +1,20 @@
+name)) {
+ $this->name = 'tax_id';
+ }
+
+ $this->path = route('modals.taxes.create');
+ $this->remoteAction = route('taxes.index', ['search' => 'enabled:1']);
+
+ $this->field = [
+ 'key' => 'id',
+ 'value' => 'title'
+ ];
+
+ $this->taxes = Model::enabled()->orderBy('name')->get();
+
+ $model = $this->getParentData('model');
+
+ $tax_id = old('tax_ids', old('tax.id', old('tax_id', null)));
+
+ if (! empty($tax_id)) {
+ $this->selected = ($this->multiple) ? (array) $tax_id : $tax_id;
+
+ foreach ($tax_id as $id) {
+ if (! $this->taxes->has($id)) {
+ $tax = Model::find($id);
+
+ $this->taxes->put($tax->id, $tax->title);
+ }
+ }
+ }
+
+ if (! empty($model) && ! empty($model->{$this->name})) {
+ $this->selected = $model->{$this->name};
+ }
+
+ if ($this->selected === null && $this->multiple) {
+ $this->selected = (setting('default.tax')) ? [setting('default.tax')] : null;
+ } else if ($this->selected === null) {
+ $this->selected = setting('default.tax', null);
+ }
+
+ return view('components.form.group.tax');
+ }
+}
diff --git a/app/View/Components/Form/Group/Text.php b/app/View/Components/Form/Group/Text.php
new file mode 100644
index 00000000..cffc6d4b
--- /dev/null
+++ b/app/View/Components/Form/Group/Text.php
@@ -0,0 +1,20 @@
+options)) {
+ $options = [];
+
+ foreach ($this->options as $option) {
+ $options[$option->id] = $option->name;
+ }
+
+ $this->options = $options;
+ }
+
+ return view('components.form.input.file');
+ }
+}
diff --git a/app/View/Components/Form/Input/Hidden.php b/app/View/Components/Form/Input/Hidden.php
new file mode 100644
index 00000000..a59b345f
--- /dev/null
+++ b/app/View/Components/Form/Input/Hidden.php
@@ -0,0 +1,20 @@
+options)) {
+ $options = [];
+
+ foreach ($this->options as $option) {
+ $options[$option->id] = $option->name;
+ }
+
+ $this->options = $options;
+ }
+
+ return view('components.form.input.import');
+ }
+}
diff --git a/app/View/Components/Form/Input/Input.php b/app/View/Components/Form/Input/Input.php
new file mode 100644
index 00000000..803e6187
--- /dev/null
+++ b/app/View/Components/Form/Input/Input.php
@@ -0,0 +1,20 @@
+spacingVertical = $spacingVertical;
+ $this->spacingHorizontal = $spacingHorizontal;
+ $this->columnNumber = $columnNumber;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.form.section.index');
+ }
+}
diff --git a/app/View/Components/Icon.php b/app/View/Components/Icon.php
new file mode 100644
index 00000000..10d31f15
--- /dev/null
+++ b/app/View/Components/Icon.php
@@ -0,0 +1,86 @@
+icon = $icon;
+ $this->class = ($simpleIcons) ? 'w-8 h-8 ' . $class : $class;
+ $this->filled = $filled;
+ $this->rounded = $rounded;
+ $this->sharp = $sharp;
+ $this->simpleIcons = $simpleIcons;
+ $this->custom = $custom;
+ $this->alias = $alias;
+
+ if ($custom) {
+ $this->icon = $this->getCustomIcon($icon, $alias);
+ }
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.icon');
+ }
+
+ protected function getCustomIcon($icon, $alias)
+ {
+ $slug = Str::replace('custom-', '', $icon);
+
+ $base_path = 'public/img/icons/';
+
+ if (! empty($alias)) {
+ $base_path = 'modules/' . Str::studly($alias) . '/Resources/assets/img/icons/';
+ }
+
+ $path = base_path($base_path . $slug . '.svg');
+
+ if (! file_exists($path)) {
+ $path = 'public/img/akaunting-logo-purple.svg';
+ }
+
+ return $path;
+ }
+}
diff --git a/app/View/Components/Index/Balance.php b/app/View/Components/Index/Balance.php
new file mode 100644
index 00000000..28c513e2
--- /dev/null
+++ b/app/View/Components/Index/Balance.php
@@ -0,0 +1,71 @@
+amount = $this->getAmount($amount);
+ $this->textColor = $this->getTextColor($amount);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.index.balance');
+ }
+
+ /**
+ * Formats the amount according to the location context.
+ *
+ * @param float $amount
+ * @return string
+ */
+ protected function getAmount($amount)
+ {
+ return money($amount, setting('default.currency'), true);
+ }
+
+ /**
+ * Gets the class of color considering given amount.
+ *
+ * @param float $amount
+ * @return string|null
+ */
+ protected function getTextColor($amount)
+ {
+ switch ($amount) {
+ case $amount > 0:
+ return 'text-green';
+ case $amount < 0:
+ return 'text-red';
+ default:
+ return '';
+ }
+ }
+}
diff --git a/app/View/Components/Index/Bulkaction.php b/app/View/Components/Index/Bulkaction.php
new file mode 100644
index 00000000..677f7a54
--- /dev/null
+++ b/app/View/Components/Index/Bulkaction.php
@@ -0,0 +1,108 @@
+class = $class;
+ $this->text = $text;
+ $this->path = $path;
+ $this->actions = $this->getActions($actions);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.index.bulkaction.index');
+ }
+
+ protected function getActions($actions)
+ {
+ if (! empty($actions)) {
+ return $actions;
+ }
+
+ if (class_exists($this->class)) {
+ $bulk_action = app($this->class);
+
+ event(new BulkActionsAdding($bulk_action));
+
+ $this->text = $bulk_action->text;
+
+ if (is_array($bulk_action->path)) {
+ $this->path = route('bulk-actions.action', $bulk_action->path);
+ } else {
+ $this->path = url('common/bulk-actions/' . $bulk_action->path);
+ }
+ } else {
+ $bulk_action = new \stdClass();
+ $bulk_action->text = '';
+ $bulk_action->path = '';
+ $bulk_action->actions = [];
+ $bulk_action->icons = [];
+
+ event(new BulkActionsAdding($bulk_action));
+
+ if (is_array($bulk_action->path)) {
+ $this->path = route('bulk-actions.action', $bulk_action->path);
+ } else {
+ $this->path = url('common/bulk-actions/' . $bulk_action->path);
+ }
+ }
+
+ $query = [];
+
+ if (request()->has('page')) {
+ $query['page'] = request()->get('page');
+ }
+
+ if (request()->has('limit')) {
+ $query['limit'] = request()->get('limit');
+ }
+
+ if ($query) {
+ $this->path .= '?' . http_build_query($query);
+ }
+
+ $actions = [];
+
+ if ($bulk_action->actions) {
+ foreach ($bulk_action->actions as $key => $action) {
+ if ((isset($action['permission']) && ! user()->can($action['permission']))) {
+ continue;
+ }
+
+ if (empty($action['icon']) && array_key_exists($key, $bulk_action->icons)) {
+ $action['icon'] = $bulk_action->icons[$key];
+ }
+
+ $actions[$key] = $action;
+ }
+ }
+
+ return $actions;
+ }
+}
diff --git a/app/View/Components/Index/Category.php b/app/View/Components/Index/Category.php
new file mode 100644
index 00000000..595a8b02
--- /dev/null
+++ b/app/View/Components/Index/Category.php
@@ -0,0 +1,120 @@
+model = $model;
+
+ $this->name = $this->getName($name, $model);
+ $this->backgroundColor = $this->getBackgroundColor($backgroundColor, $model);
+ $this->textColor = $this->getTextColor($textColor, $this->backgroundColor);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.index.category');
+ }
+
+ protected function getName($name, $model)
+ {
+ if (! empty($name)) {
+ return $name;
+ }
+
+ if (! empty($model)) {
+ $name = $model->name;
+ }
+
+ return $name;
+ }
+
+ protected function getBackgroundColor($backgroundColor, $model)
+ {
+ if (! empty($backgroundColor)) {
+ return $backgroundColor;
+ }
+
+ if (! empty($model)) {
+ if (Str::contains($model->color, ['#'])) {
+ $this->backgroundStyle = $model->color;
+
+ return $backgroundColor;
+ }
+
+ $backgroundColor = 'bg-' . $model->color;
+ }
+
+ return $backgroundColor;
+ }
+
+ protected function getTextColor($textColor, $backgroundColor)
+ {
+ if (! empty($textColor)) {
+ return $textColor;
+ }
+
+ if (! empty($backgroundColor)) {
+ $x = explode('-', $backgroundColor);
+
+ $textColor = 'text-black';
+
+ if ($x[1] >= 500) {
+ $textColor = 'text-white';
+ }
+ }
+
+ return $textColor;
+ }
+}
diff --git a/app/View/Components/Index/Country.php b/app/View/Components/Index/Country.php
new file mode 100644
index 00000000..14af1023
--- /dev/null
+++ b/app/View/Components/Index/Country.php
@@ -0,0 +1,46 @@
+code = $code;
+ $this->country = trans('general.na');
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ if (! empty($this->code) && array_key_exists($this->code, trans('countries'))) {
+ $this->country = trans('countries.' . $this->code);
+ }
+
+ return view('components.index.country');
+ }
+}
diff --git a/app/View/Components/Index/Currency.php b/app/View/Components/Index/Currency.php
new file mode 100644
index 00000000..a9ef781d
--- /dev/null
+++ b/app/View/Components/Index/Currency.php
@@ -0,0 +1,45 @@
+code = $code;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ $code = ($this->code) ? $this->code : default_currency();
+
+ $this->currency = currency($code)->getName();
+
+ return view('components.index.currency');
+ }
+}
diff --git a/app/View/Components/Index/Ddefault.php b/app/View/Components/Index/Ddefault.php
new file mode 100644
index 00000000..ce1c8bae
--- /dev/null
+++ b/app/View/Components/Index/Ddefault.php
@@ -0,0 +1,63 @@
+id = 'tooltip-default-' . mt_rand(1, time());
+ $this->position = $position;
+ $this->icon = $icon;
+ $this->iconType = $iconType;
+ $this->text = $text;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.index.default');
+ }
+}
diff --git a/app/View/Components/Index/Disable.php b/app/View/Components/Index/Disable.php
new file mode 100644
index 00000000..16a53c45
--- /dev/null
+++ b/app/View/Components/Index/Disable.php
@@ -0,0 +1,80 @@
+id = 'tooltip-disable-' . mt_rand(1, time());
+ $this->position = $position;
+ $this->icon = $icon;
+ $this->iconType = $iconType;
+ $this->disableText = $this->getDisableText($text, $disableText);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.index.disable');
+ }
+
+ protected function getDisableText($text, $disableText)
+ {
+ if (! empty($disableText)) {
+ return $disableText;
+ }
+
+ return trans('general.disabled_type', ['type' => Str::lower($text)]);
+ }
+}
diff --git a/app/View/Components/Index/Search.php b/app/View/Components/Index/Search.php
new file mode 100644
index 00000000..5ba715c9
--- /dev/null
+++ b/app/View/Components/Index/Search.php
@@ -0,0 +1,121 @@
+searchString = $searchString;
+ $this->bulkAction = $bulkAction;
+ $this->action = $this->getAction($action, $route, $url);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.index.search');
+ }
+
+ protected function getAction($action, $route, $url)
+ {
+ if (! empty($action)) {
+ return $action;
+ }
+
+ if (! empty($route)) {
+ return $this->getRouteAction($route);
+ }
+
+ if (! empty($url)) {
+ return $this->getUrlAction($url);
+ }
+
+ return '';
+ }
+
+ /**
+ * Get the action for a "url" option.
+ *
+ * @param array|string $options
+ *
+ * @return string
+ */
+ protected function getUrlAction($options)
+ {
+ if (is_array($options)) {
+ return url($options[0], array_slice($options, 1));
+ }
+
+ return url($options);
+ }
+
+ /**
+ * Get the action for a "route" option.
+ *
+ * @param array|string $options
+ *
+ * @return string
+ */
+ protected function getRouteAction($options)
+ {
+ if (is_array($options)) {
+ $parameters = array_slice($options, 1);
+
+ if (array_keys($options) === [0, 1]) {
+ $parameters = head($parameters);
+ }
+
+ return route($options[0], $parameters);
+ }
+
+ return route($options);
+ }
+}
diff --git a/app/View/Components/Index/Status.php b/app/View/Components/Index/Status.php
new file mode 100644
index 00000000..ca48bce0
--- /dev/null
+++ b/app/View/Components/Index/Status.php
@@ -0,0 +1,56 @@
+backgroundColor = ! empty($backgroundColor) ? $backgroundColor : 'bg-lilac-900';
+ $this->textColor = ! empty($textColor) ? $textColor : 'text-purple';
+ $this->status = $status;
+ $this->textStatus = $this->getTextStatus($textStatus);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.index.status');
+ }
+
+ protected function getTextStatus($textStatus)
+ {
+ if (! empty($textStatus)) {
+ return $textStatus;
+ }
+
+ $textStatus = trans('documents.statuses.' . $this->status);
+
+ return $textStatus;
+ }
+}
diff --git a/app/View/Components/Index/Summary.php b/app/View/Components/Index/Summary.php
new file mode 100644
index 00000000..fb207452
--- /dev/null
+++ b/app/View/Components/Index/Summary.php
@@ -0,0 +1,31 @@
+items = $items;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.index.summary');
+ }
+}
diff --git a/app/View/Components/Layouts/Admin.php b/app/View/Components/Layouts/Admin.php
new file mode 100644
index 00000000..f7de6430
--- /dev/null
+++ b/app/View/Components/Layouts/Admin.php
@@ -0,0 +1,18 @@
+companies = $this->getCompanies();
+
+ $this->notification_count = $this->getNotificationCount();
+
+ return view('components.layouts.admin.menu');
+ }
+
+ public function getCompanies()
+ {
+ $companies = [];
+
+ if ($user = user()) {
+ $companies = $user->companies()->enabled()->limit(10)->get()->sortBy('name');
+ }
+
+ return $companies;
+ }
+
+ public function getNotificationCount()
+ {
+ // Get nofitications
+ $notifications = new \stdClass();
+ $notifications->notifications = collect();
+ $notifications->keyword = '';
+
+ event(new NotificationsCreated($notifications));
+
+ return $notifications->notifications->count();
+ }
+}
diff --git a/app/View/Components/Layouts/Admin/Notifications.php b/app/View/Components/Layouts/Admin/Notifications.php
new file mode 100644
index 00000000..f525034a
--- /dev/null
+++ b/app/View/Components/Layouts/Admin/Notifications.php
@@ -0,0 +1,57 @@
+notifications = $this->getNotify();
+
+ return view('components.layouts.admin.notifications');
+ }
+
+ public function getNotify()
+ {
+ if (! $path = Route::current()->uri()) {
+ return [];
+ }
+
+ $path = str_replace('{company_id}/', '', $path);
+ $path = str_replace('{company_id}', '', $path);
+
+ $notify = [];
+ $notifications = $this->getNotifications($path);
+
+ // Push to a stack
+ foreach ($notifications as $notification) {
+ $path = str_replace('/', '#', $notification->path);
+
+ $message = str_replace('#path#', $path, $notification->message);
+ $message = str_replace('#token#', csrf_token(), $message);
+ $message = str_replace('#url#', route('dashboard'), $message);
+ $message = str_replace('#company_id#', company_id(), $message);
+
+ if (! setting('notifications.' . $notification->path . '.' . $notification->id . '.status', 1)) {
+ continue;
+ }
+
+ $notify[] = $message;
+ }
+
+ return $notify;
+ }
+}
diff --git a/app/View/Components/Layouts/Modules.php b/app/View/Components/Layouts/Modules.php
new file mode 100644
index 00000000..631bc989
--- /dev/null
+++ b/app/View/Components/Layouts/Modules.php
@@ -0,0 +1,23 @@
+keyword = request()->get('keyword');
+ $this->categories = $this->getCategories();
+ $this->popular = $this->getPopularModules();
+
+ return view('components.layouts.modules.bar');
+ }
+
+ protected function getCategories()
+ {
+ $categories = collect([
+ '*' => trans('general.all_type', ['type' => trans_choice('general.categories', 2)]),
+ ]);
+
+ if (setting('apps.api_key')) {
+ $categories = Cache::remember('modules.categories.' . language()->getShortCode(), Date::now()->addHour(6), function () {
+ return collect($this->getCategoriesOfModules())->pluck('name', 'slug')
+ ->prepend(trans('general.all_type', ['type' => trans_choice('general.categories', 2)]), '*');
+ });
+ }
+
+ return $categories;
+ }
+}
diff --git a/app/View/Components/Layouts/Modules/Releases.php b/app/View/Components/Layouts/Modules/Releases.php
new file mode 100644
index 00000000..bf58e7fa
--- /dev/null
+++ b/app/View/Components/Layouts/Modules/Releases.php
@@ -0,0 +1,26 @@
+releases = $releases;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.layouts.modules.releases');
+ }
+}
diff --git a/app/View/Components/Layouts/Modules/Reviews.php b/app/View/Components/Layouts/Modules/Reviews.php
new file mode 100644
index 00000000..476bfd65
--- /dev/null
+++ b/app/View/Components/Layouts/Modules/Reviews.php
@@ -0,0 +1,26 @@
+reviews = $reviews;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.layouts.modules.reviews');
+ }
+}
diff --git a/app/View/Components/Layouts/Portal/Menu.php b/app/View/Components/Layouts/Portal/Menu.php
new file mode 100644
index 00000000..2d26e429
--- /dev/null
+++ b/app/View/Components/Layouts/Portal/Menu.php
@@ -0,0 +1,34 @@
+companies = $this->getCompanies();
+
+ return view('components.layouts.portal.menu');
+ }
+
+ public function getCompanies()
+ {
+ if ($user = user()) {
+ $companies = $user->companies()->enabled()->limit(10)->get()->sortBy('name');
+ } else {
+ $companies = [];
+ }
+
+ return $companies;
+ }
+}
diff --git a/app/View/Components/Layouts/Wizard.php b/app/View/Components/Layouts/Wizard.php
new file mode 100644
index 00000000..cf039ac0
--- /dev/null
+++ b/app/View/Components/Layouts/Wizard.php
@@ -0,0 +1,18 @@
+company = $this->getCompany();
+
+ $this->translations = $this->getTransalations();
+
+ $this->currencies = $this->getCurrencies();
+
+ // Prepare codes
+ $this->currency_codes = $this->getCurrencyCodes();
+
+ $this->modules = $this->getFeaturedModules([
+ 'query' => [
+ 'limit' => 5
+ ]
+ ]);
+
+ return view('components.layouts.wizard.scripts');
+ }
+
+ protected function getCompany()
+ {
+ $company = company();
+
+ $company->api_key = setting('apps.api_key');
+ $company->financial_start = setting('localisation.financial_start');
+
+ $logo_id = setting('company.logo');
+
+ $logo = false;
+
+ if ($logo_id) {
+ $logo = Media::find($logo_id);
+
+ if ($logo) {
+ $logo->path = route('uploads.get', $logo->id);
+ }
+ }
+
+ $company->logo = $logo;
+
+ return $company;
+ }
+
+ /* Wizard page transactions */
+ protected function getTransalations()
+ {
+ return [
+ 'company' => [
+ 'title' => trans_choice('general.companies', 1),
+ 'api_key' => trans('modules.api_key'),
+ 'form_enter' => trans('general.form.enter'),
+ 'get_api_key' => trans('modules.get_api_key'),
+ 'tax_number' => trans('general.tax_number'),
+ 'financial_start' => trans('settings.localisation.financial_start'),
+ 'address' => trans('settings.company.address'),
+ 'logo' => trans('settings.company.logo'),
+ 'skip' => trans('companies.skip_step'),
+ 'save' => trans('general.save'),
+ 'country' => trans_choice('general.countries', 1),
+ ],
+
+ 'currencies' => [
+ 'title' => trans_choice('general.currencies', 2),
+ 'add_new' => trans('general.add_new'),
+ 'no_currency' => trans('currencies.no_currency'),
+ 'new_currency' => trans('currencies.new_currency'),
+ 'create_currency' => trans('currencies.create_currency'),
+ 'name' => trans('general.name'),
+ 'code' => trans('currencies.code'),
+ 'rate' => trans('currencies.rate'),
+ 'default' => trans('currencies.default'),
+ 'enabled' => trans('general.enabled'),
+ 'actions' => trans('general.actions') ,
+ 'yes' => trans('general.yes'),
+ 'no' => trans('general.no'),
+ 'edit' => trans('general.edit'),
+ 'delete' => trans('general.delete'),
+ 'save' => trans('general.save'),
+ 'precision' => trans('currencies.precision'),
+ 'symbol' => trans('currencies.symbol.symbol'),
+ 'position' => trans('currencies.symbol.position'),
+ 'decimal_mark' => trans('currencies.decimal_mark'),
+ 'thousands_separator' => trans('currencies.thousands_separator'),
+ 'previous' => trans('pagination.previous'),
+ 'next' => trans('pagination.next'),
+ 'delete_confirm' => trans('general.delete_confirm'),
+ 'cancel' => trans('general.cancel'),
+ ],
+
+ 'finish' => [
+ 'title' => trans('modules.ready'),
+ 'recommended_apps' => trans('modules.recommended_apps'),
+ 'no_apps' => trans('modules.no_apps'),
+ 'developer' => trans('modules.developer'),
+ 'from' => trans('general.from'),
+ 'apps_managing' => trans('modules.apps_managing'),
+ 'reviews' => trans('modules.tab.reviews'),
+ 'previous' => trans('companies.skip_step'),
+ 'go_to_dashboard' => trans('general.go_to_dashboard'),
+ 'create_first_invoice' => trans('general.create_first_invoice'),
+ 'error_message' => trans('errors.title.500'),
+ ]
+ ];
+ }
+
+ protected function getCurrencies()
+ {
+ $currencies = collect();
+
+ Currency::all()->each(function ($currency) use (&$currencies) {
+ $currency->default = default_currency() == $currency->code;
+
+ $currencies->push($currency);
+ });
+
+ return $currencies;
+ }
+
+ protected function getCurrencyCodes()
+ {
+ $codes = [];
+ $money_currencies = MoneyCurrency::getCurrencies();
+
+ foreach ($money_currencies as $key => $item) {
+ $codes[$key] = $key;
+ }
+
+ return $codes;
+ }
+}
diff --git a/app/View/Components/Link.php b/app/View/Components/Link.php
new file mode 100644
index 00000000..d89551ad
--- /dev/null
+++ b/app/View/Components/Link.php
@@ -0,0 +1,66 @@
+override = $this->getOverride($override);
+
+ $this->kind = $kind;
+ $this->class = $this->getClass($class);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.link.index');
+ }
+
+ protected function getOverride($override)
+ {
+ return explode(',', $override);
+ }
+
+ protected function getClass($class)
+ {
+ $default = 'px-3 py-1.5 mb-3 sm:mb-0 rounded-xl text-sm font-medium leading-6';
+
+ switch ($this->kind) {
+ case 'primary':
+ $default .= ' bg-green hover:bg-green-700 text-white disabled:bg-green-100';
+ break;
+ case 'secondary':
+ $default .= ' bg-purple hover:bg-purple-700 text-white disabled:bg-purple-100';
+ break;
+ default:
+ $default .= ' bg-gray-100 hover:bg-gray-200 disabled:bg-gray-50';
+ }
+
+ if (in_array('class', $this->override)) {
+ return $class;
+ }
+
+ return $default;
+ }
+}
diff --git a/app/View/Components/Link/Hover.php b/app/View/Components/Link/Hover.php
new file mode 100644
index 00000000..66159994
--- /dev/null
+++ b/app/View/Components/Link/Hover.php
@@ -0,0 +1,36 @@
+color = $color;
+ $this->groupHover = $groupHover;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.link.hover');
+ }
+}
diff --git a/app/View/Components/Media/Ffile.php b/app/View/Components/Media/Ffile.php
new file mode 100644
index 00000000..5b5f9079
--- /dev/null
+++ b/app/View/Components/Media/Ffile.php
@@ -0,0 +1,36 @@
+file = $file;
+ $this->column_name = ! empty($column_name) ? $column_name : 'attachment';
+ $this->options = ! empty($options) ? $options : false;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.media.file');
+ }
+}
diff --git a/app/View/Components/Menu/Favorite.php b/app/View/Components/Menu/Favorite.php
new file mode 100644
index 00000000..3d2cb44f
--- /dev/null
+++ b/app/View/Components/Menu/Favorite.php
@@ -0,0 +1,41 @@
+title = $title;
+ $this->icon = $icon;
+ $this->route = $route;
+ $this->url = $url;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.menu.favorite');
+ }
+}
diff --git a/app/View/Components/Modules/Banners.php b/app/View/Components/Modules/Banners.php
new file mode 100644
index 00000000..7c84b346
--- /dev/null
+++ b/app/View/Components/Modules/Banners.php
@@ -0,0 +1,12 @@
+module = $model;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.modules.item');
+ }
+}
diff --git a/app/View/Components/Modules/Items.php b/app/View/Components/Modules/Items.php
new file mode 100644
index 00000000..6f7262e9
--- /dev/null
+++ b/app/View/Components/Modules/Items.php
@@ -0,0 +1,257 @@
+limit = $limit;
+ $this->seeMore = $this->getSeeMore($seeMore, $model);
+ $this->modules = $this->getModel($model);
+ $this->installedStatus = $this->getInstalledStatus();
+
+ $this->view = $this->getView();
+ $this->type = $this->getType();
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view($this->view);
+ }
+
+ protected function getSeeMore($seeMore, $model)
+ {
+ if (empty($seeMore)) {
+ return $seeMore;
+ }
+
+ if (! empty($model) && ($model->current_page < $model->last_page)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ protected function getModel($model)
+ {
+ if (! empty($model)) {
+ if (isset($model->data)) {
+ return $model->data;
+ }
+
+ return $model;
+ }
+
+ $model = [];
+
+ switch ($this->type) {
+ case 'banners':
+ $model = $this->getBanners();
+ break;
+ case 'new':
+ $model = $this->getNew($this->limit);
+ break;
+ case 'paid':
+ $model = $this->getPaid($this->limit);
+ break;
+ case 'free':
+ $model = $this->getFree($this->limit);
+ break;
+ case 'pre_sale':
+ $model = $this->getPreSale($this->limit);
+ break;
+ case 'purchased':
+ $model = $this->getPurchased($this->limit);
+ break;
+ case 'installed':
+ $model = $this->getInstalled();
+ break;
+ case 'no-apps':
+ $model = $this->getTestimonials();
+ break;
+ }
+
+ return $model;
+ }
+
+ protected function getView()
+ {
+ if (! empty($this->view)) {
+ return $this->view;
+ }
+
+ return 'components.modules.items';
+ }
+
+ protected function getBanners()
+ {
+ $model = $this->getBannersOfModules();
+
+ return $model;
+ }
+
+ protected function getNew($limit)
+ {
+ $model = [];
+
+ $response = $this->getNewModules([
+ 'query' => [
+ 'limit' => $limit
+ ]
+ ]);
+
+ if ($response) {
+ $model = $response->data;
+ }
+
+ return $model;
+ }
+
+ protected function getFree($limit)
+ {
+ $model = [];
+
+ $response = $this->getFreeModules([
+ 'query' => [
+ 'limit' => $limit
+ ]
+ ]);
+
+ if ($response) {
+ $model = $response->data;
+ }
+
+ return $model;
+ }
+
+ protected function getPaid($limit)
+ {
+ $model = [];
+
+ $response = $this->getPaidModules([
+ 'query' => [
+ 'limit' => $limit
+ ]
+ ]);
+
+ if ($response) {
+ $model = $response->data;
+ }
+
+ return $model;
+ }
+
+ protected function getPreSale($limit)
+ {
+ $model = [];
+
+ $response = $this->getPreSaleModules([
+ 'query' => [
+ 'limit' => $limit
+ ]
+ ]);
+
+ if ($response) {
+ $model = $response->data;
+ }
+
+ return $model;
+ }
+
+ protected function getPurchased($limit)
+ {
+ $model = [];
+ $data = [];
+
+ if ($limit != 4) {
+ $data = [
+ 'query' => [
+ 'limit' => $limit
+ ]
+ ];
+ }
+
+ $response = $this->getMyModules($data);
+
+ if ($response) {
+ $model = $response;
+ }
+
+ return $model;
+ }
+
+ protected function getInstalled()
+ {
+ $model = [];
+
+ $response = $this->getInstalledModules();
+
+ if ($response) {
+ $model = $response;
+ }
+
+ return $model;
+ }
+
+ protected function getTestimonials()
+ {
+ $model = [];
+
+ $response = $this->getTestimonialModules();
+
+ if ($response) {
+ $model = $response;
+ }
+
+ return $model;
+ }
+
+ protected function getInstalledStatus()
+ {
+ $installed = Module::where('company_id', '=', company_id())->pluck('enabled', 'alias')->toArray();
+
+ return $installed;
+ }
+
+ protected function getType()
+ {
+ if (! empty($this->type)) {
+ return $this->type;
+ }
+
+ $name = Route::currentRouteName();
+
+ $keys = explode('.', $name);
+
+ return count($keys) > 2 ? $keys[1] : last($keys);
+ }
+}
diff --git a/app/View/Components/Modules/Nnew.php b/app/View/Components/Modules/Nnew.php
new file mode 100644
index 00000000..7f54a63e
--- /dev/null
+++ b/app/View/Components/Modules/Nnew.php
@@ -0,0 +1,12 @@
+items = $items;
+ $this->limits = $this->getLimits($limits);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.pagination');
+ }
+
+ protected function getLimits($limits)
+ {
+ if (! empty($limits)) {
+ return $limits;
+ }
+
+ $limits = [
+ '10' => '10',
+ '25' => '25',
+ '50' => '50',
+ '100' => '100'
+ ];
+
+ return $limits;
+ }
+}
diff --git a/app/View/Components/PaymentMethod.php b/app/View/Components/PaymentMethod.php
new file mode 100644
index 00000000..c5f625b2
--- /dev/null
+++ b/app/View/Components/PaymentMethod.php
@@ -0,0 +1,81 @@
+code = $code;
+ $this->method = $method;
+ $this->type = $type;
+
+ $this->payment_methods = $this->getPaymentMethods($payment_methods, $type);
+ $this->payment_method = $this->getPaymentMethod($payment_method, $code);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.payment_method');
+ }
+
+ protected function getPaymentMethods($payment_methods, $type)
+ {
+ if (! empty($payment_methods)) {
+ return $payment_methods;
+ }
+
+ // check here protal or admin panel..
+ if (empty($type)) {
+ $type = Str::contains(request()?->route()?->getName(), 'portal') ? 'customer' : 'all';
+ }
+
+ $payment_methods = Modules::getPaymentMethods($type);
+
+ return $payment_methods;
+ }
+
+ protected function getPaymentMethod($payment_method, $code)
+ {
+ if (! empty($payment_methods)) {
+ return $payment_methods;
+ }
+
+ if (! empty($this->payment_methods[$code])) {
+ return $this->payment_methods[$code];
+ }
+
+ if (! empty($this->payment_methods[$this->method])) {
+ return $this->payment_methods[$this->method];
+ }
+
+ if (! empty($this->payment_methods[$payment_method])) {
+ return $this->payment_methods[$payment_method];
+ }
+
+ return false;
+ }
+}
diff --git a/app/View/Components/Script.php b/app/View/Components/Script.php
new file mode 100644
index 00000000..1268585a
--- /dev/null
+++ b/app/View/Components/Script.php
@@ -0,0 +1,72 @@
+alias = $alias;
+ $this->folder = $folder;
+ $this->file = $file;
+
+ $this->source = $this->getSource($alias, $folder, $file);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.script');
+ }
+
+ protected function getSource($alias, $folder, $file)
+ {
+ $path = 'public/js/';
+ $version = version('short');
+
+ if ($alias != 'core') {
+ try {
+ $module = module($alias);
+
+ if ($module) {
+ $path = 'modules/' . $module->getStudlyName() . '/Resources/assets/js/';
+ $version = module_version($alias);
+ }
+ } catch (\Exception $e) {
+
+ }
+ }
+
+ if (! empty($folder)) {
+ $path .= $folder . '/';
+ }
+
+ $path .= $file . '.min.js?v=' . $version;
+
+ return $path;
+ }
+}
diff --git a/app/View/Components/Script/Declaration.php b/app/View/Components/Script/Declaration.php
new file mode 100644
index 00000000..768aca18
--- /dev/null
+++ b/app/View/Components/Script/Declaration.php
@@ -0,0 +1,65 @@
+kind = $kind;
+ $this->value = $value;
+
+ $this->scripts = $this->getScripts();
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.script.declaration');
+ }
+
+ protected function getScripts()
+ {
+ $scripts = '';
+
+ foreach ($this->value as $key => $value) {
+ $scripts .= $this->getScript($key, $value) . "\n";
+ }
+
+ return $scripts;
+ }
+
+ protected function getScript($key, $value = null)
+ {
+ $script = $this->kind . ' ' . $key;
+
+ if (! is_null($value)) {
+ $script .= ' = ' . Js::from($value)->toHtml() . ';';
+ } else {
+ $script .= ' = ' . 'null;';
+ }
+
+ return $script;
+ }
+}
diff --git a/app/View/Components/Script/Exceptions/Trackers.php b/app/View/Components/Script/Exceptions/Trackers.php
new file mode 100644
index 00000000..0b4de28f
--- /dev/null
+++ b/app/View/Components/Script/Exceptions/Trackers.php
@@ -0,0 +1,144 @@
+channel = $this->getChannel($channel);
+ $this->action = $this->getAction($action);
+ $this->ip = $this->getIp($ip);
+ $this->tags = $this->getTags($tags);
+ $this->params = $this->getParams($params);
+ }
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.script.exceptions.trackers');
+ }
+
+ public function getChannel($channel)
+ {
+ if (! empty($channel)) {
+ return $channel;
+ }
+
+ return config('logging.default');
+ }
+
+ public function getAction($action)
+ {
+ if (! empty($action)) {
+ return $action;
+ }
+
+ switch ($this->channel) {
+ case 'bugsnag':
+ $action = config('bugsnag.api_key');
+ break;
+ case 'sentry':
+ $action = config('sentry.dsn');
+ break;
+ }
+
+ return $action;
+ }
+
+ public function getIp($ip)
+ {
+ if (! empty($ip)) {
+ return $ip;
+ }
+
+ return Info::ip();
+ }
+
+ public function getTags($tags)
+ {
+ if (! empty($tags)) {
+ return $tags;
+ }
+
+ return static::getTrackerTags();
+ }
+
+ public function getParams($params)
+ {
+ if (! empty($params)) {
+ return $params;
+ }
+
+ switch ($this->channel) {
+ case 'bugsnag':
+ $params = [
+ 'app_version' => version('short'),
+ ];
+ break;
+ case 'sentry':
+ $params = [
+ 'release' => version('short'),
+ 'traces_sample_rate' => static::sentryTracesSampleRate(),
+ 'replays_session_sample_rate' => static::sentryReplaysSessionSampleRate(),
+ 'replays_on_error_sample_rate' => static::sentryReplaysOnErrorSampleRate(),
+ ];
+ break;
+ }
+
+ return $params;
+ }
+
+ public static function sentryTracesSampleRate()
+ {
+ $user_agent = request()->userAgent();
+
+ $filter_agents = explode(',', env('SENTRY_TRACES_FILTER_AGENTS'));
+
+ foreach ($filter_agents as $filter_agent) {
+ if (! Str::contains($user_agent, $filter_agent)) {
+ continue;
+ }
+
+ return 0.0;
+ }
+
+ return (float) config('sentry.traces_sample_rate', 1.0);
+ }
+
+ public static function sentryReplaysSessionSampleRate()
+ {
+ return (float) config('sentry.replays_session_sample_rate', 0.1);
+ }
+
+ public static function sentryReplaysOnErrorSampleRate()
+ {
+ return (float) config('sentry.replays_on_error_sample_rate', 1.0);
+ }
+}
diff --git a/app/View/Components/SearchString.php b/app/View/Components/SearchString.php
new file mode 100644
index 00000000..5ed5ef68
--- /dev/null
+++ b/app/View/Components/SearchString.php
@@ -0,0 +1,292 @@
+model = $model;
+ $this->filters = $filters;
+ $this->filtered = $filtered;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ if (empty($this->filters)) {
+ $search_string = config('search-string');
+
+ $this->filters = [];
+
+ if (!empty($search_string[$this->model])) {
+ $columns = $search_string[$this->model]['columns'];
+
+ foreach ($columns as $column => $options) {
+ // This column skip for filter
+ if (! empty($options['searchable'])) {
+ continue;
+ }
+
+ // This column skip for filter
+ if (in_array($column, $this->skip_columns)) {
+ continue;
+ }
+
+ if (!is_array($options)) {
+ $column = $options;
+ }
+
+ if (!$this->isFilter($column, $options)) {
+ continue;
+ }
+
+ $this->filters[] = [
+ 'key' => $this->getFilterKey($column, $options),
+ 'value' => $this->getFilterName($column, $options),
+ 'type' => $this->getFilterType($options),
+ 'url' => $this->getFilterUrl($column, $options),
+ 'values' => $this->getFilterValues($column, $options),
+ 'value_option_fields' => $options['fields'] ?? [],
+ //'operators' => $options['operators'] ?? $this->getOperators($options['type'] ?? 'string'),
+ 'operator' => $options['operator'] ?? '=',
+ 'multiple' => $options['multiple'] ?? false,
+ ];
+ }
+ }
+ }
+
+ return view('components.search-string');
+ }
+
+ protected function isFilter($column, $options)
+ {
+ $filter = true;
+
+ if (empty($this->getFilterUrl($column, $options)) && (!isset($options['date']) && !isset($options['boolean']) && !isset($options['values']))) {
+ $filter = false;
+ }
+
+ return $filter;
+ }
+
+ protected function getFilterKey($column, $options)
+ {
+ if (isset($options['key'])) {
+ $column = $options['key'];
+ }
+
+ if (isset($options['relationship']) && isset($options['foreign_key']) && ! empty($options['foreign_key'])) {
+ $column .= '.' . $options['foreign_key'];
+ }
+
+ if (isset($options['relationship']) && ! isset($options['foreign_key'])) {
+ $column .= '.id';
+ }
+
+ return $column;
+ }
+
+ protected function getFilterName($column, $options)
+ {
+ $column = last(explode('.', $column));
+
+ if (strpos($column, '_id') !== false) {
+ $column = str_replace('_id', '', $column);
+ } else if (strpos($column, '_code') !== false) {
+ $column = str_replace('_code', '', $column);
+ }
+
+ if (! empty($options['translation']) && ! isset($options['boolean'])) {
+ return $this->findTranslation($options['translation']);
+ }
+
+ if (!empty($options['key'])) {
+ $column = $options['key'];
+ }
+
+ $plural = Str::plural($column, 2);
+
+ if (strpos($this->model, 'Modules') !== false) {
+ $module_class = explode('\\', $this->model);
+
+ $prefix = Str::kebab($module_class[1]) . '::';
+
+ $translation_keys[] = $prefix . 'general.';
+ $translation_keys[] = $prefix . 'search_string.columns.';
+ }
+
+ $translation_keys[] = 'general.';
+ $translation_keys[] = 'search_string.columns.';
+
+ foreach ($translation_keys as $translation_key) {
+ if (trans_choice($translation_key . $plural, 1) !== $translation_key . $plural) {
+ return trans_choice($translation_key . $plural, 1);
+ }
+
+ if (trans($translation_key . $column) !== $translation_key . $column) {
+ return trans($translation_key . $column);
+ }
+ }
+
+ return $column;
+ }
+
+ protected function getFilterType($options)
+ {
+ $type = 'select';
+
+ if (isset($options['boolean'])) {
+ $type = 'boolean';
+ }
+
+ if (isset($options['date'])) {
+ $type = 'date';
+ }
+
+ return $type;
+ }
+
+ protected function getFilterUrl($column, $options)
+ {
+ $url = '';
+
+ if (isset($options['boolean']) || isset($options['date'])) {
+ return $url;
+ }
+
+ if (!empty($options['route'])) {
+ if (is_array($options['route'])) {
+ $url = route($options['route'][0], $options['route'][1]);
+ } else {
+ $url = route($options['route']);
+ }
+ } else {
+ if (strpos($this->model, 'Modules') !== false) {
+ $module_class = explode('\\', $this->model);
+
+ $url .= Str::kebab($module_class[1]) . '::';
+ }
+
+ if (strpos($column, '_id') !== false) {
+ $column = str_replace('_id', '', $column);
+ }
+
+ $plural = Str::plural($column, 2);
+
+ try {
+ $url = route($url . $plural . '.index');
+ } catch (\Exception $e) {
+ $url = '';
+ }
+ }
+
+ return $url;
+ }
+
+ protected function getFilterValues($column, $options)
+ {
+ $values = [];
+
+ if (isset($options['boolean'])) {
+ $values = [
+ [
+ 'key' => 0,
+ 'value' => empty($options['translation']) ? trans('general.no') : $this->findTranslation($options['translation'][0], 1),
+ ],
+ [
+ 'key' => 1,
+ 'value' => empty($options['translation']) ? trans('general.yes') : $this->findTranslation($options['translation'][1], 1),
+ ],
+ ];
+ } elseif (isset($options['values'])) {
+ foreach ($options['values'] as $key => $value) {
+ $name = $this->findTranslation($value, 1);
+ if (! empty($name)) {
+ $values[$key] = $name;
+ }
+ }
+ } elseif ($search = request()->get('search', false)) {
+ $fields = explode(' ', $search);
+
+ foreach ($fields as $field) {
+ if (strpos($field, ':') === false) {
+ continue;
+ }
+
+ $filters = explode(':', $field);
+
+ if ($filters[0] != $column) {
+ continue;
+ }
+ }
+ }
+
+ return $values;
+ }
+
+ protected function getOperators($type)
+ {
+ $operators = [
+ 'equal' => [
+ 'enabled' => true,
+ 'symbol' => [
+ 'sign' => '=',
+ 'icon' => 'drag_handle',
+ ],
+ 'text' => trans('general.is'),
+ ],
+ 'not_equal' => [
+ 'enabled' => true,
+ 'symbol' => [
+ 'sign' => '!=',
+ 'img' => asset('public/img/tailwind_icons/not-equal.svg'),
+ ],
+ 'text' => trans('general.isnot'),
+ ],
+ 'range' => [
+ 'enabled' => false,
+ 'symbol' => [
+ 'sign' => '><',
+ 'class' => 'transform rotate-90',
+ 'icon' => 'height',
+ ],
+ 'text' => trans('general.range'),
+ ],
+ ];
+
+ if ($type == 'date') {
+ $operators['range']['enabled'] = true;
+ }
+
+ return $operators;
+ }
+}
diff --git a/app/View/Components/Show/Accordion.php b/app/View/Components/Show/Accordion.php
new file mode 100644
index 00000000..d38c1bef
--- /dev/null
+++ b/app/View/Components/Show/Accordion.php
@@ -0,0 +1,46 @@
+type = $type;
+ $this->icon = $this->getIcon($icon);
+ $this->open = $open;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.show.accordion.index');
+ }
+
+ protected function getIcon($icon)
+ {
+ if (! empty($icon)) {
+ return $icon;
+ }
+
+ return 'expand_more';
+ }
+}
diff --git a/app/View/Components/Show/NoRecords.php b/app/View/Components/Show/NoRecords.php
new file mode 100644
index 00000000..9939abf7
--- /dev/null
+++ b/app/View/Components/Show/NoRecords.php
@@ -0,0 +1,144 @@
+ [
+ 'transactions' => 'accounts.create-income',
+ 'transfers' => 'accounts.create-transfer',
+ ],
+ 'customer' => [
+ 'invoices' => 'customers.create-invoice',
+ 'transactions' => 'customers.create-income',
+ ],
+ 'vendor' => [
+ 'bills' => 'vendors.create-bill',
+ 'transactions' => 'vendors.create-expense',
+ ],
+ ];
+
+ /**
+ * Create a new component instance.
+ *
+ * @return void
+ */
+ public function __construct(
+ string $type = '', $model = false, string $page = '', string $group = '',
+ string $backgroundColor = '', string $textColor = '',
+ string $image = '', string $description = '', string $url = '', string $textAction = ''
+ ) {
+ $this->type = $type;
+ $this->model = $model;
+ $this->page = $page;
+ $this->group = $group;
+ $this->backgroundColor = ! empty($backgroundColor) ? $backgroundColor : 'bg-lilac-900';
+ $this->textColor = ! empty($textColor) ? $textColor : 'text-purple';
+ $this->image = $this->getImage($image);
+ $this->description = $this->getDescription($description);
+ $this->url = $this->getUrl($url);
+ $this->textAction = $this->getTextAction($textAction);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.show.no-records');
+ }
+
+ protected function getImage($image)
+ {
+ if (! empty($image)) {
+ return $image;
+ }
+
+ $image = match ($this->type) {
+ 'account' => 'public/img/no_records/accounts_' . $this->page . '.png',
+ 'customer' => 'public/img/no_records/customers_' . $this->page . '.png',
+ 'vendor' => 'public/img/no_records/vendors_' . $this->page . '.png',
+ default => '',
+ };
+
+ return $image;
+ }
+
+ protected function getDescription($description)
+ {
+ if (! empty($description)) {
+ return $description;
+ }
+
+ $prefix = Str::plural($this->type);
+
+ $description = match ($this->type) {
+ 'account', 'customer', 'vendor' => trans($prefix . '.no_records.' . $this->page),
+ default => '',
+ };
+
+ return $description;
+ }
+
+ protected function getUrl($url)
+ {
+ if (! empty($url)) {
+ return $url;
+ }
+
+ if (array_key_exists($this->type, $this->urls) && array_key_exists($this->page, $this->urls[$this->type])) {
+ return route($this->urls[$this->type][$this->page], $this->model->id);
+ }
+
+ return $url;
+ }
+
+ protected function getTextAction($textAction)
+ {
+ if (! empty($textAction)) {
+ return $textAction;
+ }
+
+ $textAction = match ($this->type) {
+ 'account', 'customer', 'vendor' => trans('general.title.new', ['type' => trans_choice('general.' . $this->page, 1)]),
+ default => trans('modules.learn_more'),
+ };
+
+ return $textAction;
+ }
+}
diff --git a/app/View/Components/Show/Right.php b/app/View/Components/Show/Right.php
new file mode 100644
index 00000000..e998ce92
--- /dev/null
+++ b/app/View/Components/Show/Right.php
@@ -0,0 +1,21 @@
+backgroundColor = ! empty($backgroundColor) ? $backgroundColor : 'bg-lilac-900';
+ $this->textColor = ! empty($textColor) ? $textColor : 'text-purple';
+ $this->status = $status;
+ $this->textStatus = $this->getTextStatus($textStatus);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.show.status');
+ }
+
+ protected function getTextStatus($textStatus)
+ {
+ if (! empty($textStatus)) {
+ return $textStatus;
+ }
+
+ $textStatus = trans('documents.statuses.' . $this->status);
+
+ return $textStatus;
+ }
+}
diff --git a/app/View/Components/Suggestions.php b/app/View/Components/Suggestions.php
new file mode 100644
index 00000000..efe0b9b4
--- /dev/null
+++ b/app/View/Components/Suggestions.php
@@ -0,0 +1,81 @@
+suggestions = $this->setSuggestions($suggestions);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.suggestions');
+ }
+
+ protected function setSuggestions()
+ {
+ // No need to add suggestions in console
+ if (app()->runningInConsole() || !config('app.installed')) {
+ return [];
+ }
+
+ if ((! $user = user()) || $user->cannot('read-modules-home')) {
+ return [];
+ }
+
+ if (! $path = Route::current()->uri()) {
+ return [];
+ }
+
+ $path = str_replace('{company_id}/', '', $path);
+
+ if (! $suggestions = $this->getSuggestions($path)) {
+ return [];
+ }
+
+ $modules = [];
+
+ foreach ($suggestions->modules as $s_module) {
+ if ($this->moduleIsEnabled($s_module->alias)) {
+ continue;
+ }
+
+ $s_module->action_url = company_id() . '/' . $s_module->action_url;
+
+ $modules[] = $s_module;
+ }
+
+ if (empty($modules)) {
+ return [];
+ }
+
+ if (count($modules) < 3) {
+ return $modules;
+ }
+
+ return Arr::random($modules, 2);
+ }
+}
diff --git a/app/View/Components/Table/Actions.php b/app/View/Components/Table/Actions.php
new file mode 100644
index 00000000..fb94ac37
--- /dev/null
+++ b/app/View/Components/Table/Actions.php
@@ -0,0 +1,114 @@
+model = $model;
+ $this->actions = $this->getActions($actions);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.table.actions');
+ }
+
+ protected function getActions($actions)
+ {
+ if (empty($actions)) {
+ $actions = [];
+
+ if ($this->model && ! empty($this->model->line_actions)) {
+ $actions = $this->model->line_actions;
+ }
+ }
+
+ foreach ($actions as $key => $action) {
+ $attributes = [];
+
+ if (! empty($action['attributes'])) {
+ $attributes = $action['attributes'];
+ }
+
+ $actions[$key]['attributes'] = $this->getAttributes($attributes);
+ }
+
+ return $actions;
+ }
+
+ /**
+ * Build an HTML attribute string from an array.
+ *
+ * @param array $attributes
+ *
+ * @return string
+ */
+ public function getAttributes($attributes)
+ {
+ $html = [];
+
+ foreach ((array) $attributes as $key => $value) {
+ $element = $this->attributeElement($key, $value);
+
+ if (! is_null($element)) {
+ $html[] = $element;
+ }
+ }
+
+ return count($html) > 0 ? ' ' . implode(' ', $html) : '';
+ }
+
+ /**
+ * Build a single attribute element.
+ *
+ * @param string $key
+ * @param string $value
+ *
+ * @return string
+ */
+ protected function attributeElement($key, $value)
+ {
+ // For numeric keys we will assume that the value is a boolean attribute
+ // where the presence of the attribute represents a true value and the
+ // absence represents a false value.
+ // This will convert HTML attributes such as "required" to a correct
+ // form instead of using incorrect numerics.
+ if (is_numeric($key)) {
+ return $value;
+ }
+
+ // Treat boolean attributes as HTML properties
+ if (is_bool($value) && $key !== 'value') {
+ return $value ? $key : '';
+ }
+
+ if (is_array($value) && $key === 'class') {
+ return 'class=' . implode(' ', $value);
+ }
+
+ if (! is_null($value)) {
+ return $key . '=' . e($value, false);
+ }
+ }
+}
diff --git a/app/View/Components/Table/Tbody.php b/app/View/Components/Table/Tbody.php
new file mode 100644
index 00000000..4c2ddafe
--- /dev/null
+++ b/app/View/Components/Table/Tbody.php
@@ -0,0 +1,18 @@
+override = $this->getOverride($override);
+
+ $this->kind = $kind;
+ $this->hiddenMobile = $hiddenMobile;
+ $this->class = $this->getClass($class);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.table.td');
+ }
+
+ protected function getOverride($override)
+ {
+ return explode(',', $override);
+ }
+
+ protected function getClass($class)
+ {
+ if (in_array('class', $this->override)) {
+ return $class;
+ }
+
+ if ($this->hiddenMobile) {
+ $class = $class . ' ' . 'hidden sm:table-cell';
+ }
+
+ $default = 'py-4 whitespace-nowrap text-sm font-normal text-black truncate';
+
+ switch ($this->kind) {
+ case 'amount':
+ $default = $class . ' ltr:pl-6 rtl:pr-6 ltr:text-right rtl:text-left ' . $default;
+ break;
+ case 'right':
+ $default = $class . ' ltr:pl-6 rtl:pr-6 ltr:text-right rtl:text-left ' . $default;
+ break;
+ case 'bulkaction':
+ $default = $class . 'ltr:pr-6 rtl:pl-6 hidden sm:table-cell';
+ break;
+ case 'action':
+ $default = 'p-0';
+ break;
+ case 'cursor-none':
+ $default = $class . ' cursor-default ' . $default;
+ break;
+ default:
+ $default = $class . ' ltr:pr-6 rtl:pl-6 ltr:text-left rtl:text-right cursor-pointer ' . $default;
+ }
+
+ return $default;
+ }
+}
diff --git a/app/View/Components/Table/Th.php b/app/View/Components/Table/Th.php
new file mode 100644
index 00000000..570301f2
--- /dev/null
+++ b/app/View/Components/Table/Th.php
@@ -0,0 +1,75 @@
+override = $this->getOverride($override);
+
+ $this->kind = $kind;
+ $this->hiddenMobile = $hiddenMobile;
+ $this->class = $this->getClass($class);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.table.th');
+ }
+
+ protected function getOverride($override)
+ {
+ return explode(',', $override);
+ }
+
+ protected function getClass($class)
+ {
+ if (in_array('class', $this->override)) {
+ return $class;
+ }
+
+ if ($this->hiddenMobile) {
+ $class = $class . ' ' . 'hidden sm:table-cell';
+ }
+
+ $default = 'py-3 text-xs font-medium text-black tracking-wider';
+
+ switch ($this->kind) {
+ case 'amount':
+ $default = $class . ' ltr:pl-6 rtl:pr-6 ltr:text-right rtl:text-left ' . $default;
+ break;
+ case 'right':
+ $default = $class . ' ltr:pl-6 rtl:pr-6 ltr:text-right rtl:text-left' . $default;
+ break;
+ case 'bulkaction':
+ $default = $class . 'ltr:pr-6 rtl:pl-6 hidden sm:table-cell';
+ break;
+ default:
+ $default = $class . ' ltr:pr-6 rtl:pl-6 ltr:text-left rtl:text-right ' . $default;
+ }
+
+ return $default;
+ }
+}
diff --git a/app/View/Components/Table/Thead.php b/app/View/Components/Table/Thead.php
new file mode 100644
index 00000000..eddbe8f5
--- /dev/null
+++ b/app/View/Components/Table/Thead.php
@@ -0,0 +1,18 @@
+class = $this->getClass($class);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.table.tr');
+ }
+
+ protected function getClass($class)
+ {
+ if (! empty($class)) {
+ return $class;
+ }
+
+ $self = new ReflectionProperty($this::class, 'methodCache');
+ $self->setAccessible(true);
+
+ $values = $self->getValue();
+
+ $self = new ReflectionProperty($this::class, 'factory');
+ $self->setAccessible(true);
+
+ $factory = $self->getValue();
+
+ if (array_key_exists('App\View\Components\Table\Tbody', $values)
+ && ($factory instanceof \Illuminate\View\Factory && $factory->getLoopStack())
+ ) {
+ return 'relative flex items-center px-1 group border-b hover:bg-gray-100';
+ }
+
+ else if (array_key_exists('App\View\Components\Table\Thead', $values)) {
+ return 'flex items-center px-1';
+ }
+
+ return '';
+ }
+}
diff --git a/app/View/Components/Tips.php b/app/View/Components/Tips.php
new file mode 100644
index 00000000..2d6504f8
--- /dev/null
+++ b/app/View/Components/Tips.php
@@ -0,0 +1,104 @@
+position = $position;
+ $this->path = $path;
+ $this->tips = collect();
+
+ $this->setTips($tips);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ switch ($this->position) {
+ case 'fixed':
+ $view = 'components.tips.fixed';
+ break;
+ default:
+ $view = 'components.tips.relative';
+ }
+
+ if ($this->tips->count() > 1) {
+ $view = 'components.tips.relative';
+ }
+
+ return view($view);
+ }
+
+ protected function setTips($tips)
+ {
+ if (! empty($tips)) {
+ $this->tips = $tips;
+ }
+
+ if (! $path = Route::current()->uri()) {
+ return;
+ }
+
+ $path = Str::replace('{company_id}/', '', $path);
+
+ if (! $tips = $this->getTips($path)) {
+ return;
+ }
+
+ $rows = collect();
+
+ shuffle($tips);
+
+ foreach ($tips as $tip) {
+ if ($tip->position != $this->position) {
+ continue;
+ }
+
+ if (! empty($tip->alias) && $this->moduleIsEnabled($tip->alias)) {
+ continue;
+ }
+
+ if (Str::contains($tip->action, '{company_id}')) {
+ $tip->action = Str::replace('{company_id}', company_id(), $tip->action);
+ }
+
+ $rows->push($tip);
+ }
+
+ if ($rows->count()) {
+ $row = $rows->shuffle()->first();
+
+ $this->tips->push($row);
+ }
+ }
+}
diff --git a/app/View/Components/Title.php b/app/View/Components/Title.php
new file mode 100644
index 00000000..ce424d47
--- /dev/null
+++ b/app/View/Components/Title.php
@@ -0,0 +1,46 @@
+textSize = $this->getTextSize($textSize);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.title');
+ }
+
+ protected function getTextSize($textSize)
+ {
+ switch ($textSize) {
+ case 'short':
+ $textSize = '15';
+ break;
+ default:
+ $textSize = '25';
+ break;
+ }
+
+ return $textSize;
+ }
+}
diff --git a/app/View/Components/Tooltip.php b/app/View/Components/Tooltip.php
new file mode 100644
index 00000000..65d9d065
--- /dev/null
+++ b/app/View/Components/Tooltip.php
@@ -0,0 +1,163 @@
+id = $this->getId($id);
+ $this->placement = $this->getPlacement($placement);
+ $this->tooltipPosition = $this->getTooltipPosition($tooltipPosition);
+
+ $this->backgroundColor = $this->getBackgroundColor($backgroundColor);
+ $this->textColor = $this->getTextColor($textColor);
+ $this->borderColor = $this->getBorderColor($borderColor);
+
+ $this->message = $this->getMessage($message);
+ $this->size = $this->getSize($size);
+ $this->whitespace = $this->getWhiteSpace($whitespace);
+ $this->width = $width;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.tooltip');
+ }
+
+ protected function getId($id)
+ {
+ if (! empty($id)) {
+ return $id;
+ }
+
+ return 'tooltip-' . Str::random(19);
+ }
+
+ protected function getPlacement($placement)
+ {
+ if (! empty($placement)) {
+ return $placement;
+ }
+
+ return 'top';
+ }
+
+ protected function getTooltipPosition($tooltipPosition)
+ {
+ if (! empty($tooltipPosition)) {
+ return $tooltipPosition;
+ }
+
+ switch ($this->placement) {
+ case 'bottom':
+ $tooltipPosition = "-top-1 before:border-b-0 before:border-r-0";
+ break;
+ case 'left':
+ $tooltipPosition = "-right-1 before:border-b-0 before:border-l-0";
+ break;
+ case 'right':
+ $tooltipPosition = "-left-1 before:border-t-0 before:border-r-0";
+ break;
+ case 'top':
+ default:
+ $tooltipPosition = "-bottom-1 before:border-t-0 before:border-l-0";
+ break;
+ }
+
+ return $tooltipPosition;
+ }
+
+ protected function getBackgroundColor($backgroundColor)
+ {
+ if (! empty($backgroundColor)) {
+ return $backgroundColor;
+ }
+
+ return 'bg-white';
+ }
+
+ protected function getTextColor($textColor)
+ {
+ if (! empty($textColor)) {
+ return $textColor;
+ }
+
+ return 'text-gray-900';
+ }
+
+ protected function getBorderColor($borderColor)
+ {
+ if (! empty($borderColor)) {
+ return $borderColor;
+ }
+
+ return 'border-gray-200';
+ }
+
+ protected function getMessage($message)
+ {
+ if (! empty($message)) {
+ return $message;
+ }
+
+ return trans('general.na');
+ }
+
+ protected function getSize($size)
+ {
+ if (! empty($size)) {
+ return $size;
+ }
+
+ return 'w-auto';
+ }
+
+ protected function getWhiteSpace($whitespace)
+ {
+ if (! empty($whitespace)) {
+ return $whitespace;
+ }
+
+ return 'whitespace-normal';
+ }
+}
diff --git a/app/View/Components/Transactions/Index/BulkAction.php b/app/View/Components/Transactions/Index/BulkAction.php
new file mode 100644
index 00000000..1d507ef1
--- /dev/null
+++ b/app/View/Components/Transactions/Index/BulkAction.php
@@ -0,0 +1,18 @@
+type = $type;
+ $this->transaction = $transaction;
+
+ $this->alias = $this->getAlias($type, $alias);
+ $this->folder = $this->getScriptFolder($type, $folder);
+ $this->file = $this->getScriptFile($type, $file);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.transactions.script');
+ }
+}
diff --git a/app/View/Components/Transactions/Show/Attachment.php b/app/View/Components/Transactions/Show/Attachment.php
new file mode 100644
index 00000000..4a79b3cc
--- /dev/null
+++ b/app/View/Components/Transactions/Show/Attachment.php
@@ -0,0 +1,18 @@
+model = ! empty($model) ? $model : $transfer;
+ $this->transfer = ! empty($model) ? $model : $transfer;
+
+ $this->alias = $this->getAlias($type, $alias);
+ $this->folder = $this->getScriptFolder($type, $folder);
+ $this->file = $this->getScriptFile($type, $file);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.transfers.script');
+ }
+}
diff --git a/app/View/Components/Transfers/Show/Attachment.php b/app/View/Components/Transfers/Show/Attachment.php
new file mode 100644
index 00000000..ae126917
--- /dev/null
+++ b/app/View/Components/Transfers/Show/Attachment.php
@@ -0,0 +1,18 @@
+alerts = $this->getAlerts($alerts);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.update-alert');
+ }
+
+ public function getAlerts()
+ {
+ $alerts = [];
+
+ $updates = Versions::getUpdates();
+
+ if (! $updates) {
+ return $alerts;
+ }
+
+ foreach ($updates as $alias => $update) {
+ if (! $update->errors) {
+ continue;
+ }
+
+ foreach ($update->errors as $key => $error) {
+ switch ($key) {
+ case 'core':
+ $type = 'danger';
+ break;
+ case 'expires':
+ case 'compatible':
+ $type = 'warning';
+ break;
+ default:
+ $type = 'danger';
+ }
+
+ if (is_object($error) || is_array($error)) {
+ foreach ($error as $message) {
+ $alerts[$type][] = $message;
+ }
+ } else {
+ $alerts[$type][] = $error;
+ }
+ }
+ }
+
+ return $alerts;
+ }
+}
diff --git a/app/View/Components/Widgets/Contact.php b/app/View/Components/Widgets/Contact.php
new file mode 100644
index 00000000..2c914a1b
--- /dev/null
+++ b/app/View/Components/Widgets/Contact.php
@@ -0,0 +1,52 @@
+model = ! empty($model) ? $model : user()->contact;
+ $this->contact = ! empty($contact) ? $contact : $this->model;
+ }
+
+ /**
+ * Determine if the given full_name is the currently selected full_name.
+ *
+ * @param string $full_name
+ * @return string
+ */
+ public function shortName($full_name)
+ {
+ if (empty($full_name)) {
+ return trans('general.na');
+ }
+
+ $names = explode(' ', $full_name);
+
+ return strtoupper(substr($names[0], 0, 1) . substr(end($names), 0, 1));
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.widgets.contact');
+ }
+}
diff --git a/app/View/Components/Widgets/InvoiceHistory.php b/app/View/Components/Widgets/InvoiceHistory.php
new file mode 100644
index 00000000..99e96c3f
--- /dev/null
+++ b/app/View/Components/Widgets/InvoiceHistory.php
@@ -0,0 +1,50 @@
+contact = ! empty($contact) ? $contact : user()->contact;
+ $this->model = $model;
+ $this->invoices = $this->getPayment($model, $invoices);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.widgets.invoice_history');
+ }
+
+ protected function getPayment($model, $invoices)
+ {
+ if (! empty($model)) {
+ return $model;
+ }
+
+ if (! empty($invoices)) {
+ return $invoices;
+ }
+
+ return $this->contact->invoices()->orderBy('created_at', 'desc')->limit(3)->get();
+ }
+}
diff --git a/app/View/Components/Widgets/LastPayment.php b/app/View/Components/Widgets/LastPayment.php
new file mode 100644
index 00000000..78b458a4
--- /dev/null
+++ b/app/View/Components/Widgets/LastPayment.php
@@ -0,0 +1,50 @@
+contact = ! empty($contact) ? $contact : user()->contact;
+ $this->model = $model;
+ $this->payment = $this->getPayment($model, $payment);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.widgets.last_payment');
+ }
+
+ protected function getPayment($model, $payment)
+ {
+ if (! empty($model)) {
+ return $model;
+ }
+
+ if (! empty($payment)) {
+ return $payment;
+ }
+
+ return $this->contact->income_transactions()->orderBy('created_at', 'desc')->first();
+ }
+}
diff --git a/app/View/Components/Widgets/LatestInvoices.php b/app/View/Components/Widgets/LatestInvoices.php
new file mode 100644
index 00000000..07caa785
--- /dev/null
+++ b/app/View/Components/Widgets/LatestInvoices.php
@@ -0,0 +1,50 @@
+contact = ! empty($contact) ? $contact : user()->contact;
+ $this->model = $model;
+ $this->invoices = $this->getInvoices($model, $invoices);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.widgets.latest_invoices');
+ }
+
+ protected function getInvoices($model, $invoices)
+ {
+ if (! empty($model)) {
+ return $model;
+ }
+
+ if (! empty($invoices)) {
+ return $invoices;
+ }
+
+ return $this->contact->invoices()->orderBy('created_at', 'desc')->limit(3)->get();
+ }
+}
diff --git a/app/View/Components/Widgets/OutstandingBalance.php b/app/View/Components/Widgets/OutstandingBalance.php
new file mode 100644
index 00000000..4c1a8538
--- /dev/null
+++ b/app/View/Components/Widgets/OutstandingBalance.php
@@ -0,0 +1,32 @@
+contact = ! empty($contact) ? $contact : user()->contact;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.widgets.outstanding_balance');
+ }
+}
diff --git a/app/View/Components/Widgets/PaymentHistory.php b/app/View/Components/Widgets/PaymentHistory.php
new file mode 100644
index 00000000..3f8ce186
--- /dev/null
+++ b/app/View/Components/Widgets/PaymentHistory.php
@@ -0,0 +1,50 @@
+contact = ! empty($contact) ? $contact : user()->contact;
+ $this->model = $model;
+ $this->payments = $this->getPayment($model, $payments);
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\Contracts\View\View|string
+ */
+ public function render()
+ {
+ return view('components.widgets.payment_history');
+ }
+
+ protected function getPayment($model, $payment)
+ {
+ if (! empty($model)) {
+ return $model;
+ }
+
+ if (! empty($payment)) {
+ return $payment;
+ }
+
+ return $this->contact->income_transactions()->orderBy('created_at', 'desc')->limit(3)->get();
+ }
+}
diff --git a/app/View/Presenters/Menu.php b/app/View/Presenters/Menu.php
new file mode 100644
index 00000000..78f4369f
--- /dev/null
+++ b/app/View/Presenters/Menu.php
@@ -0,0 +1,280 @@
+
+
+
+
';
+ }
+
+ /**
+ * {@inheritdoc }.
+ */
+ public function getHeaderWrapper($item)
+ {
+ return '' . $item->title . '
';
+ }
+
+ /**
+ * {@inheritdoc }.
+ */
+ public function getMenuWithDropDownWrapper($item)
+ {
+ $id = Str::slug($item->title);
+
+ return '
+
+
+
+
+
+
+
, by listening to the
+ | Response after the App is done. If you disable this, you have to add them
+ | in your template yourself. See http://phpdebugbar.com/docs/rendering.html
+ |
+ */
+
+ 'inject' => true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | DebugBar route prefix
+ |--------------------------------------------------------------------------
+ |
+ | Sometimes you want to set route prefix to be used by DebugBar to load
+ | its resources from. Usually the need comes from misconfigured web server or
+ | from trying to overcome bugs like this: http://trac.nginx.org/nginx/ticket/97
+ |
+ */
+ 'route_prefix' => '_debugbar',
+
+ /*
+ |--------------------------------------------------------------------------
+ | DebugBar route domain
+ |--------------------------------------------------------------------------
+ |
+ | By default DebugBar route served from the same domain that request served.
+ | To override default domain, specify it as a non-empty value.
+ */
+ 'route_domain' => null,
+
+ /*
+ |--------------------------------------------------------------------------
+ | DebugBar theme
+ |--------------------------------------------------------------------------
+ |
+ | Switches between light and dark theme. If set to auto it will respect system preferences
+ | Possible values: auto, light, dark
+ */
+ 'theme' => env('DEBUGBAR_THEME', 'auto'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Backtrace stack limit
+ |--------------------------------------------------------------------------
+ |
+ | By default, the DebugBar limits the number of frames returned by the 'debug_backtrace()' function.
+ | If you need larger stacktraces, you can increase this number. Setting it to 0 will result in no limit.
+ */
+ 'debug_backtrace_limit' => 50,
+];
diff --git a/config/dompdf.php b/config/dompdf.php
new file mode 100644
index 00000000..2a6c4eb2
--- /dev/null
+++ b/config/dompdf.php
@@ -0,0 +1,284 @@
+ env('DOMPDF_DISK', null),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Settings
+ |--------------------------------------------------------------------------
+ |
+ | Set some default values. It is possible to add all defines that can be set
+ | in dompdf_config.inc.php. You can also override the entire config file.
+ |
+ */
+ 'show_warnings' => false, // Throw an Exception on warnings from dompdf
+
+ 'public_path' => null, // Override the public path if needed
+
+ /*
+ * Dejavu Sans font is missing glyphs for converted entities, turn it off if you need to show € and £.
+ */
+ 'convert_entities' => false,
+
+ 'options' => [
+ /**
+ * The location of the DOMPDF font directory
+ *
+ * The location of the directory where DOMPDF will store fonts and font metrics
+ * Note: This directory must exist and be writable by the webserver process.
+ * *Please note the trailing slash.*
+ *
+ * Notes regarding fonts:
+ * Additional .afm font metrics can be added by executing load_font.php from command line.
+ *
+ * Only the original "Base 14 fonts" are present on all pdf viewers. Additional fonts must
+ * be embedded in the pdf file or the PDF may not display correctly. This can significantly
+ * increase file size unless font subsetting is enabled. Before embedding a font please
+ * review your rights under the font license.
+ *
+ * Any font specification in the source HTML is translated to the closest font available
+ * in the font directory.
+ *
+ * The pdf standard "Base 14 fonts" are:
+ * Courier, Courier-Bold, Courier-BoldOblique, Courier-Oblique,
+ * Helvetica, Helvetica-Bold, Helvetica-BoldOblique, Helvetica-Oblique,
+ * Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic,
+ * Symbol, ZapfDingbats.
+ */
+ "font_dir" => storage_path('fonts'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
+
+ /**
+ * The location of the DOMPDF font cache directory
+ *
+ * This directory contains the cached font metrics for the fonts used by DOMPDF.
+ * This directory can be the same as DOMPDF_FONT_DIR
+ *
+ * Note: This directory must exist and be writable by the webserver process.
+ */
+ "font_cache" => storage_path('fonts'),
+
+ /**
+ * The location of a temporary directory.
+ *
+ * The directory specified must be writeable by the webserver process.
+ * The temporary directory is required to download remote images and when
+ * using the PFDLib back end.
+ */
+ "temp_dir" => sys_get_temp_dir(),
+
+ /**
+ * ==== IMPORTANT ====
+ *
+ * dompdf's "chroot": Prevents dompdf from accessing system files or other
+ * files on the webserver. All local files opened by dompdf must be in a
+ * subdirectory of this directory. DO NOT set it to '/' since this could
+ * allow an attacker to use dompdf to read any files on the server. This
+ * should be an absolute path.
+ * This is only checked on command line call by dompdf.php, but not by
+ * direct class use like:
+ * $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output();
+ */
+ "chroot" => realpath(base_path()),
+
+ /**
+ * Protocol whitelist
+ *
+ * Protocols and PHP wrappers allowed in URIs, and the validation rules
+ * that determine if a resouce may be loaded. Full support is not guaranteed
+ * for the protocols/wrappers specified
+ * by this array.
+ *
+ * @var array
+ */
+ 'allowed_protocols' => [
+ "file://" => ["rules" => []],
+ "data://" => ["rules" => []]
+ ],
+
+ /**
+ * @var string
+ */
+ 'log_output_file' => null,
+
+ /**
+ * Whether to enable font subsetting or not.
+ */
+ "enable_font_subsetting" => true,
+
+ /**
+ * The PDF rendering backend to use
+ *
+ * Valid settings are 'PDFLib', 'CPDF' (the bundled R&OS PDF class), 'GD' and
+ * 'auto'. 'auto' will look for PDFLib and use it if found, or if not it will
+ * fall back on CPDF. 'GD' renders PDFs to graphic files. {@link
+ * Canvas_Factory} ultimately determines which rendering class to instantiate
+ * based on this setting.
+ *
+ * Both PDFLib & CPDF rendering backends provide sufficient rendering
+ * capabilities for dompdf, however additional features (e.g. object,
+ * image and font support, etc.) differ between backends. Please see
+ * {@link PDFLib_Adapter} for more information on the PDFLib backend
+ * and {@link CPDF_Adapter} and lib/class.pdf.php for more information
+ * on CPDF. Also see the documentation for each backend at the links
+ * below.
+ *
+ * The GD rendering backend is a little different than PDFLib and
+ * CPDF. Several features of CPDF and PDFLib are not supported or do
+ * not make any sense when creating image files. For example,
+ * multiple pages are not supported, nor are PDF 'objects'. Have a
+ * look at {@link GD_Adapter} for more information. GD support is
+ * experimental, so use it at your own risk.
+ *
+ * @link http://www.pdflib.com
+ * @link http://www.ros.co.nz/pdf
+ * @link http://www.php.net/image
+ */
+ "pdf_backend" => "CPDF",
+
+ /**
+ * PDFlib license key
+ *
+ * If you are using a licensed, commercial version of PDFlib, specify
+ * your license key here. If you are using PDFlib-Lite or are evaluating
+ * the commercial version of PDFlib, comment out this setting.
+ *
+ * @link http://www.pdflib.com
+ *
+ * If pdflib present in web server and auto or selected explicitely above,
+ * a real license code must exist!
+ */
+ //"DOMPDF_PDFLIB_LICENSE" => "your license key here",
+
+ /**
+ * html target media view which should be rendered into pdf.
+ * List of types and parsing rules for future extensions:
+ * http://www.w3.org/TR/REC-html40/types.html
+ * screen, tty, tv, projection, handheld, print, braille, aural, all
+ * Note: aural is deprecated in CSS 2.1 because it is replaced by speech in CSS 3.
+ * Note, even though the generated pdf file is intended for print output,
+ * the desired content might be different (e.g. screen or projection view of html file).
+ * Therefore allow specification of content here.
+ */
+ "default_media_type" => "screen",
+
+ /**
+ * The default paper size.
+ *
+ * North America standard is "letter"; other countries generally "a4"
+ *
+ * @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
+ */
+ "default_paper_size" => "a4",
+
+ /**
+ * The default paper orientation.
+ *
+ * The orientation of the page (portrait or landscape).
+ *
+ * @var string
+ */
+ 'default_paper_orientation' => "portrait",
+
+ /**
+ * The default font family
+ *
+ * Used if no suitable fonts can be found. This must exist in the font folder.
+ * @var string
+ */
+ "default_font" => "DejaVu Sans, sans-serif",
+
+ /**
+ * Image DPI setting
+ *
+ * This setting determines the default DPI setting for images and fonts. The
+ * DPI may be overridden for inline images by explictly setting the
+ * image's width & height style attributes (i.e. if the image's native
+ * width is 600 pixels and you specify the image's width as 72 points,
+ * the image will have a DPI of 600 in the rendered PDF. The DPI of
+ * background images can not be overridden and is controlled entirely
+ * via this parameter.
+ *
+ * For the purposes of DOMPDF, pixels per inch (PPI) = dots per inch (DPI).
+ * If a size in html is given as px (or without unit as image size),
+ * this tells the corresponding size in pt.
+ * This adjusts the relative sizes to be similar to the rendering of the
+ * html page in a reference browser.
+ *
+ * In pdf, always 1 pt = 1/72 inch
+ *
+ * Rendering resolution of various browsers in px per inch:
+ * Windows Firefox and Internet Explorer:
+ * SystemControl->Display properties->FontResolution: Default:96, largefonts:120, custom:?
+ * Linux Firefox:
+ * about:config *resolution: Default:96
+ * (xorg screen dimension in mm and Desktop font dpi settings are ignored)
+ *
+ * Take care about extra font/image zoom factor of browser.
+ *
+ * In images, size in pixel attribute, img css style, are overriding
+ * the real image dimension in px for rendering.
+ *
+ * @var int
+ */
+ "dpi" => 96,
+
+ /**
+ * Enable inline PHP
+ *
+ * If this setting is set to true then DOMPDF will automatically evaluate
+ * inline PHP contained within tags.
+ *
+ * Enabling this for documents you do not trust (e.g. arbitrary remote html
+ * pages) is a security risk. Set this option to false if you wish to process
+ * untrusted documents.
+ *
+ * @var bool
+ */
+ "enable_php" => false,
+
+ /**
+ * Enable inline Javascript
+ *
+ * If this setting is set to true then DOMPDF will automatically insert
+ * JavaScript code contained within tags.
+ *
+ * @var bool
+ */
+ "enable_javascript" => true,
+
+ /**
+ * Enable remote file access
+ *
+ * If this setting is set to true, DOMPDF will access remote sites for
+ * images and CSS files as required.
+ * This is required for part of test case www/test/image_variants.html through www/examples.php
+ *
+ * Attention!
+ * This can be a security risk, in particular in combination with DOMPDF_ENABLE_PHP and
+ * allowing remote access to dompdf.php or on allowing remote html code to be passed to
+ * $dompdf = new DOMPDF(, $dompdf->load_html(...,
+ * This allows anonymous users to download legally doubtful internet content which on
+ * tracing back appears to being downloaded by your server, or allows malicious php code
+ * in remote html pages to be executed by your server with your account privileges.
+ *
+ * @var bool
+ */
+ "enable_remote" => true,
+
+ /**
+ * A ratio applied to the fonts height to be more like browsers' line height
+ */
+ "font_height_ratio" => 1.1,
+
+ /**
+ * Use the HTML5 Lib parser
+ *
+ * @deprecated This feature is now always on in dompdf 2.x
+ * @var bool
+ */
+ "enable_html5_parser" => true,
+ ],
+
+];
diff --git a/config/excel.php b/config/excel.php
new file mode 100644
index 00000000..6d107518
--- /dev/null
+++ b/config/excel.php
@@ -0,0 +1,355 @@
+ [
+
+ /*
+ |--------------------------------------------------------------------------
+ | Chunk size
+ |--------------------------------------------------------------------------
+ |
+ | When using FromQuery, the query is automatically chunked.
+ | Here you can specify how big the chunk should be.
+ |
+ */
+ 'chunk_size' => env('EXCEL_EXPORTS_CHUNK_SIZE', 100),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Pre-calculate formulas during export
+ |--------------------------------------------------------------------------
+ */
+ 'pre_calculate_formulas' => env('EXCEL_EXPORTS_PRE_CALCULATE_FORMULAS', false),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Enable strict null comparison
+ |--------------------------------------------------------------------------
+ |
+ | When enabling strict null comparison empty cells ('') will
+ | be added to the sheet.
+ */
+ 'strict_null_comparison' => env('EXCEL_EXPORTS_STRING_NULL_COMPARISON', false),
+
+ /*
+ |--------------------------------------------------------------------------
+ | CSV Settings
+ |--------------------------------------------------------------------------
+ |
+ | Configure e.g. delimiter, enclosure and line ending for CSV exports.
+ |
+ */
+ 'csv' => [
+ 'delimiter' => ',',
+ 'enclosure' => '"',
+ 'line_ending' => PHP_EOL,
+ 'use_bom' => false,
+ 'include_separator_line' => false,
+ 'excel_compatibility' => false,
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Worksheet properties
+ |--------------------------------------------------------------------------
+ |
+ | Configure e.g. default title, creator, subject,...
+ |
+ */
+ 'properties' => [
+ 'creator' => 'Akaunting',
+ 'lastModifiedBy' => '',
+ 'title' => '',
+ 'description' => '',
+ 'subject' => '',
+ 'keywords' => '',
+ 'category' => '',
+ 'manager' => '',
+ 'company' => '',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Export validations
+ |--------------------------------------------------------------------------
+ |
+ | Number of rows that will have the dropdown
+ |
+ */
+ 'row_count' => env('EXCEL_EXPORTS_ROW_COUNT', 250),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Export validations
+ |--------------------------------------------------------------------------
+ |
+ | Number of columns to be auto sized
+ |
+ */
+ 'column_count' => env('EXCEL_EXPORTS_COLUMN_COUNT', 25),
+ ],
+
+ 'imports' => [
+
+ 'chunk_size' => env('EXCEL_IMPORTS_CHUNK_SIZE', 100),
+
+ 'row_limit' => env('EXCEL_IMPORTS_ROW_LIMIT', 1000),
+
+ 'extensions' => env('EXCEL_IMPORTS_EXTENSIONS', 'xls,xlsx'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Read Only
+ |--------------------------------------------------------------------------
+ |
+ | When dealing with imports, you might only be interested in the
+ | data that the sheet exists. By default we ignore all styles,
+ | however if you want to do some logic based on style data
+ | you can enable it by setting read_only to false.
+ |
+ */
+ 'read_only' => env('EXCEL_IMPORTS_READ_ONLY', true),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Ignore Empty
+ |--------------------------------------------------------------------------
+ |
+ | When dealing with imports, you might be interested in ignoring
+ | rows that have null values or empty strings. By default rows
+ | containing empty strings or empty values are not ignored but can be
+ | ignored by enabling the setting ignore_empty to true.
+ |
+ */
+ 'ignore_empty' => env('EXCEL_IMPORTS_IGNORE_EMPTY', true),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Heading Row Formatter
+ |--------------------------------------------------------------------------
+ |
+ | Configure the heading row formatter.
+ | Available options: none|slug|custom
+ |
+ */
+ 'heading_row' => [
+ 'formatter' => 'slug',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | CSV Settings
+ |--------------------------------------------------------------------------
+ |
+ | Configure e.g. delimiter, enclosure and line ending for CSV imports.
+ |
+ */
+ 'csv' => [
+ 'delimiter' => ',',
+ 'enclosure' => '"',
+ 'escape_character' => '\\',
+ 'contiguous' => false,
+ 'input_encoding' => 'UTF-8',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Worksheet properties
+ |--------------------------------------------------------------------------
+ |
+ | Configure e.g. default title, creator, subject,...
+ |
+ */
+ 'properties' => [
+ 'creator' => 'Akaunting',
+ 'lastModifiedBy' => '',
+ 'title' => '',
+ 'description' => '',
+ 'subject' => '',
+ 'keywords' => '',
+ 'category' => '',
+ 'manager' => '',
+ 'company' => '',
+ ],
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Extension detector
+ |--------------------------------------------------------------------------
+ |
+ | Configure here which writer type should be used when
+ | the package needs to guess the correct type
+ | based on the extension alone.
+ |
+ */
+ 'extension_detector' => [
+ 'xlsx' => 'Xlsx',
+ 'xlsm' => 'Xlsx',
+ 'xltx' => 'Xlsx',
+ 'xltm' => 'Xlsx',
+ 'xls' => 'Xls',
+ 'xlt' => 'Xls',
+ 'ods' => 'Ods',
+ 'ots' => 'Ods',
+ 'slk' => 'Slk',
+ 'xml' => 'Xml',
+ 'gnumeric' => 'Gnumeric',
+ 'htm' => 'Html',
+ 'html' => 'Html',
+ 'csv' => 'Csv',
+ 'tsv' => 'Csv',
+
+ /*
+ |--------------------------------------------------------------------------
+ | PDF Extension
+ |--------------------------------------------------------------------------
+ |
+ | Configure here which Pdf driver should be used by default.
+ | Available options: Excel::MPDF | Excel::TCPDF | Excel::DOMPDF
+ |
+ */
+ 'pdf' => 'Dompdf',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Value Binder
+ |--------------------------------------------------------------------------
+ |
+ | PhpSpreadsheet offers a way to hook into the process of a value being
+ | written to a cell. In there some assumptions are made on how the
+ | value should be formatted. If you want to change those defaults,
+ | you can implement your own default value binder.
+ |
+ | Possible value binders:
+ |
+ | [x] Maatwebsite\Excel\DefaultValueBinder::class
+ | [x] PhpOffice\PhpSpreadsheet\Cell\StringValueBinder::class
+ | [x] PhpOffice\PhpSpreadsheet\Cell\AdvancedValueBinder::class
+ |
+ */
+ 'value_binder' => [
+ 'default' => 'Maatwebsite\Excel\DefaultValueBinder',
+ ],
+
+ 'cache' => [
+ /*
+ |--------------------------------------------------------------------------
+ | Default cell caching driver
+ |--------------------------------------------------------------------------
+ |
+ | By default PhpSpreadsheet keeps all cell values in memory, however when
+ | dealing with large files, this might result into memory issues. If you
+ | want to mitigate that, you can configure a cell caching driver here.
+ | When using the illuminate driver, it will store each value in a the
+ | cache store. This can slow down the process, because it needs to
+ | store each value. You can use the "batch" store if you want to
+ | only persist to the store when the memory limit is reached.
+ |
+ | Drivers: memory|illuminate|batch
+ |
+ */
+ 'driver' => env('EXCEL_CACHE_DRIVER', 'memory'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Batch memory caching
+ |--------------------------------------------------------------------------
+ |
+ | When dealing with the "batch" caching driver, it will only
+ | persist to the store when the memory limit is reached.
+ | Here you can tweak the memory limit to your liking.
+ |
+ */
+ 'batch' => [
+ 'memory_limit' => env('EXCEL_CACHE_BATCH_MEMORY_LIMIT', 60000),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Illuminate cache
+ |--------------------------------------------------------------------------
+ |
+ | When using the "illuminate" caching driver, it will automatically use
+ | your default cache store. However if you prefer to have the cell
+ | cache on a separate store, you can configure the store name here.
+ | You can use any store defined in your cache config. When leaving
+ | at "null" it will use the default store.
+ |
+ */
+ 'illuminate' => [
+ 'store' => null,
+ ],
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Transaction Handler
+ |--------------------------------------------------------------------------
+ |
+ | By default the import is wrapped in a transaction. This is useful
+ | for when an import may fail and you want to retry it. With the
+ | transactions, the previous import gets rolled-back.
+ |
+ | You can disable the transaction handler by setting this to null.
+ | Or you can choose a custom made transaction handler here.
+ |
+ | Supported handlers: null|db
+ |
+ */
+ 'transactions' => [
+ 'handler' => env('EXCEL_TRANSACTIONS_HANDLER', 'db'),
+ ],
+
+ 'temporary_files' => [
+
+ /*
+ |--------------------------------------------------------------------------
+ | Local Temporary Path
+ |--------------------------------------------------------------------------
+ |
+ | When exporting and importing files, we use a temporary file, before
+ | storing reading or downloading. Here you can customize that path.
+ |
+ */
+ 'local_path' => storage_path('app/temp'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Remote Temporary Disk
+ |--------------------------------------------------------------------------
+ |
+ | When dealing with a multi server setup with queues in which you
+ | cannot rely on having a shared local temporary path, you might
+ | want to store the temporary file on a shared disk. During the
+ | queue executing, we'll retrieve the temporary file from that
+ | location instead. When left to null, it will always use
+ | the local path. This setting only has effect when using
+ | in conjunction with queued imports and exports.
+ |
+ */
+ 'remote_disk' => env('EXCEL_TEMPORARY_FILES_REMOTE_DISK'),
+ 'remote_prefix' => env('EXCEL_TEMPORARY_FILES_REMOTE_PREFIX'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Force Resync
+ |--------------------------------------------------------------------------
+ |
+ | When dealing with a multi server setup as above, it's possible
+ | for the clean up that occurs after entire queue has been run to only
+ | cleanup the server that the last AfterImportJob runs on. The rest of the server
+ | would still have the local temporary file stored on it. In this case your
+ | local storage limits can be exceeded and future imports won't be processed.
+ | To mitigate this you can set this config value to be true, so that after every
+ | queued chunk is processed the local temporary file is deleted on the server that
+ | processed it.
+ |
+ */
+ 'force_resync_remote' => env('EXCEL_TEMPORARY_FILES_FORCE_RESYNC_REMOTE'),
+
+ ],
+
+];
diff --git a/config/filesystems.php b/config/filesystems.php
new file mode 100644
index 00000000..d6566e25
--- /dev/null
+++ b/config/filesystems.php
@@ -0,0 +1,136 @@
+ env('FILESYSTEM_DISK', 'uploads'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Default Cloud Filesystem Disk
+ |--------------------------------------------------------------------------
+ |
+ | Many applications store files both locally and in the cloud. For this
+ | reason, you may specify a default "cloud" driver here. This driver
+ | will be bound as the Cloud disk implementation in the container.
+ |
+ */
+
+ 'cloud' => 'uploads',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Allowed file mimes
+ |--------------------------------------------------------------------------
+ */
+
+ 'mimes' => env('FILESYSTEM_MIMES', 'pdf,jpeg,jpg,png'),
+
+
+ /*
+ |--------------------------------------------------------------------------
+ | Allowed file max size, in MB
+ |--------------------------------------------------------------------------
+ */
+
+ 'max_size' => env('FILESYSTEM_MAX_SIZE', '2'),
+
+
+ /*
+ |--------------------------------------------------------------------------
+ | Allowed image max width, in pixes
+ |--------------------------------------------------------------------------
+ */
+
+ 'max_width' => env('FILESYSTEM_MAX_WIDTH', '1000'),
+
+
+ /*
+ |--------------------------------------------------------------------------
+ | Allowed image max height, in pixes
+ |--------------------------------------------------------------------------
+ */
+
+ 'max_height' => env('FILESYSTEM_MAX_HEIGHT', '1000'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Filesystem Disks
+ |--------------------------------------------------------------------------
+ |
+ | Here you may configure as many filesystem "disks" as you wish, and you
+ | may even configure multiple disks of the same driver. Defaults have
+ | been set up for each driver as an example of the required values.
+ |
+ | Supported Drivers: "local", "ftp", "sftp", "s3"
+ |
+ */
+
+ 'disks' => [
+
+ 'local' => [
+ 'driver' => 'local',
+ 'root' => storage_path('app'),
+ 'throw' => false,
+ ],
+
+ 'public' => [
+ 'driver' => 'local',
+ 'root' => storage_path('app/public'),
+ 'url' => app()->runningInConsole() ? '' : url('/') . '/storage',
+ 'visibility' => 'public',
+ 'throw' => false,
+ ],
+
+ 'temp' => [
+ 'driver' => 'local',
+ 'root' => storage_path('app/temp'),
+ 'url' => app()->runningInConsole() ? '' : url('/') . '/temp',
+ 'visibility' => 'private',
+ 'throw' => false,
+ ],
+
+ 'uploads' => [
+ 'driver' => 'local',
+ 'root' => storage_path('app/uploads'),
+ 'url' => app()->runningInConsole() ? '' : url('/') . '/uploads',
+ 'visibility' => 'private',
+ 'throw' => false,
+ ],
+
+ 's3' => [
+ 'driver' => 'local',
+ 'root' => storage_path('app/uploads'),
+ 'url' => app()->runningInConsole() ? '' : url('/') . '/uploads',
+ 'visibility' => 'private',
+ 'throw' => false,
+ ],
+
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Symbolic Links
+ |--------------------------------------------------------------------------
+ |
+ | Here you may configure the symbolic links that will be created when the
+ | `storage:link` Artisan command is executed. The array keys should be
+ | the locations of the links and the values should be their targets.
+ |
+ */
+
+ 'links' => [
+ public_path('storage') => storage_path('app/public'),
+ ],
+
+];
diff --git a/config/firewall.php b/config/firewall.php
new file mode 100644
index 00000000..d3675728
--- /dev/null
+++ b/config/firewall.php
@@ -0,0 +1,466 @@
+ env('FIREWALL_ENABLED', false),
+
+ 'whitelist' => explode(',', env('FIREWALL_WHITELIST', '')),
+
+ 'models' => [
+ 'user' => '\App\Models\Auth\User',
+ // 'log' => '\App\Models\YourLogModel',
+ // 'ip' => '\App\Models\YourIpModel',
+ ],
+
+ 'log' => [
+ 'max_request_size' => 2048,
+ ],
+
+ 'cron' => [
+ 'enabled' => env('FIREWALL_CRON_ENABLED', true),
+ 'expression' => env('FIREWALL_CRON_EXPRESSION', '* * * * *'),
+ ],
+
+ 'responses' => [
+
+ 'block' => [
+ 'view' => env('FIREWALL_BLOCK_VIEW', null),
+ 'redirect' => env('FIREWALL_BLOCK_REDIRECT', null),
+ 'abort' => env('FIREWALL_BLOCK_ABORT', false),
+ 'code' => env('FIREWALL_BLOCK_CODE', 403),
+ ],
+
+ ],
+
+ 'notifications' => [
+
+ 'mail' => [
+ 'enabled' => env('FIREWALL_EMAIL_ENABLED', false),
+ 'name' => env('FIREWALL_EMAIL_NAME', 'Akaunting Firewall'),
+ 'from' => env('FIREWALL_EMAIL_FROM', 'firewall@mydomain.com'),
+ 'to' => env('FIREWALL_EMAIL_TO', 'admin@mydomain.com'),
+ 'queue' => env('FIREWALL_EMAIL_QUEUE', 'default'),
+ ],
+
+ 'slack' => [
+ 'enabled' => env('FIREWALL_SLACK_ENABLED', false),
+ 'emoji' => env('FIREWALL_SLACK_EMOJI', ':fire:'),
+ 'from' => env('FIREWALL_SLACK_FROM', 'Akaunting Firewall'),
+ 'to' => env('FIREWALL_SLACK_TO'), // webhook url
+ 'channel' => env('FIREWALL_SLACK_CHANNEL', null), // set null to use the default channel of webhook
+ 'queue' => env('FIREWALL_SLACK_QUEUE', 'default'),
+ ],
+
+ ],
+
+ 'all_middleware' => [
+ 'firewall.ip',
+ 'firewall.agent',
+ 'firewall.bot',
+ 'firewall.geo',
+ 'firewall.lfi',
+ 'firewall.php',
+ 'firewall.referrer',
+ 'firewall.rfi',
+ 'firewall.session',
+ //'firewall.sqli',
+ 'firewall.swear',
+ 'firewall.xss',
+ //'App\Http\Middleware\YourCustomRule',
+ ],
+
+ 'middleware' => [
+
+ 'ip' => [
+ 'enabled' => env('FIREWALL_MIDDLEWARE_IP_ENABLED', env('FIREWALL_ENABLED', true)),
+
+ 'methods' => ['all'],
+
+ 'routes' => [
+ 'only' => [], // i.e. 'contact'
+ 'except' => [], // i.e. 'admin/*'
+ ],
+ ],
+
+ 'agent' => [
+ 'enabled' => env('FIREWALL_MIDDLEWARE_AGENT_ENABLED', env('FIREWALL_ENABLED', true)),
+
+ 'methods' => ['all'],
+
+ 'routes' => [
+ 'only' => [], // i.e. 'contact'
+ 'except' => [], // i.e. 'admin/*'
+ ],
+
+ // https://github.com/jenssegers/agent
+ 'browsers' => [
+ 'allow' => [], // i.e. 'Chrome', 'Firefox'
+ 'block' => [], // i.e. 'IE'
+ ],
+
+ 'platforms' => [
+ 'allow' => [], // i.e. 'Ubuntu', 'Windows'
+ 'block' => [], // i.e. 'OS X'
+ ],
+
+ 'devices' => [
+ 'allow' => [], // i.e. 'Desktop', 'Mobile'
+ 'block' => [], // i.e. 'Tablet'
+ ],
+
+ 'properties' => [
+ 'allow' => [], // i.e. 'Gecko', 'Version/5.1.7'
+ 'block' => [], // i.e. 'AppleWebKit'
+ ],
+
+ 'auto_block' => [
+ 'attempts' => env('FIREWALL_MIDDLEWARE_AGENT_AUTO_BLOCK_ATTEMPTS', 5),
+ 'frequency' => 1 * 60, // 1 minute
+ 'period' => 30 * 60, // 30 minutes
+ ],
+ ],
+
+ 'bot' => [
+ 'enabled' => env('FIREWALL_MIDDLEWARE_BOT_ENABLED', env('FIREWALL_ENABLED', true)),
+
+ 'methods' => ['all'],
+
+ 'routes' => [
+ 'only' => [], // i.e. 'contact'
+ 'except' => [], // i.e. 'admin/*'
+ ],
+
+ // https://github.com/JayBizzle/Crawler-Detect/blob/master/raw/Crawlers.txt
+ 'crawlers' => [
+ 'allow' => [], // i.e. 'GoogleSites', 'GuzzleHttp'
+ 'block' => [], // i.e. 'Holmes'
+ ],
+
+ 'auto_block' => [
+ 'attempts' => env('FIREWALL_MIDDLEWARE_BOT_AUTO_BLOCK_ATTEMPTS', 5),
+ 'frequency' => 1 * 60, // 1 minute
+ 'period' => 30 * 60, // 30 minutes
+ ],
+ ],
+
+ 'geo' => [
+ 'enabled' => env('FIREWALL_MIDDLEWARE_GEO_ENABLED', env('FIREWALL_ENABLED', true)),
+
+ 'methods' => ['all'],
+
+ 'routes' => [
+ 'only' => [], // i.e. 'contact'
+ 'except' => [], // i.e. 'admin/*'
+ ],
+
+ 'continents' => [
+ 'allow' => [], // i.e. 'Africa'
+ 'block' => [], // i.e. 'Europe'
+ ],
+
+ 'regions' => [
+ 'allow' => [], // i.e. 'California'
+ 'block' => [], // i.e. 'Nevada'
+ ],
+
+ 'countries' => [
+ 'allow' => [], // i.e. 'Albania'
+ 'block' => [], // i.e. 'Madagascar'
+ ],
+
+ 'cities' => [
+ 'allow' => [], // i.e. 'Istanbul'
+ 'block' => [], // i.e. 'London'
+ ],
+
+ // ipapi, extremeiplookup, ipstack, ipdata, ipinfo
+ 'service' => 'ipapi',
+
+ 'auto_block' => [
+ 'attempts' => env('FIREWALL_MIDDLEWARE_GEO_AUTO_BLOCK_ATTEMPTS', 3),
+ 'frequency' => 5 * 60, // 5 minutes
+ 'period' => 30 * 60, // 30 minutes
+ ],
+ ],
+
+ 'lfi' => [
+ 'enabled' => env('FIREWALL_MIDDLEWARE_LFI_ENABLED', env('FIREWALL_ENABLED', true)),
+
+ 'methods' => ['get', 'delete'],
+
+ 'routes' => [
+ 'only' => [], // i.e. 'contact'
+ 'except' => [], // i.e. 'admin/*'
+ ],
+
+ 'inputs' => [
+ 'only' => [], // i.e. 'first_name'
+ 'except' => [], // i.e. 'password'
+ ],
+
+ 'patterns' => [
+ '#\.\/#is',
+ ],
+
+ 'auto_block' => [
+ 'attempts' => env('FIREWALL_MIDDLEWARE_LFI_AUTO_BLOCK_ATTEMPTS', 3),
+ 'frequency' => 5 * 60, // 5 minutes
+ 'period' => 30 * 60, // 30 minutes
+ ],
+ ],
+
+ 'login' => [
+ 'enabled' => env('FIREWALL_MIDDLEWARE_LOGIN_ENABLED', env('FIREWALL_ENABLED', true)),
+
+ 'auto_block' => [
+ 'attempts' => env('FIREWALL_MIDDLEWARE_LOGIN_AUTO_BLOCK_ATTEMPTS', 10),
+ 'frequency' => 1 * 60, // 1 minute
+ 'period' => 30 * 60, // 30 minutes
+ ],
+ ],
+
+ 'php' => [
+ 'enabled' => env('FIREWALL_MIDDLEWARE_PHP_ENABLED', env('FIREWALL_ENABLED', true)),
+
+ 'methods' => ['get', 'post', 'delete'],
+
+ 'routes' => [
+ 'only' => [], // i.e. 'contact'
+ 'except' => [], // i.e. 'admin/*'
+ ],
+
+ 'inputs' => [
+ 'only' => [], // i.e. 'first_name'
+ 'except' => [], // i.e. 'password'
+ ],
+
+ 'patterns' => [
+ 'bzip2://',
+ 'expect://',
+ 'glob://',
+ 'phar://',
+ 'php://',
+ 'ogg://',
+ 'rar://',
+ 'ssh2://',
+ 'zip://',
+ 'zlib://',
+ ],
+
+ 'auto_block' => [
+ 'attempts' => env('FIREWALL_MIDDLEWARE_PHP_AUTO_BLOCK_ATTEMPTS', 3),
+ 'frequency' => 5 * 60, // 5 minutes
+ 'period' => 30 * 60, // 30 minutes
+ ],
+ ],
+
+ 'referrer' => [
+ 'enabled' => env('FIREWALL_MIDDLEWARE_REFERRER_ENABLED', env('FIREWALL_ENABLED', true)),
+
+ 'methods' => ['all'],
+
+ 'routes' => [
+ 'only' => [], // i.e. 'contact'
+ 'except' => [], // i.e. 'admin/*'
+ ],
+
+ 'blocked' => [],
+
+ 'auto_block' => [
+ 'attempts' => env('FIREWALL_MIDDLEWARE_REFERRER_AUTO_BLOCK_ATTEMPTS', 3),
+ 'frequency' => 5 * 60, // 5 minutes
+ 'period' => 30 * 60, // 30 minutes
+ ],
+ ],
+
+ 'rfi' => [
+ 'enabled' => env('FIREWALL_MIDDLEWARE_RFI_ENABLED', env('FIREWALL_ENABLED', true)),
+
+ 'methods' => ['get', 'post', 'delete'],
+
+ 'routes' => [
+ 'only' => [], // i.e. 'contact'
+ 'except' => [], // i.e. 'admin/*'
+ ],
+
+ 'inputs' => [
+ 'only' => [], // i.e. 'first_name'
+ 'except' => [
+ 'body', // for custom email add link
+ ], // i.e. 'password'
+ ],
+
+ 'patterns' => [
+ '#(http|ftp){1,1}(s){0,1}://.*#i',
+ ],
+
+ 'exceptions' => [],
+
+ 'auto_block' => [
+ 'attempts' => env('FIREWALL_MIDDLEWARE_RFI_AUTO_BLOCK_ATTEMPTS', 3),
+ 'frequency' => 5 * 60, // 5 minutes
+ 'period' => 30 * 60, // 30 minutes
+ ],
+ ],
+
+ 'session' => [
+ 'enabled' => env('FIREWALL_MIDDLEWARE_SESSION_ENABLED', env('FIREWALL_ENABLED', true)),
+
+ 'methods' => ['get', 'post', 'delete'],
+
+ 'routes' => [
+ 'only' => [], // i.e. 'contact'
+ 'except' => [], // i.e. 'admin/*'
+ ],
+
+ 'inputs' => [
+ 'only' => [], // i.e. 'first_name'
+ 'except' => [], // i.e. 'password'
+ ],
+
+ 'patterns' => [
+ '@[\|:]O:\d{1,}:"[\w_][\w\d_]{0,}":\d{1,}:{@i',
+ '@[\|:]a:\d{1,}:{@i',
+ ],
+
+ 'auto_block' => [
+ 'attempts' => env('FIREWALL_MIDDLEWARE_SESSION_AUTO_BLOCK_ATTEMPTS', 3),
+ 'frequency' => 5 * 60, // 5 minutes
+ 'period' => 30 * 60, // 30 minutes
+ ],
+ ],
+
+ 'sqli' => [
+ 'enabled' => env('FIREWALL_MIDDLEWARE_SQLI_ENABLED', env('FIREWALL_ENABLED', true)),
+
+ 'methods' => ['get', 'delete'],
+
+ 'routes' => [
+ 'only' => [], // i.e. 'contact'
+ 'except' => [], // i.e. 'admin/*'
+ ],
+
+ 'inputs' => [
+ 'only' => [], // i.e. 'first_name'
+ 'except' => [], // i.e. 'password'
+ ],
+
+ 'patterns' => [
+ '#[\d\W](union select|union join|union distinct)[\d\W]#is',
+ '#[\d\W](union|union select|insert|from|where|concat|into|cast|truncate|select|delete|having)[\d\W]#is',
+ ],
+
+ 'auto_block' => [
+ 'attempts' => env('FIREWALL_MIDDLEWARE_SQLI_AUTO_BLOCK_ATTEMPTS', 3),
+ 'frequency' => 5 * 60, // 5 minutes
+ 'period' => 30 * 60, // 30 minutes
+ ],
+ ],
+
+ 'swear' => [
+ 'enabled' => env('FIREWALL_MIDDLEWARE_SWEAR_ENABLED', env('FIREWALL_ENABLED', true)),
+
+ 'methods' => ['post', 'put', 'patch'],
+
+ 'routes' => [
+ 'only' => [], // i.e. 'contact'
+ 'except' => [], // i.e. 'admin/*'
+ ],
+
+ 'inputs' => [
+ 'only' => [], // i.e. 'first_name'
+ 'except' => [], // i.e. 'password'
+ ],
+
+ 'words' => [],
+
+ 'auto_block' => [
+ 'attempts' => env('FIREWALL_MIDDLEWARE_SWEAR_AUTO_BLOCK_ATTEMPTS', 3),
+ 'frequency' => 5 * 60, // 5 minutes
+ 'period' => 30 * 60, // 30 minutes
+ ],
+ ],
+
+ 'url' => [
+ 'enabled' => env('FIREWALL_MIDDLEWARE_URL_ENABLED', env('FIREWALL_ENABLED', true)),
+
+ 'methods' => ['all'],
+
+ 'inspections' => [], // i.e. 'admin'
+
+ 'auto_block' => [
+ 'attempts' => env('FIREWALL_MIDDLEWARE_URL_AUTO_BLOCK_ATTEMPTS', 5),
+ 'frequency' => 1 * 60, // 1 minute
+ 'period' => 30 * 60, // 30 minutes
+ ],
+ ],
+
+ 'whitelist' => [
+ 'enabled' => env('FIREWALL_MIDDLEWARE_WHITELIST_ENABLED', env('FIREWALL_ENABLED', true)),
+
+ 'methods' => ['all'],
+
+ 'routes' => [
+ 'only' => [], // i.e. 'contact'
+ 'except' => [], // i.e. 'admin/*'
+ ],
+ ],
+
+ 'xss' => [
+ 'enabled' => env('FIREWALL_MIDDLEWARE_XSS_ENABLED', env('FIREWALL_ENABLED', true)),
+
+ 'methods' => ['post', 'put', 'patch'],
+
+ 'routes' => [
+ 'only' => [], // i.e. 'contact'
+ 'except' => [], // i.e. 'admin/*'
+ ],
+
+ 'inputs' => [
+ 'only' => [], // i.e. 'first_name'
+ 'except' => [], // i.e. 'password'
+ ],
+
+ 'patterns' => [
+ // Evil starting attributes
+ '#(<[^>]+[\x00-\x20\"\'\/])(form|formaction|on\w*|style|xmlns|xlink:href)[^>]*>?#iUu',
+
+ // javascript:, livescript:, vbscript:, mocha: protocols (literal)
+ '!((java|live|vb)script|mocha|feed|data):(\w)*!iUu',
+ '#-moz-binding[\x00-\x20]*:#u',
+
+ // HTML entity-encoded protocol bypass (e.g. javascript: or java\u0073cript:)
+ '/j[\s\S]*a[\s\S]*v[\s\S]*a[\s\S]*s[\s\S]*c[\s\S]*r[\s\S]*i[\s\S]*p[\s\S]*t[\s\S]*:/iu',
+
+ // Unneeded tags
+ '#*(applet|meta|xml|blink|link|style|script|embed|object|iframe|frame|frameset|ilayer|layer|bgsound|title|base|img)[^>]*>?#i'
+ ],
+
+ 'auto_block' => [
+ 'attempts' => env('FIREWALL_MIDDLEWARE_XSS_AUTO_BLOCK_ATTEMPTS', 3),
+ 'frequency' => 5 * 60, // 5 minutes
+ 'period' => 30 * 60, // 30 minutes
+ ],
+ ],
+
+ // Custom middleware
+ 'too_many_emails_sent' => [
+ 'enabled' => env('FIREWALL_MIDDLEWARE_TOO_MANY_EMAILS_SENT_ENABLED', env('FIREWALL_ENABLED', true)),
+
+ 'methods' => ['post'],
+
+ 'routes' => [
+ 'only' => [], // i.e. 'contact'
+ 'except' => [], // i.e. 'admin/*'
+ ],
+
+ 'auto_block' => [
+ 'attempts' => env('FIREWALL_MIDDLEWARE_TOO_MANY_EMAILS_SENT_AUTO_BLOCK_ATTEMPTS', 20),
+ 'frequency' => 1 * 60, // 1 minute
+ 'period' => 30 * 60, // 30 minutes
+ ],
+ ],
+
+ ],
+
+];
diff --git a/config/hashing.php b/config/hashing.php
new file mode 100644
index 00000000..bcd3be4c
--- /dev/null
+++ b/config/hashing.php
@@ -0,0 +1,52 @@
+ 'bcrypt',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Bcrypt Options
+ |--------------------------------------------------------------------------
+ |
+ | Here you may specify the configuration options that should be used when
+ | passwords are hashed using the Bcrypt algorithm. This will allow you
+ | to control the amount of time it takes to hash the given password.
+ |
+ */
+
+ 'bcrypt' => [
+ 'rounds' => env('BCRYPT_ROUNDS', 10),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Argon Options
+ |--------------------------------------------------------------------------
+ |
+ | Here you may specify the configuration options that should be used when
+ | passwords are hashed using the Argon algorithm. These will allow you
+ | to control the amount of time it takes to hash the given password.
+ |
+ */
+
+ 'argon' => [
+ 'memory' => 65536,
+ 'threads' => 1,
+ 'time' => 4,
+ ],
+
+];
diff --git a/config/ide-helper.php b/config/ide-helper.php
new file mode 100644
index 00000000..1d579eba
--- /dev/null
+++ b/config/ide-helper.php
@@ -0,0 +1,246 @@
+ '_ide_helper',
+ 'format' => 'php',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Where to write the PhpStorm specific meta file
+ |--------------------------------------------------------------------------
+ |
+ | PhpStorm also supports the directory `.phpstorm.meta.php/` with arbitrary
+ | files in it, should you need additional files for your project; e.g.
+ | `.phpstorm.meta.php/laravel_ide_Helper.php'.
+ |
+ */
+ 'meta_filename' => '.phpstorm.meta.php',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Fluent helpers
+ |--------------------------------------------------------------------------
+ |
+ | Set to true to generate commonly used Fluent methods
+ |
+ */
+
+ 'include_fluent' => true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Factory Builders
+ |--------------------------------------------------------------------------
+ |
+ | Set to true to generate factory generators for better factory()
+ | method auto-completion.
+ |
+ */
+
+ 'include_factory_builders' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Write Model Magic methods
+ |--------------------------------------------------------------------------
+ |
+ | Set to false to disable write magic methods of model
+ |
+ */
+
+ 'write_model_magic_where' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Write Model relation count properties
+ |--------------------------------------------------------------------------
+ |
+ | Set to false to disable writing of relation count properties to model DocBlocks.
+ |
+ */
+
+ 'write_model_relation_count_properties' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Write Eloquent Model Mixins
+ |--------------------------------------------------------------------------
+ |
+ | This will add the necessary DocBlock mixins to the model class
+ | contained in the Laravel Framework. This helps the IDE with
+ | auto-completion.
+ |
+ | Please be aware that this setting changes a file within the /vendor directory.
+ |
+ */
+
+ 'write_eloquent_model_mixins' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Helper files to include
+ |--------------------------------------------------------------------------
+ |
+ | Include helper files. By default not included, but can be toggled with the
+ | -- helpers (-H) option. Extra helper files can be included.
+ |
+ */
+
+ 'include_helpers' => false,
+
+ 'helper_files' => array(
+ base_path() . '/vendor/laravel/framework/src/Illuminate/Support/helpers.php',
+ ),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Model locations to include
+ |--------------------------------------------------------------------------
+ |
+ | Define in which directories the ide-helper:models command should look
+ | for models.
+ |
+ | glob patterns are supported to easier reach models in sub-directories,
+ | e.g. `app/Services/* /Models` (without the space)
+ |
+ */
+
+ 'model_locations' => array(
+ 'app/Models',
+ 'modules/*/Models'
+ ),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Models to ignore
+ |--------------------------------------------------------------------------
+ |
+ | Define which models should be ignored.
+ |
+ */
+
+ 'ignored_models' => array(
+
+ ),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Extra classes
+ |--------------------------------------------------------------------------
+ |
+ | These implementations are not really extended, but called with magic functions
+ |
+ */
+
+ 'extra' => array(
+ 'Eloquent' => array('Illuminate\Database\Eloquent\Builder', 'Illuminate\Database\Query\Builder'),
+ 'Session' => array('Illuminate\Session\Store'),
+ ),
+
+ 'magic' => array(),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Interface implementations
+ |--------------------------------------------------------------------------
+ |
+ | These interfaces will be replaced with the implementing class. Some interfaces
+ | are detected by the helpers, others can be listed below.
+ |
+ */
+
+ 'interfaces' => array(
+
+ ),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Support for custom DB types
+ |--------------------------------------------------------------------------
+ |
+ | This setting allow you to map any custom database type (that you may have
+ | created using CREATE TYPE statement or imported using database plugin
+ | / extension to a Doctrine type.
+ |
+ | Each key in this array is a name of the Doctrine2 DBAL Platform. Currently valid names are:
+ | 'postgresql', 'db2', 'drizzle', 'mysql', 'oracle', 'sqlanywhere', 'sqlite', 'mssql'
+ |
+ | This name is returned by getName() method of the specific Doctrine/DBAL/Platforms/AbstractPlatform descendant
+ |
+ | The value of the array is an array of type mappings. Key is the name of the custom type,
+ | (for example, "jsonb" from Postgres 9.4) and the value is the name of the corresponding Doctrine2 type (in
+ | our case it is 'json_array'. Doctrine types are listed here:
+ | http://doctrine-dbal.readthedocs.org/en/latest/reference/types.html
+ |
+ | So to support jsonb in your models when working with Postgres, just add the following entry to the array below:
+ |
+ | "postgresql" => array(
+ | "jsonb" => "json_array",
+ | ),
+ |
+ */
+ 'custom_db_types' => array(
+
+ ),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Support for camel cased models
+ |--------------------------------------------------------------------------
+ |
+ | There are some Laravel packages (such as Eloquence) that allow for accessing
+ | Eloquent model properties via camel case, instead of snake case.
+ |
+ | Enabling this option will support these packages by saving all model
+ | properties as camel case, instead of snake case.
+ |
+ | For example, normally you would see this:
+ |
+ | * @property \Illuminate\Support\Carbon $created_at
+ | * @property \Illuminate\Support\Carbon $updated_at
+ |
+ | With this enabled, the properties will be this:
+ |
+ | * @property \Illuminate\Support\Carbon $createdAt
+ | * @property \Illuminate\Support\Carbon $updatedAt
+ |
+ | Note, it is currently an all-or-nothing option.
+ |
+ */
+ 'model_camel_case_properties' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Property Casts
+ |--------------------------------------------------------------------------
+ |
+ | Cast the given "real type" to the given "type".
+ |
+ */
+ 'type_overrides' => array(
+ 'integer' => 'int',
+ 'boolean' => 'bool',
+ ),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Include DocBlocks from classes
+ |--------------------------------------------------------------------------
+ |
+ | Include DocBlocks from classes to allow additional code inspection for
+ | magic methods and properties.
+ |
+ */
+ 'include_class_docblocks' => false,
+
+);
diff --git a/config/image.php b/config/image.php
new file mode 100644
index 00000000..b106809e
--- /dev/null
+++ b/config/image.php
@@ -0,0 +1,20 @@
+ 'gd'
+
+);
diff --git a/config/imagecache.php b/config/imagecache.php
new file mode 100644
index 00000000..99a1cc8a
--- /dev/null
+++ b/config/imagecache.php
@@ -0,0 +1,71 @@
+ null,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Storage paths
+ |--------------------------------------------------------------------------
+ |
+ | The following paths will be searched for the image filename, submitted
+ | by URI.
+ |
+ | Define as many directories as you like.
+ |
+ */
+
+ 'paths' => array(
+ public_path('upload'),
+ public_path('images')
+ ),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Manipulation templates
+ |--------------------------------------------------------------------------
+ |
+ | Here you may specify your own manipulation filter templates.
+ | The keys of this array will define which templates
+ | are available in the URI:
+ |
+ | {route}/{template}/{filename}
+ |
+ | The values of this array will define which filter class
+ | will be applied, by its fully qualified name.
+ |
+ */
+
+ 'templates' => array(
+ 'small' => 'Intervention\Image\Templates\Small',
+ 'medium' => 'Intervention\Image\Templates\Medium',
+ 'large' => 'Intervention\Image\Templates\Large',
+ ),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Image Cache Lifetime
+ |--------------------------------------------------------------------------
+ |
+ | Lifetime in minutes of the images handled by the imagecache route.
+ |
+ */
+
+ 'lifetime' => 43200,
+
+);
diff --git a/config/import.php b/config/import.php
new file mode 100644
index 00000000..c0d187c9
--- /dev/null
+++ b/config/import.php
@@ -0,0 +1,36 @@
+ [
+
+ // Type
+ 'transactions' => [
+
+ //'path' => 'banking/transactions',
+ //'title_type => 'transactions',
+ //'sample_file' => 'public/files/import/transactions.xlsx',
+ /*'form_params' => [
+ 'id' => 'import',
+ '@submit.prevent' => 'onSubmit',
+ '@keydown' => 'form.errors.clear($event.target.name)',
+ 'files' => true,
+ 'role' => 'form',
+ 'class' => 'form-loading-button',
+ 'novalidate' => true,
+ 'route' => '',
+ 'url' => '',
+ ],*/
+ 'document_link' => '',
+ 'view' => 'banking.transactions.import',
+
+ ],
+ ],
+
+];
diff --git a/config/language.php b/config/language.php
new file mode 100644
index 00000000..9606a9d4
--- /dev/null
+++ b/config/language.php
@@ -0,0 +1,231 @@
+ true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Enable Language Home Route
+ |--------------------------------------------------------------------------
+ |
+ | This option enable language route to set language and return
+ | to url('/')
+ |
+ */
+ 'home' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Add Language Code
+ |--------------------------------------------------------------------------
+ |
+ | This option will add the language code to the redirected url
+ |
+ */
+ 'url' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Set strategy
+ |--------------------------------------------------------------------------
+ |
+ | This option will determine the strategy used to determine the back url.
+ | It can be 'session' (default) or 'referer'
+ |
+ */
+ 'back' => 'session',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Carbon Language
+ |--------------------------------------------------------------------------
+ |
+ | This option the language of carbon library.
+ |
+ */
+ 'carbon' => true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Date Language
+ |--------------------------------------------------------------------------
+ |
+ | This option the language of jenssegers/date library.
+ |
+ */
+ 'date' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Auto Change Language
+ |--------------------------------------------------------------------------
+ |
+ | This option allows to change website language to user's
+ | browser language.
+ |
+ */
+ 'auto' => true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Routes Prefix
+ |--------------------------------------------------------------------------
+ |
+ | This option indicates the prefix for language routes.
+ |
+ */
+ 'prefix' => 'languages',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Middleware
+ |--------------------------------------------------------------------------
+ |
+ | This option indicates the middleware to change language.
+ |
+ */
+ 'middleware' => 'Akaunting\Language\Middleware\SetLocale',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Controller
+ |--------------------------------------------------------------------------
+ |
+ | This option indicates the controller to be used.
+ |
+ */
+ 'controller' => 'Akaunting\Language\Controllers\Language',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Flags
+ |--------------------------------------------------------------------------
+ |
+ | This option indicates the flags features.
+ |
+ */
+ 'flags' => ['width' => '22px', 'ul_class' => 'menu', 'li_class' => '', 'img_class' => ''],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Language code mode
+ |--------------------------------------------------------------------------
+ |
+ | This option indicates the language code and name to be used, short/long
+ | and english/native.
+ | Short: language code (en)
+ | Long: languagecode-COUNTRYCODE (en-GB)
+ |
+ */
+ 'mode' => ['code' => 'long', 'name' => 'native'],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Allowed languages
+ |--------------------------------------------------------------------------
+ |
+ | This options indicates the language allowed languages.
+ |
+ */
+ 'allowed' => [
+ 'ar-SA',
+ 'de-DE',
+ 'el-GR',
+ 'en-AU',
+ 'en-GB',
+ 'en-US',
+ 'es-ES',
+ 'es-MX',
+ 'fr-FR',
+ 'hi-IN',
+ 'id-ID',
+ 'it-IT',
+ 'ms-MY',
+ 'nl-NL',
+ 'pl-PL',
+ 'pt-BR',
+ 'ru-RU',
+ 'tr-TR',
+ 'zh-CN',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | All Languages
+ |--------------------------------------------------------------------------
+ |
+ | This option indicates the language codes and names.
+ |
+ */
+ 'all' => [
+ ['short' => 'ar', 'long' => 'ar-SA', 'direction' => 'rtl', 'english' => 'Arabic', 'native' => 'العربية'],
+ ['short' => 'az', 'long' => 'az-AZ', 'direction' => 'ltr', 'english' => 'Azerbaijani', 'native' => 'Azərbaycan'],
+ ['short' => 'bg', 'long' => 'bg-BG', 'direction' => 'ltr', 'english' => 'Bulgarian', 'native' => 'български'],
+ ['short' => 'bn', 'long' => 'bn-BD', 'direction' => 'ltr', 'english' => 'Bengali', 'native' => 'বাংলা'],
+ ['short' => 'bs', 'long' => 'bs-BA', 'direction' => 'ltr', 'english' => 'Bosnian', 'native' => 'Bosanski'],
+ ['short' => 'ca', 'long' => 'ca-ES', 'direction' => 'ltr', 'english' => 'Catalan', 'native' => 'Català'],
+ ['short' => 'cn', 'long' => 'zh-CN', 'direction' => 'ltr', 'english' => 'Chinese (S)', 'native' => '简体中文'],
+ ['short' => 'cs', 'long' => 'cs-CZ', 'direction' => 'ltr', 'english' => 'Czech', 'native' => 'Čeština'],
+ ['short' => 'da', 'long' => 'da-DK', 'direction' => 'ltr', 'english' => 'Danish', 'native' => 'Dansk'],
+ ['short' => 'de', 'long' => 'de-DE', 'direction' => 'ltr', 'english' => 'German', 'native' => 'Deutsch'],
+ ['short' => 'de', 'long' => 'de-AT', 'direction' => 'ltr', 'english' => 'Austrian', 'native' => 'Österreichisches Deutsch'],
+ ['short' => 'fi', 'long' => 'fi-FI', 'direction' => 'ltr', 'english' => 'Finnish', 'native' => 'Suomi'],
+ ['short' => 'fr', 'long' => 'fr-FR', 'direction' => 'ltr', 'english' => 'French', 'native' => 'Français'],
+ ['short' => 'ea', 'long' => 'es-AR', 'direction' => 'ltr', 'english' => 'Spanish (Argentina)', 'native' => 'Español de Argentina'],
+ ['short' => 'el', 'long' => 'el-GR', 'direction' => 'ltr', 'english' => 'Greek', 'native' => 'Ελληνικά'],
+ ['short' => 'en', 'long' => 'en-AU', 'direction' => 'ltr', 'english' => 'English (AU)', 'native' => 'English (AU)'],
+ ['short' => 'en', 'long' => 'en-CA', 'direction' => 'ltr', 'english' => 'English (CA)', 'native' => 'English (CA)'],
+ ['short' => 'en', 'long' => 'en-GB', 'direction' => 'ltr', 'english' => 'English (GB)', 'native' => 'English (GB)'],
+ ['short' => 'en', 'long' => 'en-US', 'direction' => 'ltr', 'english' => 'English (US)', 'native' => 'English (US)'],
+ ['short' => 'es', 'long' => 'es-ES', 'direction' => 'ltr', 'english' => 'Spanish', 'native' => 'Español'],
+ ['short' => 'et', 'long' => 'et-EE', 'direction' => 'ltr', 'english' => 'Estonian', 'native' => 'Eesti'],
+ ['short' => 'he', 'long' => 'he-IL', 'direction' => 'rtl', 'english' => 'Hebrew', 'native' => 'עִבְרִית'],
+ ['short' => 'hi', 'long' => 'hi-IN', 'direction' => 'ltr', 'english' => 'Hindi', 'native' => 'हिन्दी'],
+ ['short' => 'hr', 'long' => 'hr-HR', 'direction' => 'ltr', 'english' => 'Croatian', 'native' => 'Hrvatski'],
+ ['short' => 'hu', 'long' => 'hu-HU', 'direction' => 'ltr', 'english' => 'Hungarian', 'native' => 'Magyar'],
+ ['short' => 'hy', 'long' => 'hy-AM', 'direction' => 'ltr', 'english' => 'Armenian', 'native' => 'Հայերեն',],
+ ['short' => 'id', 'long' => 'id-ID', 'direction' => 'ltr', 'english' => 'Indonesian', 'native' => 'Bahasa Indonesia'],
+ ['short' => 'is', 'long' => 'is-IS', 'direction' => 'ltr', 'english' => 'Icelandic', 'native' => 'Íslenska'],
+ ['short' => 'it', 'long' => 'it-IT', 'direction' => 'ltr', 'english' => 'Italian', 'native' => 'Italiano'],
+ ['short' => 'ir', 'long' => 'fa-IR', 'direction' => 'rtl', 'english' => 'Persian', 'native' => 'فارسی'],
+ ['short' => 'jp', 'long' => 'ja-JP', 'direction' => 'ltr', 'english' => 'Japanese', 'native' => '日本語'],
+ ['short' => 'ka', 'long' => 'ka-GE', 'direction' => 'ltr', 'english' => 'Georgian', 'native' => 'ქართული'],
+ ['short' => 'ko', 'long' => 'ko-KR', 'direction' => 'ltr', 'english' => 'Korean', 'native' => '한국어'],
+ ['short' => 'lt', 'long' => 'lt-LT', 'direction' => 'ltr', 'english' => 'Lithuanian', 'native' => 'Lietuvių'],
+ ['short' => 'lv', 'long' => 'lv-LV', 'direction' => 'ltr', 'english' => 'Latvian', 'native' => 'Latviešu valoda'],
+ ['short' => 'mk', 'long' => 'mk-MK', 'direction' => 'ltr', 'english' => 'Macedonian', 'native' => 'Македонски јазик'],
+ ['short' => 'ms', 'long' => 'ms-MY', 'direction' => 'ltr', 'english' => 'Malay', 'native' => 'Bahasa Melayu'],
+ ['short' => 'mx', 'long' => 'es-MX', 'direction' => 'ltr', 'english' => 'Mexico', 'native' => 'Español de México'],
+ ['short' => 'nb', 'long' => 'nb-NO', 'direction' => 'ltr', 'english' => 'Norwegian', 'native' => 'Norsk Bokmål'],
+ ['short' => 'ne', 'long' => 'ne-NP', 'direction' => 'ltr', 'english' => 'Nepali', 'native' => 'नेपाली'],
+ ['short' => 'nl', 'long' => 'nl-NL', 'direction' => 'ltr', 'english' => 'Dutch', 'native' => 'Nederlands'],
+ ['short' => 'pl', 'long' => 'pl-PL', 'direction' => 'ltr', 'english' => 'Polish', 'native' => 'Polski'],
+ ['short' => 'pt-BR', 'long' => 'pt-BR', 'direction' => 'ltr', 'english' => 'Brazilian', 'native' => 'Português do Brasil'],
+ ['short' => 'pt', 'long' => 'pt-PT', 'direction' => 'ltr', 'english' => 'Portuguese', 'native' => 'Português'],
+ ['short' => 'ro', 'long' => 'ro-RO', 'direction' => 'ltr', 'english' => 'Romanian', 'native' => 'Română'],
+ ['short' => 'ru', 'long' => 'ru-RU', 'direction' => 'ltr', 'english' => 'Russian', 'native' => 'Русский'],
+ ['short' => 'sr', 'long' => 'sr-RS', 'direction' => 'ltr', 'english' => 'Serbian (Cyrillic)', 'native' => 'Српски језик'],
+ ['short' => 'sr', 'long' => 'sr-CS', 'direction' => 'ltr', 'english' => 'Serbian (Latin)', 'native' => 'Српски језик'],
+ ['short' => 'sq', 'long' => 'sq-AL', 'direction' => 'ltr', 'english' => 'Albanian', 'native' => 'Shqip'],
+ ['short' => 'sk', 'long' => 'sk-SK', 'direction' => 'ltr', 'english' => 'Slovak', 'native' => 'Slovenčina'],
+ ['short' => 'sl', 'long' => 'sl-SI', 'direction' => 'ltr', 'english' => 'Slovenian', 'native' => 'Slovenščina'],
+ ['short' => 'sv', 'long' => 'sv-SE', 'direction' => 'ltr', 'english' => 'Swedish', 'native' => 'Svenska'],
+ ['short' => 'th', 'long' => 'th-TH', 'direction' => 'ltr', 'english' => 'Thai', 'native' => 'ไทย'],
+ ['short' => 'tr', 'long' => 'tr-TR', 'direction' => 'ltr', 'english' => 'Turkish', 'native' => 'Türkçe'],
+ ['short' => 'tw', 'long' => 'zh-TW', 'direction' => 'ltr', 'english' => 'Chinese (T)', 'native' => '繁體中文'],
+ ['short' => 'uk', 'long' => 'uk-UA', 'direction' => 'ltr', 'english' => 'Ukrainian', 'native' => 'Українська'],
+ ['short' => 'ur', 'long' => 'ur-PK', 'direction' => 'rtl', 'english' => 'Urdu (Pakistan)', 'native' => 'اردو'],
+ ['short' => 'uz', 'long' => 'uz-UZ', 'direction' => 'ltr', 'english' => 'Uzbek', 'native' => 'O\'zbek'],
+ ['short' => 'vi', 'long' => 'vi-VN', 'direction' => 'ltr', 'english' => 'Vietnamese', 'native' => 'Tiếng Việt'],
+ ],
+
+];
diff --git a/config/laratrust.php b/config/laratrust.php
new file mode 100644
index 00000000..3b2300fd
--- /dev/null
+++ b/config/laratrust.php
@@ -0,0 +1,360 @@
+ true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Which permissions and role checker to use.
+ |--------------------------------------------------------------------------
+ |
+ | Defines if you want to use the roles and permissions checker.
+ | Available:
+ | - default: Check for the roles and permissions using the method that Laratrust
+ has always used.
+ | - query: Check for the roles and permissions using direct queries to the database.
+ | This method doesn't support cache yet.
+ |
+ */
+ 'checker' => 'default',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Cache
+ |--------------------------------------------------------------------------
+ |
+ | Manage Laratrust's cache configurations. It uses the driver defined in the
+ | config/cache.php file.
+ |
+ */
+ 'cache' => [
+ /*
+ |--------------------------------------------------------------------------
+ | Use cache in the package
+ |--------------------------------------------------------------------------
+ |
+ | Defines if Laratrust will use Laravel's Cache to cache the roles and permissions.
+ | NOTE: Currently the database check does not use cache.
+ |
+ */
+ 'enabled' => true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Time to store in cache Laratrust's roles and permissions.
+ |--------------------------------------------------------------------------
+ |
+ | Determines the time in SECONDS to store Laratrust's roles and permissions in the cache.
+ |
+ */
+ 'expiration_time' => 3600,
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Laratrust User Models
+ |--------------------------------------------------------------------------
+ |
+ | This is the array that contains the information of the user models.
+ | This information is used in the add-trait command, and for the roles and
+ | permissions relationships with the possible user models.
+ |
+ | The key in the array is the name of the relationship inside the roles and permissions.
+ |
+ */
+ 'user_models' => [
+ 'users' => 'App\Models\Auth\User',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Laratrust Models
+ |--------------------------------------------------------------------------
+ |
+ | These are the models used by Laratrust to define the roles, permissions and teams.
+ | If you want the Laratrust models to be in a different namespace or
+ | to have a different name, you can do it here.
+ |
+ */
+ 'models' => [
+ /**
+ * Role model
+ */
+ 'role' => 'App\Models\Auth\Role',
+
+ /**
+ * Permission model
+ */
+ 'permission' => 'App\Models\Auth\Permission',
+
+ /**
+ * Team model
+ */
+ 'team' => 'App\Models\Auth\Team',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Laratrust Tables
+ |--------------------------------------------------------------------------
+ |
+ | These are the tables used by Laratrust to store all the authorization data.
+ |
+ */
+ 'tables' => [
+ /**
+ * Roles table.
+ */
+ 'roles' => 'roles',
+
+ /**
+ * Permissions table.
+ */
+ 'permissions' => 'permissions',
+
+ /**
+ * Teams table.
+ */
+ 'teams' => 'teams',
+
+ /**
+ * Role - User intermediate table.
+ */
+ 'role_user' => 'user_roles',
+
+ /**
+ * Permission - User intermediate table.
+ */
+ 'permission_user' => 'user_permissions',
+
+ /**
+ * Permission - Role intermediate table.
+ */
+ 'permission_role' => 'role_permissions',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Laratrust Foreign Keys
+ |--------------------------------------------------------------------------
+ |
+ | These are the foreign keys used by laratrust in the intermediate tables.
+ |
+ */
+ 'foreign_keys' => [
+ /**
+ * User foreign key on Laratrust's role_user and permission_user tables.
+ */
+ 'user' => 'user_id',
+
+ /**
+ * Role foreign key on Laratrust's role_user and permission_role tables.
+ */
+ 'role' => 'role_id',
+
+ /**
+ * Role foreign key on Laratrust's permission_user and permission_role tables.
+ */
+ 'permission' => 'permission_id',
+
+ /**
+ * Role foreign key on Laratrust's role_user and permission_user tables.
+ */
+ 'team' => 'team_id',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Laratrust Middleware
+ |--------------------------------------------------------------------------
+ |
+ | This configuration helps to customize the Laratrust middleware behavior.
+ |
+ */
+ 'middleware' => [
+ /**
+ * Define if the laratrust middleware are registered automatically in the service provider
+ */
+ 'register' => false,
+
+ /**
+ * Method to be called in the middleware return case.
+ * Available: abort|redirect
+ */
+ 'handling' => 'abort',
+
+ /**
+ * Handlers for the unauthorized method in the middlewares.
+ * The name of the handler must be the same as the handling.
+ */
+ 'handlers' => [
+ /**
+ * Aborts the execution with a 403 code.
+ */
+ 'abort' => [
+ 'code' => 403,
+ 'message' => 'User does not have any of the necessary access rights.'
+ ],
+ /**
+ * Redirects the user to the given url.
+ * If you want to flash a key to the session,
+ * you can do it by setting the key and the content of the message
+ * If the message content is empty it won't be added to the redirection.
+ */
+ 'redirect' => [
+ 'url' => 'auth/login',
+ 'message' => [
+ 'key' => 'error',
+ 'content' => ''
+ ]
+ ]
+ ]
+ ],
+
+ 'teams' => [
+ /*
+ |--------------------------------------------------------------------------
+ | Use teams feature in the package
+ |--------------------------------------------------------------------------
+ |
+ | Defines if Laratrust will use the teams feature.
+ | Please check the docs to see what you need to do in case you have the package already configured.
+ |
+ */
+ 'enabled' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Strict check for roles/permissions inside teams
+ |--------------------------------------------------------------------------
+ |
+ | Determines if a strict check should be done when checking if a role or permission
+ | is attached inside a team.
+ | If it's false, when checking a role/permission without specifying the team,
+ | it will check only if the user has attached that role/permission ignoring the team.
+ |
+ */
+ 'strict_check' => false,
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Laratrust Magic 'isAbleTo' Method
+ |--------------------------------------------------------------------------
+ |
+ | Supported cases for the magic is able to method (Refer to the docs).
+ | Available: camel_case|snake_case|kebab_case
+ |
+ */
+ 'magic_is_able_to_method_case' => 'kebab_case',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Laratrust Permissions as Gates
+ |--------------------------------------------------------------------------
+ |
+ | Determines if you can check if a user has a permission using the "can" method.
+ |
+ */
+ 'permissions_as_gates' => true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Laratrust Panel
+ |--------------------------------------------------------------------------
+ |
+ | Section to manage everything related with the admin panel for the roles and permissions.
+ |
+ */
+ 'panel' => [
+ /*
+ |--------------------------------------------------------------------------
+ | Laratrust Panel Register
+ |--------------------------------------------------------------------------
+ |
+ | This manages if routes used for the admin panel should be registered.
+ | Turn this value to false if you don't want to use Laratrust admin panel
+ |
+ */
+ 'register' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Laratrust Panel Path
+ |--------------------------------------------------------------------------
+ |
+ | This is the URI path where Laratrust panel for roles and permissions
+ | will be accessible from.
+ |
+ */
+ 'path' => 'laratrust',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Laratrust Panel Path
+ |--------------------------------------------------------------------------
+ |
+ | The route where the go back link should point
+ |
+ */
+ 'go_back_route' => '/',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Laratrust Panel Route Middleware
+ |--------------------------------------------------------------------------
+ |
+ | These middleware will get attached onto each Laratrust panel route.
+ |
+ */
+ 'middleware' => ['web'],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Enable permissions assignment
+ |--------------------------------------------------------------------------
+ |
+ | Enable/Disable the permissions assignment to the users.
+ |
+ */
+ 'assign_permissions_to_user' => true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Add restriction to roles in the panel
+ |--------------------------------------------------------------------------
+ |
+ | Configure which roles can not be editable, deletable and removable.
+ | To add a role to the restriction, use name of the role here.
+ |
+ */
+ 'roles_restrictions' => [
+ // The user won't be able to remove roles already assigned to users.
+ 'not_removable' => [],
+
+ // The user won't be able to edit the role and the permissions assigned.
+ 'not_editable' => [],
+
+ // The user won't be able to delete the role.
+ 'not_deletable' => [],
+ ],
+ ],
+
+];
diff --git a/config/laravel-model-caching.php b/config/laravel-model-caching.php
new file mode 100644
index 00000000..57865442
--- /dev/null
+++ b/config/laravel-model-caching.php
@@ -0,0 +1,13 @@
+ env('MODEL_CACHE_ENABLED', true),
+
+ 'cache-prefix' => env('MODEL_CACHE_PREFIX', 'model'),
+
+ 'use-database-keying' => env('MODEL_CACHE_USE_DATABASE_KEYING', true),
+
+ 'store' => env('MODEL_CACHE_STORE', 'array'),
+
+];
diff --git a/config/livewire.php b/config/livewire.php
new file mode 100644
index 00000000..8033a556
--- /dev/null
+++ b/config/livewire.php
@@ -0,0 +1,159 @@
+ 'App\\Http\\Livewire',
+
+ /*
+ |---------------------------------------------------------------------------
+ | View Path
+ |---------------------------------------------------------------------------
+ |
+ | This value is used to specify where Livewire component Blade templates are
+ | stored when running file creation commands like `artisan make:livewire`.
+ | It is also used if you choose to omit a component's render() method.
+ |
+ */
+
+ 'view_path' => resource_path('views/livewire'),
+
+ /*
+ |---------------------------------------------------------------------------
+ | Layout
+ |---------------------------------------------------------------------------
+ | The view that will be used as the layout when rendering a single component
+ | as an entire page via `Route::get('/post/create', CreatePost::class);`.
+ | In this case, the view returned by CreatePost will render into $slot.
+ |
+ */
+
+ 'layout' => 'components.layouts.admin',
+
+ /*
+ |---------------------------------------------------------------------------
+ | Lazy Loading Placeholder
+ |---------------------------------------------------------------------------
+ | Livewire allows you to lazy load components that would otherwise slow down
+ | the initial page load. Every component can have a custom placeholder or
+ | you can define the default placeholder view for all components below.
+ |
+ */
+
+ 'lazy_placeholder' => null,
+
+ /*
+ |---------------------------------------------------------------------------
+ | Temporary File Uploads
+ |---------------------------------------------------------------------------
+ |
+ | Livewire handles file uploads by storing uploads in a temporary directory
+ | before the file is stored permanently. All file uploads are directed to
+ | a global endpoint for temporary storage. You may configure this below:
+ |
+ */
+
+ 'temporary_file_upload' => [
+ 'disk' => null, // Example: 'local', 's3' | Default: 'default'
+ 'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB)
+ 'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
+ 'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
+ 'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
+ 'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
+ 'mov', 'avi', 'wmv', 'mp3', 'm4a',
+ 'jpg', 'jpeg', 'mpga', 'webp', 'wma',
+ ],
+ 'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated...
+ ],
+
+ /*
+ |---------------------------------------------------------------------------
+ | Render On Redirect
+ |---------------------------------------------------------------------------
+ |
+ | This value determines if Livewire will run a component's `render()` method
+ | after a redirect has been triggered using something like `redirect(...)`
+ | Setting this to true will render the view once more before redirecting
+ |
+ */
+
+ 'render_on_redirect' => false,
+/*
+ |---------------------------------------------------------------------------
+ | Eloquent Model Binding
+ |---------------------------------------------------------------------------
+ |
+ | Previous versions of Livewire supported binding directly to eloquent model
+ | properties using wire:model by default. However, this behavior has been
+ | deemed too "magical" and has therefore been put under a feature flag.
+ |
+ */
+
+ 'legacy_model_binding' => false,
+
+ /*
+ |---------------------------------------------------------------------------
+ | Auto-inject Frontend Assets
+ |---------------------------------------------------------------------------
+ |
+ | By default, Livewire automatically injects its JavaScript and CSS into the
+ |
and