From 0f661495dbc1fc89b9c4b0305643c4e31ade1062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Thu, 30 Jan 2020 02:27:22 +0100 Subject: [PATCH] Create 'Credits' module (#3263) * Create 'Credits' module * Various fixes on Credit module * Fix MarkCreditPaid factory --- app/Events/Credit/CreditWasCreated.php | 29 +++ app/Events/Credit/CreditWasEmailed.php | 25 +++ .../Credit/CreditWasEmailedAndFailed.php | 24 +++ app/Events/Credit/CreditWasUpdated.php | 24 +++ app/Factory/CloneCreditToQuoteFactory.php | 58 ++++++ app/Filters/CreditFilters.php | 192 ++++++++++++++++++ app/Http/Controllers/CreditController.php | 188 +++++++++++++++++ .../Requests/Credit/ActionCreditRequest.php | 30 +++ .../Requests/Credit/CreateCreditRequest.php | 31 +++ .../Requests/Credit/DestroyCreditRequest.php | 30 +++ .../Requests/Credit/EditCreditRequest.php | 30 +++ .../Requests/Credit/ShowCreditRequest.php | 30 +++ .../Requests/Credit/StoreCreditRequest.php | 31 +++ .../Requests/Credit/UpdateCreditRequest.php | 52 +++++ app/Jobs/Credit/EmailCredit.php | 102 ++++++++++ app/Jobs/Credit/MarkCreditPaid.php | 85 ++++++++ app/Jobs/Credit/StoreCredit.php | 57 ++++++ database/factories/CloneCreditFactory.php | 34 ++++ routes/api.php | 10 +- 19 files changed, 1060 insertions(+), 2 deletions(-) create mode 100644 app/Events/Credit/CreditWasCreated.php create mode 100644 app/Events/Credit/CreditWasEmailed.php create mode 100644 app/Events/Credit/CreditWasEmailedAndFailed.php create mode 100644 app/Events/Credit/CreditWasUpdated.php create mode 100644 app/Factory/CloneCreditToQuoteFactory.php create mode 100644 app/Filters/CreditFilters.php create mode 100644 app/Http/Controllers/CreditController.php create mode 100644 app/Http/Requests/Credit/ActionCreditRequest.php create mode 100644 app/Http/Requests/Credit/CreateCreditRequest.php create mode 100644 app/Http/Requests/Credit/DestroyCreditRequest.php create mode 100644 app/Http/Requests/Credit/EditCreditRequest.php create mode 100644 app/Http/Requests/Credit/ShowCreditRequest.php create mode 100644 app/Http/Requests/Credit/StoreCreditRequest.php create mode 100644 app/Http/Requests/Credit/UpdateCreditRequest.php create mode 100644 app/Jobs/Credit/EmailCredit.php create mode 100644 app/Jobs/Credit/MarkCreditPaid.php create mode 100644 app/Jobs/Credit/StoreCredit.php create mode 100644 database/factories/CloneCreditFactory.php diff --git a/app/Events/Credit/CreditWasCreated.php b/app/Events/Credit/CreditWasCreated.php new file mode 100644 index 000000000000..6e24a0c4408c --- /dev/null +++ b/app/Events/Credit/CreditWasCreated.php @@ -0,0 +1,29 @@ +credit = $credit; + $this->company = $company; + } +} diff --git a/app/Events/Credit/CreditWasEmailed.php b/app/Events/Credit/CreditWasEmailed.php new file mode 100644 index 000000000000..377226a48d24 --- /dev/null +++ b/app/Events/Credit/CreditWasEmailed.php @@ -0,0 +1,25 @@ +credit = $credit; + } +} diff --git a/app/Events/Credit/CreditWasEmailedAndFailed.php b/app/Events/Credit/CreditWasEmailedAndFailed.php new file mode 100644 index 000000000000..66ba51a78617 --- /dev/null +++ b/app/Events/Credit/CreditWasEmailedAndFailed.php @@ -0,0 +1,24 @@ +credit = $credit; + + $this->errors = $errors; + } +} diff --git a/app/Events/Credit/CreditWasUpdated.php b/app/Events/Credit/CreditWasUpdated.php new file mode 100644 index 000000000000..20ac02e1e777 --- /dev/null +++ b/app/Events/Credit/CreditWasUpdated.php @@ -0,0 +1,24 @@ +invoice = $credit; + $this->company = $company; + } +} diff --git a/app/Factory/CloneCreditToQuoteFactory.php b/app/Factory/CloneCreditToQuoteFactory.php new file mode 100644 index 000000000000..f8161f9a72be --- /dev/null +++ b/app/Factory/CloneCreditToQuoteFactory.php @@ -0,0 +1,58 @@ +client_id = $credit->client_id; + $quote->user_id = $user_id; + $quote->company_id = $credit->company_id; + $quote->discount = $credit->discount; + $quote->is_amount_discount = $credit->is_amount_discount; + $quote->po_number = $credit->po_number; + $quote->is_deleted = false; + $quote->backup = null; + $quote->footer = $credit->footer; + $quote->public_notes = $credit->public_notes; + $quote->private_notes = $credit->private_notes; + $quote->terms = $credit->terms; + $quote->tax_name1 = $credit->tax_name1; + $quote->tax_rate1 = $credit->tax_rate1; + $quote->tax_name2 = $credit->tax_name2; + $quote->tax_rate2 = $credit->tax_rate2; + $quote->custom_value1 = $credit->custom_value1; + $quote->custom_value2 = $credit->custom_value2; + $quote->custom_value3 = $credit->custom_value3; + $quote->custom_value4 = $credit->custom_value4; + $quote->amount = $credit->amount; + $quote->balance = $credit->balance; + $quote->partial = $credit->partial; + $quote->partial_due_date = $credit->partial_due_date; + $quote->last_viewed = $credit->last_viewed; + + $quote->status_id = Quote::STATUS_DRAFT; + $quote->number = ''; + $quote->date = null; + $quote->due_date = null; + $quote->partial_due_date = null; + $quote->balance = $credit->amount; + $quote->line_items = $credit->line_items; + + return $quote; + } +} diff --git a/app/Filters/CreditFilters.php b/app/Filters/CreditFilters.php new file mode 100644 index 000000000000..1c5a69db00a0 --- /dev/null +++ b/app/Filters/CreditFilters.php @@ -0,0 +1,192 @@ +builder; + } + + $status_parameters = explode(",", $value); + + if (in_array('all', $status_parameters)) { + return $this->builder; + } + + if (in_array('draft', $status_parameters)) { + $this->builder->where('status_id', Credit::STATUS_DRAFT); + } + + if (in_array('partial', $status_parameters)) { + $this->builder->where('status_id', Credit::STAUTS_PARTIAL); + } + + if(in_array('applied', $status_parameters)) { + $this->builder->where('status_id', Credit::STATUS_APPLIED); + } + + //->where('due_date', '>', Carbon::now()) + //->orWhere('partial_due_date', '>', Carbon::now()); + + return $this->builder; + } + + /** + * Filter based on search text + * + * @param string query filter + * @return Illuminate\Database\Query\Builder + * @deprecated + * + */ + public function filter(string $filter = '') : Builder + { + if (strlen($filter) == 0) { + return $this->builder; + } + + return $this->builder->where(function ($query) use ($filter) { + $query->where('credits.number', 'like', '%'.$filter.'%') + ->orWhere('credits.number', 'like', '%'.$filter.'%') + ->orWhere('credits.date', 'like', '%'.$filter.'%') + ->orWhere('credits.amount', 'like', '%'.$filter.'%') + ->orWhere('credits.balance', 'like', '%'.$filter.'%') + ->orWhere('credits.custom_value1', 'like', '%'.$filter.'%') + ->orWhere('credits.custom_value2', 'like', '%'.$filter.'%') + ->orWhere('credits.custom_value3', 'like', '%'.$filter.'%') + ->orWhere('credits.custom_value4', 'like', '%'.$filter.'%'); + }); + } + + /** + * Filters the list based on the status + * archived, active, deleted - legacy from V1 + * + * @param string filter + * @return Illuminate\Database\Query\Builder + */ + public function status(string $filter = '') : Builder + { + if (strlen($filter) == 0) { + return $this->builder; + } + + $table = 'credits'; + $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 Illuminate\Database\Query\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 + * @return Illuminate\Database\Query\Builder + * @deprecated + */ + public function baseQuery(int $company_id, User $user) : Builder + { + // .. + } + + /** + * Filters the query by the users company ID + * + * We need to ensure we are using the correct company ID + * as we could be hitting this from either the client or company auth guard + * + * @param $company_id The company Id + * @return Illuminate\Database\Query\Builder + */ + public function entityFilter() + { + if (auth('contact')->user()) { + return $this->contactViewFilter(); + } else { + return $this->builder->company(); + } + +// return $this->builder->whereCompanyId(auth()->user()->company()->id); + } + + /** + * We need additional filters when showing credits for the + * client portal. Need to automatically exclude drafts and cancelled credits + * + * @return Illuminate\Database\Query\Builder + */ + private function contactViewFilter() : Builder + { + return $this->builder + ->whereCompanyId(auth('contact')->user()->company->id) + ->whereNotIn('status_id', [Credit::STATUS_DRAFT]); + } +} diff --git a/app/Http/Controllers/CreditController.php b/app/Http/Controllers/CreditController.php new file mode 100644 index 000000000000..074bf1cd12a5 --- /dev/null +++ b/app/Http/Controllers/CreditController.php @@ -0,0 +1,188 @@ +credit_repository = $credit_repository; + } + + public function index(CreditFilters $filters) + { + $credits = Credit::filter($filters); + + return $this->listResponse($credits); + } + + public function create(CreateCreditRequest $request) + { + $credit = CreditFactory::create(auth()->user()->company()->id, auth()->user()->id); + + return $this->itemResponse($credit); + } + + public function store(StoreCreditRequest $request) + { + $credit = $this->credit_repository->save($request->all(), CreditFactory::create(auth()->user()->company()->id, auth()->user()->id)); + + $credit = StoreCredit::dispatchNow($credit, $request->all(), $credit->company); + + event(new CreditWasCreated($credit, $credit->company)); + + return $this->itemResponse($credit); + } + + public function show(ShowCreditRequest $request, Credit $credit) + { + return $this->itemResponse($credit); + } + + public function edit(EditCreditRequest $request, Credit $credit) + { + return $this->itemResponse($credit); + } + + public function update(UpdateCreditRequest $request, Credit $credit) + { + if($request->entityIsDeleted($credit)) + return $request->disallowUpdate(); + + $credit = $this->credit_repo->save($request->all(), $credit); + + event(new CreditWasUpdated($credit, $credit->company)); + + return $this->itemResponse($credit); + } + + public function destroy(DestroyCreditRequest $request, Credit $credit) + { + $credit->delete(); + + return response()->json([], 200); + } + + public function bulk() + { + $action = request()->input('action'); + + $ids = request()->input('ids'); + + $credits = Credit::withTrashed()->whereIn('id', $this->transformKeys($ids)); + + if (!$credits) { + return response()->json(['message' => 'No Credits Found']); + } + + $credits->each(function ($credit, $key) use ($action) { + if (auth()->user()->can('edit', $credit)) { + $this->performAction($credit, $action, true); + } + }); + + return $this->listResponse(Credit::withTrashed()->whereIn('id', $this->transformKeys($ids))); + } + + public function action(ActionCreditRequest $request, Credit $credit, $action) + { + return $this->performAction($credit, $action); + } + + private function performAction(Credit $credit, $action, $bulk = false) + { + /*If we are using bulk actions, we don't want to return anything */ + switch ($action) { + case 'clone_to_credit': + $credit = CloneCreditFactory::create($credit, auth()->user()->id); + return $this->itemResponse($credit); + break; + case 'clone_to_quote': + $quote = CloneCreditToQuoteFactory::create($credit, auth()->user()->id); + // todo build the quote transformer and return response here + break; + case 'history': + # code... + break; + case 'delivery_note': + # code... + break; + case 'mark_paid': + if ($credit->balance < 0 || $credit->status_id == Credit::STATUS_PAID || $credit->is_deleted === true) { + return $this->errorResponse(['message' => 'Credit cannot be marked as paid'], 400); + } + + $credit = MarkInvoicePaid::dispatchNow($credit, $credit->company); + + if (!$bulk) { + return $this->itemResponse($credit); + } + break; + case 'mark_sent': + $credit->markSent(); + + if (!$bulk) { + return $this->itemResponse($credit); + } + break; + case 'download': + return response()->download(public_path($credit->pdf_file_path())); + break; + case 'archive': + $this->credit_repo->archive($credit); + + if (!$bulk) { + return $this->listResponse($credit); + } + break; + case 'delete': + $this->credit_repo->delete($credit); + + if (!$bulk) { + return $this->listResponse($credit); + } + break; + case 'email': + EmailCredit::dispatch($credit, $credit->company); + if (!$bulk) { + return response()->json(['message'=>'email sent'], 200); + } + break; + + default: + return response()->json(['message' => "The requested action `{$action}` is not available."], 400); + break; + } + } +} diff --git a/app/Http/Requests/Credit/ActionCreditRequest.php b/app/Http/Requests/Credit/ActionCreditRequest.php new file mode 100644 index 000000000000..6889c663831a --- /dev/null +++ b/app/Http/Requests/Credit/ActionCreditRequest.php @@ -0,0 +1,30 @@ +user()->can('edit', $this->credit); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + // + ]; + } +} diff --git a/app/Http/Requests/Credit/CreateCreditRequest.php b/app/Http/Requests/Credit/CreateCreditRequest.php new file mode 100644 index 000000000000..686f130908d2 --- /dev/null +++ b/app/Http/Requests/Credit/CreateCreditRequest.php @@ -0,0 +1,31 @@ +user()->can('create', Credit::class); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + // + ]; + } +} diff --git a/app/Http/Requests/Credit/DestroyCreditRequest.php b/app/Http/Requests/Credit/DestroyCreditRequest.php new file mode 100644 index 000000000000..97d1f6dfc3a8 --- /dev/null +++ b/app/Http/Requests/Credit/DestroyCreditRequest.php @@ -0,0 +1,30 @@ +user()->can('edit', $this->credit); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + // + ]; + } +} diff --git a/app/Http/Requests/Credit/EditCreditRequest.php b/app/Http/Requests/Credit/EditCreditRequest.php new file mode 100644 index 000000000000..e1d099d236f9 --- /dev/null +++ b/app/Http/Requests/Credit/EditCreditRequest.php @@ -0,0 +1,30 @@ +user()->can('edit', $this->credit); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + // + ]; + } +} diff --git a/app/Http/Requests/Credit/ShowCreditRequest.php b/app/Http/Requests/Credit/ShowCreditRequest.php new file mode 100644 index 000000000000..808c45354e58 --- /dev/null +++ b/app/Http/Requests/Credit/ShowCreditRequest.php @@ -0,0 +1,30 @@ +user()->can('view', $this->credit); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + // + ]; + } +} diff --git a/app/Http/Requests/Credit/StoreCreditRequest.php b/app/Http/Requests/Credit/StoreCreditRequest.php new file mode 100644 index 000000000000..3d36d2c712a3 --- /dev/null +++ b/app/Http/Requests/Credit/StoreCreditRequest.php @@ -0,0 +1,31 @@ +user()->can('create', Credit::class); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + // + ]; + } +} diff --git a/app/Http/Requests/Credit/UpdateCreditRequest.php b/app/Http/Requests/Credit/UpdateCreditRequest.php new file mode 100644 index 000000000000..9e1d82dc052c --- /dev/null +++ b/app/Http/Requests/Credit/UpdateCreditRequest.php @@ -0,0 +1,52 @@ +user()->can('edit', $this->credit); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + 'documents' => 'mimes:png,ai,svg,jpeg,tiff,pdf,gif,psd,txt,doc,xls,ppt,xlsx,docx,pptx', + //'client_id' => 'required|integer', + //'invoice_type_id' => 'integer', + ]; + } + + protected function prepareForValidation() + { + $input = $this->all(); + + if (isset($input['client_id'])) { + $input['client_id'] = $this->decodePrimaryKey($input['client_id']); + } + + $input['line_items'] = isset($input['line_items']) ? $this->cleanItems($input['line_items']) : []; + + $this->replace($input); + } +} diff --git a/app/Jobs/Credit/EmailCredit.php b/app/Jobs/Credit/EmailCredit.php new file mode 100644 index 000000000000..569d04a512f6 --- /dev/null +++ b/app/Jobs/Credit/EmailCredit.php @@ -0,0 +1,102 @@ +credit = $credit; + + $this->company = $company; + } + + /** + * Execute the job. + * + * + * @return void + */ + public function handle() + { + /*Jobs are not multi-db aware, need to set! */ + MultiDB::setDB($this->company->db); + + //todo - change runtime config of mail driver if necessary + + $template_style = $this->credit->client->getSetting('email_style'); + + $this->credit->invitations->each(function ($invitation) use ($template_style) { + if ($invitation->contact->send_invoice && $invitation->contact->email) { + $message_array = $this->credit->getEmailData('', $invitation->contact); + $message_array['title'] = &$message_array['subject']; + $message_array['footer'] = "Sent to ".$invitation->contact->present()->name(); + + //change the runtime config of the mail provider here: + + //send message + Mail::to($invitation->contact->email, $invitation->contact->present()->name()) + ->send(new TemplateEmail($message_array, $template_style, $invitation->contact->user, $invitation->contact->client)); + + if (count(Mail::failures()) > 0) { + event(new CreditWasEmailedAndFailed($this->credit, Mail::failures())); + + return $this->logMailError($errors); + } + + //fire any events + event(new CreditWasEmailed($this->credit)); + + //sleep(5); + } + }); + } + + private function logMailError($errors) + { + SystemLogger::dispatch( + $errors, + SystemLog::CATEGORY_MAIL, + SystemLog::EVENT_MAIL_SEND, + SystemLog::TYPE_FAILURE, + $this->credit->client + ); + } +} diff --git a/app/Jobs/Credit/MarkCreditPaid.php b/app/Jobs/Credit/MarkCreditPaid.php new file mode 100644 index 000000000000..1815b347e8ed --- /dev/null +++ b/app/Jobs/Credit/MarkCreditPaid.php @@ -0,0 +1,85 @@ +credit = $credit; + $this->company = $company; + } + + /** + * Execute the job. + * + * + * @return void + */ + public function handle() + { + MultiDB::setDB($this->company->db); + + if($this->credit->status_id == Credit::STATUS_DRAFT) + $this->credit->markSent(); + + /* Create Payment */ + $payment = PaymentFactory::create($this->credit->company_id, $this->credit->user_id); + + $payment->amount = $this->credit->balance; + $payment->status_id = Credit::STATUS_COMPLETED; + $payment->client_id = $this->credit->client_id; + $payment->transaction_reference = ctrans('texts.manual_entry'); + /* Create a payment relationship to the invoice entity */ + $payment->save(); + + $payment->credits()->attach($this->credit->id, [ + 'amount' => $payment->amount + ]); + + $this->credit->updateBalance($payment->amount*-1); + + /* Update Credit balance */ + event(new PaymentWasCreated($payment, $payment->company)); + + // UpdateCompanyLedgerWithPayment::dispatchNow($payment, ($payment->amount*-1), $this->company); + // UpdateClientBalance::dispatchNow($payment->client, $payment->amount*-1, $this->company); + // UpdateClientPaidToDate::dispatchNow($payment->client, $payment->amount, $this->company); + + return $this->credit; + } +} diff --git a/app/Jobs/Credit/StoreCredit.php b/app/Jobs/Credit/StoreCredit.php new file mode 100644 index 000000000000..87ae46e33d98 --- /dev/null +++ b/app/Jobs/Credit/StoreCredit.php @@ -0,0 +1,57 @@ +credit = $credit; + + $this->data = $data; + + $this->company = $company; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle(CreditRepository $credit_repository): ?Credit + { + MultiDB::setDB($this->company->db); + + $payment = false; + + if ($payment) { + PaymentNotification::dispatch($payment, $payment->company); + } + + return $this->credit; + } +} diff --git a/database/factories/CloneCreditFactory.php b/database/factories/CloneCreditFactory.php new file mode 100644 index 000000000000..f15c65a139e0 --- /dev/null +++ b/database/factories/CloneCreditFactory.php @@ -0,0 +1,34 @@ +replicate(); + $clone_credit->status_id = credit::STATUS_DRAFT; + $clone_credit->number = null; + $clone_credit->date = null; + $clone_credit->due_date = null; + $clone_credit->partial_due_date = null; + $clone_credit->user_id = $user_id; + $clone_credit->balance = $credit->amount; + $clone_credit->line_items = $credit->line_items; + $clone_credit->backup = null; + + return $clone_credit; + } +} diff --git a/routes/api.php b/routes/api.php index e2b734ef2967..ce5e073f2cf1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -41,11 +41,17 @@ Route::group(['middleware' => ['api_db', 'api_secret_check', 'token_auth', 'loca Route::post('clients/bulk', 'ClientController@bulk')->name('clients.bulk'); Route::resource('invoices', 'InvoiceController'); // name = (invoices. index / create / show / update / destroy / edit - + Route::get('invoices/{invoice}/{action}', 'InvoiceController@action')->name('invoices.action'); - + Route::post('invoices/bulk', 'InvoiceController@bulk')->name('invoices.bulk'); + Route::resource('credits', 'CreditsController'); // name = (credits. index / create / show / update / destroy / edit + + Route::get('credits/{credit}/{action}', 'CreditController@action')->name('credits.action'); + + Route::post('credits/bulk', 'CreditController@bulk')->name('credits.bulk'); + Route::resource('products', 'ProductController'); // name = (products. index / create / show / update / destroy / edit Route::post('products/bulk', 'ProductController@bulk')->name('products.bulk');