diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 30833128eb7f..7e32ab6de533 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -12,6 +12,7 @@ namespace App\Console; use App\Jobs\Cron\AutoBillCron; +use App\Jobs\Cron\RecurringExpensesCron; use App\Jobs\Cron\RecurringInvoicesCron; use App\Jobs\Cron\SubscriptionCron; use App\Jobs\Ninja\AdjustEmailQuota; @@ -61,7 +62,9 @@ class Kernel extends ConsoleKernel $schedule->job(new SubscriptionCron)->daily()->withoutOverlapping(); $schedule->job(new RecurringInvoicesCron)->hourly()->withoutOverlapping(); - + + $schedule->job(new RecurringExpensesCron)->dailyAt('23:45')->withoutOverlapping(); + $schedule->job(new AutoBillCron)->dailyAt('00:30')->withoutOverlapping(); $schedule->job(new SchedulerCheck)->daily()->withoutOverlapping(); diff --git a/app/DataMapper/CompanySettings.php b/app/DataMapper/CompanySettings.php index 288a0a60a8d0..a45639f65f0b 100644 --- a/app/DataMapper/CompanySettings.php +++ b/app/DataMapper/CompanySettings.php @@ -98,6 +98,12 @@ class CompanySettings extends BaseSettings public $expense_number_pattern = ''; //@implemented public $expense_number_counter = 1; //@implemented + public $recurring_expense_number_pattern = ''; + public $recurring_expense_number_counter = 1; + + public $recurring_quote_number_pattern = ''; + public $recurring_quote_number_counter = 1; + public $vendor_number_pattern = ''; //@implemented public $vendor_number_counter = 1; //@implemented @@ -349,6 +355,10 @@ class CompanySettings extends BaseSettings 'task_number_counter' => 'int', 'expense_number_pattern' => 'string', 'expense_number_counter' => 'int', + 'recurring_expense_number_pattern' => 'string', + 'recurring_expense_number_counter' => 'int', + 'recurring_quote_number_pattern' => 'string', + 'recurring_quote_number_counter' => 'int', 'vendor_number_pattern' => 'string', 'vendor_number_counter' => 'int', 'ticket_number_pattern' => 'string', diff --git a/app/Events/RecurringExpense/RecurringExpenseWasArchived.php b/app/Events/RecurringExpense/RecurringExpenseWasArchived.php new file mode 100644 index 000000000000..e446ee406bf3 --- /dev/null +++ b/app/Events/RecurringExpense/RecurringExpenseWasArchived.php @@ -0,0 +1,47 @@ +recurring_expense = $recurring_expense; + $this->company = $company; + $this->event_vars = $event_vars; + } +} diff --git a/app/Events/RecurringExpense/RecurringExpenseWasCreated.php b/app/Events/RecurringExpense/RecurringExpenseWasCreated.php new file mode 100644 index 000000000000..501c593efcfe --- /dev/null +++ b/app/Events/RecurringExpense/RecurringExpenseWasCreated.php @@ -0,0 +1,47 @@ +recurring_expense = $recurring_expense; + $this->company = $company; + $this->event_vars = $event_vars; + } +} diff --git a/app/Events/RecurringExpense/RecurringExpenseWasDeleted.php b/app/Events/RecurringExpense/RecurringExpenseWasDeleted.php new file mode 100644 index 000000000000..730628990a60 --- /dev/null +++ b/app/Events/RecurringExpense/RecurringExpenseWasDeleted.php @@ -0,0 +1,47 @@ +recurring_expense = $recurring_expense; + $this->company = $company; + $this->event_vars = $event_vars; + } +} diff --git a/app/Events/RecurringExpense/RecurringExpenseWasRestored.php b/app/Events/RecurringExpense/RecurringExpenseWasRestored.php new file mode 100644 index 000000000000..03f809cc9925 --- /dev/null +++ b/app/Events/RecurringExpense/RecurringExpenseWasRestored.php @@ -0,0 +1,49 @@ +recurring_expense = $recurring_expense; + $this->fromDeleted = $fromDeleted; + $this->company = $company; + $this->event_vars = $event_vars; + } +} diff --git a/app/Events/RecurringExpense/RecurringExpenseWasUpdated.php b/app/Events/RecurringExpense/RecurringExpenseWasUpdated.php new file mode 100644 index 000000000000..7fd385433937 --- /dev/null +++ b/app/Events/RecurringExpense/RecurringExpenseWasUpdated.php @@ -0,0 +1,47 @@ +recurring_expense = $recurring_expense; + $this->company = $company; + $this->event_vars = $event_vars; + } +} diff --git a/app/Events/RecurringQuote/RecurringQuoteWasArchived.php b/app/Events/RecurringQuote/RecurringQuoteWasArchived.php new file mode 100644 index 000000000000..769460b68864 --- /dev/null +++ b/app/Events/RecurringQuote/RecurringQuoteWasArchived.php @@ -0,0 +1,47 @@ +recurring_quote = $recurring_quote; + $this->company = $company; + $this->event_vars = $event_vars; + } +} diff --git a/app/Events/RecurringQuote/RecurringQuoteWasCreated.php b/app/Events/RecurringQuote/RecurringQuoteWasCreated.php new file mode 100644 index 000000000000..88814748b3f3 --- /dev/null +++ b/app/Events/RecurringQuote/RecurringQuoteWasCreated.php @@ -0,0 +1,47 @@ +recurring_quote = $recurring_quote; + $this->company = $company; + $this->event_vars = $event_vars; + } +} diff --git a/app/Events/RecurringQuote/RecurringQuoteWasDeleted.php b/app/Events/RecurringQuote/RecurringQuoteWasDeleted.php new file mode 100644 index 000000000000..3c27baa847f1 --- /dev/null +++ b/app/Events/RecurringQuote/RecurringQuoteWasDeleted.php @@ -0,0 +1,47 @@ +recurring_quote = $recurring_quote; + $this->company = $company; + $this->event_vars = $event_vars; + } +} diff --git a/app/Events/RecurringQuote/RecurringQuoteWasRestored.php b/app/Events/RecurringQuote/RecurringQuoteWasRestored.php new file mode 100644 index 000000000000..624c807af690 --- /dev/null +++ b/app/Events/RecurringQuote/RecurringQuoteWasRestored.php @@ -0,0 +1,51 @@ +recurring_quote = $recurring_quote; + $this->fromDeleted = $fromDeleted; + $this->company = $company; + $this->event_vars = $event_vars; + } +} diff --git a/app/Events/RecurringQuote/RecurringQuoteWasUpdated.php b/app/Events/RecurringQuote/RecurringQuoteWasUpdated.php new file mode 100644 index 000000000000..27c704fe2c4c --- /dev/null +++ b/app/Events/RecurringQuote/RecurringQuoteWasUpdated.php @@ -0,0 +1,49 @@ +recurring_quote = $recurring_quote; + $this->company = $company; + $this->event_vars = $event_vars; + } +} diff --git a/app/Factory/QuoteToRecurringQuoteFactory.php b/app/Factory/QuoteToRecurringQuoteFactory.php new file mode 100644 index 000000000000..1931a852ddb5 --- /dev/null +++ b/app/Factory/QuoteToRecurringQuoteFactory.php @@ -0,0 +1,57 @@ +status_id = RecurringQuote::STATUS_DRAFT; + $recurring_quote->discount = $quote->discount; + $recurring_quote->number = ''; + $recurring_quote->is_amount_discount = $quote->is_amount_discount; + $recurring_quote->po_number = $quote->po_number; + $recurring_quote->footer = $quote->footer; + $recurring_quote->terms = $quote->terms; + $recurring_quote->public_notes = $quote->public_notes; + $recurring_quote->private_notes = $quote->private_notes; + $recurring_quote->date = date_create()->format($quote->client->date_format()); + $recurring_quote->due_date = $quote->due_date; //todo calculate based on terms + $recurring_quote->is_deleted = $quote->is_deleted; + $recurring_quote->line_items = $quote->line_items; + $recurring_quote->tax_name1 = $quote->tax_name1; + $recurring_quote->tax_rate1 = $quote->tax_rate1; + $recurring_quote->tax_name2 = $quote->tax_name2; + $recurring_quote->tax_rate2 = $quote->tax_rate2; + $recurring_quote->custom_value1 = $quote->custom_value1; + $recurring_quote->custom_value2 = $quote->custom_value2; + $recurring_quote->custom_value3 = $quote->custom_value3; + $recurring_quote->custom_value4 = $quote->custom_value4; + $recurring_quote->amount = $quote->amount; + // $recurring_quote->balance = $quote->balance; + $recurring_quote->user_id = $quote->user_id; + $recurring_quote->client_id = $quote->client_id; + $recurring_quote->company_id = $quote->company_id; + $recurring_quote->frequency_id = RecurringQuote::FREQUENCY_MONTHLY; + $recurring_quote->last_sent_date = null; + $recurring_quote->next_send_date = null; + $recurring_quote->remaining_cycles = 0; + $recurring_quote->paid_to_date = 0; + + return $recurring_quote; + } +} diff --git a/app/Factory/RecurringExpenseFactory.php b/app/Factory/RecurringExpenseFactory.php new file mode 100644 index 000000000000..8d179a33d113 --- /dev/null +++ b/app/Factory/RecurringExpenseFactory.php @@ -0,0 +1,53 @@ +status_id = RecurringInvoice::STATUS_DRAFT; + $recurring_expense->user_id = $user_id; + $recurring_expense->company_id = $company_id; + $recurring_expense->is_deleted = false; + $recurring_expense->invoice_documents = false; + $recurring_expense->should_be_invoiced = false; + $recurring_expense->tax_name1 = ''; + $recurring_expense->tax_rate1 = 0; + $recurring_expense->tax_name2 = ''; + $recurring_expense->tax_rate2 = 0; + $recurring_expense->tax_name3 = ''; + $recurring_expense->tax_rate3 = 0; + $recurring_expense->tax_amount1 = 0; + $recurring_expense->tax_amount2 = 0; + $recurring_expense->tax_amount3 = 0; + $recurring_expense->date = null; + $recurring_expense->payment_date = null; + $recurring_expense->amount = 0; + $recurring_expense->foreign_amount = 0; + $recurring_expense->private_notes = ''; + $recurring_expense->public_notes = ''; + $recurring_expense->transaction_reference = ''; + $recurring_expense->custom_value1 = ''; + $recurring_expense->custom_value2 = ''; + $recurring_expense->custom_value3 = ''; + $recurring_expense->custom_value4 = ''; + $recurring_expense->uses_inclusive_taxes = true; + $recurring_expense->calculate_tax_by_amount = true; + + return $recurring_expense; + } +} diff --git a/app/Factory/RecurringExpenseToExpenseFactory.php b/app/Factory/RecurringExpenseToExpenseFactory.php new file mode 100644 index 000000000000..76a3b0456e9e --- /dev/null +++ b/app/Factory/RecurringExpenseToExpenseFactory.php @@ -0,0 +1,62 @@ +user_id = $recurring_expense->user_id; + $expense->assigned_user_id = $recurring_expense->assigned_user_id; + $expense->vendor_id = $recurring_expense->vendor_id; + $expense->invoice_id = $recurring_expense->invoice_id; + $expense->currency_id = $recurring_expense->currency_id; + $expense->company_id = $recurring_expense->company_id; + $expense->bank_id = $recurring_expense->bank_id; + $expense->exchange_rate = $recurring_expense->exchange_rate; + $expense->is_deleted = false; + $expense->should_be_invoiced = $recurring_expense->should_be_invoiced; + $expense->tax_name1 = $recurring_expense->tax_name1; + $expense->tax_rate1 = $recurring_expense->tax_rate1; + $expense->tax_name2 = $recurring_expense->tax_name2; + $expense->tax_rate2 = $recurring_expense->tax_rate2; + $expense->tax_name3 = $recurring_expense->tax_name3; + $expense->tax_rate3 = $recurring_expense->tax_rate3; + $expense->date = now()->format('Y-m-d'); + $expense->payment_date = $recurring_expense->payment_date; + $expense->amount = $recurring_expense->amount; + $expense->foreign_amount = $recurring_expense->foreign_amount; + $expense->private_notes = $recurring_expense->private_notes; + $expense->public_notes = $recurring_expense->public_notes; + $expense->transaction_reference = $recurring_expense->transaction_reference; + $expense->custom_value1 = $recurring_expense->custom_value1; + $expense->custom_value2 = $recurring_expense->custom_value2; + $expense->custom_value3 = $recurring_expense->custom_value3; + $expense->custom_value4 = $recurring_expense->custom_value4; + $expense->transaction_id = $recurring_expense->transaction_id; + $expense->category_id = $recurring_expense->category_id; + $expense->payment_type_id = $recurring_expense->payment_type_id; + $expense->project_id = $recurring_expense->project_id; + $expense->invoice_documents = $recurring_expense->invoice_documents; + $expense->tax_amount1 = $recurring_expense->tax_amount1; + $expense->tax_amount2 = $recurring_expense->tax_amount2; + $expense->tax_amount3 = $recurring_expense->tax_amount3; + $expense->uses_inclusive_taxes = $recurring_expense->uses_inclusive_taxes; + $expense->calculate_tax_by_amount = $recurring_expense->calculate_tax_by_amount; + + return $expense; + } +} diff --git a/app/Factory/RecurringQuoteFactory.php b/app/Factory/RecurringQuoteFactory.php index 7b76b6dbfc9b..cb326e2917e0 100644 --- a/app/Factory/RecurringQuoteFactory.php +++ b/app/Factory/RecurringQuoteFactory.php @@ -22,6 +22,7 @@ class RecurringQuoteFactory $quote->discount = 0; $quote->is_amount_discount = true; $quote->po_number = ''; + $quote->number = ''; $quote->footer = ''; $quote->terms = ''; $quote->public_notes = ''; @@ -48,6 +49,7 @@ class RecurringQuoteFactory $quote->last_sent_date = null; $quote->next_send_date = null; $quote->remaining_cycles = 0; + $quote->paid_to_date = 0; return $quote; } diff --git a/app/Factory/RecurringQuoteToQuoteFactory.php b/app/Factory/RecurringQuoteToQuoteFactory.php new file mode 100644 index 000000000000..9a615b05e66a --- /dev/null +++ b/app/Factory/RecurringQuoteToQuoteFactory.php @@ -0,0 +1,60 @@ +status_id = Quote::STATUS_DRAFT; + $quote->discount = $recurring_quote->discount; + $quote->is_amount_discount = $recurring_quote->is_amount_discount; + $quote->po_number = $recurring_quote->po_number; + $quote->footer = $recurring_quote->footer; + $quote->terms = $recurring_quote->terms; + $quote->public_notes = $recurring_quote->public_notes; + $quote->private_notes = $recurring_quote->private_notes; + //$quote->date = now()->format($client->date_format()); + //$quote->due_date = $recurring_quote->calculateDueDate(now()); + $quote->is_deleted = $recurring_quote->is_deleted; + $quote->line_items = $recurring_quote->line_items; + $quote->tax_name1 = $recurring_quote->tax_name1; + $quote->tax_rate1 = $recurring_quote->tax_rate1; + $quote->tax_name2 = $recurring_quote->tax_name2; + $quote->tax_rate2 = $recurring_quote->tax_rate2; + $quote->tax_name3 = $recurring_quote->tax_name3; + $quote->tax_rate3 = $recurring_quote->tax_rate3; + $quote->total_taxes = $recurring_quote->total_taxes; + $quote->subscription_id = $recurring_quote->subscription_id; + $quote->custom_value1 = $recurring_quote->custom_value1; + $quote->custom_value2 = $recurring_quote->custom_value2; + $quote->custom_value3 = $recurring_quote->custom_value3; + $quote->custom_value4 = $recurring_quote->custom_value4; + $quote->amount = $recurring_quote->amount; + // $quote->balance = $recurring_quote->balance; + $quote->user_id = $recurring_quote->user_id; + $quote->assigned_user_id = $recurring_quote->assigned_user_id; + $quote->company_id = $recurring_quote->company_id; + $quote->recurring_id = $recurring_quote->id; + $quote->client_id = $client->id; + $quote->auto_bill_enabled = $recurring_quote->auto_bill_enabled; + $quote->paid_to_date = 0; + $quote->design_id = $recurring_quote->design_id; + + return $quote; + } +} diff --git a/app/Filters/ExpenseFilters.php b/app/Filters/ExpenseFilters.php index 8992424d59f6..11d4ee46ae26 100644 --- a/app/Filters/ExpenseFilters.php +++ b/app/Filters/ExpenseFilters.php @@ -38,11 +38,6 @@ class ExpenseFilters extends QueryFilters return $this->builder->where(function ($query) use ($filter) { $query->where('expenses.name', 'like', '%'.$filter.'%') ->orWhere('expenses.id_number', 'like', '%'.$filter.'%') - ->orWhereHas('contacts', function ($query) use($filter){ - $query->where('expense_contacts.first_name', 'like', '%'.$filter.'%'); - $query->orWhere('expense_contacts.last_name', 'like', '%'.$filter.'%'); - $query->orWhere('expense_contacts.email', 'like', '%'.$filter.'%'); - }) ->orWhere('expenses.custom_value1', 'like', '%'.$filter.'%') ->orWhere('expenses.custom_value2', 'like', '%'.$filter.'%') ->orWhere('expenses.custom_value3', 'like', '%'.$filter.'%') diff --git a/app/Filters/RecurringExpenseFilters.php b/app/Filters/RecurringExpenseFilters.php new file mode 100644 index 000000000000..e8c3c4740436 --- /dev/null +++ b/app/Filters/RecurringExpenseFilters.php @@ -0,0 +1,148 @@ +builder; + } + + return $this->builder->where(function ($query) use ($filter) { + $query->where('recurring_expenses.name', 'like', '%'.$filter.'%') + ->orWhere('recurring_expenses.id_number', 'like', '%'.$filter.'%') + ->orWhere('recurring_expenses.custom_value1', 'like', '%'.$filter.'%') + ->orWhere('recurring_expenses.custom_value2', 'like', '%'.$filter.'%') + ->orWhere('recurring_expenses.custom_value3', 'like', '%'.$filter.'%') + ->orWhere('recurring_expenses.custom_value4', 'like', '%'.$filter.'%'); + }); + } + + /** + * Filters the list based on the status + * archived, active, deleted. + * + * @param string filter + * @return Builder + */ + public function status(string $filter = '') : Builder + { + if (strlen($filter) == 0) { + return $this->builder; + } + + $table = 'expenses'; + $filters = explode(',', $filter); + + return $this->builder->where(function ($query) use ($filters, $table) { + $query->whereNull($table.'.id'); + + if (in_array(parent::STATUS_ACTIVE, $filters)) { + $query->orWhereNull($table.'.deleted_at'); + } + + if (in_array(parent::STATUS_ARCHIVED, $filters)) { + $query->orWhere(function ($query) use ($table) { + $query->whereNotNull($table.'.deleted_at'); + + if (! in_array($table, ['users'])) { + $query->where($table.'.is_deleted', '=', 0); + } + }); + } + + if (in_array(parent::STATUS_DELETED, $filters)) { + $query->orWhere($table.'.is_deleted', '=', 1); + } + }); + } + + /** + * Sorts the list based on $sort. + * + * @param string sort formatted as column|asc + * @return Builder + */ + public function sort(string $sort) : Builder + { + $sort_col = explode('|', $sort); + + return $this->builder->orderBy($sort_col[0], $sort_col[1]); + } + + /** + * Returns the base query. + * + * @param int company_id + * @param User $user + * @return Builder + * @deprecated + */ + public function baseQuery(int $company_id, User $user) : Builder + { + $query = DB::table('recurring_expenses') + ->join('companies', 'companies.id', '=', 'recurring_expenses.company_id') + ->where('recurring_expenses.company_id', '=', $company_id) + ->select( + DB::raw('COALESCE(recurring_expenses.country_id, companies.country_id) country_id'), + 'recurring_expenses.id', + 'recurring_expenses.private_notes', + 'recurring_expenses.custom_value1', + 'recurring_expenses.custom_value2', + 'recurring_expenses.custom_value3', + 'recurring_expenses.custom_value4', + 'recurring_expenses.created_at', + 'recurring_expenses.created_at as expense_created_at', + 'recurring_expenses.deleted_at', + 'recurring_expenses.is_deleted', + 'recurring_expenses.user_id', + ); + + /* + * If the user does not have permissions to view all invoices + * limit the user to only the invoices they have created + */ + if (Gate::denies('view-list', RecurringExpense::class)) { + $query->where('recurring_expenses.user_id', '=', $user->id); + } + + return $query; + } + + /** + * Filters the query by the users company ID. + * + * @return Illuminate\Database\Query\Builder + */ + public function entityFilter() + { + return $this->builder->company(); + } +} diff --git a/app/Http/Controllers/BaseController.php b/app/Http/Controllers/BaseController.php index b4cfa4aabf4a..0cc06230a100 100644 --- a/app/Http/Controllers/BaseController.php +++ b/app/Http/Controllers/BaseController.php @@ -84,6 +84,7 @@ class BaseController extends Controller 'company.payments.documents', 'company.payment_terms.company', 'company.projects.documents', + 'company.recurring_expenses', 'company.recurring_invoices', 'company.recurring_invoices.invitations.contact', 'company.recurring_invoices.invitations.company', @@ -307,6 +308,13 @@ class BaseController extends Controller if(!$user->hasPermission('view_recurring_invoice')) $query->where('recurring_invoices.user_id', $user->id)->orWhere('recurring_invoices.assigned_user_id', $user->id); + }, + 'company.recurring_expenses'=> function ($query) use ($updated_at, $user) { + $query->where('updated_at', '>=', $updated_at)->with('documents'); + + if(!$user->hasPermission('view_recurring_expense')) + $query->where('recurring_expenses.user_id', $user->id)->orWhere('recurring_expenses.assigned_user_id', $user->id); + }, 'company.tasks'=> function ($query) use ($updated_at, $user) { $query->where('updated_at', '>=', $updated_at)->with('documents'); diff --git a/app/Http/Controllers/RecurringExpenseController.php b/app/Http/Controllers/RecurringExpenseController.php new file mode 100644 index 000000000000..a3bbf630f251 --- /dev/null +++ b/app/Http/Controllers/RecurringExpenseController.php @@ -0,0 +1,616 @@ +recurring_expense_repo = $recurring_expense_repo; + } + + /** + * @OA\Get( + * path="/api/v1/recurring_expenses", + * operationId="getRecurringExpenses", + * tags={"recurring_expenses"}, + * summary="Gets a list of recurring_expenses", + * description="Lists recurring_expenses, search and filters allow fine grained lists to be generated. + + Query parameters can be added to performed more fine grained filtering of the recurring_expenses, these are handled by the RecurringExpenseFilters class which defines the methods available", + * @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\Parameter(ref="#/components/parameters/index"), + * @OA\Response( + * response=200, + * description="A list of recurring_expenses", + * @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/RecurringExpense"), + * ), + * @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"), + * ), + * ) + * @param RecurringExpenseFilters $filters + * @return Response|mixed + */ + public function index(RecurringExpenseFilters $filters) + { + $recurring_expenses = RecurringExpense::filter($filters); + + return $this->listResponse($recurring_expenses); + } + + /** + * Display the specified resource. + * + * @param ShowRecurringExpenseRequest $request + * @param RecurringExpense $recurring_expense + * @return Response + * + * + * @OA\Get( + * path="/api/v1/recurring_expenses/{id}", + * operationId="showRecurringExpense", + * tags={"recurring_expenses"}, + * summary="Shows a client", + * description="Displays a client by id", + * @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\Parameter( + * name="id", + * in="path", + * description="The RecurringExpense Hashed ID", + * example="D2J234DFA", + * required=true, + * @OA\Schema( + * type="string", + * format="string", + * ), + * ), + * @OA\Response( + * response=200, + * description="Returns the recurring_expense 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/RecurringExpense"), + * ), + * @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 show(ShowRecurringExpenseRequest $request, RecurringExpense $recurring_expense) + { + return $this->itemResponse($recurring_expense); + } + + /** + * Show the form for editing the specified resource. + * + * @param EditRecurringExpenseRequest $request + * @param RecurringExpense $recurring_expense + * @return Response + * + * + * @OA\Get( + * path="/api/v1/recurring_expenses/{id}/edit", + * operationId="editRecurringExpense", + * tags={"recurring_expenses"}, + * summary="Shows a client for editting", + * description="Displays a client by id", + * @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\Parameter( + * name="id", + * in="path", + * description="The RecurringExpense Hashed ID", + * example="D2J234DFA", + * required=true, + * @OA\Schema( + * type="string", + * format="string", + * ), + * ), + * @OA\Response( + * response=200, + * description="Returns the client 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/RecurringExpense"), + * ), + * @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 edit(EditRecurringExpenseRequest $request, RecurringExpense $recurring_expense) + { + return $this->itemResponse($recurring_expense); + } + + /** + * Update the specified resource in storage. + * + * @param UpdateRecurringExpenseRequest $request + * @param RecurringExpense $recurring_expense + * @return Response + * + * + * + * @OA\Put( + * path="/api/v1/recurring_expenses/{id}", + * operationId="updateRecurringExpense", + * tags={"recurring_expenses"}, + * summary="Updates a client", + * description="Handles the updating of a client by id", + * @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\Parameter( + * name="id", + * in="path", + * description="The RecurringExpense Hashed ID", + * example="D2J234DFA", + * required=true, + * @OA\Schema( + * type="string", + * format="string", + * ), + * ), + * @OA\Response( + * response=200, + * description="Returns the client 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/RecurringExpense"), + * ), + * @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 update(UpdateRecurringExpenseRequest $request, RecurringExpense $recurring_expense) + { + if ($request->entityIsDeleted($recurring_expense)) { + return $request->disallowUpdate(); + } + + $recurring_expense = $this->recurring_expense_repo->save($request->all(), $recurring_expense); + + $this->uploadLogo($request->file('company_logo'), $recurring_expense->company, $recurring_expense); + + event(new RecurringExpenseWasUpdated($recurring_expense, $recurring_expense->company, Ninja::eventVars(auth()->user() ? auth()->user()->id : null))); + + return $this->itemResponse($recurring_expense->fresh()); + } + + /** + * Show the form for creating a new resource. + * + * @param CreateRecurringExpenseRequest $request + * @return Response + * + * + * + * @OA\Get( + * path="/api/v1/recurring_expenses/create", + * operationId="getRecurringExpensesCreate", + * tags={"recurring_expenses"}, + * summary="Gets a new blank client 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 client 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/RecurringExpense"), + * ), + * @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(CreateRecurringExpenseRequest $request) + { + $recurring_expense = RecurringExpenseFactory::create(auth()->user()->company()->id, auth()->user()->id); + + return $this->itemResponse($recurring_expense); + } + + /** + * Store a newly created resource in storage. + * + * @param StoreRecurringExpenseRequest $request + * @return Response + * + * + * + * @OA\Post( + * path="/api/v1/recurring_expenses", + * operationId="storeRecurringExpense", + * tags={"recurring_expenses"}, + * summary="Adds a client", + * description="Adds an client to a company", + * @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="Returns the saved client 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/RecurringExpense"), + * ), + * @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 store(StoreRecurringExpenseRequest $request) + { + $recurring_expense = $this->recurring_expense_repo->save($request->all(), RecurringExpenseFactory::create(auth()->user()->company()->id, auth()->user()->id)); + + event(new RecurringExpenseWasCreated($recurring_expense, $recurring_expense->company, Ninja::eventVars(auth()->user() ? auth()->user()->id : null))); + + return $this->itemResponse($recurring_expense); + } + + /** + * Remove the specified resource from storage. + * + * @param DestroyRecurringExpenseRequest $request + * @param RecurringExpense $recurring_expense + * @return Response + * + * + * @throws \Exception + * @OA\Delete( + * path="/api/v1/recurring_expenses/{id}", + * operationId="deleteRecurringExpense", + * tags={"recurring_expenses"}, + * summary="Deletes a client", + * description="Handles the deletion of a client by id", + * @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\Parameter( + * name="id", + * in="path", + * description="The RecurringExpense Hashed ID", + * example="D2J234DFA", + * required=true, + * @OA\Schema( + * type="string", + * format="string", + * ), + * ), + * @OA\Response( + * response=200, + * description="Returns a HTTP status", + * @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\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 destroy(DestroyRecurringExpenseRequest $request, RecurringExpense $recurring_expense) + { + $this->recurring_expense_repo->delete($recurring_expense); + + return $this->itemResponse($recurring_expense->fresh()); + } + + /** + * Perform bulk actions on the list view. + * + * @return Response + * + * + * @OA\Post( + * path="/api/v1/recurring_expenses/bulk", + * operationId="bulkRecurringExpenses", + * tags={"recurring_expenses"}, + * summary="Performs bulk actions on an array of recurring_expenses", + * 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="User credentials", + * 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 RecurringExpense User 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/RecurringExpense"), + * ), + * @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'); + + $ids = request()->input('ids'); + $recurring_expenses = RecurringExpense::withTrashed()->find($this->transformKeys($ids)); + + $recurring_expenses->each(function ($recurring_expense, $key) use ($action) { + if (auth()->user()->can('edit', $recurring_expense)) { + $this->performAction($recurring_expense, $action, true); + } + }); + + return $this->listResponse(RecurringExpense::withTrashed()->whereIn('id', $this->transformKeys($ids))); + } + + private function performAction(RecurringExpense $recurring_expense, string $action, $bulk = false) + { + switch ($action) { + case 'archive': + $this->recurring_expense_repo->archive($recurring_expense); + + if (! $bulk) { + return $this->listResponse($recurring_expense); + } + break; + case 'restore': + $this->recurring_expense_repo->restore($recurring_expense); + + if (! $bulk) { + return $this->listResponse($recurring_expense); + } + break; + case 'delete': + $this->recurring_expense_repo->delete($recurring_expense); + + if (! $bulk) { + return $this->listResponse($recurring_expense); + } + break; + case 'email': + //dispatch email to queue + break; + case 'start': + $recurring_expense = $recurring_expense->service()->start()->save(); + + if (! $bulk) { + $this->itemResponse($recurring_expense); + } + break; + case 'stop': + $recurring_expense = $recurring_expense->service()->stop()->save(); + + if (! $bulk) { + $this->itemResponse($recurring_expense); + } + + break; + default: + // code... + break; + } + } + + /** + * Update the specified resource in storage. + * + * @param UploadRecurringExpenseRequest $request + * @param RecurringExpense $recurring_expense + * @return Response + * + * + * + * @OA\Put( + * path="/api/v1/recurring_expenses/{id}/upload", + * operationId="uploadRecurringExpense", + * tags={"recurring_expense"}, + * summary="Uploads a document to a recurring_expense", + * description="Handles the uploading of a document to a recurring_expense", + * @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\Parameter( + * name="id", + * in="path", + * description="The RecurringExpense Hashed ID", + * example="D2J234DFA", + * required=true, + * @OA\Schema( + * type="string", + * format="string", + * ), + * ), + * @OA\Response( + * response=200, + * description="Returns the RecurringExpense 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/RecurringExpense"), + * ), + * @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 upload(UploadRecurringExpenseRequest $request, RecurringExpense $recurring_expense) + { + + if(!$this->checkFeature(Account::FEATURE_DOCUMENTS)) + return $this->featureFailure(); + + if ($request->has('documents')) + $this->saveDocuments($request->file('documents'), $recurring_expense); + + return $this->itemResponse($recurring_expense->fresh()); + + } +} diff --git a/app/Http/Controllers/RecurringInvoiceController.php b/app/Http/Controllers/RecurringInvoiceController.php index 3bf2ea4e0809..cd81b2d51ba5 100644 --- a/app/Http/Controllers/RecurringInvoiceController.php +++ b/app/Http/Controllers/RecurringInvoiceController.php @@ -31,7 +31,6 @@ use App\Utils\Ninja; use App\Utils\Traits\MakesHash; use App\Utils\Traits\SavesDocuments; use Carbon\Carbon; -use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Storage; diff --git a/app/Http/Controllers/RecurringQuoteController.php b/app/Http/Controllers/RecurringQuoteController.php index 6081035073ad..286fba9f7790 100644 --- a/app/Http/Controllers/RecurringQuoteController.php +++ b/app/Http/Controllers/RecurringQuoteController.php @@ -577,7 +577,7 @@ class RecurringQuoteController extends BaseController public function action(ActionRecurringQuoteRequest $request, RecurringQuote $recurring_quote, $action) { switch ($action) { - case 'clone_to_RecurringQuote': + case 'clone_to_recurring_quote': // $recurring_invoice = CloneRecurringQuoteFactory::create($recurring_invoice, auth()->user()->id); // return $this->itemResponse($recurring_invoice); break; diff --git a/app/Http/Requests/RecurringExpense/BulkRecurringExpenseRequest.php b/app/Http/Requests/RecurringExpense/BulkRecurringExpenseRequest.php new file mode 100644 index 000000000000..75f89a155b70 --- /dev/null +++ b/app/Http/Requests/RecurringExpense/BulkRecurringExpenseRequest.php @@ -0,0 +1,57 @@ +has('action')) { + return false; + } + + if (! in_array($this->action, $this->getBulkOptions(), true)) { + return false; + } + + return auth()->user()->can(auth()->user()->isAdmin(), RecurringExpense::class); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + $rules = $this->getGlobalRules(); + + /* We don't require IDs on bulk storing. */ + if ($this->action !== self::$STORE_METHOD) { + $rules['ids'] = ['required']; + } + + return $rules; + } +} diff --git a/app/Http/Requests/RecurringExpense/CreateRecurringExpenseRequest.php b/app/Http/Requests/RecurringExpense/CreateRecurringExpenseRequest.php new file mode 100644 index 000000000000..2f720a322d0d --- /dev/null +++ b/app/Http/Requests/RecurringExpense/CreateRecurringExpenseRequest.php @@ -0,0 +1,28 @@ +user()->can('create', RecurringExpense::class); + } +} diff --git a/app/Http/Requests/RecurringExpense/DestroyRecurringExpenseRequest.php b/app/Http/Requests/RecurringExpense/DestroyRecurringExpenseRequest.php new file mode 100644 index 000000000000..ed7e7fa795bb --- /dev/null +++ b/app/Http/Requests/RecurringExpense/DestroyRecurringExpenseRequest.php @@ -0,0 +1,27 @@ +user()->can('edit', $this->recurring_expense); + } +} diff --git a/app/Http/Requests/RecurringExpense/EditRecurringExpenseRequest.php b/app/Http/Requests/RecurringExpense/EditRecurringExpenseRequest.php new file mode 100644 index 000000000000..e24e11b19c58 --- /dev/null +++ b/app/Http/Requests/RecurringExpense/EditRecurringExpenseRequest.php @@ -0,0 +1,28 @@ +user()->can('edit', $this->recurring_expense); + } + +} diff --git a/app/Http/Requests/RecurringExpense/ShowRecurringExpenseRequest.php b/app/Http/Requests/RecurringExpense/ShowRecurringExpenseRequest.php new file mode 100644 index 000000000000..500c4ae13f9f --- /dev/null +++ b/app/Http/Requests/RecurringExpense/ShowRecurringExpenseRequest.php @@ -0,0 +1,27 @@ +user()->can('view', $this->recurring_expense); + } +} diff --git a/app/Http/Requests/RecurringExpense/StoreRecurringExpenseRequest.php b/app/Http/Requests/RecurringExpense/StoreRecurringExpenseRequest.php new file mode 100644 index 000000000000..e34918245585 --- /dev/null +++ b/app/Http/Requests/RecurringExpense/StoreRecurringExpenseRequest.php @@ -0,0 +1,75 @@ +user()->can('create', RecurringExpense::class); + } + + public function rules() + { + $rules = []; + + if ($this->number) + $rules['number'] = Rule::unique('recurring_expenses')->where('company_id', auth()->user()->company()->id); + + if(!empty($this->client_id)) + $rules['client_id'] = 'bail|sometimes|exists:clients,id,company_id,'.auth()->user()->company()->id; + + $rules['frequency_id'] = 'required|integer|digits_between:1,12'; + + return $this->globalRules($rules); + } + + protected function prepareForValidation() + { + $input = $this->all(); + + $input = $this->decodePrimaryKeys($input); + + if (array_key_exists('category_id', $input) && is_string($input['category_id'])) { + $input['category_id'] = $this->decodePrimaryKey($input['category_id']); + } + + if (! array_key_exists('currency_id', $input) || strlen($input['currency_id']) == 0) { + $input['currency_id'] = (string)auth()->user()->company()->settings->currency_id; + } + + if(array_key_exists('color', $input) && is_null($input['color'])) + $input['color'] = ''; + + $this->replace($input); + } + + public function messages() + { + return [ + 'unique' => ctrans('validation.unique', ['attribute' => 'email']), + ]; + } +} diff --git a/app/Http/Requests/RecurringExpense/UpdateRecurringExpenseRequest.php b/app/Http/Requests/RecurringExpense/UpdateRecurringExpenseRequest.php new file mode 100644 index 000000000000..c1be81d1053a --- /dev/null +++ b/app/Http/Requests/RecurringExpense/UpdateRecurringExpenseRequest.php @@ -0,0 +1,79 @@ +user()->can('edit', $this->recurring_expense); + } + + public function rules() + { + /* Ensure we have a client name, and that all emails are unique*/ + + $rules['country_id'] = 'integer|nullable'; + + $rules['contacts.*.email'] = 'nullable|distinct'; + + if (isset($this->number)) { + $rules['number'] = Rule::unique('recurring_expenses')->where('company_id', auth()->user()->company()->id)->ignore($this->recurring_expense->id); + } + + return $this->globalRules($rules); + } + + public function messages() + { + return [ + 'unique' => ctrans('validation.unique', ['attribute' => 'email']), + 'email' => ctrans('validation.email', ['attribute' => 'email']), + 'name.required' => ctrans('validation.required', ['attribute' => 'name']), + 'required' => ctrans('validation.required', ['attribute' => 'email']), + ]; + } + + protected function prepareForValidation() + { + $input = $this->all(); + + $input = $this->decodePrimaryKeys($input); + + if (array_key_exists('category_id', $input) && is_string($input['category_id'])) { + $input['category_id'] = $this->decodePrimaryKey($input['category_id']); + } + + if (array_key_exists('documents', $input)) { + unset($input['documents']); + } + + if (! array_key_exists('currency_id', $input) || strlen($input['currency_id']) == 0) { + $input['currency_id'] = (string)auth()->user()->company()->settings->currency_id; + } + + $this->replace($input); + } +} diff --git a/app/Http/Requests/RecurringExpense/UploadRecurringExpenseRequest.php b/app/Http/Requests/RecurringExpense/UploadRecurringExpenseRequest.php new file mode 100644 index 000000000000..e5fa858da8f7 --- /dev/null +++ b/app/Http/Requests/RecurringExpense/UploadRecurringExpenseRequest.php @@ -0,0 +1,39 @@ +user()->can('edit', $this->recurring_expense); + } + + public function rules() + { + + $rules = []; + + if($this->input('documents')) + $rules['documents'] = 'file|mimes:html,csv,png,ai,svg,jpeg,tiff,pdf,gif,psd,txt,doc,xls,ppt,xlsx,docx,pptx|max:2000000'; + + return $rules; + + } +} diff --git a/app/Http/Requests/RecurringQuote/StoreRecurringQuoteRequest.php b/app/Http/Requests/RecurringQuote/StoreRecurringQuoteRequest.php index 9da24cf99b2a..51ee3917501a 100644 --- a/app/Http/Requests/RecurringQuote/StoreRecurringQuoteRequest.php +++ b/app/Http/Requests/RecurringQuote/StoreRecurringQuoteRequest.php @@ -12,9 +12,12 @@ namespace App\Http\Requests\RecurringQuote; use App\Http\Requests\Request; +use App\Http\ValidationRules\Recurring\UniqueRecurringQuoteNumberRule; +use App\Models\Client; use App\Models\RecurringQuote; use App\Utils\Traits\CleanLineItems; use App\Utils\Traits\MakesHash; +use Illuminate\Http\UploadedFile; class StoreRecurringQuoteRequest extends Request { @@ -33,26 +36,61 @@ class StoreRecurringQuoteRequest extends Request public function rules() { - return [ - 'documents' => 'mimes:png,ai,svg,jpeg,tiff,pdf,gif,psd,txt,doc,xls,ppt,xlsx,docx,pptx', - 'client_id' => 'required|exists:clients,id,company_id,'.auth()->user()->company()->id, - ]; + $rules = []; + + if ($this->input('documents') && is_array($this->input('documents'))) { + $documents = count($this->input('documents')); + + foreach (range(0, $documents) as $index) { + $rules['documents.'.$index] = 'file|mimes:png,ai,svg,jpeg,tiff,pdf,gif,psd,txt,doc,xls,ppt,xlsx,docx,pptx|max:20000'; + } + + } elseif ($this->input('documents')) { + $rules['documents'] = 'file|mimes:png,ai,svg,jpeg,tiff,pdf,gif,psd,txt,doc,xls,ppt,xlsx,docx,pptx|max:20000'; + } + + $rules['client_id'] = 'required|exists:clients,id,company_id,'.auth()->user()->company()->id; + + $rules['invitations.*.client_contact_id'] = 'distinct'; + + $rules['frequency_id'] = 'required|integer|digits_between:1,12'; + + $rules['number'] = new UniqueRecurringQuoteNumberRule($this->all()); + + return $rules; } protected function prepareForValidation() { $input = $this->all(); - - if ($input['client_id']) { - $input['client_id'] = $this->decodePrimaryKey($input['client_id']); - } - - if (array_key_exists('assigned_user_id', $input) && is_string($input['assigned_user_id'])) { - $input['assigned_user_id'] = $this->decodePrimaryKey($input['assigned_user_id']); - } + $input = $this->decodePrimaryKeys($input); $input['line_items'] = isset($input['line_items']) ? $this->cleanItems($input['line_items']) : []; - //$input['line_items'] = json_encode($input['line_items']); + + if (isset($input['auto_bill'])) { + $input['auto_bill_enabled'] = $this->setAutoBillFlag($input['auto_bill']); + } else { + if ($client = Client::find($input['client_id'])) { + $input['auto_bill'] = $client->getSetting('auto_bill'); + $input['auto_bill_enabled'] = $this->setAutoBillFlag($input['auto_bill']); + } + } + $this->replace($input); } + + private function setAutoBillFlag($auto_bill) + { + if ($auto_bill == 'always' || $auto_bill == 'optout') { + return true; + } + + return false; + + } + + public function messages() + { + return []; + } } diff --git a/app/Http/Requests/RecurringQuote/UpdateRecurringQuoteRequest.php b/app/Http/Requests/RecurringQuote/UpdateRecurringQuoteRequest.php index 4a8cc51d87d1..2f620cfcce1e 100644 --- a/app/Http/Requests/RecurringQuote/UpdateRecurringQuoteRequest.php +++ b/app/Http/Requests/RecurringQuote/UpdateRecurringQuoteRequest.php @@ -14,12 +14,15 @@ namespace App\Http\Requests\RecurringQuote; use App\Http\Requests\Request; use App\Utils\Traits\ChecksEntityStatus; use App\Utils\Traits\CleanLineItems; +use App\Utils\Traits\MakesHash; +use Illuminate\Http\UploadedFile; use Illuminate\Validation\Rule; class UpdateRecurringQuoteRequest extends Request { use ChecksEntityStatus; use CleanLineItems; + use MakesHash; /** * Determine if the user is authorized to make this request. @@ -33,24 +36,64 @@ class UpdateRecurringQuoteRequest extends Request public function rules() { - return [ - 'documents' => 'mimes:png,ai,svg,jpeg,tiff,pdf,gif,psd,txt,doc,xls,ppt,xlsx,docx,pptx', - ]; + $rules = []; + + if ($this->input('documents') && is_array($this->input('documents'))) { + $documents = count($this->input('documents')); + + foreach (range(0, $documents) as $index) { + $rules['documents.'.$index] = 'file|mimes:png,ai,svg,jpeg,tiff,pdf,gif,psd,txt,doc,xls,ppt,xlsx,docx,pptx|max:20000'; + } + } elseif ($this->input('documents')) { + $rules['documents'] = 'file|mimes:png,ai,svg,jpeg,tiff,pdf,gif,psd,txt,doc,xls,ppt,xlsx,docx,pptx|max:20000'; + } + + if($this->number) + $rules['number'] = Rule::unique('recurring_quotes')->where('company_id', auth()->user()->company()->id)->ignore($this->recurring_quote->id); + + + return $rules; } protected function prepareForValidation() { $input = $this->all(); + $input = $this->decodePrimaryKeys($input); - if (array_key_exists('assigned_user_id', $input) && is_string($input['assigned_user_id'])) { - $input['assigned_user_id'] = $this->decodePrimaryKey($input['assigned_user_id']); + if (isset($input['line_items'])) { + $input['line_items'] = isset($input['line_items']) ? $this->cleanItems($input['line_items']) : []; } - $input['line_items'] = isset($input['line_items']) ? $this->cleanItems($input['line_items']) : []; - - if($this->number) - $rules['number'] = Rule::unique('recurring_quotes')->where('company_id', auth()->user()->company()->id)->ignore($this->recurring_quote->id); + if (isset($input['auto_bill'])) { + $input['auto_bill_enabled'] = $this->setAutoBillFlag($input['auto_bill']); + } + if (array_key_exists('documents', $input)) { + unset($input['documents']); + } + $this->replace($input); } + + /** + * if($auto_bill == '') + * off / optin / optout will reset the status of this field to off to allow + * the client to choose whether to auto_bill or not. + * + * @param enum $auto_bill off/always/optin/optout + * + * @return bool + */ + private function setAutoBillFlag($auto_bill) :bool + { + if ($auto_bill == 'always') { + return true; + } + + // if($auto_bill == '') + // off / optin / optout will reset the status of this field to off to allow + // the client to choose whether to auto_bill or not. + + return false; + } } diff --git a/app/Http/Requests/RecurringQuote/UploadRecurringQuoteRequest.php b/app/Http/Requests/RecurringQuote/UploadRecurringQuoteRequest.php new file mode 100644 index 000000000000..72985f6f48e7 --- /dev/null +++ b/app/Http/Requests/RecurringQuote/UploadRecurringQuoteRequest.php @@ -0,0 +1,39 @@ +user()->can('edit', $this->recurring_quote); + } + + public function rules() + { + + $rules = []; + + if($this->input('documents')) + $rules['documents'] = 'file|mimes:html,csv,png,ai,svg,jpeg,tiff,pdf,gif,psd,txt,doc,xls,ppt,xlsx,docx,pptx|max:2000000'; + + return $rules; + + } +} diff --git a/app/Http/ValidationRules/Recurring/UniqueRecurringQuoteNumberRule.php b/app/Http/ValidationRules/Recurring/UniqueRecurringQuoteNumberRule.php new file mode 100644 index 000000000000..66fca8061f3f --- /dev/null +++ b/app/Http/ValidationRules/Recurring/UniqueRecurringQuoteNumberRule.php @@ -0,0 +1,67 @@ +input = $input; + } + + /** + * @param string $attribute + * @param mixed $value + * @return bool + */ + public function passes($attribute, $value) + { + return $this->checkIfQuoteNumberUnique(); //if it exists, return false! + } + + /** + * @return string + */ + public function message() + { + return ctrans('texts.recurring_quote_number_taken', ['number' => $this->input['number']]); + } + + /** + * @return bool + */ + private function checkIfQuoteNumberUnique() : bool + { + if (empty($this->input['number'])) { + return true; + } + + $invoice = RecurringQuote::where('client_id', $this->input['client_id']) + ->where('number', $this->input['number']) + ->withTrashed() + ->exists(); + + if ($invoice) { + return false; + } + + return true; + } +} diff --git a/app/Jobs/Cron/RecurringExpensesCron.php b/app/Jobs/Cron/RecurringExpensesCron.php new file mode 100644 index 000000000000..a08f4eeedbeb --- /dev/null +++ b/app/Jobs/Cron/RecurringExpensesCron.php @@ -0,0 +1,95 @@ +format('Y-m-d h:i:s')); + + if (! config('ninja.db.multi_db_enabled')) { + + $this->getRecurringExpenses(); + + } else { + //multiDB environment, need to + foreach (MultiDB::$dbs as $db) { + MultiDB::setDB($db); + + $this->getRecurringExpenses(); + } + } + } + + private function getRecurringExpenses() + { + $recurring_expenses = RecurringExpense::where('next_send_date', '<=', now()->toDateTimeString()) + ->whereNotNull('next_send_date') + ->whereNull('deleted_at') + ->where('remaining_cycles', '!=', '0') + // ->whereHas('client', function ($query) { + // $query->where('is_deleted',0) + // ->where('deleted_at', NULL); + // }) + ->with('company') + ->cursor(); + + nlog(now()->format('Y-m-d') . ' Generating Recurring Expenses. Count = '.$recurring_expenses->count()); + + $recurring_expenses->each(function ($recurring_expense, $key) { + nlog("Current date = " . now()->format("Y-m-d") . " Recurring date = " .$recurring_expense->next_send_date); + + if (!$recurring_expense->company->is_disabled) { + $this->generateExpense($recurring_expense); + } + }); + } + + private function generateExpense(RecurringExpense $recurring_expense) + { + $expense = RecurringExpenseToExpenseFactory::create($recurring_expense); + $expense->save(); + + $expense->number = $this->getNextExpenseNumber($expense); + $expense->save(); + } + +} diff --git a/app/Jobs/Util/Import.php b/app/Jobs/Util/Import.php index f212b8853606..d35e571775c7 100644 --- a/app/Jobs/Util/Import.php +++ b/app/Jobs/Util/Import.php @@ -59,6 +59,7 @@ use App\Models\PaymentTerm; use App\Models\Product; use App\Models\Project; use App\Models\Quote; +use App\Models\RecurringExpense; use App\Models\RecurringInvoice; use App\Models\Task; use App\Models\TaskStatus; @@ -132,13 +133,14 @@ class Import implements ShouldQueue 'projects', 'products', 'credits', - 'invoices', 'recurring_invoices', + 'invoices', 'quotes', 'payments', 'expense_categories', 'task_statuses', 'expenses', + 'recurring_expenses', 'tasks', 'documents', ]; @@ -812,6 +814,68 @@ class Import implements ShouldQueue $product_repository = null; } + private function processRecurringExpenses(array $data) :void + { + RecurringExpense::unguard(); + + $rules = [ + '*.amount' => ['numeric'], + ]; + + $validator = Validator::make($data, $rules); + + if ($validator->fails()) { + throw new MigrationValidatorFailed(json_encode($validator->errors())); + } + + foreach ($data as $resource) { + $modified = $resource; + + unset($modified['id']); + + $modified['company_id'] = $this->company->id; + $modified['user_id'] = $this->processUserId($resource); + + if (isset($resource['client_id'])) { + $modified['client_id'] = $this->transformId('clients', $resource['client_id']); + } + + if (isset($resource['category_id'])) { + $modified['category_id'] = $this->transformId('expense_categories', $resource['category_id']); + } + + if (isset($resource['vendor_id'])) { + $modified['vendor_id'] = $this->transformId('vendors', $resource['vendor_id']); + } + + $expense = RecurringExpense::create($modified); + + if(array_key_exists('created_at', $modified)) + $expense->created_at = Carbon::parse($modified['created_at']); + + if(array_key_exists('updated_at', $modified)) + $expense->updated_at = Carbon::parse($modified['updated_at']); + + $expense->save(['timestamps' => false]); + + $old_user_key = array_key_exists('user_id', $resource) ?? $this->user->id; + + $key = "recurring_expenses_{$resource['id']}"; + + $this->ids['recurring_expenses'][$key] = [ + 'old' => $resource['id'], + 'new' => $expense->id, + ]; + + } + + + RecurringExpense::reguard(); + + /*Improve memory handling by setting everything to null when we have finished*/ + $data = null; + } + private function processRecurringInvoices(array $data) :void { RecurringInvoice::unguard(); @@ -908,6 +972,10 @@ class Import implements ShouldQueue } $modified['client_id'] = $this->transformId('clients', $resource['client_id']); + + if(array_key_exists('recurring_id', $resource) && !is_null($resource['recurring_id'])) + $modified['recurring_id'] = $this->transformId('recurring_invoices', (string)$resource['recurring_id']); + $modified['user_id'] = $this->processUserId($resource); $modified['company_id'] = $this->company->id; $modified['line_items'] = $this->cleanItems($modified['line_items']); diff --git a/app/Listeners/RecurringExpense/CreatedRecurringExpenseActivity.php b/app/Listeners/RecurringExpense/CreatedRecurringExpenseActivity.php new file mode 100644 index 000000000000..b84c5baa48f8 --- /dev/null +++ b/app/Listeners/RecurringExpense/CreatedRecurringExpenseActivity.php @@ -0,0 +1,57 @@ +activity_repo = $activity_repo; + } + + /** + * Handle the event. + * + * @param object $event + * @return void + */ + public function handle($event) + { + MultiDB::setDb($event->company->db); + + $recurring_expense = $event->recurring_expense; + + $fields = new stdClass; + + $user_id = array_key_exists('user_id', $event->event_vars) ? $event->event_vars['user_id'] : $event->recurring_expense->user_id; + + $fields->recurring_expense_id = $recurring_expense->id; + $fields->user_id = $user_id; + $fields->company_id = $recurring_expense->company_id; + $fields->activity_type_id = Activity::CREATE_RECURRING_EXPENSE; + + $this->activity_repo->save($fields, $recurring_expense, $event->event_vars); + } +} diff --git a/app/Listeners/RecurringExpense/RecurringExpenseArchivedActivity.php b/app/Listeners/RecurringExpense/RecurringExpenseArchivedActivity.php new file mode 100644 index 000000000000..12608e17183e --- /dev/null +++ b/app/Listeners/RecurringExpense/RecurringExpenseArchivedActivity.php @@ -0,0 +1,57 @@ +activity_repo = $activity_repo; + } + + /** + * Handle the event. + * + * @param object $event + * @return void + */ + public function handle($event) + { + MultiDB::setDb($event->company->db); + + $recurring_expense = $event->recurring_expense; + + $fields = new stdClass; + + $user_id = array_key_exists('user_id', $event->event_vars) ? $event->event_vars['user_id'] : $event->recurring_expense->user_id; + + $fields->recurring_expense_id = $recurring_expense->id; + $fields->user_id = $user_id; + $fields->company_id = $recurring_expense->company_id; + $fields->activity_type_id = Activity::ARCHIVE_RECURRING_EXPENSE; + + $this->activity_repo->save($fields, $recurring_expense, $event->event_vars); + } +} diff --git a/app/Listeners/RecurringExpense/RecurringExpenseDeletedActivity.php b/app/Listeners/RecurringExpense/RecurringExpenseDeletedActivity.php new file mode 100644 index 000000000000..236e73b9c420 --- /dev/null +++ b/app/Listeners/RecurringExpense/RecurringExpenseDeletedActivity.php @@ -0,0 +1,55 @@ +activity_repo = $activity_repo; + } + + /** + * Handle the event. + * + * @param object $event + * @return void + */ + public function handle($event) + { + MultiDB::setDb($event->company->db); + + $fields = new stdClass; + + $user_id = array_key_exists('user_id', $event->event_vars) ? $event->event_vars['user_id'] : $event->recurring_expense->user_id; + + $fields->recurring_expense_id = $event->recurring_expense->id; + $fields->user_id = $user_id; + $fields->company_id = $event->recurring_expense->company_id; + $fields->activity_type_id = Activity::DELETE_RECURRING_EXPENSE; + + $this->activity_repo->save($fields, $event->recurring_expense, $event->event_vars); + } +} diff --git a/app/Listeners/RecurringExpense/RecurringExpenseRestoredActivity.php b/app/Listeners/RecurringExpense/RecurringExpenseRestoredActivity.php new file mode 100644 index 000000000000..d175d105a747 --- /dev/null +++ b/app/Listeners/RecurringExpense/RecurringExpenseRestoredActivity.php @@ -0,0 +1,55 @@ +activity_repo = $activity_repo; + } + + /** + * Handle the event. + * + * @param object $event + * @return void + */ + public function handle($event) + { + MultiDB::setDb($event->company->db); + + $fields = new stdClass; + + $user_id = array_key_exists('user_id', $event->event_vars) ? $event->event_vars['user_id'] : $event->recurring_expense->user_id; + + $fields->recurring_expense_id = $event->recurring_expense->id; + $fields->user_id = $user_id; + $fields->company_id = $event->recurring_expense->company_id; + $fields->activity_type_id = Activity::RESTORE_RECURRING_EXPENSE; + + $this->activity_repo->save($fields, $event->recurring_expense, $event->event_vars); + } +} diff --git a/app/Listeners/RecurringExpense/RecurringExpenseUpdatedActivity.php b/app/Listeners/RecurringExpense/RecurringExpenseUpdatedActivity.php new file mode 100644 index 000000000000..dd46cc6df960 --- /dev/null +++ b/app/Listeners/RecurringExpense/RecurringExpenseUpdatedActivity.php @@ -0,0 +1,57 @@ +activity_repo = $activity_repo; + } + + /** + * Handle the event. + * + * @param object $event + * @return void + */ + public function handle($event) + { + MultiDB::setDb($event->company->db); + + $recurring_expense = $event->recurring_expense; + + $user_id = array_key_exists('user_id', $event->event_vars) ? $event->event_vars['user_id'] : $event->recurring_expense->user_id; + + $fields = new stdClass; + + $fields->recurring_expense_id = $recurring_expense->id; + $fields->user_id = $user_id; + $fields->company_id = $recurring_expense->company_id; + $fields->activity_type_id = Activity::UPDATE_RECURRING_EXPENSE; + + $this->activity_repo->save($fields, $recurring_expense, $event->event_vars); + } +} diff --git a/app/Listeners/RecurringQuote/CreateRecurringQuoteActivity.php b/app/Listeners/RecurringQuote/CreateRecurringQuoteActivity.php new file mode 100644 index 000000000000..352bbfd33fd4 --- /dev/null +++ b/app/Listeners/RecurringQuote/CreateRecurringQuoteActivity.php @@ -0,0 +1,56 @@ +activity_repo = $activity_repo; + } + + /** + * Handle the event. + * + * @param object $event + * @return void + */ + public function handle($event) + { + MultiDB::setDb($event->company->db); + + $fields = new stdClass; + + $user_id = array_key_exists('user_id', $event->event_vars) ? $event->event_vars['user_id'] : $event->recurring_quote->user_id; + + $fields->user_id = $user_id; + $fields->recurring_quote_id = $event->recurring_quote->id; + $fields->client_id = $event->recurring_quote->client_id; + $fields->company_id = $event->recurring_quote->company_id; + $fields->activity_type_id = Activity::CREATE_RECURRING_QUOTE; + + $this->activity_repo->save($fields, $event->recurring_quote, $event->event_vars); + } +} diff --git a/app/Listeners/RecurringQuote/RecurringQuoteArchivedActivity.php b/app/Listeners/RecurringQuote/RecurringQuoteArchivedActivity.php new file mode 100644 index 000000000000..1f977f963612 --- /dev/null +++ b/app/Listeners/RecurringQuote/RecurringQuoteArchivedActivity.php @@ -0,0 +1,57 @@ +activity_repo = $activity_repo; + } + + /** + * Handle the event. + * + * @param object $event + * @return void + */ + public function handle($event) + { + MultiDB::setDb($event->company->db); + + $event->recurring_quote->service()->deletePdf(); + + $fields = new stdClass; + $user_id = array_key_exists('user_id', $event->event_vars) ? $event->event_vars['user_id'] : $event->recurring_quote->user_id; + + $fields->user_id = $user_id; + $fields->recurring_quote_id = $event->recurring_quote->id; + $fields->client_id = $event->recurring_quote->client_id; + $fields->company_id = $event->recurring_quote->company_id; + $fields->activity_type_id = Activity::ARCHIVE_RECURRING_QUOTE; + + $this->activity_repo->save($fields, $event->recurring_quote, $event->event_vars); + } +} diff --git a/app/Listeners/RecurringQuote/RecurringQuoteDeletedActivity.php b/app/Listeners/RecurringQuote/RecurringQuoteDeletedActivity.php new file mode 100644 index 000000000000..605195cbed2c --- /dev/null +++ b/app/Listeners/RecurringQuote/RecurringQuoteDeletedActivity.php @@ -0,0 +1,55 @@ +activity_repo = $activity_repo; + } + + /** + * Handle the event. + * + * @param object $event + * @return void + */ + public function handle($event) + { + MultiDB::setDb($event->company->db); + + $fields = new stdClass; + $user_id = array_key_exists('user_id', $event->event_vars) ? $event->event_vars['user_id'] : $event->recurring_quote->user_id; + + $fields->user_id = $user_id; + $fields->recurring_quote_id = $event->recurring_quote->id; + $fields->client_id = $event->recurring_quote->client_id; + $fields->company_id = $event->recurring_quote->company_id; + $fields->activity_type_id = Activity::DELETE_RECURRING_QUOTE; + + $this->activity_repo->save($fields, $event->recurring_quote, $event->event_vars); + } +} diff --git a/app/Listeners/RecurringQuote/RecurringQuoteRestoredActivity.php b/app/Listeners/RecurringQuote/RecurringQuoteRestoredActivity.php new file mode 100644 index 000000000000..16972eb6cc21 --- /dev/null +++ b/app/Listeners/RecurringQuote/RecurringQuoteRestoredActivity.php @@ -0,0 +1,55 @@ +activity_repo = $activity_repo; + } + + /** + * Handle the event. + * + * @param object $event + * @return void + */ + public function handle($event) + { + MultiDB::setDb($event->company->db); + + $fields = new stdClass; + $user_id = array_key_exists('user_id', $event->event_vars) ? $event->event_vars['user_id'] : $event->recurring_quote->user_id; + + $fields->user_id = $user_id; + $fields->recurring_quote_id = $event->recurring_quote->id; + $fields->client_id = $event->recurring_quote->client_id; + $fields->company_id = $event->recurring_quote->company_id; + $fields->activity_type_id = Activity::RESTORE_RECURRING_QUOTE; + + $this->activity_repo->save($fields, $event->recurring_quote, $event->event_vars); + } +} diff --git a/app/Listeners/RecurringQuote/UpdateRecurringQuoteActivity.php b/app/Listeners/RecurringQuote/UpdateRecurringQuoteActivity.php new file mode 100644 index 000000000000..fb81d3710bc6 --- /dev/null +++ b/app/Listeners/RecurringQuote/UpdateRecurringQuoteActivity.php @@ -0,0 +1,55 @@ +activity_repo = $activity_repo; + } + + /** + * Handle the event. + * + * @param object $event + * @return void + */ + public function handle($event) + { + MultiDB::setDB($event->company->db); + + $fields = new stdClass; + $user_id = array_key_exists('user_id', $event->event_vars) ? $event->event_vars['user_id'] : $event->recurring_quote->user_id; + + $fields->user_id = $user_id; + $fields->client_id = $event->recurring_quote->client_id; + $fields->company_id = $event->recurring_quote->company_id; + $fields->activity_type_id = Activity::UPDATE_RECURRING_QUOTE; + $fields->recurring_quote_id = $event->recurring_quote->id; + + $this->activity_repo->save($fields, $event->recurring_quote, $event->event_vars); + } +} diff --git a/app/Models/Activity.php b/app/Models/Activity.php index 777b85813a06..bf9e250f6236 100644 --- a/app/Models/Activity.php +++ b/app/Models/Activity.php @@ -96,6 +96,18 @@ class Activity extends StaticModel const DELETE_RECURRING_INVOICE = 103; const RESTORE_RECURRING_INVOICE = 104; + const CREATE_RECURRING_QUOTE = 110; + const UPDATE_RECURRING_QUOTE = 111; + const ARCHIVE_RECURRING_QUOTE = 112; + const DELETE_RECURRING_QUOTE = 113; + const RESTORE_RECURRING_QUOTE = 114; + + const CREATE_RECURRING_EXPENSE = 120; + const UPDATE_RECURRING_EXPENSE = 121; + const ARCHIVE_RECURRING_EXPENSE = 122; + const DELETE_RECURRING_EXPENSE = 123; + const RESTORE_RECURRING_EXPENSE = 124; + protected $casts = [ 'is_system' => 'boolean', 'updated_at' => 'timestamp', diff --git a/app/Models/Company.php b/app/Models/Company.php index cc3c69bc687a..58a90c2b3367 100644 --- a/app/Models/Company.php +++ b/app/Models/Company.php @@ -260,6 +260,14 @@ class Company extends BaseModel return $this->hasMany(RecurringInvoice::class)->withTrashed(); } + /** + * @return HasMany + */ + public function recurring_expenses() + { + return $this->hasMany(RecurringExpense::class)->withTrashed(); + } + /** * @return HasMany */ diff --git a/app/Models/Presenters/RecurringQuotePresenter.php b/app/Models/Presenters/RecurringQuotePresenter.php new file mode 100644 index 000000000000..e69058ce8f5d --- /dev/null +++ b/app/Models/Presenters/RecurringQuotePresenter.php @@ -0,0 +1,31 @@ + 'boolean', + 'updated_at' => 'timestamp', + 'created_at' => 'timestamp', + 'deleted_at' => 'timestamp', + ]; + + protected $touches = []; + + public function getEntityType() + { + return self::class; + } + + public function documents() + { + return $this->morphMany(Document::class, 'documentable'); + } + + public function user() + { + return $this->belongsTo(User::class)->withTrashed(); + } + + public function assigned_user() + { + return $this->belongsTo(User::class, 'assigned_user_id', 'id'); + } + + public function company() + { + return $this->belongsTo(Company::class); + } + + public function vendor() + { + return $this->belongsTo(Vendor::class); + } + + public function client() + { + return $this->belongsTo(Client::class); + } + + /** + * Service entry points. + */ + public function service() :RecurringService + { + return new RecurringService($this); + } +} diff --git a/app/Models/RecurringQuote.php b/app/Models/RecurringQuote.php index 3c600e0f3833..b6a685a3a2cd 100644 --- a/app/Models/RecurringQuote.php +++ b/app/Models/RecurringQuote.php @@ -11,56 +11,73 @@ namespace App\Models; +use App\Helpers\Invoice\InvoiceSum; +use App\Helpers\Invoice\InvoiceSumInclusive; +use App\Models\Presenters\RecurringQuotePresenter; +use App\Models\Quote; +use App\Models\RecurringQuoteInvitation; +use App\Services\Recurring\RecurringService; +use App\Utils\Traits\MakesDates; use App\Utils\Traits\MakesHash; +use App\Utils\Traits\Recurring\HasRecurrence; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Carbon; +use Laracasts\Presenter\PresentableTrait; /** - * Class for Recurring Invoices. + * Class for Recurring Quotes. */ class RecurringQuote extends BaseModel { use MakesHash; use SoftDeletes; use Filterable; + use MakesDates; + use HasRecurrence; + use PresentableTrait; + + protected $presenter = RecurringQuotePresenter::class; /** - * Invoice Statuses. + * Quote Statuses. */ - const STATUS_DRAFT = 2; - const STATUS_ACTIVE = 3; + const STATUS_DRAFT = 1; + const STATUS_ACTIVE = 2; + const STATUS_PAUSED = 3; + const STATUS_COMPLETED = 4; const STATUS_PENDING = -1; - const STATUS_COMPLETED = -2; - const STATUS_CANCELLED = -3; /** - * Recurring intervals. + * Quote Frequencies. */ - const FREQUENCY_WEEKLY = 1; - const FREQUENCY_TWO_WEEKS = 2; - const FREQUENCY_FOUR_WEEKS = 3; - const FREQUENCY_MONTHLY = 4; - const FREQUENCY_TWO_MONTHS = 5; - const FREQUENCY_THREE_MONTHS = 6; - const FREQUENCY_FOUR_MONTHS = 7; - const FREQUENCY_SIX_MONTHS = 8; - const FREQUENCY_ANNUALLY = 9; - const FREQUENCY_TWO_YEARS = 10; + const FREQUENCY_DAILY = 1; + const FREQUENCY_WEEKLY = 2; + const FREQUENCY_TWO_WEEKS = 3; + const FREQUENCY_FOUR_WEEKS = 4; + const FREQUENCY_MONTHLY = 5; + const FREQUENCY_TWO_MONTHS = 6; + const FREQUENCY_THREE_MONTHS = 7; + const FREQUENCY_FOUR_MONTHS = 8; + const FREQUENCY_SIX_MONTHS = 9; + const FREQUENCY_ANNUALLY = 10; + const FREQUENCY_TWO_YEARS = 11; + const FREQUENCY_THREE_YEARS = 12; const RECURS_INDEFINITELY = -1; protected $fillable = [ 'client_id', - 'quote_number', + 'project_id', + 'number', 'discount', 'is_amount_discount', 'po_number', - 'quote_date', - 'valid_until', + 'date', + 'due_date', + 'due_date_days', 'line_items', - 'settings', 'footer', - 'public_note', + 'public_notes', 'private_notes', 'terms', 'tax_name1', @@ -74,26 +91,42 @@ class RecurringQuote extends BaseModel 'custom_value3', 'custom_value4', 'amount', + 'partial', 'frequency_id', - 'due_date_days', + 'next_send_date', + 'remaining_cycles', + 'auto_bill', + 'auto_bill_enabled', + 'design_id', + 'custom_surcharge1', + 'custom_surcharge2', + 'custom_surcharge3', + 'custom_surcharge4', + 'custom_surcharge_tax1', + 'custom_surcharge_tax2', + 'custom_surcharge_tax3', + 'custom_surcharge_tax4', + 'design_id', + 'assigned_user_id', + 'exchange_rate', ]; - protected $touches = []; - protected $casts = [ + 'settings' => 'object', 'line_items' => 'object', 'backup' => 'object', - 'settings' => 'object', 'updated_at' => 'timestamp', 'created_at' => 'timestamp', 'deleted_at' => 'timestamp', ]; - protected $with = [ - // 'client', - // 'company', + protected $appends = [ + 'hashed_id', + 'status', ]; + protected $touches = []; + public function getEntityType() { return self::class; @@ -126,6 +159,16 @@ class RecurringQuote extends BaseModel return $value; } + public function activities() + { + return $this->hasMany(Activity::class)->orderBy('id', 'DESC')->take(50); + } + + public function history() + { + return $this->hasManyThrough(Backup::class, Activity::class); + } + public function company() { return $this->belongsTo(Company::class); @@ -136,6 +179,11 @@ class RecurringQuote extends BaseModel return $this->belongsTo(Client::class)->withTrashed(); } + public function project() + { + return $this->belongsTo(Project::class)->withTrashed(); + } + public function user() { return $this->belongsTo(User::class)->withTrashed(); @@ -146,8 +194,299 @@ class RecurringQuote extends BaseModel return $this->belongsTo(User::class, 'assigned_user_id', 'id')->withTrashed(); } + public function quotes() + { + return $this->hasMany(Quote::class, 'recurring_id', 'id')->withTrashed(); + } + public function invitations() { - $this->morphMany(RecurringQuoteInvitation::class); + return $this->hasMany(RecurringQuoteInvitation::class); } + + public function documents() + { + return $this->morphMany(Document::class, 'documentable'); + } + + public function getStatusAttribute() + { + if ($this->status_id == self::STATUS_ACTIVE && Carbon::parse($this->next_send_date)->isFuture()) { + return self::STATUS_PENDING; + } else { + return $this->status_id; + } + } + + public function nextSendDate() :?Carbon + { + if (!$this->next_send_date) { + return null; + } + + $offset = $this->client->timezone_offset(); + + /* + 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; + + switch ($this->frequency_id) { + case self::FREQUENCY_DAILY: + return Carbon::parse($this->next_send_date)->startOfDay()->addDay()->addSeconds($offset); + case self::FREQUENCY_WEEKLY: + return Carbon::parse($this->next_send_date)->startOfDay()->addWeek()->addSeconds($offset); + case self::FREQUENCY_TWO_WEEKS: + return Carbon::parse($this->next_send_date)->startOfDay()->addWeeks(2)->addSeconds($offset); + case self::FREQUENCY_FOUR_WEEKS: + return Carbon::parse($this->next_send_date)->startOfDay()->addWeeks(4)->addSeconds($offset); + case self::FREQUENCY_MONTHLY: + return Carbon::parse($this->next_send_date)->startOfDay()->addMonthNoOverflow()->addSeconds($offset); + case self::FREQUENCY_TWO_MONTHS: + return Carbon::parse($this->next_send_date)->startOfDay()->addMonthsNoOverflow(2)->addSeconds($offset); + case self::FREQUENCY_THREE_MONTHS: + return Carbon::parse($this->next_send_date)->startOfDay()->addMonthsNoOverflow(3)->addSeconds($offset); + case self::FREQUENCY_FOUR_MONTHS: + return Carbon::parse($this->next_send_date)->startOfDay()->addMonthsNoOverflow(4)->addSeconds($offset); + case self::FREQUENCY_SIX_MONTHS: + return Carbon::parse($this->next_send_date)->startOfDay()->addMonthsNoOverflow(6)->addSeconds($offset); + case self::FREQUENCY_ANNUALLY: + return Carbon::parse($this->next_send_date)->startOfDay()->addYear()->addSeconds($offset); + case self::FREQUENCY_TWO_YEARS: + return Carbon::parse($this->next_send_date)->startOfDay()->addYears(2)->addSeconds($offset); + case self::FREQUENCY_THREE_YEARS: + return Carbon::parse($this->next_send_date)->startOfDay()->addYears(3)->addSeconds($offset); + default: + return null; + } + } + + public function nextDateByFrequency($date) + { + $offset = $this->client->timezone_offset(); + + switch ($this->frequency_id) { + case self::FREQUENCY_DAILY: + return Carbon::parse($date)->startOfDay()->addDay()->addSeconds($offset); + case self::FREQUENCY_WEEKLY: + return Carbon::parse($date)->startOfDay()->addWeek()->addSeconds($offset); + case self::FREQUENCY_TWO_WEEKS: + return Carbon::parse($date)->startOfDay()->addWeeks(2)->addSeconds($offset); + case self::FREQUENCY_FOUR_WEEKS: + return Carbon::parse($date)->startOfDay()->addWeeks(4)->addSeconds($offset); + case self::FREQUENCY_MONTHLY: + return Carbon::parse($date)->startOfDay()->addMonthNoOverflow()->addSeconds($offset); + case self::FREQUENCY_TWO_MONTHS: + return Carbon::parse($date)->startOfDay()->addMonthsNoOverflow(2)->addSeconds($offset); + case self::FREQUENCY_THREE_MONTHS: + return Carbon::parse($date)->startOfDay()->addMonthsNoOverflow(3)->addSeconds($offset); + case self::FREQUENCY_FOUR_MONTHS: + return Carbon::parse($date)->startOfDay()->addMonthsNoOverflow(4)->addSeconds($offset); + case self::FREQUENCY_SIX_MONTHS: + return Carbon::parse($date)->addMonthsNoOverflow(6)->addSeconds($offset); + case self::FREQUENCY_ANNUALLY: + return Carbon::parse($date)->startOfDay()->addYear()->addSeconds($offset); + case self::FREQUENCY_TWO_YEARS: + return Carbon::parse($date)->startOfDay()->addYears(2)->addSeconds($offset); + case self::FREQUENCY_THREE_YEARS: + return Carbon::parse($date)->startOfDay()->addYears(3)->addSeconds($offset); + default: + return null; + } + } + + public function remainingCycles() : int + { + if ($this->remaining_cycles == 0) { + return 0; + } elseif ($this->remaining_cycles == -1) { + return -1; + } else { + return $this->remaining_cycles - 1; + } + } + + public function setCompleted() : void + { + $this->status_id = self::STATUS_COMPLETED; + $this->next_send_date = null; + $this->remaining_cycles = 0; + $this->save(); + } + + public static function badgeForStatus(int $status) + { + switch ($status) { + case self::STATUS_DRAFT: + return '

'.ctrans('texts.draft').'

'; + break; + case self::STATUS_PENDING: + return '

'.ctrans('texts.pending').'

'; + break; + case self::STATUS_ACTIVE: + return '

'.ctrans('texts.active').'

'; + break; + case self::STATUS_COMPLETED: + return '

'.ctrans('texts.status_completed').'

'; + break; + case self::STATUS_PAUSED: + return '

'.ctrans('texts.paused').'

'; + break; + default: + // code... + break; + } + } + + public static function frequencyForKey(int $frequency_id) :string + { + switch ($frequency_id) { + case self::FREQUENCY_DAILY: + return ctrans('texts.freq_daily'); + break; + case self::FREQUENCY_WEEKLY: + return ctrans('texts.freq_weekly'); + break; + case self::FREQUENCY_TWO_WEEKS: + return ctrans('texts.freq_two_weeks'); + break; + case self::FREQUENCY_FOUR_WEEKS: + return ctrans('texts.freq_four_weeks'); + break; + case self::FREQUENCY_MONTHLY: + return ctrans('texts.freq_monthly'); + break; + case self::FREQUENCY_TWO_MONTHS: + return ctrans('texts.freq_two_months'); + break; + case self::FREQUENCY_THREE_MONTHS: + return ctrans('texts.freq_three_months'); + break; + case self::FREQUENCY_FOUR_MONTHS: + return ctrans('texts.freq_four_months'); + break; + case self::FREQUENCY_SIX_MONTHS: + return ctrans('texts.freq_six_months'); + break; + case self::FREQUENCY_ANNUALLY: + return ctrans('texts.freq_annually'); + break; + case self::FREQUENCY_TWO_YEARS: + return ctrans('texts.freq_two_years'); + break; + default: + // code... + break; + } + } + + public function calc() + { + $invoice_calc = null; + + if ($this->uses_inclusive_taxes) { + $invoice_calc = new InvoiceSumInclusive($this); + } else { + $invoice_calc = new InvoiceSum($this); + } + + return $invoice_calc->build(); + } + + /* + * Important to note when playing with carbon dates - in order + * not to modify the original instance, always use a `->copy()` + * + */ + public function recurringDates() + { + + /* Return early if nothing to send back! */ + if ($this->status_id == self::STATUS_COMPLETED || + $this->remaining_cycles == 0 || + !$this->next_send_date) { + return []; + } + + /* Endless - lets send 10 back*/ + $iterations = $this->remaining_cycles; + + if ($this->remaining_cycles == -1) { + $iterations = 10; + } + + $data = []; + + if (!Carbon::parse($this->next_send_date)) { + return $data; + } + + $next_send_date = Carbon::parse($this->next_send_date)->copy(); + + for ($x=0; $x<$iterations; $x++) { + // we don't add the days... we calc the day of the month!! + $next_due_date = $this->calculateDueDate($next_send_date->copy()->format('Y-m-d')); + $next_due_date_string = $next_due_date ? $next_due_date->format('Y-m-d') : ''; + + $next_send_date = Carbon::parse($next_send_date); + + $data[] = [ + 'send_date' => $next_send_date->format('Y-m-d'), + 'due_date' => $next_due_date_string + ]; + + /* Fixes the timeshift in case the offset is negative which cause a infinite loop due to UTC +0*/ + if($this->client->timezone_offset() < 0){ + $next_send_date = $this->nextDateByFrequency($next_send_date->addDay()->format('Y-m-d')); + } + else + $next_send_date = $this->nextDateByFrequency($next_send_date->format('Y-m-d')); + } + + return $data; + } + + + public function calculateDueDate($date) + { + switch ($this->due_date_days) { + case 'terms': + return $this->calculateDateFromTerms($date); + break; + default: + return $this->setDayOfMonth($date, $this->due_date_days); + break; + } + } + + /** + * Calculates a date based on the client payment terms. + * + * @param Carbon $date A given date + * @return NULL|Carbon The date + */ + public function calculateDateFromTerms($date) + { + $new_date = Carbon::parse($date); + + $client_payment_terms = $this->client->getSetting('payment_terms'); + + if ($client_payment_terms == '') {//no due date! return null; + return null; + } + + return $new_date->addDays($client_payment_terms); //add the number of days in the payment terms to the date + } + + /** + * Service entry points. + */ + public function service() :RecurringService + { + return new RecurringService($this); + } + + } diff --git a/app/Models/RecurringQuoteInvitation.php b/app/Models/RecurringQuoteInvitation.php new file mode 100644 index 000000000000..feb01a14a7cf --- /dev/null +++ b/app/Models/RecurringQuoteInvitation.php @@ -0,0 +1,88 @@ +belongsTo(RecurringQuote::class)->withTrashed(); + } + + /** + * @return mixed + */ + public function contact() + { + return $this->belongsTo(ClientContact::class, 'client_contact_id', 'id')->withTrashed(); + } + + /** + * @return mixed + */ + public function user() + { + return $this->belongsTo(User::class)->withTrashed(); + } + + /** + * @return BelongsTo + */ + public function company() + { + return $this->belongsTo(Company::class); + } + + public function markViewed() + { + $this->viewed_date = now(); + $this->save(); + } + + public function markOpened() + { + $this->opened_date = now(); + $this->save(); + } +} diff --git a/app/Policies/RecurringExpensePolicy.php b/app/Policies/RecurringExpensePolicy.php new file mode 100644 index 000000000000..032bf869fa19 --- /dev/null +++ b/app/Policies/RecurringExpensePolicy.php @@ -0,0 +1,31 @@ +isAdmin() || $user->hasPermission('create_recurring_expense') || $user->hasPermission('create_all'); + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index a73ed4842e07..b2a4560ba93b 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -12,7 +12,6 @@ namespace App\Providers; use App\Models\Activity; -use App\Models\Subscription; use App\Models\Client; use App\Models\Company; use App\Models\CompanyGateway; @@ -29,8 +28,10 @@ use App\Models\PaymentTerm; use App\Models\Product; use App\Models\Project; use App\Models\Quote; +use App\Models\RecurringExpense; use App\Models\RecurringInvoice; use App\Models\RecurringQuote; +use App\Models\Subscription; use App\Models\Task; use App\Models\TaskStatus; use App\Models\TaxRate; @@ -38,7 +39,6 @@ use App\Models\User; use App\Models\Vendor; use App\Models\Webhook; use App\Policies\ActivityPolicy; -use App\Policies\SubscriptionPolicy; use App\Policies\ClientPolicy; use App\Policies\CompanyGatewayPolicy; use App\Policies\CompanyPolicy; @@ -55,8 +55,10 @@ use App\Policies\PaymentTermPolicy; use App\Policies\ProductPolicy; use App\Policies\ProjectPolicy; use App\Policies\QuotePolicy; +use App\Policies\RecurringExpensePolicy; use App\Policies\RecurringInvoicePolicy; use App\Policies\RecurringQuotePolicy; +use App\Policies\SubscriptionPolicy; use App\Policies\TaskPolicy; use App\Policies\TaskStatusPolicy; use App\Policies\TaxRatePolicy; @@ -92,6 +94,7 @@ class AuthServiceProvider extends ServiceProvider Product::class => ProductPolicy::class, Project::class => ProjectPolicy::class, Quote::class => QuotePolicy::class, + RecurringExpense::class => RecurringExpensePolicy::class, RecurringInvoice::class => RecurringInvoicePolicy::class, RecurringQuote::class => RecurringQuotePolicy::class, Webhook::class => WebhookPolicy::class, diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 18583b99530c..cee0ce7eaa40 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -68,11 +68,21 @@ use App\Events\Quote\QuoteWasEmailed; use App\Events\Quote\QuoteWasRestored; use App\Events\Quote\QuoteWasUpdated; use App\Events\Quote\QuoteWasViewed; +use App\Events\RecurringExpense\RecurringExpenseWasArchived; +use App\Events\RecurringExpense\RecurringExpenseWasCreated; +use App\Events\RecurringExpense\RecurringExpenseWasDeleted; +use App\Events\RecurringExpense\RecurringExpenseWasRestored; +use App\Events\RecurringExpense\RecurringExpenseWasUpdated; use App\Events\RecurringInvoice\RecurringInvoiceWasArchived; use App\Events\RecurringInvoice\RecurringInvoiceWasCreated; use App\Events\RecurringInvoice\RecurringInvoiceWasDeleted; use App\Events\RecurringInvoice\RecurringInvoiceWasRestored; use App\Events\RecurringInvoice\RecurringInvoiceWasUpdated; +use App\Events\RecurringQuote\RecurringQuoteWasArchived; +use App\Events\RecurringQuote\RecurringQuoteWasCreated; +use App\Events\RecurringQuote\RecurringQuoteWasDeleted; +use App\Events\RecurringQuote\RecurringQuoteWasRestored; +use App\Events\RecurringQuote\RecurringQuoteWasUpdated; use App\Events\Subscription\SubscriptionWasArchived; use App\Events\Subscription\SubscriptionWasCreated; use App\Events\Subscription\SubscriptionWasDeleted; @@ -170,11 +180,21 @@ use App\Listeners\Quote\QuoteEmailedNotification; use App\Listeners\Quote\QuoteRestoredActivity; use App\Listeners\Quote\QuoteViewedActivity; use App\Listeners\Quote\ReachWorkflowSettings; +use App\Listeners\RecurringExpense\CreatedRecurringExpenseActivity; +use App\Listeners\RecurringExpense\RecurringExpenseArchivedActivity; +use App\Listeners\RecurringExpense\RecurringExpenseDeletedActivity; +use App\Listeners\RecurringExpense\RecurringExpenseRestoredActivity; +use App\Listeners\RecurringExpense\RecurringExpenseUpdatedActivity; use App\Listeners\RecurringInvoice\CreateRecurringInvoiceActivity; use App\Listeners\RecurringInvoice\RecurringInvoiceArchivedActivity; use App\Listeners\RecurringInvoice\RecurringInvoiceDeletedActivity; use App\Listeners\RecurringInvoice\RecurringInvoiceRestoredActivity; use App\Listeners\RecurringInvoice\UpdateRecurringInvoiceActivity; +use App\Listeners\RecurringQuote\CreateRecurringQuoteActivity; +use App\Listeners\RecurringQuote\RecurringQuoteArchivedActivity; +use App\Listeners\RecurringQuote\RecurringQuoteDeletedActivity; +use App\Listeners\RecurringQuote\RecurringQuoteRestoredActivity; +use App\Listeners\RecurringQuote\UpdateRecurringQuoteActivity; use App\Listeners\SendVerificationNotification; use App\Listeners\User\ArchivedUserActivity; use App\Listeners\User\CreatedUserActivity; @@ -412,6 +432,36 @@ class EventServiceProvider extends ServiceProvider QuoteWasRestored::class => [ QuoteRestoredActivity::class, ], + RecurringExpenseWasCreated::class => [ + CreatedRecurringExpenseActivity::class, + ], + RecurringExpenseWasUpdated::class => [ + RecurringExpenseUpdatedActivity::class, + ], + RecurringExpenseWasArchived::class => [ + RecurringExpenseArchivedActivity::class, + ], + RecurringExpenseWasDeleted::class => [ + RecurringExpenseDeletedActivity::class, + ], + RecurringExpenseWasRestored::class => [ + RecurringExpenseRestoredActivity::class + ], + RecurringQuoteWasUpdated::class => [ + UpdateRecurringQuoteActivity::class, + ], + RecurringQuoteWasCreated::class => [ + CreateRecurringQuoteActivity::class, + ], + RecurringQuoteWasDeleted::class => [ + RecurringQuoteDeletedActivity::class, + ], + RecurringQuoteWasArchived::class => [ + RecurringQuoteArchivedActivity::class, + ], + RecurringQuoteWasRestored::class => [ + RecurringQuoteRestoredActivity::class, + ], RecurringInvoiceWasUpdated::class => [ UpdateRecurringInvoiceActivity::class, ], diff --git a/app/Repositories/RecurringExpenseRepository.php b/app/Repositories/RecurringExpenseRepository.php new file mode 100644 index 000000000000..ead280cab40b --- /dev/null +++ b/app/Repositories/RecurringExpenseRepository.php @@ -0,0 +1,60 @@ +fill($data); + $recurring_expense->number = empty($recurring_expense->number) ? $this->getNextRecurringExpenseNumber($recurring_expense) : $recurring_expense->number; + $recurring_expense->save(); + + if (array_key_exists('documents', $data)) { + $this->saveDocuments($data['documents'], $recurring_expense); + } + + return $recurring_expense; + } + + /** + * Store recurring_expenses in bulk. + * + * @param array $recurring_expense + * @return \App\Models\RecurringExpense|null + */ + public function create($recurring_expense): ?RecurringExpense + { + return $this->save( + $recurring_expense, + RecurringExpenseFactory::create(auth()->user()->company()->id, auth()->user()->id) + ); + } +} diff --git a/app/Transformers/CompanyTransformer.php b/app/Transformers/CompanyTransformer.php index d28c00159548..971f2635b947 100644 --- a/app/Transformers/CompanyTransformer.php +++ b/app/Transformers/CompanyTransformer.php @@ -13,7 +13,6 @@ namespace App\Transformers; use App\Models\Account; use App\Models\Activity; -use App\Models\Subscription; use App\Models\Client; use App\Models\Company; use App\Models\CompanyGateway; @@ -31,13 +30,16 @@ use App\Models\PaymentTerm; use App\Models\Product; use App\Models\Project; use App\Models\Quote; +use App\Models\RecurringExpense; use App\Models\RecurringInvoice; +use App\Models\Subscription; use App\Models\SystemLog; use App\Models\Task; use App\Models\TaskStatus; use App\Models\TaxRate; use App\Models\User; use App\Models\Webhook; +use App\Transformers\RecurringExpenseTransformer; use App\Utils\Traits\MakesHash; use stdClass; @@ -92,6 +94,7 @@ class CompanyTransformer extends EntityTransformer 'expense_categories', 'task_statuses', 'subscriptions', + 'recurring_expenses', ]; /** @@ -296,6 +299,13 @@ class CompanyTransformer extends EntityTransformer return $this->includeCollection($company->recurring_invoices, $transformer, RecurringInvoice::class); } + public function includeRecurringExpenses(Company $company) + { + $transformer = new RecurringExpenseTransformer($this->serializer); + + return $this->includeCollection($company->recurring_expenses, $transformer, RecurringExpense::class); + } + public function includeQuotes(Company $company) { $transformer = new QuoteTransformer($this->serializer); diff --git a/app/Transformers/ExpenseTransformer.php b/app/Transformers/ExpenseTransformer.php index 6fa7f4236a30..784a315b93b7 100644 --- a/app/Transformers/ExpenseTransformer.php +++ b/app/Transformers/ExpenseTransformer.php @@ -94,6 +94,7 @@ class ExpenseTransformer extends EntityTransformer 'tax_amount3' => (float) $expense->tax_amount3, 'uses_inclusive_taxes' => (bool) $expense->uses_inclusive_taxes, 'calculate_tax_by_amount' => (bool) $expense->calculate_tax_by_amount, + 'entity_type' => 'expense', ]; } } diff --git a/app/Transformers/RecurringExpenseTransformer.php b/app/Transformers/RecurringExpenseTransformer.php new file mode 100644 index 000000000000..6457bb000792 --- /dev/null +++ b/app/Transformers/RecurringExpenseTransformer.php @@ -0,0 +1,106 @@ +serializer); + + return $this->includeCollection($recurring_expense->documents, $transformer, Document::class); + } + + /** + * @param RecurringExpense $recurring_expense + * + * @return array + */ + public function transform(RecurringExpense $recurring_expense) + { + return [ + 'id' => $this->encodePrimaryKey($recurring_expense->id), + 'user_id' => $this->encodePrimaryKey($recurring_expense->user_id), + 'assigned_user_id' => $this->encodePrimaryKey($recurring_expense->assigned_user_id), + 'status_id' => (string) ($recurring_expense->status_id ?: 1), + 'vendor_id' => $this->encodePrimaryKey($recurring_expense->vendor_id), + 'invoice_id' => $this->encodePrimaryKey($recurring_expense->invoice_id), + 'client_id' => $this->encodePrimaryKey($recurring_expense->client_id), + 'bank_id' => (string) $recurring_expense->bank_id ?: '', + 'invoice_currency_id' => (string) $recurring_expense->invoice_currency_id ?: '', + 'recurring_expense_currency_id' => '', //todo remove redundant in 5.0.25 + 'currency_id' => (string) $recurring_expense->currency_id ?: '', + 'category_id' => $this->encodePrimaryKey($recurring_expense->category_id), + 'payment_type_id' => (string) $recurring_expense->payment_type_id ?: '', + 'recurring_recurring_expense_id' => (string) $recurring_expense->recurring_recurring_expense_id ?: '', + 'is_deleted' => (bool) $recurring_expense->is_deleted, + 'should_be_invoiced' => (bool) $recurring_expense->should_be_invoiced, + 'invoice_documents' => (bool) $recurring_expense->invoice_documents, + 'amount' => (float) $recurring_expense->amount ?: 0, + 'foreign_amount' => (float) $recurring_expense->foreign_amount ?: 0, + 'exchange_rate' => (float) $recurring_expense->exchange_rate ?: 0, + 'tax_name1' => $recurring_expense->tax_name1 ? $recurring_expense->tax_name1 : '', + 'tax_rate1' => (float) $recurring_expense->tax_rate1, + 'tax_name2' => $recurring_expense->tax_name2 ? $recurring_expense->tax_name2 : '', + 'tax_rate2' => (float) $recurring_expense->tax_rate2, + 'tax_name3' => $recurring_expense->tax_name3 ? $recurring_expense->tax_name3 : '', + 'tax_rate3' => (float) $recurring_expense->tax_rate3, + 'private_notes' => (string) $recurring_expense->private_notes ?: '', + 'public_notes' => (string) $recurring_expense->public_notes ?: '', + 'transaction_reference' => (string) $recurring_expense->transaction_reference ?: '', + 'transaction_id' => (string) $recurring_expense->transaction_id ?: '', + 'date' => $recurring_expense->date ?: '', + 'number' => (string)$recurring_expense->number ?: '', + 'payment_date' => $recurring_expense->payment_date ?: '', + 'custom_value1' => $recurring_expense->custom_value1 ?: '', + 'custom_value2' => $recurring_expense->custom_value2 ?: '', + 'custom_value3' => $recurring_expense->custom_value3 ?: '', + 'custom_value4' => $recurring_expense->custom_value4 ?: '', + 'updated_at' => (int) $recurring_expense->updated_at, + 'archived_at' => (int) $recurring_expense->deleted_at, + 'created_at' => (int) $recurring_expense->created_at, + 'project_id' => $this->encodePrimaryKey($recurring_expense->project_id), + 'tax_amount1' => (float) $recurring_expense->tax_amount1, + 'tax_amount2' => (float) $recurring_expense->tax_amount2, + 'tax_amount3' => (float) $recurring_expense->tax_amount3, + 'uses_inclusive_taxes' => (bool) $recurring_expense->uses_inclusive_taxes, + 'calculate_tax_by_amount' => (bool) $recurring_expense->calculate_tax_by_amount, + 'entity_type' => 'recurringExpense', + 'frequency_id' => (string) $recurring_expense->frequency_id, + 'remaining_cycles' => (int) $recurring_expense->remaining_cycles, + 'last_sent_date' => $recurring_expense->last_sent_date ?: '', + 'next_send_date' => $recurring_expense->next_send_date ?: '', + ]; + } +} \ No newline at end of file diff --git a/app/Transformers/RecurringInvoiceTransformer.php b/app/Transformers/RecurringInvoiceTransformer.php index 19df457d5f6d..04611a3871c4 100644 --- a/app/Transformers/RecurringInvoiceTransformer.php +++ b/app/Transformers/RecurringInvoiceTransformer.php @@ -33,36 +33,6 @@ class RecurringInvoiceTransformer extends EntityTransformer protected $availableIncludes = [ 'activities', ]; - - /* - public function includeInvoiceItems(Invoice $invoice) - { - $transformer = new InvoiceItemTransformer($this->serializer); - - return $this->includeCollection($invoice->invoice_items, $transformer, ENTITY_INVOICE_ITEM); - } - - public function includeInvitations(Invoice $invoice) - { - $transformer = new InvitationTransformer($this->account, $this->serializer); - - return $this->includeCollection($invoice->invitations, $transformer, ENTITY_INVITATION); - } - - public function includePayments(Invoice $invoice) - { - $transformer = new PaymentTransformer($this->account, $this->serializer, $invoice); - - return $this->includeCollection($invoice->payments, $transformer, ENTITY_PAYMENT); - } - - public function includeClient(Invoice $invoice) - { - $transformer = new ClientTransformer($this->account, $this->serializer); - - return $this->includeItem($invoice->client, $transformer, ENTITY_CLIENT); - } - */ public function includeHistory(RecurringInvoice $invoice) { diff --git a/app/Transformers/RecurringQuoteInvitationTransformer.php b/app/Transformers/RecurringQuoteInvitationTransformer.php new file mode 100644 index 000000000000..db03289b0678 --- /dev/null +++ b/app/Transformers/RecurringQuoteInvitationTransformer.php @@ -0,0 +1,38 @@ + $this->encodePrimaryKey($invitation->id), + 'client_contact_id' => $this->encodePrimaryKey($invitation->client_contact_id), + 'key' => $invitation->key, + 'link' => $invitation->getLink() ?: '', + 'sent_date' => $invitation->sent_date ?: '', + 'viewed_date' => $invitation->viewed_date ?: '', + 'opened_date' => $invitation->opened_date ?: '', + 'updated_at' => (int) $invitation->updated_at, + 'archived_at' => (int) $invitation->deleted_at, + 'created_at' => (int) $invitation->created_at, + 'email_status' => $invitation->email_status ?: '', + 'email_error' => (string)$invitation->email_error, + ]; + } +} diff --git a/app/Transformers/RecurringQuoteTransformer.php b/app/Transformers/RecurringQuoteTransformer.php index 1e5d84d03762..c74c30fb5524 100644 --- a/app/Transformers/RecurringQuoteTransformer.php +++ b/app/Transformers/RecurringQuoteTransformer.php @@ -11,7 +11,14 @@ namespace App\Transformers; +use App\Models\Activity; +use App\Models\Backup; +use App\Models\Document; +use App\Models\Quote; use App\Models\RecurringQuote; +use App\Models\RecurringQuoteInvitation; +use App\Transformers\ActivityTransformer; +use App\Transformers\QuoteHistoryTransformer; use App\Utils\Traits\MakesHash; class RecurringQuoteTransformer extends EntityTransformer @@ -19,107 +26,110 @@ class RecurringQuoteTransformer extends EntityTransformer use MakesHash; protected $defaultIncludes = [ - // 'invoice_items', + 'invitations', + 'documents', ]; protected $availableIncludes = [ - // 'invitations', - // 'payments', + 'invitations', + 'documents', + 'activities', + // 'history', // 'client', - // 'documents', ]; + + public function includeHistory(RecurringQuote $quote) + { + $transformer = new QuoteHistoryTransformer($this->serializer); - /* - public function includeInvoiceItems(Invoice $quote) - { - $transformer = new InvoiceItemTransformer($this->serializer); + return $this->includeCollection($quote->history, $transformer, Backup::class); + } + + public function includeActivities(RecurringQuote $quote) + { + $transformer = new ActivityTransformer($this->serializer); - return $this->includeCollection($quote->invoice_items, $transformer, ENTITY_INVOICE_ITEM); - } + return $this->includeCollection($quote->activities, $transformer, Activity::class); + } - public function includeInvitations(Invoice $quote) - { - $transformer = new InvitationTransformer($this->account, $this->serializer); + public function includeInvitations(RecurringQuote $quote) + { + $transformer = new RecurringQuoteInvitationTransformer($this->serializer); - return $this->includeCollection($quote->invitations, $transformer, ENTITY_INVITATION); - } + return $this->includeCollection($quote->invitations, $transformer, RecurringQuoteInvitation::class); + } - public function includePayments(Invoice $quote) - { - $transformer = new PaymentTransformer($this->account, $this->serializer, $quote); + public function includeDocuments(RecurringQuote $quote) + { + $transformer = new DocumentTransformer($this->serializer); - return $this->includeCollection($quote->payments, $transformer, ENTITY_PAYMENT); - } - - public function includeClient(Invoice $quote) - { - $transformer = new ClientTransformer($this->account, $this->serializer); - - return $this->includeItem($quote->client, $transformer, ENTITY_CLIENT); - } - - public function includeExpenses(Invoice $quote) - { - $transformer = new ExpenseTransformer($this->account, $this->serializer); - - return $this->includeCollection($quote->expenses, $transformer, ENTITY_EXPENSE); - } - - public function includeDocuments(Invoice $quote) - { - $transformer = new DocumentTransformer($this->account, $this->serializer); - - $quote->documents->each(function ($document) use ($quote) { - $document->setRelation('invoice', $quote); - }); - - return $this->includeCollection($quote->documents, $transformer, ENTITY_DOCUMENT); - } - */ + return $this->includeCollection($quote->documents, $transformer, Document::class); + } + public function transform(RecurringQuote $quote) { return [ 'id' => $this->encodePrimaryKey($quote->id), 'user_id' => $this->encodePrimaryKey($quote->user_id), + 'project_id' => $this->encodePrimaryKey($quote->project_id), 'assigned_user_id' => $this->encodePrimaryKey($quote->assigned_user_id), - 'amount' => (float) $quote->amount ?: '', - 'balance' => (float) $quote->balance ?: '', - 'client_id' => (string) $quote->client_id, + 'amount' => (float) $quote->amount, + 'balance' => (float) $quote->balance, + 'client_id' => (string) $this->encodePrimaryKey($quote->client_id), + 'vendor_id' => (string) $this->encodePrimaryKey($quote->vendor_id), 'status_id' => (string) ($quote->status_id ?: 1), + 'design_id' => (string) $this->encodePrimaryKey($quote->design_id), 'created_at' => (int) $quote->created_at, 'updated_at' => (int) $quote->updated_at, 'archived_at' => (int) $quote->deleted_at, - 'discount' => (float) $quote->discount ?: '', + 'is_deleted' => (bool) $quote->is_deleted, + 'number' => $quote->number ?: '', + 'discount' => (float) $quote->discount, 'po_number' => $quote->po_number ?: '', - 'quote_date' => $quote->quote_date ?: '', - 'valid_until' => $quote->valid_until ?: '', + 'date' => $quote->date ?: '', + 'last_sent_date' => $quote->last_sent_date ?: '', + 'next_send_date' => $quote->next_send_date ?: '', + 'due_date' => $quote->due_date ?: '', 'terms' => $quote->terms ?: '', 'public_notes' => $quote->public_notes ?: '', 'private_notes' => $quote->private_notes ?: '', - 'is_deleted' => (bool) $quote->is_deleted, + 'uses_inclusive_taxes' => (bool) $quote->uses_inclusive_taxes, 'tax_name1' => $quote->tax_name1 ? $quote->tax_name1 : '', - 'tax_rate1' => (float) $quote->tax_rate1 ?: '', + 'tax_rate1' => (float) $quote->tax_rate1, 'tax_name2' => $quote->tax_name2 ? $quote->tax_name2 : '', - 'tax_rate2' => (float) $quote->tax_rate2 ?: '', + 'tax_rate2' => (float) $quote->tax_rate2, 'tax_name3' => $quote->tax_name3 ? $quote->tax_name3 : '', - 'tax_rate3' => (float) $quote->tax_rate3 ?: '', + 'tax_rate3' => (float) $quote->tax_rate3, + 'total_taxes' => (float) $quote->total_taxes, 'is_amount_discount' => (bool) ($quote->is_amount_discount ?: false), - 'quote_footer' => $quote->quote_footer ?: '', + 'footer' => $quote->footer ?: '', 'partial' => (float) ($quote->partial ?: 0.0), 'partial_due_date' => $quote->partial_due_date ?: '', - 'custom_value1' => (float) $quote->custom_value1 ?: '', - 'custom_value2' => (float) $quote->custom_value2 ?: '', - 'custom_taxes1' => (bool) $quote->custom_taxes1 ?: '', - 'custom_taxes2' => (bool) $quote->custom_taxes2 ?: '', + 'custom_value1' => (string) $quote->custom_value1 ?: '', + 'custom_value2' => (string) $quote->custom_value2 ?: '', + 'custom_value3' => (string) $quote->custom_value3 ?: '', + 'custom_value4' => (string) $quote->custom_value4 ?: '', 'has_tasks' => (bool) $quote->has_tasks, 'has_expenses' => (bool) $quote->has_expenses, - 'custom_text_value1' => $quote->custom_text_value1 ?: '', - 'custom_text_value2' => $quote->custom_text_value2 ?: '', - 'settings' => $quote->settings ?: '', - 'frequency_id' => (int) $quote->frequency_id, - 'last_sent_date' => $quote->last_sent_date ?: '', - 'next_send_date' => $quote->next_send_date ?: '', + 'custom_surcharge1' => (float) $quote->custom_surcharge1, + 'custom_surcharge2' => (float) $quote->custom_surcharge2, + 'custom_surcharge3' => (float) $quote->custom_surcharge3, + 'custom_surcharge4' => (float) $quote->custom_surcharge4, + 'exchange_rate' => (float) $quote->exchange_rate, + 'custom_surcharge_tax1' => (bool) $quote->custom_surcharge_tax1, + 'custom_surcharge_tax2' => (bool) $quote->custom_surcharge_tax2, + 'custom_surcharge_tax3' => (bool) $quote->custom_surcharge_tax3, + 'custom_surcharge_tax4' => (bool) $quote->custom_surcharge_tax4, + 'line_items' => $quote->line_items ?: (array) [], + 'entity_type' => 'recurringQuote', + 'frequency_id' => (string) $quote->frequency_id, 'remaining_cycles' => (int) $quote->remaining_cycles, + 'recurring_dates' => (array) $quote->recurringDates(), + 'auto_bill' => (string) $quote->auto_bill, + 'auto_bill_enabled' => (bool) $quote->auto_bill_enabled, + 'due_date_days' => (string) $quote->due_date_days ?: '', + 'paid_to_date' => (float) $quote->paid_to_date, + 'subscription_id' => (string)$this->encodePrimaryKey($quote->subscription_id), ]; } } diff --git a/app/Utils/Traits/GeneratesCounter.php b/app/Utils/Traits/GeneratesCounter.php index e478b4cbedba..8983461289ba 100644 --- a/app/Utils/Traits/GeneratesCounter.php +++ b/app/Utils/Traits/GeneratesCounter.php @@ -19,7 +19,9 @@ use App\Models\Invoice; use App\Models\Payment; use App\Models\Project; use App\Models\Quote; +use App\Models\RecurringExpense; use App\Models\RecurringInvoice; +use App\Models\RecurringQuote; use App\Models\Task; use App\Models\Timezone; use App\Models\Vendor; @@ -135,6 +137,12 @@ trait GeneratesCounter case RecurringInvoice::class: return 'recurring_invoice_number_counter'; break; + case RecurringQuote::class: + return 'recurring_quote_number_counter'; + break; + case RecurringExpense::class: + return 'recurring_expense_number_counter'; + break; case Payment::class: return 'payment_number_counter'; break; @@ -196,6 +204,11 @@ trait GeneratesCounter return $this->getNextEntityNumber(RecurringInvoice::class, $client); } + public function getNextRecurringQuoteNumber(Client $client) + { + return $this->getNextEntityNumber(RecurringQuote::class, $client); + } + /** * Gets the next Payment number. * @@ -312,6 +325,27 @@ trait GeneratesCounter return $expense_number; } + /** + * Gets the next expense number. + * + * @param RecurringExpense $expense The expense + * @return string The next expense number. + */ + public function getNextRecurringExpenseNumber(RecurringExpense $expense) :string + { + $this->resetCompanyCounters($expense->company); + + $counter = $expense->company->settings->recurring_expense_number_counter; + $setting_entity = $expense->company->settings->recurring_expense_number_counter; + + $expense_number = $this->checkEntityNumber(RecurringExpense::class, $expense, $counter, $expense->company->settings->counter_padding, $expense->company->settings->recurring_expense_number_pattern); + + $this->incrementCounter($expense->company, 'recurring_expense_number_counter'); + + return $expense_number; + } + + /** * Determines if it has shared counter. * diff --git a/database/factories/RecurringExpenseFactory.php b/database/factories/RecurringExpenseFactory.php new file mode 100644 index 000000000000..b90ed0ebdb31 --- /dev/null +++ b/database/factories/RecurringExpenseFactory.php @@ -0,0 +1,47 @@ + $this->faker->numberBetween(1, 10), + 'custom_value1' => $this->faker->text(10), + 'custom_value2' => $this->faker->text(10), + 'custom_value3' => $this->faker->text(10), + 'custom_value4' => $this->faker->text(10), + 'exchange_rate' => $this->faker->randomFloat(2, 0, 1), + 'date' => $this->faker->date(), + 'is_deleted' => false, + 'public_notes' => $this->faker->text(50), + 'private_notes' => $this->faker->text(50), + 'transaction_reference' => $this->faker->text(5), + 'invoice_id' => null, + ]; + } +} diff --git a/database/factories/RecurringQuoteFactory.php b/database/factories/RecurringQuoteFactory.php index 17563725b88b..b40e43d416a0 100644 --- a/database/factories/RecurringQuoteFactory.php +++ b/database/factories/RecurringQuoteFactory.php @@ -50,7 +50,6 @@ class RecurringQuoteFactory extends Factory 'due_date' => $this->faker->date(), 'line_items' => false, 'frequency_id' => RecurringQuote::FREQUENCY_MONTHLY, - 'start_date' => $this->faker->date(), 'last_sent_date' => $this->faker->date(), 'next_send_date' => $this->faker->date(), 'remaining_cycles' => $this->faker->numberBetween(1, 10), diff --git a/database/migrations/2021_04_13_013424_add_subscription_id_to_activities_table.php b/database/migrations/2021_04_13_013424_add_subscription_id_to_activities_table.php index 9b8a389ac5b4..db32a6854104 100644 --- a/database/migrations/2021_04_13_013424_add_subscription_id_to_activities_table.php +++ b/database/migrations/2021_04_13_013424_add_subscription_id_to_activities_table.php @@ -25,8 +25,5 @@ class AddSubscriptionIdToActivitiesTable extends Migration */ public function down() { - Schema::table('activities', function (Blueprint $table) { - // - }); } } diff --git a/database/migrations/2021_08_23_101529_recurring_expenses_schema.php b/database/migrations/2021_08_23_101529_recurring_expenses_schema.php new file mode 100644 index 000000000000..eefea2ebed1e --- /dev/null +++ b/database/migrations/2021_08_23_101529_recurring_expenses_schema.php @@ -0,0 +1,160 @@ +increments('id'); + $table->timestamps(6); + $table->softDeletes(); + + $table->unsignedInteger('company_id')->index(); + $table->unsignedInteger('vendor_id')->nullable(); + $table->unsignedInteger('user_id'); + $table->unsignedInteger('status_id'); + + $table->unsignedInteger('invoice_id')->nullable(); + $table->unsignedInteger('client_id')->nullable(); + $table->unsignedInteger('bank_id')->nullable(); + $table->unsignedInteger('project_id')->nullable(); + $table->unsignedInteger('payment_type_id')->nullable(); + $table->unsignedInteger('recurring_expense_id')->nullable(); + $table->boolean('is_deleted')->default(false); + $table->boolean('uses_inclusive_taxes')->default(true); + $table->string('tax_name1')->nullable(); + $table->string('tax_name2')->nullable(); + $table->string('tax_name3')->nullable(); + $table->date('date')->nullable(); + $table->date('payment_date')->nullable(); + $table->boolean('should_be_invoiced')->default(false); + $table->boolean('invoice_documents')->default(false); + $table->string('transaction_id')->nullable(); + $table->string('custom_value1')->nullable(); + $table->string('custom_value2')->nullable(); + $table->string('custom_value3')->nullable(); + $table->string('custom_value4')->nullable(); + + $table->unsignedInteger('category_id')->nullable(); + $table->boolean('calculate_tax_by_amount')->default(false); + $table->decimal('tax_amount1', 20, 6)->nullable(); + $table->decimal('tax_amount2', 20, 6)->nullable(); + $table->decimal('tax_amount3', 20, 6)->nullable(); + $table->decimal('tax_rate1', 20, 6)->nullable(); + $table->decimal('tax_rate2', 20, 6)->nullable(); + $table->decimal('tax_rate3', 20, 6)->nullable(); + $table->decimal('amount', 20, 6)->nullable(); + $table->decimal('foreign_amount', 20, 6)->nullable(); + $table->decimal('exchange_rate', 20, 6)->default(1); + $table->unsignedInteger('assigned_user_id')->nullable(); + $table->string('number')->nullable(); + $table->unsignedInteger('invoice_currency_id')->nullable(); + $table->unsignedInteger('currency_id')->nullable(); + $table->text('private_notes')->nullable(); + $table->text('public_notes')->nullable(); + $table->text('transaction_reference')->nullable(); + + $table->unsignedInteger('frequency_id'); + $table->datetime('last_sent_date')->nullable(); + $table->datetime('next_send_date')->nullable(); + $table->integer('remaining_cycles')->nullable(); + + $table->unique(['company_id', 'number']); + $table->index(['company_id', 'deleted_at']); + + // Relations + $table->foreign('company_id')->references('id')->on('companies')->onDelete('cascade')->onUpdate('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade')->onUpdate('cascade'); + + }); + + Schema::table('activities', function (Blueprint $table) { + $table->unsignedInteger('recurring_expense_id')->nullable(); + $table->unsignedInteger('recurring_quote_id')->nullable(); + }); + + Schema::table('recurring_quotes', function ($table){ + $table->string('auto_bill')->default('off'); + $table->boolean('auto_bill_enabled')->default(0); + $table->decimal('paid_to_date', 20, 6)->default(0); + $table->decimal('custom_surcharge1', 20,6)->nullable(); + $table->decimal('custom_surcharge2', 20,6)->nullable(); + $table->decimal('custom_surcharge3', 20,6)->nullable(); + $table->decimal('custom_surcharge4', 20,6)->nullable(); + $table->boolean('custom_surcharge_tax1')->default(false); + $table->boolean('custom_surcharge_tax2')->default(false); + $table->boolean('custom_surcharge_tax3')->default(false); + $table->boolean('custom_surcharge_tax4')->default(false); + $table->string('due_date_days')->nullable(); + $table->decimal('exchange_rate', 13, 6)->default(1); + $table->decimal('partial', 16, 4)->nullable(); + $table->date('partial_due_date')->nullable(); + $table->unsignedInteger('remaining_cycles')->nullable()->change(); + $table->unsignedInteger('subscription_id')->nullable(); + $table->dropColumn('start_date'); + $table->boolean('uses_inclusive_taxes')->default(true); + }); + + Schema::create('recurring_quote_invitations', function ($t) { + $t->increments('id'); + $t->unsignedInteger('company_id'); + $t->unsignedInteger('user_id'); + $t->unsignedInteger('client_contact_id'); + $t->unsignedInteger('recurring_quote_id')->index(); + $t->string('key')->index(); + + $t->foreign('recurring_quote_id')->references('id')->on('recurring_invoices')->onDelete('cascade')->onUpdate('cascade'); + $t->foreign('user_id')->references('id')->on('users')->onDelete('cascade')->onUpdate('cascade'); + $t->foreign('client_contact_id')->references('id')->on('client_contacts')->onDelete('cascade')->onUpdate('cascade'); + $t->foreign('company_id')->references('id')->on('companies')->onDelete('cascade')->onUpdate('cascade'); + + $t->string('transaction_reference')->nullable(); + $t->string('message_id')->nullable(); + $t->mediumText('email_error')->nullable(); + $t->text('signature_base64')->nullable(); + $t->datetime('signature_date')->nullable(); + + $t->datetime('sent_date')->nullable(); + $t->datetime('viewed_date')->nullable(); + $t->datetime('opened_date')->nullable(); + $t->enum('email_status', ['delivered', 'bounced', 'spam'])->nullable(); + + $t->timestamps(6); + $t->softDeletes('deleted_at', 6); + + $t->index(['deleted_at', 'recurring_quote_id', 'company_id'], 'rec_co_del_q'); + $t->unique(['client_contact_id', 'recurring_quote_id'], 'cli_rec_q'); + }); + + + + + + + + + + + + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // + } +} diff --git a/resources/lang/en/texts.php b/resources/lang/en/texts.php index b08dd95999fb..36dcf2c8cb15 100644 --- a/resources/lang/en/texts.php +++ b/resources/lang/en/texts.php @@ -4297,6 +4297,7 @@ $LANG = array( 'lang_Latvian' => 'Latvian', 'expiry_date' => 'Expiry date', 'cardholder_name' => 'Card holder name', + 'recurring_quote_number_taken' => 'Recurring Quote number :number already taken', 'account_type' => 'Account type', 'locality' => 'Locality', 'checking' => 'Checking', diff --git a/routes/api.php b/routes/api.php index e5323baa4fc9..7cc0055bf3e1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -132,12 +132,17 @@ Route::group(['middleware' => ['api_db', 'token_auth', 'locale'], 'prefix' => 'a Route::post('quotes/bulk', 'QuoteController@bulk')->name('quotes.bulk'); Route::put('quotes/{quote}/upload', 'QuoteController@upload'); + Route::resource('recurring_expenses', 'RecurringExpenseController'); + Route::post('recurring_expenses/bulk', 'RecurringExpenseController@bulk')->name('recurring_expenses.bulk'); + Route::put('recurring_expenses/{recurring_expense}/upload', 'RecurringExpenseController@upload'); + + Route::resource('recurring_invoices', 'RecurringInvoiceController'); // name = (recurring_invoices. index / create / show / update / destroy / edit Route::post('recurring_invoices/bulk', 'RecurringInvoiceController@bulk')->name('recurring_invoices.bulk'); Route::put('recurring_invoices/{recurring_invoice}/upload', 'RecurringInvoiceController@upload'); Route::resource('recurring_quotes', 'RecurringQuoteController'); // name = (recurring_invoices. index / create / show / update / destroy / edit - Route::post('recurring_quotes/bulk', 'RecurringQuoteController@bulk')->name('recurring_quotes.bulk'); + Route::put('recurring_quotes/{recurring_quote}/upload', 'RecurringQuoteController@upload'); Route::post('refresh', 'Auth\LoginController@refresh'); diff --git a/tests/Feature/RecurringExpenseApiTest.php b/tests/Feature/RecurringExpenseApiTest.php new file mode 100644 index 000000000000..586db2373426 --- /dev/null +++ b/tests/Feature/RecurringExpenseApiTest.php @@ -0,0 +1,215 @@ +makeTestData(); + + Session::start(); + + $this->faker = \Faker\Factory::create(); + + Model::reguard(); + } + + public function testRecurringExpenseGet() + { + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->get('/api/v1/recurring_expenses/'); + + $response->assertStatus(200); + } + + public function testRecurringExpenseGetSingleExpense() + { + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->get('/api/v1/recurring_expenses/'.$this->recurring_expense->hashed_id); + + $response->assertStatus(200); + } + + public function testRecurringExpensePost() + { + $data = [ + 'amount' => 10, + 'client_id' => $this->client->hashed_id, + 'number' => '123321', + 'frequency_id' => 5, + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->post('/api/v1/recurring_expenses', $data); + + $response->assertStatus(200); + + $arr = $response->json(); + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->put('/api/v1/recurring_expenses/'.$arr['data']['id'], $data)->assertStatus(200); + + try{ + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->post('/api/v1/recurring_expenses', $data); + } + catch(ValidationException $e){ + $response->assertStatus(302); + } + + } + + public function testRecurringExpensePut() + { + $data = [ + 'amount' => 20, + 'public_notes' => 'Coolio', + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->put('/api/v1/recurring_expenses/'.$this->encodePrimaryKey($this->recurring_expense->id), $data); + + $response->assertStatus(200); + } + + + public function testRecurringExpenseNotArchived() + { + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->get('/api/v1/recurring_expenses/'.$this->encodePrimaryKey($this->recurring_expense->id)); + + $arr = $response->json(); + + $this->assertEquals(0, $arr['data']['archived_at']); + } + + public function testRecurringExpenseArchived() + { + $data = [ + 'ids' => [$this->encodePrimaryKey($this->recurring_expense->id)], + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->post('/api/v1/recurring_expenses/bulk?action=archive', $data); + + $arr = $response->json(); + + $this->assertNotNull($arr['data'][0]['archived_at']); + } + + public function testRecurringExpenseRestored() + { + $data = [ + 'ids' => [$this->encodePrimaryKey($this->recurring_expense->id)], + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->post('/api/v1/recurring_expenses/bulk?action=restore', $data); + + $arr = $response->json(); + + $this->assertEquals(0, $arr['data'][0]['archived_at']); + } + + public function testRecurringExpenseDeleted() + { + $data = [ + 'ids' => [$this->encodePrimaryKey($this->recurring_expense->id)], + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->post('/api/v1/recurring_expenses/bulk?action=delete', $data); + + $arr = $response->json(); + + $this->assertTrue($arr['data'][0]['is_deleted']); + } + + public function testRecurringExpenseStart() + { + $data = [ + 'ids' => [$this->encodePrimaryKey($this->recurring_expense->id)], + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->post('/api/v1/recurring_expenses/bulk?action=start', $data); + + $arr = $response->json(); +nlog($arr); + $this->assertEquals(RecurringInvoice::STATUS_ACTIVE, $arr['data'][0]['status_id']); + } + + + public function testRecurringExpensePaused() + { + $data = [ + 'ids' => [$this->encodePrimaryKey($this->recurring_expense->id)], + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->post('/api/v1/recurring_expenses/bulk?action=start', $data); + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->post('/api/v1/recurring_expenses/bulk?action=stop', $data); + + $arr = $response->json(); +nlog($arr); + $this->assertEquals(RecurringInvoice::STATUS_PAUSED, $arr['data'][0]['status_id']); + } + + +} diff --git a/tests/Feature/RecurringQuotesTest.php b/tests/Feature/RecurringQuotesTest.php new file mode 100644 index 000000000000..78da5112771c --- /dev/null +++ b/tests/Feature/RecurringQuotesTest.php @@ -0,0 +1,195 @@ +faker = \Faker\Factory::create(); + + Model::reguard(); + + $this->withoutMiddleware( + ThrottleRequests::class + ); + + $this->makeTestData(); + } + + public function testRecurringQuoteList() + { + // Client::factory()->create(['user_id' => $this->user->id, 'company_id' => $this->company->id])->each(function ($c) { + // ClientContact::factory()->create([ + // 'user_id' => $this->user->id, + // 'client_id' => $c->id, + // 'company_id' => $this->company->id, + // 'is_primary' => 1, + // ]); + + // ClientContact::factory()->create([ + // 'user_id' => $this->user->id, + // 'client_id' => $c->id, + // 'company_id' => $this->company->id, + // ]); + // }); + + // $client = Client::all()->first(); + + // RecurringQuote::factory()->create(['user_id' => $this->user->id, 'company_id' => $this->company->id, 'client_id' => $this->client->id]); + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->get('/api/v1/recurring_quotes'); + + $response->assertStatus(200); + } + + public function testRecurringQuoteRESTEndPoints() + { + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->get('/api/v1/recurring_quotes/'.$this->recurring_quote->hashed_id); + + $response->assertStatus(200); + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->get('/api/v1/recurring_quotes/'.$this->recurring_quote->hashed_id.'/edit'); + + $response->assertStatus(200); + + $RecurringQuote_update = [ + 'status_id' => RecurringQuote::STATUS_DRAFT, + 'number' => 'customnumber' + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->put('/api/v1/recurring_quotes/'.$this->recurring_quote->hashed_id, $RecurringQuote_update); + + $response->assertStatus(200); + $arr = $response->json(); + + $this->assertEquals('customnumber', $arr['data']['number']); + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->put('/api/v1/recurring_quotes/'.$this->recurring_quote->hashed_id, $RecurringQuote_update) + ->assertStatus(200); + + $RecurringQuote_update = [ + 'status_id' => RecurringQuote::STATUS_DRAFT, + 'client_id' => $this->recurring_quote->hashed_id, + 'number' => 'customnumber' + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->post('/api/v1/recurring_quotes/', $RecurringQuote_update) + ->assertStatus(302); + + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->delete('/api/v1/recurring_quotes/'.$this->encodePrimaryKey($this->recurring_quote->id)); + + $response->assertStatus(200); + } + + public function testSubscriptionIdPassesToQuote() + { + $recurring_invoice = QuoteToRecurringQuoteFactory::create($this->quote); + $recurring_invoice->user_id = $this->user->id; + $recurring_invoice->next_send_date = \Carbon\Carbon::now()->addDays(10); + $recurring_invoice->status_id = RecurringQuote::STATUS_ACTIVE; + $recurring_invoice->remaining_cycles = 2; + $recurring_invoice->next_send_date = \Carbon\Carbon::now()->addDays(10); + $recurring_invoice->save(); + + $recurring_invoice->number = $this->getNextRecurringQuoteNumber($this->quote->client, $this->quote); + $recurring_invoice->subscription_id = 10; + $recurring_invoice->save(); + + $invoice = RecurringQuoteToQuoteFactory::create($recurring_invoice, $this->client); + + $this->assertEquals(10, $invoice->subscription_id); + } + + public function testSubscriptionIdPassesToQuoteIfNull() + { + $recurring_invoice = QuoteToRecurringQuoteFactory::create($this->quote); + $recurring_invoice->user_id = $this->user->id; + $recurring_invoice->next_send_date = \Carbon\Carbon::now()->addDays(10); + $recurring_invoice->status_id = RecurringQuote::STATUS_ACTIVE; + $recurring_invoice->remaining_cycles = 2; + $recurring_invoice->next_send_date = \Carbon\Carbon::now()->addDays(10); + $recurring_invoice->save(); + + $recurring_invoice->number = $this->getNextRecurringQuoteNumber($this->quote->client, $this->quote); + $recurring_invoice->save(); + + $invoice = RecurringQuoteToQuoteFactory::create($recurring_invoice, $this->client); + + $this->assertEquals(null, $invoice->subscription_id); + } + + public function testSubscriptionIdPassesToQuoteIfNothingSet() + { + $recurring_invoice = QuoteToRecurringQuoteFactory::create($this->quote); + $recurring_invoice->user_id = $this->user->id; + $recurring_invoice->next_send_date = \Carbon\Carbon::now()->addDays(10); + $recurring_invoice->status_id = RecurringQuote::STATUS_ACTIVE; + $recurring_invoice->remaining_cycles = 2; + $recurring_invoice->next_send_date = \Carbon\Carbon::now()->addDays(10); + $recurring_invoice->save(); + + $recurring_invoice->number = $this->getNextRecurringQuoteNumber($this->quote->client, $this->quote); + $recurring_invoice->save(); + + $invoice = RecurringQuoteToQuoteFactory::create($recurring_invoice, $this->client); + + $this->assertEquals(null, $invoice->subscription_id); + } +} diff --git a/tests/MockAccountData.php b/tests/MockAccountData.php index c1325fa854f8..e6c955aa0b47 100644 --- a/tests/MockAccountData.php +++ b/tests/MockAccountData.php @@ -36,7 +36,9 @@ use App\Models\Product; use App\Models\Project; use App\Models\Quote; use App\Models\QuoteInvitation; +use App\Models\RecurringExpense; use App\Models\RecurringInvoice; +use App\Models\RecurringQuote; use App\Models\Task; use App\Models\TaskStatus; use App\Models\User; @@ -83,6 +85,16 @@ trait MockAccountData */ public $token; + /** + * @var + */ + public $recurring_expense; + + /** + * @var + */ + public $recurring_quote; + /** * @var */ @@ -286,6 +298,20 @@ trait MockAccountData 'company_id' => $this->company->id, ]); + + $this->recurring_expense = RecurringExpense::factory()->create([ + 'user_id' => $user_id, + 'company_id' => $this->company->id, + 'frequency_id' => 5, + 'remaining_cycles' => 5, + ]); + + $this->recurring_quote = RecurringQuote::factory()->create([ + 'user_id' => $user_id, + 'company_id' => $this->company->id, + 'client_id' => $this->client->id, + ]); + $this->task = Task::factory()->create([ 'user_id' => $user_id, 'company_id' => $this->company->id, diff --git a/tests/Unit/RecurringExpenseCloneTest.php b/tests/Unit/RecurringExpenseCloneTest.php new file mode 100644 index 000000000000..6dee502b112c --- /dev/null +++ b/tests/Unit/RecurringExpenseCloneTest.php @@ -0,0 +1,69 @@ +faker = \Faker\Factory::create(); + + } + + public function testBadBase64String() + { + $account = Account::factory()->create(); + $user = User::factory()->create(['account_id' => $account->id, 'email' => $this->faker->unique()->safeEmail]); + $company = Company::factory()->create(['account_id' => $account->id]); + + $client = Client::factory()->create([ + 'user_id' => $user->id, + 'company_id' => $company->id, + ]); + + $recurring_expense = RecurringExpenseFactory::create($company->id, $user->id); + $recurring_expense->date = now(); + $recurring_expense->amount = 10; + $recurring_expense->foreign_amount = 20; + $recurring_expense->exchange_rate = 0.5; + $recurring_expense->private_notes = "private"; + $recurring_expense->public_notes = "public"; + $recurring_expense->custom_value4 = "custom4"; + $recurring_expense->should_be_invoiced = true; + + $recurring_expense->save(); + + $expense = RecurringExpenseToExpenseFactory::create($recurring_expense); + $expense->save(); + + $this->assertNotNull($expense); + $this->assertEquals(20, $expense->foreign_amount); + $this->assertEquals(10, $expense->amount); + } + +} \ No newline at end of file