1
0
mirror of https://github.com/invoiceninja/invoiceninja.git synced 2024-11-10 21:22:58 +01:00

Merge pull request #8156 from turbo124/v5-develop

Quote Filters
This commit is contained in:
David Bomba 2023-01-16 07:55:22 +11:00 committed by GitHub
commit 2a53be6584
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 2543 additions and 398 deletions

View File

@ -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,

View File

@ -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();

View File

@ -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',

View File

@ -235,12 +235,17 @@ class EmailTemplateDefaults
public static function emailStatementSubject()
{
return '';
return ctrans('texts.your_statement');
}
public static function emailStatementTemplate()
{
return '';
$statement_message = '<p>$client<br><br>'.self::transformText('client_statement_body').'<br></p>';
return $statement_message;
// return ctrans('texts.client_statement_body', ['start_date' => '$start_date', 'end_date' => '$end_date']);
}
private static function transformText($string)

View File

@ -0,0 +1,96 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\DataMapper;
use App\Models\Client;
use stdClass;
class ClientStatement
{
/**
* Defines the template name
*
* @var string
*/
public string $template = 'client_statement';
/**
* An array of clients hashed_ids
*
* Leave blank if this action should apply to all clients
*
* @var array
*/
public array $clients = [];
/**
* The consts to be used to define the date_range variable of the statement
*/
public const THIS_MONTH = 'this_month';
public const THIS_QUARTER = 'this_quarter';
public const THIS_YEAR = 'this_year';
public const PREVIOUS_MONTH = 'previous_month';
public const PREVIOUS_QUARTER = 'previous_quarter';
public const PREVIOUS_YEAR = 'previous_year';
public const CUSTOM_RANGE = "custom_range";
/**
* The date range the statement should include
*
* @var string
*/
public string $date_range = 'this_month';
/**
* If a custom range is select for the date range then
* the start_date should be supplied in Y-m-d format
*
* @var string
*/
public string $start_date = '';
/**
* If a custom range is select for the date range then
* the end_date should be supplied in Y-m-d format
*
* @var string
*/
public string $end_date = '';
/**
* Flag which allows the payment table
* to be shown
*
* @var boolean
*/
public bool $show_payments_table = true;
/**
* Flag which allows the aging table
* to be shown
*
* @var boolean
*/
public bool $show_aging_table = true;
/**
* String const which defines whether
* the invoices to be shown are either
* paid or unpaid
*
* @var string
*/
public string $status = 'paid'; // paid | unpaid
}

View File

@ -0,0 +1,32 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Factory;
use App\Models\Scheduler;
class SchedulerFactory
{
public static function create($company_id, $user_id) :Scheduler
{
$scheduler = new Scheduler;
$scheduler->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;
}
}

View File

@ -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');
});

View File

@ -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'
));

View File

@ -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') : '';

View File

@ -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();
}

View File

@ -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)) {

View File

@ -1,4 +1,13 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Controllers;

View File

@ -11,37 +11,40 @@
namespace App\Http\Controllers;
use App\Http\Requests\TaskScheduler\CreateScheduledTaskRequest;
use App\Http\Requests\TaskScheduler\UpdateScheduleRequest;
use App\Factory\SchedulerFactory;
use App\Http\Requests\TaskScheduler\CreateSchedulerRequest;
use App\Http\Requests\TaskScheduler\ShowSchedulerRequest;
use App\Http\Requests\TaskScheduler\StoreSchedulerRequest;
use App\Http\Requests\TaskScheduler\UpdateSchedulerRequest;
use App\Http\Requests\Task\DestroySchedulerRequest;
use App\Jobs\Ninja\TaskScheduler;
use App\Jobs\Report\ProfitAndLoss;
use App\Models\Scheduler;
use App\Repositories\TaskSchedulerRepository;
use App\Transformers\TaskSchedulerTransformer;
use App\Repositories\SchedulerRepository;
use App\Transformers\SchedulerTransformer;
use App\Utils\Traits\MakesHash;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Symfony\Component\HttpFoundation\Request;
class TaskSchedulerController extends BaseController
{
use MakesHash;
protected $entity_type = Scheduler::class;
protected $entity_transformer = TaskSchedulerTransformer::class;
protected $entity_transformer = SchedulerTransformer::class;
protected TaskSchedulerRepository $scheduler_repository;
public function __construct(TaskSchedulerRepository $scheduler_repository)
public function __construct(protected SchedulerRepository $scheduler_repository)
{
parent::__construct();
$this->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)));
}
}

View File

@ -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());
}
}
/**

View File

@ -32,8 +32,6 @@ class CheckMailRequest extends Request
*/
public function rules()
{
nlog($this->driver);
return [
'mail_driver' => 'required',
'encryption' => 'required_unless:mail_driver,log',

View File

@ -1,39 +0,0 @@
<?php
namespace App\Http\Requests\TaskScheduler;
use App\Http\Requests\Request;
class CreateScheduledTaskRequest extends Request
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool
{
return auth()->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);
}
}

View File

@ -0,0 +1,28 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Requests\TaskScheduler;
use App\Http\Requests\Request;
class CreateSchedulerRequest extends Request
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool
{
return auth()->user()->isAdmin();
}
}

View File

@ -0,0 +1,27 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Requests\Task;
use App\Http\Requests\Request;
class DestroySchedulerRequest extends Request
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize() : bool
{
return auth()->user()->isAdmin();
}
}

View File

@ -0,0 +1,27 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Requests\TaskScheduler;
use App\Http\Requests\Request;
class ShowSchedulerRequest extends Request
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize() : bool
{
return auth()->user()->can('view', $this->scheduler);
}
}

View File

@ -0,0 +1,44 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Requests\TaskScheduler;
use App\Http\Requests\Request;
use Illuminate\Validation\Rule;
class StoreSchedulerRequest extends Request
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool
{
return auth()->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;
}
}

View File

@ -1,25 +0,0 @@
<?php
namespace App\Http\Requests\TaskScheduler;
use App\Http\Requests\Request;
class UpdateScheduledJobRequest extends Request
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool
{
return auth()->user()->isAdmin();
}
public function rules(): array
{
return [
'action_name' => 'sometimes|string',
];
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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();

View File

@ -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();
}
}

View File

@ -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! */

View File

@ -0,0 +1,94 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Mail\Client;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Attachment;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ClientStatement extends Mailable
{
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(public array $data){}
/**
* Get the message envelope.
*
* @return \Illuminate\Mail\Mailables\Envelope
*/
public function envelope()
{
return new Envelope(
subject: $this->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;
}
}

View File

@ -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");
}

View File

@ -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',

View File

@ -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;
// }
// }
}

View File

@ -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 {

View File

@ -0,0 +1,31 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Policies;
use App\Models\User;
/**
* Class SchedulerPolicy.
*/
class SchedulerPolicy extends EntityPolicy
{
/**
* Checks if the user has create permissions.
*
* @param User $user
* @return bool
*/
public function create(User $user) : bool
{
return $user->isAdmin();
}
}

View File

@ -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,

View File

@ -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();
});
}
/**

View File

@ -0,0 +1,38 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Repositories;
use App\Models\Scheduler;
class SchedulerRepository extends BaseRepository
{
/**
* Saves the scheduler.
*
* @param array $data The data
* @param \App\Models\Scheduler $scheduler The scheduler
*
* @return \App\Models\Scheduler
*/
public function save(array $data, Scheduler $scheduler): Scheduler
{
$scheduler->fill($data);
$scheduler->save();
return $scheduler;
}
}

View File

@ -1,16 +0,0 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Repositories;
class TaskSchedulerRepository extends BaseRepository
{
}

View File

@ -34,15 +34,7 @@ class BankMatchingService implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $company_id;
protected $db;
public function __construct($company_id, $db)
{
$this->company_id = $company_id;
$this->db = $db;
}
public function __construct(public $company_id, public $db){}
public function handle() :void
{

View File

@ -0,0 +1,313 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Email;
use App\DataMapper\EmailTemplateDefaults;
use App\Models\Account;
use App\Models\Company;
use App\Services\Email\EmailObject;
use App\Utils\Ninja;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Mail;
use League\CommonMark\CommonMarkConverter;
use Illuminate\Mail\Attachment;
class EmailDefaults
{
/**
* The settings object for this email
* @var CompanySettings $settings
*/
protected $settings;
/**
* The HTML / Template to use for this email
* @var string $template
*/
private string $template;
/**
* The locale to use for
* translations for this email
*/
private string $locale;
/**
* @param EmailService $email_service The email service class
* @param EmailObject $email_object the email object class
*/
public function __construct(protected EmailService $email_service, public EmailObject $email_object){}
/**
* Entry point for generating
* the defaults for the email object
*
* @return EmailObject $email_object The email object
*/
public function run()
{
$this->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);
}
}

View File

@ -0,0 +1,109 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Email;
use App\Services\Email\EmailObject;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Attachment;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Mail\Mailables\Headers;
class EmailMailable extends Mailable
{
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(public EmailObject $email_object){}
/**
* Get the message envelope.
*
* @return \Illuminate\Mail\Mailables\Envelope
*/
public function envelope()
{
return new Envelope(
subject: $this->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,
);
}
}

View File

@ -0,0 +1,515 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Email;
use App\DataMapper\Analytics\EmailFailure;
use App\DataMapper\Analytics\EmailSuccess;
use App\DataMapper\EmailTemplateDefaults;
use App\Events\Invoice\InvoiceWasEmailedAndFailed;
use App\Events\Payment\PaymentWasEmailedAndFailed;
use App\Jobs\Util\SystemLogger;
use App\Libraries\Google\Google;
use App\Libraries\MultiDB;
use App\Models\Account;
use App\Models\ClientContact;
use App\Models\Company;
use App\Models\InvoiceInvitation;
use App\Models\Payment;
use App\Models\SystemLog;
use App\Models\User;
use App\Services\Email\EmailObject;
use App\Utils\Ninja;
use App\Utils\Traits\MakesHash;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Mail\Attachment;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailer;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Mail;
use League\CommonMark\CommonMarkConverter;
use Turbo124\Beacon\Facades\LightLogs;
class EmailMailer implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, MakesHash;
public $tries = 3; //number of retries
public $backoff = 30; //seconds to wait until retry
public $deleteWhenMissingModels = true;
public $override;
private $mailer;
protected $client_postmark_secret = false;
protected $client_mailgun_secret = false;
protected $client_mailgun_domain = false;
public function __construct(public EmailService $email_service, public Mailable $email_mailable){}
public function handle(): void
{
MultiDB::setDb($this->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)
{
}
}

View File

@ -0,0 +1,84 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Email;
use App\Models\Client;
use App\Models\ClientContact;
use App\Models\Company;
use App\Models\User;
use App\Models\Vendor;
use App\Models\VendorContact;
use Illuminate\Mail\Mailables\Address;
/**
* EmailObject.
*/
class EmailObject
{
public array $to = [];
public ?Address $from = null;
public array $reply_to = [];
public array $cc = [];
public array $bcc = [];
public ?string $subject = null;
public ?string $body = null;
public array $attachments = [];
public string $company_key;
public ?object $settings = null;
public bool $whitelabel = false;
public ?string $logo = null;
public ?string $signature = null;
public ?string $greeting = null;
public ?Client $client = null;
public ?Vendor $vendor = null;
public ?User $user = null;
public ?ClientContact $client_contact = null;
public ?VendorContact $vendor_contact = null;
public ?string $email_template_body = null;
public ?string $email_template_subject = null;
public ?string $html_template = null;
public ?string $text_template = 'email.template.text';
public array $headers = [];
public ?string $invitation_key = null;
public ?int $entity_id = null;
public ?string $entity_class = null;
public array $variables = [];
}

View File

@ -0,0 +1,155 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Email;
use App\Models\Company;
use App\Services\Email\EmailObject;
use App\Utils\Ninja;
use Illuminate\Mail\Mailable;
class EmailService
{
/**
* Used to flag whether we force send the email regardless
*
* @var bool $override;
*/
protected bool $override;
public Mailable $mailable;
public function __construct(public EmailObject $email_object, public Company $company){}
/**
* Sends the email via a dispatched job
* @param boolean $override Whether the email should send regardless
* @return void
*/
public function send($override = false) :void
{
$this->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;
}
}

View File

@ -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();
}

View File

@ -1,57 +0,0 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Schedule;
class ScheduleService
{
public function __construct(public Scheduler $scheduler) {}
public function scheduleStatement()
{
//Is it for one client
//Is it for all clients
//Is it for all clients excluding these clients
//Frequency
//show aging
//show payments
//paid/unpaid
//When to send? 1st of month
//End of month
//This date
}
public function scheduleReport()
{
//Report type
//same schema as ScheduleStatement
}
public function scheduleEntitySend()
{
//Entity
//Entity Id
//When
}
public function projectStatus()
{
//Project ID
//Tasks - task statuses
}
}

View File

@ -0,0 +1,129 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Scheduler;
use App\DataMapper\EmailTemplateDefaults;
use App\Mail\Client\ClientStatement;
use App\Models\Client;
use App\Models\Scheduler;
use App\Services\Email\EmailMailable;
use App\Services\Email\EmailObject;
use App\Services\Email\EmailService;
use App\Utils\Ninja;
use App\Utils\Traits\MakesDates;
use App\Utils\Traits\MakesHash;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Support\Str;
class SchedulerService
{
use MakesHash;
use MakesDates;
private string $method;
private Client $client;
public function __construct(public Scheduler $scheduler) {}
/**
* Called from the TaskScheduler Cron
*
* @return void
*/
public function runTask(): void
{
$this->{$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;
}
}

View File

@ -38,6 +38,8 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpFoundation\Request;
//@deprecated - never used....
class TaskSchedulerService
{

View File

@ -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,
];
}

View File

@ -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,
];
}
}

View File

@ -0,0 +1,37 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace Database\Factories;
use App\Models\RecurringInvoice;
use App\Models\Scheduler;
use Illuminate\Database\Eloquent\Factories\Factory;
class SchedulerFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
'name' => $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',
];
}
}

View File

@ -0,0 +1,71 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('accounts', function (Blueprint $table)
{
$table->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()
{
//
}
};

View File

@ -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.'
);

View File

@ -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 @@
</td>
</tr>
<tr>
<td cellpadding="20">
<div style="border: 1px solid #c2c2c2; border-top: none; border-bottom: none; padding: 20px; text-align: center" id="content">
<td cellpadding="5">
<div style="border: 1px solid #c2c2c2; border-top: none; border-bottom: none; padding: 20px; text-align: {{ $email_alignment }}" id="content">
<div style="padding-top: 10px;"></div>
{{ $slot ?? '' }}
@ -163,8 +165,8 @@
</tr>
<tr>
<td height="20">
<div style="border: 1px solid #c2c2c2; border-top: none; border-bottom: none; padding: 20px; text-align: center" id="content"> </div>
<td height="0">
<div style="border: 1px solid #c2c2c2; border-top: none; border-bottom: none; padding: 5px; text-align: center" id="content"> </div>
</td>
</tr>

View File

@ -1,29 +1,32 @@
<!DOCTYPE html>
<html data-report-errors="{{ $report_errors }}" data-rc="{{ $rc }}" data-user-agent="{{ $user_agent }}" data-login="{{ $login }}" data-signup="{{ $signup }}">
<html data-report-errors="{{ $report_errors }}" data-rc="{{ $rc }}" data-user-agent="{{ $user_agent }}" data-login="{{ $login }}" data-signup="{{ $signup }}" data-white-label="{{ $white_label }}">
<head>
<!-- Source: https://github.com/invoiceninja/invoiceninja -->
<!-- Version: {{ config('ninja.app_version') }} -->
<meta charset="UTF-8">
<title>{{config('ninja.app_name')}}</title>
<title>{{ $white_label ? "" : config('ninja.app_name') }}</title>
<meta name="google-signin-client_id" content="{{ config('services.google.client_id') }}">
<link rel="manifest" href="manifest.json?v={{ config('ninja.app_version') }}">
<script src="{{ asset('js/pdf.min.js') }}"></script>
@if(\App\Utils\Ninja::isHosted())
<!-- Apple OAuth Library -->
<script type="text/javascript" src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"></script>
<!-- Microsoft OAuth library -->
<script type="text/javascript"
src="https://alcdn.msauth.net/browser/2.14.2/js/msal-browser.min.js"
integrity="sha384-ggh+EF1aSqm+Y4yvv2n17KpurNcZTeYtUZUvhPziElsstmIEubyEB6AIVpKLuZgr"
crossorigin="anonymous">
</script>
<!-- Google Tag Manager -->
<!-- G Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-WMJ5W23');</script>
<!-- End Google Tag Manager -->
<!-- End G Tag Manager -->
@endif
<script type="text/javascript">

View File

@ -275,7 +275,8 @@ Route::group(['middleware' => ['throttle:300,1', 'api_db', 'token_auth', 'locale
Route::post('reports/tasks', TaskReportController::class);
Route::post('reports/profitloss', ProfitAndLossController::class);
Route::resource('task_scheduler', TaskSchedulerController::class)->except('edit')->parameters(['task_scheduler' => 'scheduler']);
Route::resource('task_schedulers', TaskSchedulerController::class);
Route::post('task_schedulers/bulk', [TaskSchedulerController::class, 'bulk'])->name('task_schedulers.bulk');
Route::get('scheduler', [SchedulerController::class, 'index']);
Route::post('support/messages/send', SendingController::class);

View File

@ -0,0 +1,365 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace Tests\Feature\Scheduler;
use App\Export\CSV\ClientExport;
use App\Models\RecurringInvoice;
use App\Models\Scheduler;
use App\Utils\Traits\MakesHash;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithoutEvents;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Facades\Session;
use Illuminate\Validation\ValidationException;
use Tests\MockAccountData;
use Tests\MockUnitData;
use Tests\TestCase;
class SchedulerTest extends TestCase
{
use MakesHash;
use MockAccountData;
use WithoutEvents;
protected function setUp(): void
{
parent::setUp();
Session::start();
$this->faker = \Faker\Factory::create();
Model::reguard();
$this->makeTestData();
$this->withoutMiddleware(
ThrottleRequests::class
);
$this->withoutExceptionHandling();
}
public function testGetThisMonthRange()
{
$this->travelTo(Carbon::parse('2023-01-14'));
$this->assertEqualsCanonicalizing(['2023-01-01','2023-01-31'], $this->getDateRange('this_month'));
$this->assertEqualsCanonicalizing(['2023-01-01','2023-03-31'], $this->getDateRange('this_quarter'));
$this->assertEqualsCanonicalizing(['2023-01-01','2023-12-31'], $this->getDateRange('this_year'));
$this->assertEqualsCanonicalizing(['2022-12-01','2022-12-31'], $this->getDateRange('previous_month'));
$this->assertEqualsCanonicalizing(['2022-10-01','2022-12-31'], $this->getDateRange('previous_quarter'));
$this->assertEqualsCanonicalizing(['2022-01-01','2022-12-31'], $this->getDateRange('previous_year'));
$this->travelBack();
}
private function getDateRange($range)
{
return match ($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']]
};
}
/**
* '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',
*/
public function testClientStatementGeneration()
{
$data = [
'name' => 'A test statement scheduler',
'frequency_id' => RecurringInvoice::FREQUENCY_MONTHLY,
'next_run' => '2023-01-14',
'template' => 'client_statement',
'parameters' => [
'date_range' => 'last_month',
'show_payments_table' => true,
'show_aging_table' => true,
'status' => 'paid',
'clients' => [],
],
];
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/task_schedulers', $data);
$response->assertStatus(200);
}
public function testDeleteSchedule()
{
$data = [
'ids' => [$this->scheduler->hashed_id],
];
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/task_schedulers/bulk?action=delete', $data)
->assertStatus(200);
$data = [
'ids' => [$this->scheduler->hashed_id],
];
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/task_schedulers/bulk?action=restore', $data)
->assertStatus(200);
}
public function testRestoreSchedule()
{
$data = [
'ids' => [$this->scheduler->hashed_id],
];
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/task_schedulers/bulk?action=archive', $data)
->assertStatus(200);
$data = [
'ids' => [$this->scheduler->hashed_id],
];
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/task_schedulers/bulk?action=restore', $data)
->assertStatus(200);
}
public function testArchiveSchedule()
{
$data = [
'ids' => [$this->scheduler->hashed_id],
];
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/task_schedulers/bulk?action=archive', $data)
->assertStatus(200);
}
public function testSchedulerPost()
{
$data = [
'name' => 'A different Name',
'frequency_id' => 5,
'next_run' => now()->addDays(2)->format('Y-m-d'),
'template' =>'statement',
'parameters' => [],
];
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/task_schedulers', $data);
$response->assertStatus(200);
}
public function testSchedulerPut()
{
$data = [
'name' => 'A different Name',
'frequency_id' => 5,
'next_run' => now()->addDays(2)->format('Y-m-d'),
'template' =>'statement',
'parameters' => [],
];
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->putJson('/api/v1/task_schedulers/'.$this->scheduler->hashed_id, $data);
$response->assertStatus(200);
}
public function testSchedulerGet()
{
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->get('/api/v1/task_schedulers');
$response->assertStatus(200);
}
public function testSchedulerCreate()
{
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->get('/api/v1/task_schedulers/create');
$response->assertStatus(200);
}
// public function testSchedulerPut()
// {
// $data = [
// 'description' => $this->faker->firstName(),
// ];
// $response = $this->withHeaders([
// 'X-API-SECRET' => config('ninja.api_secret'),
// 'X-API-TOKEN' => $this->token,
// ])->put('/api/v1/task_schedulers/'.$this->encodePrimaryKey($this->task->id), $data);
// $response->assertStatus(200);
// }
// public function testSchedulerCantBeCreatedWithWrongData()
// {
// $data = [
// 'repeat_every' => Scheduler::DAILY,
// 'job' => Scheduler::CREATE_CLIENT_REPORT,
// 'date_key' => '123',
// 'report_keys' => ['test'],
// 'date_range' => 'all',
// // 'start_from' => '2022-01-01'
// ];
// $response = false;
// $response = $this->withHeaders([
// 'X-API-SECRET' => config('ninja.api_secret'),
// 'X-API-TOKEN' => $this->token,
// ])->post('/api/v1/task_scheduler/', $data);
// $response->assertSessionHasErrors();
// }
// public function testSchedulerCanBeUpdated()
// {
// $response = $this->createScheduler();
// $arr = $response->json();
// $id = $arr['data']['id'];
// $scheduler = Scheduler::find($this->decodePrimaryKey($id));
// $updateData = [
// 'start_from' => 1655934741,
// ];
// $response = $this->withHeaders([
// 'X-API-SECRET' => config('ninja.api_secret'),
// 'X-API-TOKEN' => $this->token,
// ])->put('/api/v1/task_scheduler/'.$this->encodePrimaryKey($scheduler->id), $updateData);
// $responseData = $response->json();
// $this->assertEquals($updateData['start_from'], $responseData['data']['start_from']);
// }
// public function testSchedulerCanBeSeen()
// {
// $response = $this->createScheduler();
// $arr = $response->json();
// $id = $arr['data']['id'];
// $scheduler = Scheduler::find($this->decodePrimaryKey($id));
// $response = $this->withHeaders([
// 'X-API-SECRET' => config('ninja.api_secret'),
// 'X-API-TOKEN' => $this->token,
// ])->get('/api/v1/task_scheduler/'.$this->encodePrimaryKey($scheduler->id));
// $arr = $response->json();
// $this->assertEquals('create_client_report', $arr['data']['action_name']);
// }
// public function testSchedulerJobCanBeUpdated()
// {
// $response = $this->createScheduler();
// $arr = $response->json();
// $id = $arr['data']['id'];
// $scheduler = Scheduler::find($this->decodePrimaryKey($id));
// $this->assertSame('create_client_report', $scheduler->action_name);
// $updateData = [
// 'job' => Scheduler::CREATE_CREDIT_REPORT,
// 'date_range' => 'all',
// 'report_keys' => ['test1'],
// ];
// $response = $this->withHeaders([
// 'X-API-SECRET' => config('ninja.api_secret'),
// 'X-API-TOKEN' => $this->token,
// ])->put('/api/v1/task_scheduler/'.$this->encodePrimaryKey($scheduler->id), $updateData);
// $updatedSchedulerJob = Scheduler::first()->action_name;
// $arr = $response->json();
// $this->assertSame('create_credit_report', $arr['data']['action_name']);
// }
// public function createScheduler()
// {
// $data = [
// 'repeat_every' => Scheduler::DAILY,
// 'job' => Scheduler::CREATE_CLIENT_REPORT,
// 'date_key' => '123',
// 'report_keys' => ['test'],
// 'date_range' => 'all',
// 'start_from' => '2022-01-01',
// ];
// return $response = $this->withHeaders([
// 'X-API-SECRET' => config('ninja.api_secret'),
// 'X-API-TOKEN' => $this->token,
// ])->post('/api/v1/task_scheduler/', $data);
// }
}

View File

@ -48,6 +48,7 @@ use App\Models\QuoteInvitation;
use App\Models\RecurringExpense;
use App\Models\RecurringInvoice;
use App\Models\RecurringQuote;
use App\Models\Scheduler;
use App\Models\Task;
use App\Models\TaskStatus;
use App\Models\TaxRate;
@ -177,6 +178,11 @@ trait MockAccountData
*/
public $tax_rate;
/**
* @var
*/
public $scheduler;
public function makeTestData()
{
config(['database.default' => config('ninja.db.default')]);
@ -804,6 +810,14 @@ trait MockAccountData
$this->client = $this->client->fresh();
$this->invoice = $this->invoice->fresh();
$this->scheduler = Scheduler::factory()->create([
'user_id' => $user_id,
'company_id' => $this->company->id,
]);
$this->scheduler->save();
}
/**

View File

@ -42,4 +42,5 @@ class RecurringDateTest extends TestCase
$this->assertequals($trial_ends->format('Y-m-d'), '2021-12-03');
}
}