diff --git a/app/Console/Commands/CreateTestData.php b/app/Console/Commands/CreateTestData.php index 8be45787b460..cfc80835f305 100644 --- a/app/Console/Commands/CreateTestData.php +++ b/app/Console/Commands/CreateTestData.php @@ -437,7 +437,7 @@ class CreateTestData extends Command 'company_id' => $client->company->id, ]); - Document::factory()->count(5)->create([ + Document::factory()->count(1)->create([ 'user_id' => $client->user->id, 'company_id' => $client->company_id, 'documentable_type' => Vendor::class, diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 1183ec91b99a..7587da2e5062 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -71,7 +71,7 @@ class Kernel extends ConsoleKernel $schedule->job(new RecurringInvoicesCron)->hourly()->withoutOverlapping()->name('recurring-invoice-job')->onOneServer(); /* Stale Invoice Cleanup*/ - $schedule->job(new CleanStaleInvoiceOrder)->hourly()->withoutOverlapping()->name('stale-invoice-job')->onOneServer(); + $schedule->job(new CleanStaleInvoiceOrder)->hourlyAt(30)->withoutOverlapping()->name('stale-invoice-job')->onOneServer(); /* Sends recurring invoices*/ $schedule->job(new RecurringExpensesCron)->dailyAt('00:10')->withoutOverlapping()->name('recurring-expense-job')->onOneServer(); @@ -89,7 +89,7 @@ class Kernel extends ConsoleKernel $schedule->job(new SchedulerCheck)->dailyAt('01:10')->withoutOverlapping(); /* Checks for scheduled tasks */ - $schedule->job(new TaskScheduler())->dailyAt('06:50')->withoutOverlapping()->name('task-scheduler-job')->onOneServer(); + $schedule->job(new TaskScheduler())->hourlyAt(10)->withoutOverlapping()->name('task-scheduler-job')->onOneServer(); /* Performs system maintenance such as pruning the backup table */ $schedule->job(new SystemMaintenance)->sundays()->at('02:30')->withoutOverlapping()->name('system-maintenance-job')->onOneServer(); diff --git a/app/DataMapper/CompanySettings.php b/app/DataMapper/CompanySettings.php index 04abd19bc01a..6e65ccd3a8de 100644 --- a/app/DataMapper/CompanySettings.php +++ b/app/DataMapper/CompanySettings.php @@ -447,7 +447,13 @@ class CompanySettings extends BaseSettings public $mailgun_domain = ''; + public $auto_bill_standard_invoices = false; + + public $email_alignment = 'center'; // center , left, right + public static $casts = [ + 'email_alignment' => 'string', + 'auto_bill_standard_invoices' => 'bool', 'postmark_secret' => 'string', 'mailgun_secret' => 'string', 'mailgun_domain' => 'string', diff --git a/app/DataMapper/EmailTemplateDefaults.php b/app/DataMapper/EmailTemplateDefaults.php index 3ce6ab53ac96..60c40c819fb3 100644 --- a/app/DataMapper/EmailTemplateDefaults.php +++ b/app/DataMapper/EmailTemplateDefaults.php @@ -235,12 +235,17 @@ class EmailTemplateDefaults public static function emailStatementSubject() { - return ''; + return ctrans('texts.your_statement'); } public static function emailStatementTemplate() { - return ''; + + $statement_message = '

$client

'.self::transformText('client_statement_body').'

'; + + return $statement_message; + + // return ctrans('texts.client_statement_body', ['start_date' => '$start_date', 'end_date' => '$end_date']); } private static function transformText($string) diff --git a/app/DataMapper/Schedule/ClientStatement.php b/app/DataMapper/Schedule/ClientStatement.php new file mode 100644 index 000000000000..37ba76d081cb --- /dev/null +++ b/app/DataMapper/Schedule/ClientStatement.php @@ -0,0 +1,96 @@ +name = ''; + $scheduler->company_id = $company_id; + $scheduler->user_id = $user_id; + $scheduler->parameters = []; + $scheduler->is_paused = false; + $scheduler->is_deleted = false; + $scheduler->template = ''; + + return $scheduler; + } +} diff --git a/app/Filters/QuoteFilters.php b/app/Filters/QuoteFilters.php index 517479aa121d..2f342c4413f6 100644 --- a/app/Filters/QuoteFilters.php +++ b/app/Filters/QuoteFilters.php @@ -87,6 +87,7 @@ class QuoteFilters extends QueryFilters if (in_array('expired', $status_parameters)) { $this->builder->orWhere(function ($query){ $query->where('status_id', Quote::STATUS_SENT) + ->company() ->whereNotNull('due_date') ->where('due_date', '<=', now()->toDateString()); }); @@ -95,6 +96,7 @@ class QuoteFilters extends QueryFilters if (in_array('upcoming', $status_parameters)) { $this->builder->orWhere(function ($query){ $query->where('status_id', Quote::STATUS_SENT) + ->company() ->where('due_date', '>=', now()->toDateString()) ->orderBy('due_date', 'DESC'); }); diff --git a/app/Helpers/SwissQr/SwissQrGenerator.php b/app/Helpers/SwissQr/SwissQrGenerator.php index 488c0d1a623c..e69db7d4734f 100644 --- a/app/Helpers/SwissQr/SwissQrGenerator.php +++ b/app/Helpers/SwissQr/SwissQrGenerator.php @@ -87,10 +87,10 @@ class SwissQrGenerator $qrBill->setUltimateDebtor( QrBill\DataGroup\Element\StructuredAddress::createWithStreet( substr($this->client->present()->name(), 0 , 70), - $this->client->address1 ? substr($this->client->address1, 0 , 70) : '_', - $this->client->address2 ? substr($this->client->address2, 0 , 16) : '_', - $this->client->postal_code ? substr($this->client->postal_code, 0, 16) : '_', - $this->client->city ? substr($this->client->city, 0, 35) : '_', + $this->client->address1 ? substr($this->client->address1, 0 , 70) : ' ', + $this->client->address2 ? substr($this->client->address2, 0 , 16) : ' ', + $this->client->postal_code ? substr($this->client->postal_code, 0, 16) : ' ', + $this->client->city ? substr($this->client->city, 0, 35) : ' ', 'CH' )); diff --git a/app/Http/Controllers/BaseController.php b/app/Http/Controllers/BaseController.php index b5b0f249f2ea..6eb9920a6bdd 100644 --- a/app/Http/Controllers/BaseController.php +++ b/app/Http/Controllers/BaseController.php @@ -984,6 +984,9 @@ class BaseController extends Controller //pass report errors bool to front end $data['report_errors'] = Ninja::isSelfHost() ? $account->report_errors : true; + //pass whitelabel bool to front end + $data['white_label'] = Ninja::isSelfHost() ? $account->isPaid() : false; + //pass referral code to front end $data['rc'] = request()->has('rc') ? request()->input('rc') : ''; $data['build'] = request()->has('build') ? request()->input('build') : ''; diff --git a/app/Http/Controllers/ClientPortal/NinjaPlanController.php b/app/Http/Controllers/ClientPortal/NinjaPlanController.php index 7d059d0d7b54..18ed6537e5e3 100644 --- a/app/Http/Controllers/ClientPortal/NinjaPlanController.php +++ b/app/Http/Controllers/ClientPortal/NinjaPlanController.php @@ -148,6 +148,7 @@ class NinjaPlanController extends Controller $account->plan_term = 'month'; $account->plan_started = now(); $account->plan_expires = now()->addDays(14); + $account->is_trial=true; $account->save(); } @@ -216,7 +217,7 @@ class NinjaPlanController extends Controller if ($account) { //offer the option to have a free trial - if (! $account->trial_started && ! $account->plan) { + if (!$account->is_trial) { return $this->trial(); } diff --git a/app/Http/Controllers/PurchaseOrderController.php b/app/Http/Controllers/PurchaseOrderController.php index 452c5895374d..821f0c88847a 100644 --- a/app/Http/Controllers/PurchaseOrderController.php +++ b/app/Http/Controllers/PurchaseOrderController.php @@ -502,7 +502,6 @@ class PurchaseOrderController extends BaseController /* * Download Purchase Order/s */ - if ($action == 'bulk_download' && $purchase_orders->count() >= 1) { $purchase_orders->each(function ($purchase_order) { if (auth()->user()->cannot('view', $purchase_order)) { diff --git a/app/Http/Controllers/SchedulerController.php b/app/Http/Controllers/SchedulerController.php index e1788ce4e85c..dcd7b35d1b52 100644 --- a/app/Http/Controllers/SchedulerController.php +++ b/app/Http/Controllers/SchedulerController.php @@ -1,4 +1,13 @@ scheduler_repository = $scheduler_repository; } /** * @OA\GET( - * path="/api/v1/task_scheduler/", + * path="/api/v1/task_schedulers/", * operationId="getTaskSchedulers", - * tags={"task_scheduler"}, + * tags={"task_schedulers"}, * summary="Task Scheduler Index", * description="Get all schedulers with associated jobs", * @OA\Parameter(ref="#/components/parameters/X-Api-Secret"), @@ -67,11 +70,57 @@ class TaskSchedulerController extends BaseController return $this->listResponse($schedulers); } + /** + * Show the form for creating a new resource. + * + * @param CreateSchedulerRequest $request The request + * + * @return Response + * + * + * @OA\Get( + * path="/api/v1/invoices/task_schedulers", + * operationId="getTaskScheduler", + * tags={"task_schedulers"}, + * summary="Gets a new blank scheduler object", + * description="Returns a blank object with default values", + * @OA\Parameter(ref="#/components/parameters/X-Api-Secret"), + * @OA\Parameter(ref="#/components/parameters/X-Api-Token"), + * @OA\Parameter(ref="#/components/parameters/X-Requested-With"), + * @OA\Parameter(ref="#/components/parameters/include"), + * @OA\Response( + * response=200, + * description="A blank scheduler object", + * @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"), + * @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"), + * @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"), + * @OA\JsonContent(ref="#/components/schemas/TaskSchedulerSchema"), + * ), + * @OA\Response( + * response=422, + * description="Validation error", + * @OA\JsonContent(ref="#/components/schemas/ValidationError"), + * + * ), + * @OA\Response( + * response="default", + * description="Unexpected Error", + * @OA\JsonContent(ref="#/components/schemas/Error"), + * ), + * ) + */ + public function create(CreateSchedulerRequest $request) + { + $scheduler = SchedulerFactory::create(auth()->user()->company()->id, auth()->user()->id); + + return $this->itemResponse($scheduler); + } + /** * @OA\Post( - * path="/api/v1/task_scheduler/", + * path="/api/v1/task_schedulers/", * operationId="createTaskScheduler", - * tags={"task_scheduler"}, + * tags={"task_schedulers"}, * summary="Create task scheduler with job ", * description="Create task scheduler with a job (action(job) request should be sent via request also. Example: We want client report to be job which will be run * multiple times, we should send the same parameters in the request as we would send if we wanted to get report, see example", @@ -100,19 +149,18 @@ class TaskSchedulerController extends BaseController * ), * ) */ - public function store(CreateScheduledTaskRequest $request) + public function store(StoreSchedulerRequest $request) { - $scheduler = new Scheduler(); - $scheduler->service()->store($scheduler, $request); + $scheduler = $this->scheduler_repository->save($request->all(), SchedulerFactory::create(auth()->user()->company()->id, auth()->user()->id)); return $this->itemResponse($scheduler); } /** * @OA\GET( - * path="/api/v1/task_scheduler/{id}", + * path="/api/v1/task_schedulers/{id}", * operationId="showTaskScheduler", - * tags={"task_scheduler"}, + * tags={"task_schedulers"}, * summary="Show given scheduler", * description="Get scheduler with associated job", * @OA\Parameter(ref="#/components/parameters/X-Api-Secret"), @@ -142,16 +190,16 @@ class TaskSchedulerController extends BaseController * ), * ) */ - public function show(Scheduler $scheduler) + public function show(ShowSchedulerRequest $request, Scheduler $scheduler) { return $this->itemResponse($scheduler); } /** * @OA\PUT( - * path="/api/v1/task_scheduler/{id}", + * path="/api/v1/task_schedulers/{id}", * operationId="updateTaskScheduler", - * tags={"task_scheduler"}, + * tags={"task_schedulers"}, * summary="Update task scheduler ", * description="Update task scheduler", * @OA\Parameter(ref="#/components/parameters/X-Api-Secret"), @@ -168,7 +216,7 @@ class TaskSchedulerController extends BaseController * ), * ), * @OA\RequestBody( * required=true, - * @OA\JsonContent(ref="#/components/schemas/UpdateTaskSchedulerSchema") + * @OA\JsonContent(ref="#/components/schemas/TaskSchedulerSchema") * ), * @OA\Response( * response=200, @@ -189,18 +237,18 @@ class TaskSchedulerController extends BaseController * ), * ) */ - public function update(Scheduler $scheduler, UpdateScheduleRequest $request) + public function update(UpdateSchedulerRequest $request, Scheduler $scheduler) { - $scheduler->service()->update($scheduler, $request); + $this->scheduler_repository->save($request->all(), $scheduler); return $this->itemResponse($scheduler); } /** * @OA\DELETE( - * path="/api/v1/task_scheduler/{id}", + * path="/api/v1/task_schedulers/{id}", * operationId="destroyTaskScheduler", - * tags={"task_scheduler"}, + * tags={"task_schedulers"}, * summary="Destroy Task Scheduler", * description="Destroy task scheduler and its associated job", * @OA\Parameter(ref="#/components/parameters/X-Api-Secret"), @@ -230,10 +278,83 @@ class TaskSchedulerController extends BaseController * ), * ) */ - public function destroy(Scheduler $scheduler) + public function destroy(DestroySchedulerRequest $request, Scheduler $scheduler) { $this->scheduler_repository->delete($scheduler); return $this->itemResponse($scheduler->fresh()); } + + + /** + * Perform bulk actions on the list view. + * + * @return Response + * + * + * @OA\Post( + * path="/api/v1/task_schedulers/bulk", + * operationId="bulkTaskSchedulerActions", + * tags={"task_schedulers"}, + * summary="Performs bulk actions on an array of task_schedulers", + * description="", + * @OA\Parameter(ref="#/components/parameters/X-Api-Secret"), + * @OA\Parameter(ref="#/components/parameters/X-Api-Token"), + * @OA\Parameter(ref="#/components/parameters/X-Requested-With"), + * @OA\Parameter(ref="#/components/parameters/index"), + * @OA\RequestBody( + * description="array of ids", + * required=true, + * @OA\MediaType( + * mediaType="application/json", + * @OA\Schema( + * type="array", + * @OA\Items( + * type="integer", + * description="Array of hashed IDs to be bulk 'actioned", + * example="[0,1,2,3]", + * ), + * ) + * ) + * ), + * @OA\Response( + * response=200, + * description="The TaskSchedule response", + * @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"), + * @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"), + * @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"), + * @OA\JsonContent(ref="#/components/schemas/TaskScheduleSchema"), + * ), + * @OA\Response( + * response=422, + * description="Validation error", + * @OA\JsonContent(ref="#/components/schemas/ValidationError"), + * ), + * @OA\Response( + * response="default", + * description="Unexpected Error", + * @OA\JsonContent(ref="#/components/schemas/Error"), + * ), + * ) + */ + public function bulk() + { + $action = request()->input('action'); + + if(!in_array($action, ['archive', 'restore', 'delete'])) + return response()->json(['message' => 'Bulk action does not exist'], 400); + + $ids = request()->input('ids'); + + $task_schedulers = Scheduler::withTrashed()->find($this->transformKeys($ids)); + + $task_schedulers->each(function ($task_scheduler, $key) use ($action) { + if (auth()->user()->can('edit', $task_scheduler)) { + $this->scheduler_repository->{$action}($task_scheduler); + } + }); + + return $this->listResponse(Scheduler::withTrashed()->whereIn('id', $this->transformKeys($ids))); + } + } diff --git a/app/Http/Livewire/BillingPortalPurchase.php b/app/Http/Livewire/BillingPortalPurchase.php index 404194f58531..496c241b79aa 100644 --- a/app/Http/Livewire/BillingPortalPurchase.php +++ b/app/Http/Livewire/BillingPortalPurchase.php @@ -202,6 +202,12 @@ class BillingPortalPurchase extends Component elseif(strlen($this->subscription->promo_code) == 0 && $this->subscription->promo_discount > 0){ $this->price = $this->subscription->promo_price; } + + /* Leave this here, otherwise a logged in user will need to reauth... painfully */ + if(Auth::guard('contact')->check()){ + return $this->getPaymentMethods(auth()->guard('contact')->user()); + } + } /** diff --git a/app/Http/Requests/Setup/CheckMailRequest.php b/app/Http/Requests/Setup/CheckMailRequest.php index 7df6368212c9..dd6e7b1413e5 100644 --- a/app/Http/Requests/Setup/CheckMailRequest.php +++ b/app/Http/Requests/Setup/CheckMailRequest.php @@ -32,8 +32,6 @@ class CheckMailRequest extends Request */ public function rules() { - nlog($this->driver); - return [ 'mail_driver' => 'required', 'encryption' => 'required_unless:mail_driver,log', diff --git a/app/Http/Requests/TaskScheduler/CreateScheduledTaskRequest.php b/app/Http/Requests/TaskScheduler/CreateScheduledTaskRequest.php deleted file mode 100644 index 80b4fc76bfd0..000000000000 --- a/app/Http/Requests/TaskScheduler/CreateScheduledTaskRequest.php +++ /dev/null @@ -1,39 +0,0 @@ -user()->isAdmin(); - } - - public function rules() - { - return [ - 'paused' => 'sometimes|bool', - 'repeat_every' => 'required|string|in:DAY,WEEK,MONTH,3MONTHS,YEAR', - 'start_from' => 'sometimes|string', - 'job' => 'required', - ]; - } - - public function prepareForValidation() - { - $input = $this->all(); - - if (! array_key_exists('start_from', $input)) { - $input['start_from'] = now(); - } - - $this->replace($input); - } -} diff --git a/app/Http/Requests/TaskScheduler/CreateSchedulerRequest.php b/app/Http/Requests/TaskScheduler/CreateSchedulerRequest.php new file mode 100644 index 000000000000..71c0a8f3d9f2 --- /dev/null +++ b/app/Http/Requests/TaskScheduler/CreateSchedulerRequest.php @@ -0,0 +1,28 @@ +user()->isAdmin(); + } + +} diff --git a/app/Http/Requests/TaskScheduler/DestroySchedulerRequest.php b/app/Http/Requests/TaskScheduler/DestroySchedulerRequest.php new file mode 100644 index 000000000000..93e15f06df95 --- /dev/null +++ b/app/Http/Requests/TaskScheduler/DestroySchedulerRequest.php @@ -0,0 +1,27 @@ +user()->isAdmin(); + } +} diff --git a/app/Http/Requests/TaskScheduler/ShowSchedulerRequest.php b/app/Http/Requests/TaskScheduler/ShowSchedulerRequest.php new file mode 100644 index 000000000000..a459edab3c99 --- /dev/null +++ b/app/Http/Requests/TaskScheduler/ShowSchedulerRequest.php @@ -0,0 +1,27 @@ +user()->can('view', $this->scheduler); + } +} diff --git a/app/Http/Requests/TaskScheduler/StoreSchedulerRequest.php b/app/Http/Requests/TaskScheduler/StoreSchedulerRequest.php new file mode 100644 index 000000000000..0c63f95a9542 --- /dev/null +++ b/app/Http/Requests/TaskScheduler/StoreSchedulerRequest.php @@ -0,0 +1,44 @@ +user()->isAdmin(); + } + + public function rules() + { + + $rules = [ + 'name' => ['bail', 'required', Rule::unique('schedulers')->where('company_id', auth()->user()->company()->id)], + 'is_paused' => 'bail|sometimes|boolean', + 'frequency_id' => 'bail|required|integer|digits_between:1,12', + 'next_run' => 'bail|required|date:Y-m-d', + 'template' => 'bail|required|string', + 'parameters' => 'bail|array', + ]; + + return $rules; + + } +} diff --git a/app/Http/Requests/TaskScheduler/UpdateScheduledJobRequest.php b/app/Http/Requests/TaskScheduler/UpdateScheduledJobRequest.php deleted file mode 100644 index 0db34ccc000f..000000000000 --- a/app/Http/Requests/TaskScheduler/UpdateScheduledJobRequest.php +++ /dev/null @@ -1,25 +0,0 @@ -user()->isAdmin(); - } - - public function rules(): array - { - return [ - 'action_name' => 'sometimes|string', - ]; - } -} diff --git a/app/Http/Requests/TaskScheduler/UpdateScheduleRequest.php b/app/Http/Requests/TaskScheduler/UpdateSchedulerRequest.php similarity index 51% rename from app/Http/Requests/TaskScheduler/UpdateScheduleRequest.php rename to app/Http/Requests/TaskScheduler/UpdateSchedulerRequest.php index 69b819e84575..7e3ec3267152 100644 --- a/app/Http/Requests/TaskScheduler/UpdateScheduleRequest.php +++ b/app/Http/Requests/TaskScheduler/UpdateSchedulerRequest.php @@ -8,14 +8,12 @@ * * @license https://www.elastic.co/licensing/elastic-license */ - namespace App\Http\Requests\TaskScheduler; use App\Http\Requests\Request; -use Carbon\Carbon; use Illuminate\Validation\Rule; -class UpdateScheduleRequest extends Request +class UpdateSchedulerRequest extends Request { /** * Determine if the user is authorized to make this request. @@ -29,23 +27,17 @@ class UpdateScheduleRequest extends Request public function rules(): array { - return [ - 'paused' => 'sometimes|bool', - 'repeat_every' => 'sometimes|string|in:DAY,WEEK,BIWEEKLY,MONTH,3MONTHS,YEAR', - 'start_from' => 'sometimes', - 'scheduled_run'=>'sometimes', + + $rules = [ + 'name' => ['bail', 'sometimes', Rule::unique('schedulers')->where('company_id', auth()->user()->company()->id)->ignore($this->task_scheduler->id)], + 'is_paused' => 'bail|sometimes|boolean', + 'frequency_id' => 'bail|required|integer|digits_between:1,12', + 'next_run' => 'bail|required|date:Y-m-d', + 'template' => 'bail|required|string', + 'parameters' => 'bail|array', ]; - } - public function prepareForValidation() - { - $input = $this->all(); - - if (isset($input['start_from'])) { - $input['scheduled_run'] = Carbon::parse((int) $input['start_from']); - $input['start_from'] = Carbon::parse((int) $input['start_from']); - } - - $this->replace($input); + return $rules; + } } diff --git a/app/Jobs/Ledger/ClientLedgerBalanceUpdate.php b/app/Jobs/Ledger/ClientLedgerBalanceUpdate.php index 11f1ca358719..d0f5c86191bd 100644 --- a/app/Jobs/Ledger/ClientLedgerBalanceUpdate.php +++ b/app/Jobs/Ledger/ClientLedgerBalanceUpdate.php @@ -56,6 +56,7 @@ class ClientLedgerBalanceUpdate implements ShouldQueue if ($company_ledger->balance == 0) { + $last_record = CompanyLedger::where('client_id', $company_ledger->client_id) ->where('company_id', $company_ledger->company_id) ->where('balance', '!=', 0) @@ -69,15 +70,12 @@ class ClientLedgerBalanceUpdate implements ShouldQueue ->first(); } - // nlog("Updating Balance NOW"); + } $company_ledger->balance = $last_record->balance + $company_ledger->adjustment; $company_ledger->save(); - - } - + }); - // nlog("Updating company ledger for client ". $this->client->id); } } diff --git a/app/Jobs/Mail/NinjaMailerJob.php b/app/Jobs/Mail/NinjaMailerJob.php index b1c7ed2931ea..5302a41d6397 100644 --- a/app/Jobs/Mail/NinjaMailerJob.php +++ b/app/Jobs/Mail/NinjaMailerJob.php @@ -450,16 +450,6 @@ class NinjaMailerJob implements ShouldQueue $this->checkValidSendingUser($user); - /* Always ensure the user is set on the correct account */ - // if($user->account_id != $this->company->account_id){ - - // $this->nmo->settings->email_sending_method = 'default'; - // return $this->setMailDriver(); - - // } - - $this->checkValidSendingUser($user); - nlog("Sending via {$user->name()}"); $google = (new Google())->init(); diff --git a/app/Jobs/Ninja/TaskScheduler.php b/app/Jobs/Ninja/TaskScheduler.php index 76ebad1883d1..69862f8877c2 100644 --- a/app/Jobs/Ninja/TaskScheduler.php +++ b/app/Jobs/Ninja/TaskScheduler.php @@ -21,6 +21,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +//@rebuild it class TaskScheduler implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; @@ -45,9 +46,10 @@ class TaskScheduler implements ShouldQueue MultiDB::setDB($db); Scheduler::with('company') - ->where('paused', false) + ->where('is_paused', false) ->where('is_deleted', false) - ->where('scheduled_run', '<', now()) + ->whereNotNull('next_run') + ->where('next_run', '<=', now()) ->cursor() ->each(function ($scheduler) { $this->doJob($scheduler); @@ -57,59 +59,16 @@ class TaskScheduler implements ShouldQueue private function doJob(Scheduler $scheduler) { - nlog("Doing job {$scheduler->action_name}"); - - $company = $scheduler->company; - - $parameters = $scheduler->parameters; - - switch ($scheduler->action_name) { - case Scheduler::CREATE_CLIENT_REPORT: - SendToAdmin::dispatch($company, $parameters, $scheduler->action_class, 'contacts.csv'); - break; - case Scheduler::CREATE_CLIENT_CONTACT_REPORT: - SendToAdmin::dispatch($company, $parameters, $scheduler->action_class, 'clients.csv'); - break; - case Scheduler::CREATE_CREDIT_REPORT: - SendToAdmin::dispatch($company, $parameters, $scheduler->action_class, 'credits.csv'); - break; - case Scheduler::CREATE_DOCUMENT_REPORT: - SendToAdmin::dispatch($company, $parameters, $scheduler->action_class, 'documents.csv'); - break; - case Scheduler::CREATE_EXPENSE_REPORT: - SendToAdmin::dispatch($company, $parameters, $scheduler->action_class, 'expense.csv'); - break; - case Scheduler::CREATE_INVOICE_ITEM_REPORT: - SendToAdmin::dispatch($company, $parameters, $scheduler->action_class, 'invoice_items.csv'); - break; - case Scheduler::CREATE_INVOICE_REPORT: - SendToAdmin::dispatch($company, $parameters, $scheduler->action_class, 'invoices.csv'); - break; - case Scheduler::CREATE_PAYMENT_REPORT: - SendToAdmin::dispatch($company, $parameters, $scheduler->action_class, 'payments.csv'); - break; - case Scheduler::CREATE_PRODUCT_REPORT: - SendToAdmin::dispatch($company, $parameters, $scheduler->action_class, 'products.csv'); - break; - case Scheduler::CREATE_PROFIT_AND_LOSS_REPORT: - SendToAdmin::dispatch($company, $parameters, $scheduler->action_class, 'profit_and_loss.csv'); - break; - case Scheduler::CREATE_QUOTE_ITEM_REPORT: - SendToAdmin::dispatch($company, $parameters, $scheduler->action_class, 'quote_items.csv'); - break; - case Scheduler::CREATE_QUOTE_REPORT: - SendToAdmin::dispatch($company, $parameters, $scheduler->action_class, 'quotes.csv'); - break; - case Scheduler::CREATE_RECURRING_INVOICE_REPORT: - SendToAdmin::dispatch($company, $parameters, $scheduler->action_class, 'recurring_invoices.csv'); - break; - case Scheduler::CREATE_TASK_REPORT: - SendToAdmin::dispatch($company, $parameters, $scheduler->action_class, 'tasks.csv'); - break; - + nlog("Doing job {$scheduler->name}"); + + try { + $scheduler->service()->runTask(); + } + catch(\Exception $e){ + nlog($e->getMessage()); + } - - $scheduler->scheduled_run = $scheduler->nextScheduledDate(); - $scheduler->save(); } + + } diff --git a/app/Jobs/RecurringInvoice/SendRecurring.php b/app/Jobs/RecurringInvoice/SendRecurring.php index 63f6ecc2d645..81cf7b2f7a00 100644 --- a/app/Jobs/RecurringInvoice/SendRecurring.php +++ b/app/Jobs/RecurringInvoice/SendRecurring.php @@ -66,13 +66,6 @@ class SendRecurring implements ShouldQueue // Generate Standard Invoice $invoice = RecurringInvoiceToInvoiceFactory::create($this->recurring_invoice, $this->recurring_invoice->client); - if ($this->recurring_invoice->auto_bill === 'always') { - $invoice->auto_bill_enabled = true; - } elseif ($this->recurring_invoice->auto_bill === 'optout' || $this->recurring_invoice->auto_bill === 'optin') { - } elseif ($this->recurring_invoice->auto_bill === 'off') { - $invoice->auto_bill_enabled = false; - } - $invoice->date = date('Y-m-d'); nlog("Recurring Invoice Date Set on Invoice = {$invoice->date} - ". now()->format('Y-m-d')); @@ -94,6 +87,14 @@ class SendRecurring implements ShouldQueue ->save(); } + //12-01-2023 i moved this block after fillDefaults to handle if standard invoice auto bill config has been enabled, recurring invoice should override. + if ($this->recurring_invoice->auto_bill === 'always') { + $invoice->auto_bill_enabled = true; + } elseif ($this->recurring_invoice->auto_bill === 'optout' || $this->recurring_invoice->auto_bill === 'optin') { + } elseif ($this->recurring_invoice->auto_bill === 'off') { + $invoice->auto_bill_enabled = false; + } + $invoice = $this->createRecurringInvitations($invoice); /* 09-01-2022 ensure we create the PDFs at this point in time! */ diff --git a/app/Mail/Client/ClientStatement.php b/app/Mail/Client/ClientStatement.php new file mode 100644 index 000000000000..18ffb563d831 --- /dev/null +++ b/app/Mail/Client/ClientStatement.php @@ -0,0 +1,94 @@ +data['subject'], + tags: [$this->data['company_key']], + replyTo: $this->data['reply_to'], + from: $this->data['from'], + to: $this->data['to'], + bcc: $this->data['bcc'] + ); + } + + /** + * Get the message content definition. + * + * @return \Illuminate\Mail\Mailables\Content + */ + public function content() + { + return new Content( + view: 'email.template.client', + text: 'email.template.text', + with: [ + 'text_body' => $this->data['body'], + 'body' => $this->data['body'], + 'whitelabel' => $this->data['whitelabel'], + 'settings' => $this->data['settings'], + 'whitelabel' => $this->data['whitelabel'], + 'logo' => $this->data['logo'], + 'signature' => $this->data['signature'], + 'company' => $this->data['company'], + 'greeting' => $this->data['greeting'], + ] + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments() + { + $array_of_attachments = []; + + foreach($this->data['attachments'] as $attachment) + { + + $array_of_attachments[] = + Attachment::fromData(fn () => base64_decode($attachment['file']), $attachment['name']) + ->withMime('application/pdf'); + + } + + return $array_of_attachments; + + } +} diff --git a/app/Models/BaseModel.php b/app/Models/BaseModel.php index 22fc9ba12915..fdd51dff88d2 100644 --- a/app/Models/BaseModel.php +++ b/app/Models/BaseModel.php @@ -170,6 +170,7 @@ class BaseModel extends Model */ public function resolveRouteBinding($value, $field = null) { + if (is_numeric($value)) { throw new ModelNotFoundException("Record with value {$value} not found"); } diff --git a/app/Models/Company.php b/app/Models/Company.php index 7864220ca00d..0d9d6793890b 100644 --- a/app/Models/Company.php +++ b/app/Models/Company.php @@ -66,6 +66,7 @@ class Company extends BaseModel protected $presenter = CompanyPresenter::class; protected $fillable = [ + 'invoice_task_hours', 'markdown_enabled', 'calculate_expense_tax_by_amount', 'invoice_expense_documents', diff --git a/app/Models/Scheduler.php b/app/Models/Scheduler.php index 287129638fb1..50c693cee6af 100644 --- a/app/Models/Scheduler.php +++ b/app/Models/Scheduler.php @@ -11,8 +11,7 @@ namespace App\Models; -use App\Services\TaskScheduler\TaskSchedulerService; -use Illuminate\Database\Eloquent\Factories\HasFactory; +use App\Services\Scheduler\SchedulerService; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Carbon; @@ -20,8 +19,8 @@ use Illuminate\Support\Carbon; * @property bool paused * @property bool is_deleted * @property \Carbon\Carbon|mixed start_from - * @property string repeat_every - * @property \Carbon\Carbon|mixed scheduled_run + * @property int frequency_id + * @property \Carbon\Carbon|mixed next_run * @property int company_id * @property int updated_at * @property int created_at @@ -33,76 +32,38 @@ use Illuminate\Support\Carbon; */ class Scheduler extends BaseModel { - use HasFactory, SoftDeletes; + use SoftDeletes; protected $fillable = [ - 'start_from', - 'paused', - 'repeat_every', + 'name', + 'frequency_id', + 'next_run', 'scheduled_run', - 'action_class', - 'action_name', + 'template', + 'is_paused', 'parameters', - 'company_id', ]; protected $casts = [ - 'start_from' => 'timestamp', - 'scheduled_run' => 'timestamp', + 'next_run' => 'datetime', 'created_at' => 'timestamp', 'updated_at' => 'timestamp', 'deleted_at' => 'timestamp', - 'paused' => 'boolean', + 'is_paused' => 'boolean', 'is_deleted' => 'boolean', 'parameters' => 'array', ]; - const DAILY = 'DAY'; - - const WEEKLY = 'WEEK'; - - const BIWEEKLY = 'BIWEEKLY'; - - const MONTHLY = 'MONTH'; - - const QUARTERLY = '3MONTHS'; - - const ANNUALLY = 'YEAR'; - - const CREATE_CLIENT_REPORT = 'create_client_report'; - - const CREATE_CLIENT_CONTACT_REPORT = 'create_client_contact_report'; - - const CREATE_CREDIT_REPORT = 'create_credit_report'; - - const CREATE_DOCUMENT_REPORT = 'create_document_report'; - - const CREATE_EXPENSE_REPORT = 'create_expense_report'; - - const CREATE_INVOICE_ITEM_REPORT = 'create_invoice_item_report'; - - const CREATE_INVOICE_REPORT = 'create_invoice_report'; - - const CREATE_PAYMENT_REPORT = 'create_payment_report'; - - const CREATE_PRODUCT_REPORT = 'create_product_report'; - - const CREATE_PROFIT_AND_LOSS_REPORT = 'create_profit_and_loss_report'; - - const CREATE_QUOTE_ITEM_REPORT = 'create_quote_item_report'; - - const CREATE_QUOTE_REPORT = 'create_quote_report'; - - const CREATE_RECURRING_INVOICE_REPORT = 'create_recurring_invoice_report'; - - const CREATE_TASK_REPORT = 'create_task_report'; + protected $appends = [ + 'hashed_id', + ]; /** * Service entry points. */ - public function service(): TaskSchedulerService + public function service(): SchedulerService { - return new TaskSchedulerService($this); + return new SchedulerService($this); } public function company(): \Illuminate\Database\Eloquent\Relations\BelongsTo @@ -110,43 +71,43 @@ class Scheduler extends BaseModel return $this->belongsTo(Company::class); } - public function nextScheduledDate(): ?Carbon - { - $offset = 0; + // public function nextScheduledDate(): ?Carbon + // { + // $offset = 0; - $entity_send_time = $this->company->settings->entity_send_time; + // $entity_send_time = $this->company->settings->entity_send_time; - if ($entity_send_time != 0) { - $timezone = $this->company->timezone(); + // if ($entity_send_time != 0) { + // $timezone = $this->company->timezone(); - $offset -= $timezone->utc_offset; - $offset += ($entity_send_time * 3600); - } + // $offset -= $timezone->utc_offset; + // $offset += ($entity_send_time * 3600); + // } - /* - As we are firing at UTC+0 if our offset is negative it is technically firing the day before so we always need - to add ON a day - a day = 86400 seconds - */ + // /* + // As we are firing at UTC+0 if our offset is negative it is technically firing the day before so we always need + // to add ON a day - a day = 86400 seconds + // */ - if ($offset < 0) { - $offset += 86400; - } + // if ($offset < 0) { + // $offset += 86400; + // } - switch ($this->repeat_every) { - case self::DAILY: - return Carbon::parse($this->scheduled_run)->startOfDay()->addDay()->addSeconds($offset); - case self::WEEKLY: - return Carbon::parse($this->scheduled_run)->startOfDay()->addWeek()->addSeconds($offset); - case self::BIWEEKLY: - return Carbon::parse($this->scheduled_run)->startOfDay()->addWeeks(2)->addSeconds($offset); - case self::MONTHLY: - return Carbon::parse($this->scheduled_run)->startOfDay()->addMonthNoOverflow()->addSeconds($offset); - case self::QUARTERLY: - return Carbon::parse($this->scheduled_run)->startOfDay()->addMonthsNoOverflow(3)->addSeconds($offset); - case self::ANNUALLY: - return Carbon::parse($this->scheduled_run)->startOfDay()->addYearNoOverflow()->addSeconds($offset); - default: - return null; - } - } + // switch ($this->repeat_every) { + // case self::DAILY: + // return Carbon::parse($this->scheduled_run)->startOfDay()->addDay()->addSeconds($offset); + // case self::WEEKLY: + // return Carbon::parse($this->scheduled_run)->startOfDay()->addWeek()->addSeconds($offset); + // case self::BIWEEKLY: + // return Carbon::parse($this->scheduled_run)->startOfDay()->addWeeks(2)->addSeconds($offset); + // case self::MONTHLY: + // return Carbon::parse($this->scheduled_run)->startOfDay()->addMonthNoOverflow()->addSeconds($offset); + // case self::QUARTERLY: + // return Carbon::parse($this->scheduled_run)->startOfDay()->addMonthsNoOverflow(3)->addSeconds($offset); + // case self::ANNUALLY: + // return Carbon::parse($this->scheduled_run)->startOfDay()->addYearNoOverflow()->addSeconds($offset); + // default: + // return null; + // } + // } } diff --git a/app/PaymentDrivers/CheckoutCom/Utilities.php b/app/PaymentDrivers/CheckoutCom/Utilities.php index 7ac9601d5d60..2407c1c395cb 100644 --- a/app/PaymentDrivers/CheckoutCom/Utilities.php +++ b/app/PaymentDrivers/CheckoutCom/Utilities.php @@ -90,9 +90,7 @@ trait Utilities nlog("checkout failure"); nlog($_payment); - if (is_array($_payment) && array_key_exists('actions', $_payment) && array_key_exists('response_summary', end($_payment['actions']))) { - $error_message = end($_payment['actions'])['response_summary']; - } elseif (is_array($_payment) && array_key_exists('status', $_payment)) { + if (is_array($_payment) && array_key_exists('status', $_payment)) { $error_message = $_payment['status']; } else { diff --git a/app/Policies/SchedulerPolicy.php b/app/Policies/SchedulerPolicy.php new file mode 100644 index 000000000000..b5eaba785f60 --- /dev/null +++ b/app/Policies/SchedulerPolicy.php @@ -0,0 +1,31 @@ +isAdmin(); + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index eef723dcaab3..353e7d6caa3f 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -36,6 +36,7 @@ use App\Models\Quote; use App\Models\RecurringExpense; use App\Models\RecurringInvoice; use App\Models\RecurringQuote; +use App\Models\Scheduler; use App\Models\Subscription; use App\Models\Task; use App\Models\TaskStatus; @@ -67,6 +68,7 @@ use App\Policies\QuotePolicy; use App\Policies\RecurringExpensePolicy; use App\Policies\RecurringInvoicePolicy; use App\Policies\RecurringQuotePolicy; +use App\Policies\SchedulerPolicy; use App\Policies\SubscriptionPolicy; use App\Policies\TaskPolicy; use App\Policies\TaskStatusPolicy; @@ -109,6 +111,7 @@ class AuthServiceProvider extends ServiceProvider RecurringExpense::class => RecurringExpensePolicy::class, RecurringInvoice::class => RecurringInvoicePolicy::class, RecurringQuote::class => RecurringQuotePolicy::class, + Scheduler::class => SchedulerPolicy::class, Subscription::class => SubscriptionPolicy::class, Task::class => TaskPolicy::class, TaskStatus::class => TaskStatusPolicy::class, diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 2684f395df89..0a993436e417 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -11,7 +11,9 @@ namespace App\Providers; +use App\Models\Scheduler; use App\Utils\Traits\MakesHash; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; use Illuminate\Support\Facades\Route; @@ -27,6 +29,21 @@ class RouteServiceProvider extends ServiceProvider public function boot() { parent::boot(); + + + Route::bind('task_scheduler', function ($value) { + + if (is_numeric($value)) { + throw new ModelNotFoundException("Record with value {$value} not found"); + } + + return Scheduler::query() + ->withTrashed() + ->where('id', $this->decodePrimaryKey($value))->firstOrFail(); + + }); + + } /** diff --git a/app/Repositories/SchedulerRepository.php b/app/Repositories/SchedulerRepository.php new file mode 100644 index 000000000000..5c9b9ad4e19c --- /dev/null +++ b/app/Repositories/SchedulerRepository.php @@ -0,0 +1,38 @@ +fill($data); + + $scheduler->save(); + + return $scheduler; + + } + +} diff --git a/app/Repositories/TaskSchedulerRepository.php b/app/Repositories/TaskSchedulerRepository.php deleted file mode 100644 index 8eddc7637775..000000000000 --- a/app/Repositories/TaskSchedulerRepository.php +++ /dev/null @@ -1,16 +0,0 @@ -company_id = $company_id; - $this->db = $db; - } + public function __construct(public $company_id, public $db){} public function handle() :void { diff --git a/app/Services/Email/EmailDefaults.php b/app/Services/Email/EmailDefaults.php new file mode 100644 index 000000000000..3648a63dd329 --- /dev/null +++ b/app/Services/Email/EmailDefaults.php @@ -0,0 +1,313 @@ +settings = $this->email_object->settings; + + $this->setLocale() + ->setFrom() + ->setTemplate() + ->setBody() + ->setSubject() + ->setReplyTo() + ->setBcc() + ->setAttachments() + ->setMetaData() + ->setVariables(); + + return $this->email_object; + + } + + /** + * Sets the meta data for the Email object + */ + private function setMetaData(): self + { + + $this->email_object->company_key = $this->email_service->company->company_key; + + $this->email_object->logo = $this->email_service->company->present()->logo(); + + $this->email_object->signature = $this->email_object->signature ?: $this->settings->email_signature; + + $this->email_object->whitelabel = $this->email_object->company->account->isPaid() ? true : false; + + return $this; + + } + + /** + * Sets the locale + */ + private function setLocale(): self + { + + if($this->email_object->client) + $this->locale = $this->email_object->client->locale(); + elseif($this->email_object->vendor) + $this->locale = $this->email_object->vendor->locale(); + else + $this->locale = $this->email_service->company->locale(); + + App::setLocale($this->locale); + App::forgetInstance('translator'); + $t = app('translator'); + $t->replace(Ninja::transformTranslations($this->settings)); + + return $this; + } + + /** + * Sets the template + */ + private function setTemplate(): self + { + $this->template = $this->email_object->settings->email_style; + + match($this->email_object->settings->email_style){ + 'light' => $this->template = 'email.template.client', + 'dark' => $this->template = 'email.template.client', + 'custom' => $this->template = 'email.template.custom', + default => $this->template = 'email.template.client', + }; + + $this->email_object->html_template = $this->template; + + return $this; + } + + /** + * Sets the FROM address + */ + private function setFrom(): self + { + if($this->email_object->from) + return $this; + + $this->email_object->from = new Address($this->email_service->company->owner()->email, $this->email_service->company->owner()->name()); + + return $this; + + } + + /** + * Sets the body of the email + */ + private function setBody(): self + { + + if($this->email_object->body){ + $this->email_object->body = $this->email_object->body; + } + elseif(strlen($this->email_object->settings->{$this->email_object->email_template_body}) > 3){ + $this->email_object->body = $this->email_object->settings->{$this->email_object->email_template_body}; + } + else{ + $this->email_object->body = EmailTemplateDefaults::getDefaultTemplate($this->email_object->email_template_body, $this->locale); + } + + if($this->template == 'email.template.custom'){ + $this->email_object->body = (str_replace('$body', $this->email_object->body, $this->email_object->settings->email_style_custom)); + } + + return $this; + + } + + /** + * Sets the subject of the email + */ + private function setSubject(): self + { + + if ($this->email_object->subject) //where the user updates the subject from the UI + return $this; + elseif(strlen($this->email_object->settings->{$this->email_object->email_template_subject}) > 3) + $this->email_object->subject = $this->email_object->settings->{$this->email_object->email_template_subject}; + else + $this->email_object->subject = EmailTemplateDefaults::getDefaultTemplate($this->email_object->email_template_subject, $this->locale); + + return $this; + + } + + /** + * Sets the reply to of the email + */ + private function setReplyTo(): self + { + + $reply_to_email = str_contains($this->email_object->settings->reply_to_email, "@") ? $this->email_object->settings->reply_to_email : $this->email_service->company->owner()->email; + + $reply_to_name = strlen($this->email_object->settings->reply_to_name) > 3 ? $this->email_object->settings->reply_to_name : $this->email_service->company->owner()->present()->name(); + + $this->email_object->reply_to = array_merge($this->email_object->reply_to, [new Address($reply_to_email, $reply_to_name)]); + + return $this; + } + + /** + * Replaces the template placeholders + * with variable values. + */ + public function setVariables(): self + { + + $this->email_object->body = strtr($this->email_object->body, $this->email_object->variables); + + $this->email_object->subject = strtr($this->email_object->subject, $this->email_object->variables); + + if($this->template != 'custom') + $this->email_object->body = $this->parseMarkdownToHtml($this->email_object->body); + + return $this; + } + + /** + * Sets the BCC of the email + */ + private function setBcc(): self + { + $bccs = []; + $bcc_array = []; + + if (strlen($this->email_object->settings->bcc_email) > 1) { + + if (Ninja::isHosted() && $this->email_service->company->account->isPaid()) { + $bccs = array_slice(explode(',', str_replace(' ', '', $this->email_object->settings->bcc_email)), 0, 2); + } else { + $bccs(explode(',', str_replace(' ', '', $this->email_object->settings->bcc_email))); + } + } + + foreach($bccs as $bcc) + { + $bcc_array[] = new Address($bcc); + } + + $this->email_object->bcc = array_merge($this->email_object->bcc, $bcc_array); + + return $this; + } + + /** + * Sets the CC of the email + * @todo at some point.... + */ + private function buildCc() + { + return [ + + ]; + } + + /** + * Sets the attachments for the email + * + * Note that we base64 encode these, as they + * sometimes may not survive serialization. + * + * We decode these in the Mailable later + */ + private function setAttachments(): self + { + $attachments = []; + + if ($this->email_object->settings->document_email_attachment && $this->email_service->company->account->hasFeature(Account::FEATURE_DOCUMENTS)) { + + foreach ($this->email_service->company->documents as $document) { + + $attachments[] = ['file' => base64_encode($document->getFile()), 'name' => $document->name]; + + } + + } + + $this->email_object->attachments = array_merge($this->email_object->attachments, $attachments); + + return $this; + + } + + /** + * Sets the headers for the email + */ + private function setHeaders(): self + { + if($this->email_object->invitation_key) + $this->email_object->headers = array_merge($this->email_object->headers, ['x-invitation-key' => $this->email_object->invitation_key]); + + return $this; + } + + /** + * Converts any markdown to HTML in the email + * + * @param string $markdown The body to convert + * @return string The parsed markdown response + */ + private function parseMarkdownToHtml(string $markdown): ?string + { + $converter = new CommonMarkConverter([ + 'allow_unsafe_links' => false, + ]); + + return $converter->convert($markdown); + } + +} \ No newline at end of file diff --git a/app/Services/Email/EmailMailable.php b/app/Services/Email/EmailMailable.php new file mode 100644 index 000000000000..a7036b7e6a15 --- /dev/null +++ b/app/Services/Email/EmailMailable.php @@ -0,0 +1,109 @@ +email_object->subject, + tags: [$this->email_object->company_key], + replyTo: $this->email_object->reply_to, + from: $this->email_object->from, + to: $this->email_object->to, + bcc: $this->email_object->bcc + ); + } + + /** + * Get the message content definition. + * + * @return \Illuminate\Mail\Mailables\Content + */ + public function content() + { + return new Content( + view: $this->email_object->html_template, + text: $this->email_object->text_template, + with: [ + 'text_body' => strip_tags($this->email_object->body), //@todo this is a bit hacky here. + 'body' => $this->email_object->body, + 'settings' => $this->email_object->settings, + 'whitelabel' => $this->email_object->whitelabel, + 'logo' => $this->email_object->logo, + 'signature' => $this->email_object->signature, + 'company' => $this->email_object->company, + 'greeting' => '' + ] + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments() + { + + $attachments = []; + + foreach($this->email_object->attachments as $file) + { + $attachments[] = Attachment::fromData(fn () => base64_decode($file['file']), $file['name']); + } + + return $attachments; + + } + + /** + * Get the message headers. + * + * @return \Illuminate\Mail\Mailables\Headers + */ + public function headers() + { + + return new Headers( + messageId: null, + references: [], + text: $this->email_object->headers, + ); + + } + +} diff --git a/app/Services/Email/EmailMailer.php b/app/Services/Email/EmailMailer.php new file mode 100644 index 000000000000..a5be905327c3 --- /dev/null +++ b/app/Services/Email/EmailMailer.php @@ -0,0 +1,515 @@ +email_service->company->db); + + /* Perform final checks */ + if($this->email_service->preFlightChecksFail()) + return; + + /* Boot the required driver*/ + $this->setMailDriver(); + + /* Init the mailer*/ + $mailer = Mail::mailer($this->mailer); + + /* Additional configuration if using a client third party mailer */ + if($this->client_postmark_secret) + $mailer->postmark_config($this->client_postmark_secret); + + if($this->client_mailgun_secret) + $mailer->mailgun_config($this->client_mailgun_secret, $this->client_mailgun_domain); + + /* Attempt the send! */ + try { + + nlog("Using mailer => ". $this->mailer. " ". now()->toDateTimeString()); + + $mailer->send($this->email_mailable); + + Cache::increment($this->email_service->company->account->key); + + LightLogs::create(new EmailSuccess($this->email_service->company->company_key)) + ->send(); + + } catch (\Exception | \RuntimeException | \Google\Service\Exception $e) { + + nlog("error failed with {$e->getMessage()}"); + + $this->cleanUpMailers(); + + $message = $e->getMessage(); + + /** + * Post mark buries the proper message in a a guzzle response + * this merges a text string with a json object + * need to harvest the ->Message property using the following + */ + if($e instanceof ClientException) { //postmark specific failure + + $response = $e->getResponse(); + $message_body = json_decode($response->getBody()->getContents()); + + if($message_body && property_exists($message_body, 'Message')){ + $message = $message_body->Message; + nlog($message); + } + + } + + /* If the is an entity attached to the message send a failure mailer */ + $this->entityEmailFailed($message); + + /* Don't send postmark failures to Sentry */ + if(Ninja::isHosted() && (!$e instanceof ClientException)) + app('sentry')->captureException($e); + + $message = null; + + } + + } + + /** + * Entity notification when an email fails to send + * + * @todo - rewrite this + * @param string $message + * @return void + */ + private function entityEmailFailed($message) + { + + if(!$this->email_service->email_object->entity_id) + return; + + switch ($this->email_service->email_object->entity_class) { + case Invoice::class: + $invitation = InvoiceInvitation::withTrashed()->find($this->email_service->email_object->entity_id); + if($invitation) + event(new InvoiceWasEmailedAndFailed($invitation, $this->email_service->company, $message, $this->email_service->email_object->reminder_template, Ninja::eventVars(auth()->user() ? auth()->user()->id : null))); + break; + case Payment::class: + $payment = Payment::withTrashed()->find($this->email_service->email_object->entity_id); + if($payment) + event(new PaymentWasEmailedAndFailed($payment, $this->email_service->company, $message, Ninja::eventVars(auth()->user() ? auth()->user()->id : null))); + break; + default: + # code... + break; + } + + if ($this->email_service->email_object->client_contact instanceof ClientContact) + $this->logMailError($message, $this->email_service->email_object->client_contact); + + } + + /** + * Sets the mail driver to use and applies any specific configuration + * the the mailable + */ + private function setMailDriver(): self + { + + switch ($this->email_service->email_object->settings->email_sending_method) { + case 'default': + $this->mailer = config('mail.default'); + break; + case 'gmail': + $this->mailer = 'gmail'; + $this->setGmailMailer(); + return $this; + case 'office365': + $this->mailer = 'office365'; + $this->setOfficeMailer(); + return $this; + case 'client_postmark': + $this->mailer = 'postmark'; + $this->setPostmarkMailer(); + return $this; + case 'client_mailgun': + $this->mailer = 'mailgun'; + $this->setMailgunMailer(); + return $this; + + default: + break; + } + + if(Ninja::isSelfHost()) + $this->setSelfHostMultiMailer(); + + return $this; + + } + + /** + * Allows configuration of multiple mailers + * per company for use by self hosted users + */ + private function setSelfHostMultiMailer(): void + { + + if (env($this->email_service->company->id . '_MAIL_HOST')) + { + + config([ + 'mail.mailers.smtp' => [ + 'transport' => 'smtp', + 'host' => env($this->email_service->company->id . '_MAIL_HOST'), + 'port' => env($this->email_service->company->id . '_MAIL_PORT'), + 'username' => env($this->email_service->company->id . '_MAIL_USERNAME'), + 'password' => env($this->email_service->company->id . '_MAIL_PASSWORD'), + ], + ]); + + if(env($this->email_service->company->id . '_MAIL_FROM_ADDRESS')) + { + $this->email_mailable + ->from(env($this->email_service->company->id . '_MAIL_FROM_ADDRESS', env('MAIL_FROM_ADDRESS')), env($this->email_service->company->id . '_MAIL_FROM_NAME', env('MAIL_FROM_NAME'))); + } + + } + + } + + + /** + * Ensure we discard any data that is not required + * + * @return void + */ + private function cleanUpMailers(): void + { + $this->client_postmark_secret = false; + + $this->client_mailgun_secret = false; + + $this->client_mailgun_domain = false; + + //always dump the drivers to prevent reuse + app('mail.manager')->forgetMailers(); + } + + /** + * Check to ensure no cross account + * emails can be sent. + * + * @param User $user + */ + private function checkValidSendingUser($user) + { + /* Always ensure the user is set on the correct account */ + if($user->account_id != $this->email_service->company->account_id) + { + $this->email_service->email_object->settings->email_sending_method = 'default'; + + return $this->setMailDriver(); + } + } + + /** + * Resolves the sending user + * when configuring the Mailer + * on behalf of the client + * + * @return User $user + */ + private function resolveSendingUser(): ?User + { + $sending_user = $this->email_service->email_object->settings->gmail_sending_user_id; + + if($sending_user == "0") + $user = $this->email_service->company->owner(); + else + $user = User::find($this->decodePrimaryKey($sending_user)); + + return $user; + } + /** + * Configures Mailgun using client supplied secret + * as the Mailer + */ + private function setMailgunMailer() + { + if(strlen($this->email_service->email_object->settings->mailgun_secret) > 2 && strlen($this->email_service->email_object->settings->mailgun_domain) > 2){ + $this->client_mailgun_secret = $this->email_service->email_object->settings->mailgun_secret; + $this->client_mailgun_domain = $this->email_service->email_object->settings->mailgun_domain; + } + else{ + $this->email_service->email_object->settings->email_sending_method = 'default'; + return $this->setMailDriver(); + } + + $user = $this->resolveSendingUser(); + + $this->mailable + ->from($user->email, $user->name()); + } + + /** + * Configures Postmark using client supplied secret + * as the Mailer + */ + private function setPostmarkMailer() + { + if(strlen($this->email_service->email_object->settings->postmark_secret) > 2){ + $this->client_postmark_secret = $this->email_service->email_object->settings->postmark_secret; + } + else{ + $this->email_service->email_object->settings->email_sending_method = 'default'; + return $this->setMailDriver(); + } + + $user = $this->resolveSendingUser(); + + $this->mailable + ->from($user->email, $user->name()); + } + + /** + * Configures Microsoft via Oauth + * as the Mailer + */ + private function setOfficeMailer() + { + $user = $this->resolveSendingUser(); + + $this->checkValidSendingUser($user); + + nlog("Sending via {$user->name()}"); + + $token = $this->refreshOfficeToken($user); + + if($token) + { + $user->oauth_user_token = $token; + $user->save(); + + } + else { + + $this->email_service->email_object->settings->email_sending_method = 'default'; + return $this->setMailDriver(); + + } + + $this->mailable + ->from($user->email, $user->name()) + ->withSymfonyMessage(function ($message) use($token) { + $message->getHeaders()->addTextHeader('gmailtoken', $token); + }); + + sleep(rand(1,3)); + } + + /** + * Configures GMail via Oauth + * as the Mailer + */ + private function setGmailMailer() + { + + $user = $this->resolveSendingUser(); + + $this->checkValidSendingUser($user); + + nlog("Sending via {$user->name()}"); + + $google = (new Google())->init(); + + try{ + + if ($google->getClient()->isAccessTokenExpired()) { + $google->refreshToken($user); + $user = $user->fresh(); + } + + $google->getClient()->setAccessToken(json_encode($user->oauth_user_token)); + + sleep(rand(2,4)); + } + catch(\Exception $e) { + $this->logMailError('Gmail Token Invalid', $this->email_service->company->clients()->first()); + $this->email_service->email_object->settings->email_sending_method = 'default'; + return $this->setMailDriver(); + } + + /** + * If the user doesn't have a valid token, notify them + */ + + if(!$user->oauth_user_token) { + $this->email_service->company->account->gmailCredentialNotification(); + $this->email_service->email_object->settings->email_sending_method = 'default'; + return $this->setMailDriver(); + } + + /* + * Now that our token is refreshed and valid we can boot the + * mail driver at runtime and also set the token which will persist + * just for this request. + */ + + $token = $user->oauth_user_token->access_token; + + if(!$token) { + $this->email_service->company->account->gmailCredentialNotification(); + $this->email_service->email_object->settings->email_sending_method = 'default'; + return $this->setMailDriver(); + } + + $this->mailable + ->from($user->email, $user->name()) + ->withSymfonyMessage(function ($message) use($token) { + $message->getHeaders()->addTextHeader('gmailtoken', $token); + }); + + } + + /** + * Logs any errors to the SystemLog + * + * @param string $errors + * @param App\Models\User | App\Models\Client $recipient_object + * @return void + */ + private function logMailError($errors, $recipient_object) :void + { + + (new SystemLogger( + $errors, + SystemLog::CATEGORY_MAIL, + SystemLog::EVENT_MAIL_SEND, + SystemLog::TYPE_FAILURE, + $recipient_object, + $this->email_service->company + ))->handle(); + + $job_failure = new EmailFailure($this->email_service->company->company_key); + $job_failure->string_metric5 = 'failed_email'; + $job_failure->string_metric6 = substr($errors, 0, 150); + + LightLogs::create($job_failure) + ->send(); + + $job_failure = null; + + } + + /** + * Attempts to refresh the Microsoft refreshToken + * + * @param App\Models\User + * @return mixed + */ + private function refreshOfficeToken(User $user): mixed + { + $expiry = $user->oauth_user_token_expiry ?: now()->subDay(); + + if($expiry->lt(now())) + { + $guzzle = new \GuzzleHttp\Client(); + $url = 'https://login.microsoftonline.com/common/oauth2/v2.0/token'; + + $token = json_decode($guzzle->post($url, [ + 'form_params' => [ + 'client_id' => config('ninja.o365.client_id') , + 'client_secret' => config('ninja.o365.client_secret') , + 'scope' => 'email Mail.Send offline_access profile User.Read openid', + 'grant_type' => 'refresh_token', + 'refresh_token' => $user->oauth_user_refresh_token + ], + ])->getBody()->getContents()); + + if($token){ + + $user->oauth_user_refresh_token = property_exists($token, 'refresh_token') ? $token->refresh_token : $user->oauth_user_refresh_token; + $user->oauth_user_token = $token->access_token; + $user->oauth_user_token_expiry = now()->addSeconds($token->expires_in); + $user->save(); + + return $token->access_token; + } + + return false; + } + + return $user->oauth_user_token; + + } + + public function failed($exception = null) + { + + } + +} diff --git a/app/Services/Email/EmailObject.php b/app/Services/Email/EmailObject.php new file mode 100644 index 000000000000..27a1dd72bdfa --- /dev/null +++ b/app/Services/Email/EmailObject.php @@ -0,0 +1,84 @@ +override = $override; + + $this->setDefaults() + ->updateMailable() + ->email(); + } + + public function sendNow($force = false) :void + { + $this->setDefaults() + ->updateMailable() + ->email($force); + } + + private function email($force = false): void + { + if($force) + (new EmailMailer($this, $this->mailable))->handle(); + else + EmailMailer::dispatch($this, $this->mailable)->delay(2); + + } + + private function setDefaults(): self + { + $defaults = new EmailDefaults($this, $this->email_object); + $defaults->run(); + + return $this; + } + + private function updateMailable() + { + $this->mailable = new EmailMailable($this->email_object); + + return $this; + } + + /** + * On the hosted platform we scan all outbound email for + * spam. This sequence processes the filters we use on all + * emails. + * + * @return bool + */ + public function preFlightChecksFail(): bool + { + + /* If we are migrating data we don't want to fire any emails */ + if($this->company->is_disabled && !$this->override) + return true; + + /* To handle spam users we drop all emails from flagged accounts */ + if(Ninja::isHosted() && $this->company->account && $this->company->account->is_flagged) + return true; + + /* On the hosted platform we set default contacts a @example.com email address - we shouldn't send emails to these types of addresses */ + if(Ninja::isHosted() && $this->hasValidEmails()) + return true; + + /* GMail users are uncapped */ + if(Ninja::isHosted() && in_array($this->email_object->settings->email_sending_method, ['gmail', 'office365', 'client_postmark', 'client_mailgun'])) + return false; + + /* On the hosted platform, if the user is over the email quotas, we do not send the email. */ + if(Ninja::isHosted() && $this->company->account && $this->company->account->emailQuotaExceeded()) + return true; + + /* If the account is verified, we allow emails to flow */ + if(Ninja::isHosted() && $this->company->account && $this->company->account->is_verified_account) { + + //11-01-2022 + + /* Continue to analyse verified accounts in case they later start sending poor quality emails*/ + // if(class_exists(\Modules\Admin\Jobs\Account\EmailQuality::class)) + // (new \Modules\Admin\Jobs\Account\EmailQuality($this->nmo, $this->company))->run(); + + return false; + } + + /* On the hosted platform if the user has not verified their account we fail here - but still check what they are trying to send! */ + if(Ninja::isHosted() && $this->company->account && !$this->company->account->account_sms_verified){ + + if(class_exists(\Modules\Admin\Jobs\Account\EmailFilter::class)) + return (new \Modules\Admin\Jobs\Account\EmailFilter($this->email_object, $this->company))->run(); + + return true; + } + + /* On the hosted platform we actively scan all outbound emails to ensure outbound email quality remains high */ + if(class_exists(\Modules\Admin\Jobs\Account\EmailFilter::class)) + return (new \Modules\Admin\Jobs\Account\EmailFilter($this->email_object, $this->company))->run(); + + return false; + } + + private function hasValidEmails(): bool + { + + foreach($this->email_object->to as $address_object) + { + + if(strpos($address_object->address, '@example.com') !== false) + return true; + + if(!str_contains($address_object->address, "@")) + return true; + + } + + + return false; + } + + +} \ No newline at end of file diff --git a/app/Services/Invoice/InvoiceService.php b/app/Services/Invoice/InvoiceService.php index bdc770c7826b..0fcb71899d71 100644 --- a/app/Services/Invoice/InvoiceService.php +++ b/app/Services/Invoice/InvoiceService.php @@ -35,12 +35,7 @@ class InvoiceService { use MakesHash; - public $invoice; - - public function __construct($invoice) - { - $this->invoice = $invoice; - } + public function __construct(public Invoice $invoice){} /** * Marks as invoice as paid @@ -531,6 +526,10 @@ class InvoiceService $this->invoice->exchange_rate = $this->invoice->client->currency()->exchange_rate; } + if ($settings->auto_bill_standard_invoices) { + $this->invoice->auto_bill_enabled = true; + } + if ($settings->counter_number_applied == 'when_saved') { $this->invoice->service()->applyNumber()->save(); } diff --git a/app/Services/Schedule/ScheduleService.php b/app/Services/Schedule/ScheduleService.php deleted file mode 100644 index b9638095b430..000000000000 --- a/app/Services/Schedule/ScheduleService.php +++ /dev/null @@ -1,57 +0,0 @@ -{$this->scheduler->template}(); + } + + private function client_statement() + { + $query = Client::query() + ->where('company_id', $this->scheduler->company_id); + + //Email only the selected clients + if(count($this->scheduler->parameters['clients']) >= 1) + $query->where('id', $this->transformKeys($this->scheduler->parameters['clients'])); + + + $query->cursor() + ->each(function ($_client){ + + $this->client = $_client; + $statement_properties = $this->calculateStatementProperties(); + + //work out the date range + $pdf = $_client->service()->statement($statement_properties); + + $email_service = new EmailService($this->buildMailableData($pdf), $_client->company); + $email_service->send(); + + //calculate next run dates; + + }); + + } + + private function calculateStatementProperties() + { + $start_end = $this->calculateStartAndEndDates(); + + $this->client_start_date = $this->translateDate($start_end[0], $this->client->date_format(), $this->client->locale()); + $this->client_end_date = $this->translateDate($start_end[1], $this->client->date_format(), $this->client->locale()); + + return [ + 'start_date' =>$start_end[0], + 'end_date' =>$start_end[1], + 'show_payments_table' => $this->scheduler->parameters['show_payments_table'], + 'show_aging_table' => $this->scheduler->parameters['show_aging_table'], + 'status' => $this->scheduler->parameters['status'] + ]; + + } + + private function calculateStartAndEndDates() + { + return match ($this->scheduler->parameters['date_range']) { + 'this_month' => [now()->firstOfMonth()->format('Y-m-d'), now()->lastOfMonth()->format('Y-m-d')], + 'this_quarter' => [now()->firstOfQuarter()->format('Y-m-d'), now()->lastOfQuarter()->format('Y-m-d')], + 'this_year' => [now()->firstOfYear()->format('Y-m-d'), now()->lastOfYear()->format('Y-m-d')], + 'previous_month' => [now()->subMonth()->firstOfMonth()->format('Y-m-d'), now()->subMonth()->lastOfMonth()->format('Y-m-d')], + 'previous_quarter' => [now()->subQuarter()->firstOfQuarter()->format('Y-m-d'), now()->subQuarter()->lastOfQuarter()->format('Y-m-d')], + 'previous_year' => [now()->subYear()->firstOfYear()->format('Y-m-d'), now()->subYear()->lastOfYear()->format('Y-m-d')], + 'custom_range' => [$this->scheduler->parameters['start_date'], $this->scheduler->parameters['end_date']], + default => [now()->firstOfMonth()->format('Y-m-d'), now()->lastOfMonth()->format('Y-m-d')], + }; + } + + private function buildMailableData($pdf) + { + + $email_object = new EmailObject; + $email_object->to = [new Address($this->client->present()->email(), $this->client->present()->name())]; + $email_object->attachments = [['file' => base64_encode($pdf), 'name' => ctrans('texts.statement') . ".pdf"]]; + $email_object->settings = $this->client->getMergedSettings(); + $email_object->company = $this->client->company; + $email_object->client = $this->client; + $email_object->email_template_subject = 'email_subject_statement'; + $email_object->email_template_body = 'email_template_statement'; + $email_object->variables = [ + '$client' => $this->client->present()->name(), + '$start_date' => $this->client_start_date, + '$end_date' => $this->client_end_date, + ]; + + return $email_object; + + } + + +} \ No newline at end of file diff --git a/app/Services/TaskScheduler/TaskSchedulerService.php b/app/Services/TaskScheduler/TaskSchedulerService.php index c0320a8b1c89..2b4302eaf62b 100644 --- a/app/Services/TaskScheduler/TaskSchedulerService.php +++ b/app/Services/TaskScheduler/TaskSchedulerService.php @@ -38,6 +38,8 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Validator; use Symfony\Component\HttpFoundation\Request; + +//@deprecated - never used.... class TaskSchedulerService { diff --git a/app/Transformers/CompanyTransformer.php b/app/Transformers/CompanyTransformer.php index 2f20c5300617..77bcae0fa469 100644 --- a/app/Transformers/CompanyTransformer.php +++ b/app/Transformers/CompanyTransformer.php @@ -194,6 +194,7 @@ class CompanyTransformer extends EntityTransformer 'convert_payment_currency' => (bool) $company->convert_payment_currency, 'convert_expense_currency' => (bool) $company->convert_expense_currency, 'notify_vendor_when_paid' => (bool) $company->notify_vendor_when_paid, + 'invoice_task_hours' => (bool) $company->invoice_task_hours, ]; } diff --git a/app/Transformers/TaskSchedulerTransformer.php b/app/Transformers/SchedulerTransformer.php similarity index 66% rename from app/Transformers/TaskSchedulerTransformer.php rename to app/Transformers/SchedulerTransformer.php index 88fbf9d29999..584ea4cbe9f9 100644 --- a/app/Transformers/TaskSchedulerTransformer.php +++ b/app/Transformers/SchedulerTransformer.php @@ -14,7 +14,7 @@ namespace App\Transformers; use App\Models\Scheduler; use App\Utils\Traits\MakesHash; -class TaskSchedulerTransformer extends EntityTransformer +class SchedulerTransformer extends EntityTransformer { use MakesHash; @@ -22,17 +22,17 @@ class TaskSchedulerTransformer extends EntityTransformer { return [ 'id' => $this->encodePrimaryKey($scheduler->id), + 'name' => (string) $scheduler->name, + 'frequency_id' => (string) $scheduler->frequency_id, + 'next_run' => $scheduler->next_run, + 'template' => (string) $scheduler->template, + 'is_paused' => (bool) $scheduler->is_paused, + 'is_deleted' => (bool) $scheduler->is_deleted, + 'parameters'=> (array) $scheduler->parameters, 'is_deleted' => (bool) $scheduler->is_deleted, - 'paused' => (bool) $scheduler->paused, - 'repeat_every' => (string) $scheduler->repeat_every, - 'start_from' => (int) $scheduler->start_from, - 'scheduled_run' => (int) $scheduler->scheduled_run, 'updated_at' => (int) $scheduler->updated_at, 'created_at' => (int) $scheduler->created_at, 'archived_at' => (int) $scheduler->deleted_at, - 'action_name' => (string) $scheduler->action_name, - 'action_class' => (string) $scheduler->action_class, - 'parameters'=> (array) $scheduler->parameters, ]; } } diff --git a/database/factories/SchedulerFactory.php b/database/factories/SchedulerFactory.php new file mode 100644 index 000000000000..92c86d8ad8ce --- /dev/null +++ b/database/factories/SchedulerFactory.php @@ -0,0 +1,37 @@ + $this->faker->name(), + 'is_paused' => rand(0,1), + 'is_deleted' => rand(0,1), + 'parameters' => [], + 'frequency_id' => RecurringInvoice::FREQUENCY_MONTHLY, + 'next_run' => now()->addSeconds(rand(86400,8640000)), + 'template' => 'statement_task', + ]; + } +} diff --git a/database/migrations/2023_01_12_125540_set_auto_bill_on_regular_invoice_setting.php b/database/migrations/2023_01_12_125540_set_auto_bill_on_regular_invoice_setting.php new file mode 100644 index 000000000000..fade7d7ad3f4 --- /dev/null +++ b/database/migrations/2023_01_12_125540_set_auto_bill_on_regular_invoice_setting.php @@ -0,0 +1,71 @@ +boolean('is_trial')->default(false); + }); + + Schema::table('companies', function (Blueprint $table) + { + $table->boolean('invoice_task_hours')->default(false); + }); + + Schema::table('schedulers', function (Blueprint $table) + { + + $table->dropColumn('repeat_every'); + $table->dropColumn('start_from'); + $table->dropColumn('scheduled_run'); + $table->dropColumn('action_name'); + $table->dropColumn('action_class'); + $table->dropColumn('paused'); + $table->dropColumn('company_id'); + + }); + + + Schema::table('schedulers', function (Blueprint $table) + { + + $table->unsignedInteger('company_id'); + $table->boolean('is_paused')->default(false); + $table->unsignedInteger('frequency_id')->nullable(); + $table->datetime('next_run')->nullable(); + $table->datetime('next_run_client')->nullable(); + $table->unsignedInteger('user_id'); + $table->string('name', 191); + $table->string('template', 191); + + $table->foreign('company_id')->references('id')->on('companies')->onDelete('cascade')->onUpdate('cascade'); + + $table->unique(['company_id', 'name']); + $table->index(['company_id', 'deleted_at']); + + }); + + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // + } +}; diff --git a/lang/en/texts.php b/lang/en/texts.php index 388d200053c7..f983eaf0238a 100644 --- a/lang/en/texts.php +++ b/lang/en/texts.php @@ -4924,6 +4924,7 @@ $LANG = array( 'action_add_to_invoice' => 'Add To Invoice', 'danger_zone' => 'Danger Zone', 'import_completed' => 'Import completed', + 'client_statement_body' => 'Your statement from :start_date to :end_date is attached.' ); diff --git a/resources/views/email/template/client.blade.php b/resources/views/email/template/client.blade.php index 93e772abe888..5fcf2b9bdcd1 100644 --- a/resources/views/email/template/client.blade.php +++ b/resources/views/email/template/client.blade.php @@ -1,5 +1,6 @@ @php $primary_color = isset($settings) ? $settings->primary_color : '#4caf50'; + $email_alignment = isset($settings) ? $settings->email_alignment : 'center'; @endphp @@ -60,7 +61,8 @@ font-size: 13px; padding: 15px 50px; font-weight: 600; - margin-bottom: 30px; + margin-bottom: 5px; + margin-top: 10px; } #content h1 { font-family: 'canada-type-gibson', 'roboto', Arial, Helvetica, sans-serif; @@ -146,8 +148,8 @@ - -
+ +
{{ $slot ?? '' }} @@ -163,8 +165,8 @@ - -
+ +
diff --git a/resources/views/index/index.blade.php b/resources/views/index/index.blade.php index 2d95233ce2f3..b0dc8e5d5e34 100644 --- a/resources/views/index/index.blade.php +++ b/resources/views/index/index.blade.php @@ -1,29 +1,32 @@ - + - {{config('ninja.app_name')}} + {{ $white_label ? "" : config('ninja.app_name') }} @if(\App\Utils\Ninja::isHosted()) + + + - + - + @endif