diff --git a/VERSION.txt b/VERSION.txt index ca69410c4af0..67fd02cd7eea 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -5.7.10 \ No newline at end of file +5.7.11 \ No newline at end of file diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index b87ea25c7a77..9da20b1020c9 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -107,7 +107,7 @@ class Kernel extends ConsoleKernel $schedule->job(new AdjustEmailQuota)->dailyAt('23:30')->withoutOverlapping(); /* Pulls in bank transactions from third party services */ - $schedule->job(new BankTransactionSync)->dailyAt('04:10')->withoutOverlapping()->name('bank-trans-sync-job')->onOneServer(); + $schedule->job(new BankTransactionSync)->everyFourHours()->withoutOverlapping()->name('bank-trans-sync-job')->onOneServer(); $schedule->command('ninja:check-data --database=db-ninja-01')->dailyAt('02:10')->withoutOverlapping()->name('check-data-db-1-job')->onOneServer(); diff --git a/app/DataMapper/Tax/BaseRule.php b/app/DataMapper/Tax/BaseRule.php index efca1de3573a..36635429a6d7 100644 --- a/app/DataMapper/Tax/BaseRule.php +++ b/app/DataMapper/Tax/BaseRule.php @@ -363,7 +363,15 @@ class BaseRule implements RuleInterface public function override($item): self { + $this->tax_rate1 = $item->tax_rate1; + $this->tax_name1 = $item->tax_name1; + $this->tax_rate2 = $item->tax_rate2; + $this->tax_name2 = $item->tax_name2; + $this->tax_rate3 = $item->tax_rate3; + $this->tax_name3 = $item->tax_name3; + return $this; + } public function calculateRates(): self diff --git a/app/DataMapper/Tax/US/Rule.php b/app/DataMapper/Tax/US/Rule.php index 469d16c74a6f..e880027201ea 100644 --- a/app/DataMapper/Tax/US/Rule.php +++ b/app/DataMapper/Tax/US/Rule.php @@ -49,6 +49,10 @@ class Rule extends BaseRule implements RuleInterface $this->tax_rate1 = $item->tax_rate1; $this->tax_name1 = $item->tax_name1; + $this->tax_rate2 = $item->tax_rate2; + $this->tax_name2 = $item->tax_name2; + $this->tax_rate3 = $item->tax_rate3; + $this->tax_name3 = $item->tax_name3; return $this; diff --git a/app/Http/Controllers/ClientPortal/SwitchCompanyController.php b/app/Http/Controllers/ClientPortal/SwitchCompanyController.php index 8f6dc09366dd..ec0f15541f7a 100644 --- a/app/Http/Controllers/ClientPortal/SwitchCompanyController.php +++ b/app/Http/Controllers/ClientPortal/SwitchCompanyController.php @@ -22,9 +22,10 @@ class SwitchCompanyController extends Controller public function __invoke(string $contact) { - $client_contact = ClientContact::where('email', auth()->user()->email) - ->where('id', $this->transformKeys($contact)) - ->first(); + $client_contact = ClientContact::query() + ->where('email', auth()->user()->email) + ->where('id', $this->transformKeys($contact)) + ->firstOrFail(); auth()->guard('contact')->loginUsingId($client_contact->id, true); diff --git a/app/Http/Controllers/CompanyUserController.php b/app/Http/Controllers/CompanyUserController.php index c3d1c6ed45b1..69de122d5f4b 100644 --- a/app/Http/Controllers/CompanyUserController.php +++ b/app/Http/Controllers/CompanyUserController.php @@ -115,7 +115,7 @@ class CompanyUserController extends BaseController $auth_user = auth()->user(); $company = $auth_user->company(); - $company_user = CompanyUser::whereUserId($user->id)->whereCompanyId($company->id)->first(); + $company_user = CompanyUser::query()->where('user_id', $user->id)->where('company_id',$company->id)->first(); if (! $company_user) { throw new ModelNotFoundException(ctrans('texts.company_user_not_found')); @@ -128,6 +128,11 @@ class CompanyUserController extends BaseController } else { $company_user->settings = $request->input('company_user')['settings']; $company_user->notifications = $request->input('company_user')['notifications']; + + if(isset($request->input('company_user')['react_settings'])) { + $company_user->react_settings = $request->input('company_user')['react_settings']; + } + } $company_user->save(); diff --git a/app/Http/Controllers/ImportController.php b/app/Http/Controllers/ImportController.php index f6ae18029457..2871bc41e7b5 100644 --- a/app/Http/Controllers/ImportController.php +++ b/app/Http/Controllers/ImportController.php @@ -120,36 +120,45 @@ class ImportController extends Controller foreach($headers as $key => $value) { - - $hit = false; - $unsetKey = false; - // array_multisort(array_column($translated_keys, 'label'), SORT_ASC, $translated_keys); foreach($translated_keys as $tkey => $tvalue) { if($this->testMatch($value, $tvalue['label'])) { - $hit = $available_keys[$tvalue['key']]; - $unsetKey = $tkey; + $hit = $tvalue['key']; + $hints[$key] = $hit; + unset($translated_keys[$tkey]); + break; + } + else { + $hints[$key] = null; } - // elseif($this->testMatch($value, $tvalue['index'])) { - // $hit = $available_keys[$tvalue['key']]; - // $unsetKey = $tkey; - // } } - if($hit) { - $hints[$key] = $hit; - unset($translated_keys[$unsetKey]); - } else { - $hints[$key] = null; - } - } - nlog($translated_keys); + //second pass using the index of the translation here + foreach($headers as $key => $value) + { + if(isset($hints[$key])) { + continue; + } + + foreach($translated_keys as $tkey => $tvalue) + { + if($this->testMatch($value, $tvalue['index'])) { + $hit = $tvalue['key']; + $hints[$key] = $hit; + unset($translated_keys[$tkey]); + break; + } else { + $hints[$key] = null; + } + } + + } return $hints; } diff --git a/app/Http/Controllers/QuoteController.php b/app/Http/Controllers/QuoteController.php index 4d06e8bc5ca6..301307372ae6 100644 --- a/app/Http/Controllers/QuoteController.php +++ b/app/Http/Controllers/QuoteController.php @@ -16,6 +16,7 @@ use App\Models\Quote; use App\Models\Client; use App\Models\Account; use App\Models\Invoice; +use App\Models\Project; use Illuminate\Http\Request; use App\Factory\QuoteFactory; use App\Filters\QuoteFilters; @@ -33,6 +34,7 @@ use App\Transformers\QuoteTransformer; use App\Utils\Traits\GeneratesCounter; use Illuminate\Support\Facades\Storage; use App\Transformers\InvoiceTransformer; +use App\Transformers\ProjectTransformer; use App\Factory\CloneQuoteToInvoiceFactory; use App\Factory\CloneQuoteToProjectFactory; use App\Http\Requests\Quote\EditQuoteRequest; @@ -555,7 +557,7 @@ class QuoteController extends BaseController } }); - return $this->listResponse(Quote::withTrashed()->whereIn('id', $this->transformKeys($ids))->company()); + return $this->listResponse(Quote::query()->withTrashed()->whereIn('id', $this->transformKeys($ids))->company()); } if ($action == 'bulk_print' && $user->can('view', $quotes->first())) { @@ -585,7 +587,7 @@ class QuoteController extends BaseController } }); - return $this->listResponse(Quote::withTrashed()->whereIn('id', $this->transformKeys($ids))->company()); + return $this->listResponse(Quote::query()->withTrashed()->whereIn('id', $this->transformKeys($ids))->company()); } /* @@ -683,7 +685,14 @@ class QuoteController extends BaseController private function performAction(Quote $quote, $action, $bulk = false) { switch ($action) { - case 'convert': + case 'convert_to_project': + + $this->entity_type = Project::class; + $this->entity_transformer = ProjectTransformer::class; + + return $this->itemResponse($quote->service()->convertToProject()); + + case 'convert': case 'convert_to_invoice': $this->entity_type = Invoice::class; @@ -691,8 +700,6 @@ class QuoteController extends BaseController return $this->itemResponse($quote->service()->convertToInvoice()); - break; - case 'clone_to_invoice': $this->entity_type = Invoice::class; @@ -701,19 +708,19 @@ class QuoteController extends BaseController $invoice = CloneQuoteToInvoiceFactory::create($quote, auth()->user()->id); return $this->itemResponse($invoice); - break; + case 'clone_to_quote': $quote = CloneQuoteFactory::create($quote, auth()->user()->id); return $this->itemResponse($quote); - break; + case 'approve': if (! in_array($quote->status_id, [Quote::STATUS_SENT, Quote::STATUS_DRAFT])) { return response()->json(['message' => ctrans('texts.quote_unapprovable')], 400); } return $this->itemResponse($quote->service()->approveWithNoCoversion()->save()); - break; + case 'history': // code... break; @@ -725,16 +732,14 @@ class QuoteController extends BaseController echo Storage::get($file); }, basename($file), ['Content-Type' => 'application/pdf']); - - break; case 'restore': $this->quote_repo->restore($quote); if (! $bulk) { return $this->itemResponse($quote); } - break; + case 'archive': $this->quote_repo->archive($quote); @@ -752,16 +757,11 @@ class QuoteController extends BaseController break; case 'email': - $quote->service()->sendEmail(); - - return response()->json(['message'=> ctrans('texts.sent_message')], 200); - break; - case 'send_email': + $quote->service()->sendEmail(); return response()->json(['message'=> ctrans('texts.sent_message')], 200); - break; case 'mark_sent': $quote->service()->markSent()->save(); diff --git a/app/Http/Requests/CompanyUser/UpdateCompanyUserRequest.php b/app/Http/Requests/CompanyUser/UpdateCompanyUserRequest.php index 8db3228d423a..380d063ea347 100644 --- a/app/Http/Requests/CompanyUser/UpdateCompanyUserRequest.php +++ b/app/Http/Requests/CompanyUser/UpdateCompanyUserRequest.php @@ -25,7 +25,10 @@ class UpdateCompanyUserRequest extends Request */ public function authorize() : bool { - return auth()->user()->isAdmin() || (auth()->user()->id == $this->user->id); + /** @var \App\Models\User $auth_user */ + $auth_user = auth()->user(); + + return $auth_user->isAdmin() || ($auth_user->id == $this->user->id); } public function rules() diff --git a/app/Http/Requests/Quote/BulkActionQuoteRequest.php b/app/Http/Requests/Quote/BulkActionQuoteRequest.php index b3fb8857f3bc..c9f41313a568 100644 --- a/app/Http/Requests/Quote/BulkActionQuoteRequest.php +++ b/app/Http/Requests/Quote/BulkActionQuoteRequest.php @@ -30,9 +30,11 @@ class BulkActionQuoteRequest extends Request { $input = $this->all(); - $rules = []; + $rules = [ + 'action' => 'sometimes|in:convert_to_invoice,convert_to_project,email,bulk_download,bulk_print,clone_to_invoice,approve,download,restore,archive,delete,send_email,mark_sent', + ]; - if ($input['action'] == 'convert_to_invoice') { + if (in_array($input['action'], ['convert,convert_to_invoice']) ) { $rules['action'] = [new ConvertableQuoteRule()]; } diff --git a/app/Http/Requests/Task/StoreTaskRequest.php b/app/Http/Requests/Task/StoreTaskRequest.php index 6dab0aa252c7..52d331d3ab92 100644 --- a/app/Http/Requests/Task/StoreTaskRequest.php +++ b/app/Http/Requests/Task/StoreTaskRequest.php @@ -28,23 +28,30 @@ class StoreTaskRequest extends Request */ public function authorize() : bool { - return auth()->user()->can('create', Task::class); + /** @var \App\Models\User $user */ + $user = auth()->user(); + + return $user->can('create', Task::class); } public function rules() { + + /** @var \App\Models\User $user */ + $user = auth()->user(); + $rules = []; if (isset($this->number)) { - $rules['number'] = Rule::unique('tasks')->where('company_id', auth()->user()->company()->id); + $rules['number'] = Rule::unique('tasks')->where('company_id', $user->company()->id); } if (isset($this->client_id)) { - $rules['client_id'] = 'bail|required|exists:clients,id,company_id,'.auth()->user()->company()->id.',is_deleted,0'; + $rules['client_id'] = 'bail|required|exists:clients,id,company_id,'.$user->company()->id.',is_deleted,0'; } if (isset($this->project_id)) { - $rules['project_id'] = 'bail|required|exists:projects,id,company_id,'.auth()->user()->company()->id.',is_deleted,0'; + $rules['project_id'] = 'bail|required|exists:projects,id,company_id,'.$user->company()->id.',is_deleted,0'; } $rules['timelog'] = ['bail','array',function ($attribute, $values, $fail) { @@ -77,9 +84,9 @@ class StoreTaskRequest extends Request public function prepareForValidation() { - $input = $this->all(); - $input = $this->decodePrimaryKeys($this->all()); + $input = $this->decodePrimaryKeys($this->all()); + if (array_key_exists('status_id', $input) && is_string($input['status_id'])) { $input['status_id'] = $this->decodePrimaryKey($input['status_id']); } diff --git a/app/Mail/Admin/EntitySentObject.php b/app/Mail/Admin/EntitySentObject.php index da2c1030fbc1..e1ae21c90de9 100644 --- a/app/Mail/Admin/EntitySentObject.php +++ b/app/Mail/Admin/EntitySentObject.php @@ -73,7 +73,7 @@ class EntitySentObject ); $mail_obj->data = [ 'title' => $mail_obj->subject, - 'message' => ctrans( + 'content' => ctrans( $this->template_body, [ 'amount' => $mail_obj->amount, @@ -98,7 +98,7 @@ class EntitySentObject $mail_obj->markdown = 'email.admin.generic'; $mail_obj->tag = $this->company->company_key; } - + nlog($mail_obj); return $mail_obj; } @@ -186,7 +186,7 @@ class EntitySentObject return [ 'title' => $this->getSubject(), - 'message' => $this->getMessage(), + 'content' => $this->getMessage(), 'url' => $this->invitation->getAdminLink($this->use_react_url), 'button' => ctrans("texts.view_{$this->entity_type}"), 'signature' => $settings->email_signature, diff --git a/app/Models/Task.php b/app/Models/Task.php index caee4ca8a374..be0c86135e8a 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -226,4 +226,15 @@ class Task extends BaseModel { return ctrans('texts.task'); } + + public function getRate(): float + { + if($this->project && $this->project->task_rate > 0) + return $this->project->task_rate; + + if($this->client) + return $this->client->getSetting('default_task_rate'); + + return $this->company->settings->default_task_rate ?? 0; + } } diff --git a/app/PaymentDrivers/Authorize/RefundTransaction.php b/app/PaymentDrivers/Authorize/RefundTransaction.php index 462d4f4236cc..998ffbad33a9 100644 --- a/app/PaymentDrivers/Authorize/RefundTransaction.php +++ b/app/PaymentDrivers/Authorize/RefundTransaction.php @@ -12,14 +12,16 @@ namespace App\PaymentDrivers\Authorize; -use App\Jobs\Util\SystemLogger; use App\Models\Payment; use App\Models\SystemLog; +use App\Jobs\Util\SystemLogger; use App\PaymentDrivers\AuthorizePaymentDriver; -use net\authorize\api\contract\v1\CreateTransactionRequest; -use net\authorize\api\contract\v1\CustomerProfilePaymentType; +use net\authorize\api\contract\v1\PaymentType; +use net\authorize\api\contract\v1\CreditCardType; use net\authorize\api\contract\v1\PaymentProfileType; use net\authorize\api\contract\v1\TransactionRequestType; +use net\authorize\api\contract\v1\CreateTransactionRequest; +use net\authorize\api\contract\v1\CustomerProfilePaymentType; use net\authorize\api\controller\CreateTransactionController; /** @@ -43,24 +45,42 @@ class RefundTransaction $transaction_details = $this->authorize_transaction->getTransactionDetails($payment->transaction_reference); + $creditCard = $transaction_details->getTransaction()->getPayment()->getCreditCard(); + $creditCardNumber = $creditCard->getCardNumber(); + $creditCardExpiry = $creditCard->getExpirationDate(); + $transaction_status = $transaction_details->getTransaction()->getTransactionStatus(); + + $transaction_type = $transaction_status == 'capturedPendingSettlement' ? 'voidTransaction' : 'refundTransaction'; + + if($transaction_type == 'voidTransaction') { + $amount = $transaction_details->getTransaction()->getAuthAmount(); + } + $this->authorize->init(); // Set the transaction's refId $refId = 'ref'.time(); - $paymentProfile = new PaymentProfileType(); - $paymentProfile->setPaymentProfileId($transaction_details->getTransaction()->getProfile()->getCustomerPaymentProfileId()); + // $paymentProfile = new PaymentProfileType(); + // $paymentProfile->setPaymentProfileId($transaction_details->getTransaction()->getProfile()->getCustomerPaymentProfileId()); - // set customer profile - $customerProfile = new CustomerProfilePaymentType(); - $customerProfile->setCustomerProfileId($transaction_details->getTransaction()->getProfile()->getCustomerProfileId()); - $customerProfile->setPaymentProfile($paymentProfile); + // // // set customer profile + // $customerProfile = new CustomerProfilePaymentType(); + // $customerProfile->setCustomerProfileId($transaction_details->getTransaction()->getProfile()->getCustomerProfileId()); + // $customerProfile->setPaymentProfile($paymentProfile); + + $creditCard = new CreditCardType(); + $creditCard->setCardNumber($creditCardNumber); + $creditCard->setExpirationDate($creditCardExpiry); + $paymentOne = new PaymentType(); + $paymentOne->setCreditCard($creditCard); //create a transaction $transactionRequest = new TransactionRequestType(); - $transactionRequest->setTransactionType('refundTransaction'); + $transactionRequest->setTransactionType($transaction_type); $transactionRequest->setAmount($amount); - $transactionRequest->setProfile($customerProfile); + // $transactionRequest->setProfile($customerProfile); + $transactionRequest->setPayment($paymentOne); $transactionRequest->setRefTransId($payment->transaction_reference); $request = new CreateTransactionRequest(); @@ -83,6 +103,7 @@ class RefundTransaction 'transaction_response' => $tresponse->getResponseCode(), 'payment_id' => $payment->id, 'amount' => $amount, + 'voided' => $transaction_status == 'capturedPendingSettlement' ? true : false, ]; SystemLogger::dispatch($data, SystemLog::CATEGORY_GATEWAY_RESPONSE, SystemLog::EVENT_GATEWAY_SUCCESS, SystemLog::TYPE_AUTHORIZE, $this->authorize->client, $this->authorize->client->company); @@ -166,4 +187,5 @@ class RefundTransaction SystemLogger::dispatch($data, SystemLog::CATEGORY_GATEWAY_RESPONSE, SystemLog::EVENT_GATEWAY_FAILURE, SystemLog::TYPE_AUTHORIZE, $this->authorize->client, $this->authorize->client->company); } + } diff --git a/app/Repositories/TaskRepository.php b/app/Repositories/TaskRepository.php index f3f5642dc198..dbb954d35a60 100644 --- a/app/Repositories/TaskRepository.php +++ b/app/Repositories/TaskRepository.php @@ -45,7 +45,11 @@ class TaskRepository extends BaseRepository $task->saveQuietly(); if ($this->new_task && ! $task->status_id) { - $this->setDefaultStatus($task); + $task->status_id = $this->setDefaultStatus($task); + } + + if($this->new_task && (!$task->rate || $task->rate <= 0)) { + $task->rate = $task->getRate(); } $task->number = empty($task->number) || ! array_key_exists('number', $data) ? $this->trySaving($task) : $data['number']; diff --git a/app/Services/Credit/SendEmail.php b/app/Services/Credit/SendEmail.php index 40ce3240f438..f17f5f902dc7 100644 --- a/app/Services/Credit/SendEmail.php +++ b/app/Services/Credit/SendEmail.php @@ -11,8 +11,10 @@ namespace App\Services\Credit; -use App\Jobs\Entity\EmailEntity; +use App\Utils\Ninja; use App\Models\ClientContact; +use App\Jobs\Entity\EmailEntity; +use App\Events\Credit\CreditWasEmailed; class SendEmail { @@ -40,12 +42,17 @@ class SendEmail $this->reminder_template = $this->credit->calculateTemplate('credit'); } + $this->credit->service()->markSent()->save(); + $this->credit->invitations->each(function ($invitation) { if (! $invitation->contact->trashed() && $invitation->contact->email) { EmailEntity::dispatch($invitation, $invitation->company, $this->reminder_template)->delay(2); } }); - $this->credit->service()->markSent()->save(); + if ($this->credit->invitations->count() >= 1) { + event(new CreditWasEmailed($this->credit->invitations->first(), $this->credit->company, Ninja::eventVars(auth()->user() ? auth()->user()->id : null), 'credit')); + } + } } diff --git a/app/Services/Invoice/SendEmail.php b/app/Services/Invoice/SendEmail.php index 5cf05dd6a3d9..7cbc7a0cc0ee 100644 --- a/app/Services/Invoice/SendEmail.php +++ b/app/Services/Invoice/SendEmail.php @@ -11,10 +11,12 @@ namespace App\Services\Invoice; -use App\Jobs\Entity\EmailEntity; -use App\Models\ClientContact; +use App\Utils\Ninja; use App\Models\Invoice; +use App\Models\ClientContact; +use App\Jobs\Entity\EmailEntity; use App\Services\AbstractService; +use App\Events\Invoice\InvoiceWasEmailed; class SendEmail extends AbstractService { @@ -36,5 +38,10 @@ class SendEmail extends AbstractService EmailEntity::dispatch($invitation, $invitation->company, $this->reminder_template)->delay(10); } }); + + if ($this->invoice->invitations->count() >= 1) { + event(new InvoiceWasEmailed($this->invoice->invitations->first(), $this->invoice->company, Ninja::eventVars(auth()->user() ? auth()->user()->id : null), $this->reminder_template ?? 'invoice')); + } + } } diff --git a/app/Services/Payment/RefundPayment.php b/app/Services/Payment/RefundPayment.php index fe56ef089145..007f1df6efbf 100644 --- a/app/Services/Payment/RefundPayment.php +++ b/app/Services/Payment/RefundPayment.php @@ -35,6 +35,10 @@ class RefundPayment private $activity_repository; + private bool $refund_failed = false; + + private string $refund_failed_message = ''; + public function __construct($payment, $refund_data) { $this->payment = $payment; @@ -50,12 +54,14 @@ class RefundPayment public function run() { - $this->payment = $this->calculateTotalRefund() //sets amount for the refund (needed if we are refunding multiple invoices in one payment) + $this->payment = $this + ->calculateTotalRefund() //sets amount for the refund (needed if we are refunding multiple invoices in one payment) + ->processGatewayRefund() //process the gateway refund if needed ->setStatus() //sets status of payment ->updateCreditables() //return the credits first ->updatePaymentables() //update the paymentable items ->adjustInvoices() - ->processGatewayRefund() //process the gateway refund if needed + ->finalize() ->save(); if (array_key_exists('email_receipt', $this->refund_data) && $this->refund_data['email_receipt'] == 'true') { @@ -71,9 +77,28 @@ class RefundPayment return $this->payment; } + private function finalize(): self + { + if($this->refund_failed) + throw new PaymentRefundFailed($this->refund_failed_message); + + return $this; + } + /** * Process the refund through the gateway. * + * @var array $response + * [ + * 'transaction_reference' => (string), + * 'transaction_response' => (string), + * 'success' => (bool), + * 'description' => (string), + * 'code' => (string), + * 'payment_id' => (int), + * 'amount' => (float), + * ]; + * * @return $this * @throws PaymentRefundFailed */ @@ -83,16 +108,27 @@ class RefundPayment if ($this->payment->company_gateway) { $response = $this->payment->company_gateway->driver($this->payment->client)->refund($this->payment, $this->total_refund); + if($response['amount'] ?? false) + $this->total_refund = $response['amount']; + + if($response['voided'] ?? false) + { + //When a transaction is voided - all invoices attached to the payment need to be reversed, this + //block prevents the edge case where a partial refund was attempted. + $this->refund_data['invoices'] = $this->payment->invoices->map(function ($invoice){ + return [ + 'invoice_id' => $invoice->id, + 'amount' => $invoice->pivot->amount, + ]; + })->toArray(); + } + $this->payment->refunded += $this->total_refund; if ($response['success'] == false) { $this->payment->save(); - - if (array_key_exists('description', $response)) { - throw new PaymentRefundFailed($response['description']); - } else { - throw new PaymentRefundFailed(); - } + $this->refund_failed = true; + $this->refund_failed_message = $response['description'] ?? ''; } } } else { diff --git a/app/Services/PdfMaker/Design.php b/app/Services/PdfMaker/Design.php index c55fadf26c47..d0e54a9b1891 100644 --- a/app/Services/PdfMaker/Design.php +++ b/app/Services/PdfMaker/Design.php @@ -932,7 +932,9 @@ class Design extends BaseDesign } elseif (Str::startsWith($variable, '$custom_surcharge')) { $_variable = ltrim($variable, '$'); // $custom_surcharge1 -> custom_surcharge1 - $visible = intval($this->entity->{$_variable}) != 0; + //07/09/2023 don't show custom values if they are empty + // $visible = intval($this->entity->{$_variable}) != 0; + $visible = intval(str_replace(['0','.'],'', $this->entity->{$_variable})) != 0; $elements[1]['elements'][] = ['element' => 'div', 'elements' => [ ['element' => 'span', 'content' => $variable . '_label', 'properties' => ['hidden' => !$visible, 'data-ref' => 'totals_table-' . substr($variable, 1) . '-label']], diff --git a/app/Services/Quote/ConvertQuoteToProject.php b/app/Services/Quote/ConvertQuoteToProject.php new file mode 100644 index 000000000000..d74b05f49ebd --- /dev/null +++ b/app/Services/Quote/ConvertQuoteToProject.php @@ -0,0 +1,76 @@ +quote->line_items)->filter(function ($item){ + return $item->type_id == '2'; + }); + + $project = ProjectFactory::create($this->quote->company_id, $this->quote->user_id); + $project->name = ctrans('texts.quote_number_short'). " " . $this->quote->number . "[{$this->quote->client->present()->name()}]"; + $project->client_id = $this->quote->client_id; + $project->public_notes = $this->quote->public_notes; + $project->private_notes = $this->quote->private_notes; + $project->budgeted_hours = $quote_items->sum('quantity') ?? 0; + $project->task_rate = $this->quote->client->getSetting('default_task_rate'); + $project->saveQuietly(); + $project->number = $this->getNextProjectNumber($project); + $project->saveQuietly(); + + $this->quote->project_id = $project->id; + $this->quote->saveQuietly(); + + $task_status = $this->quote->company->task_statuses() + ->whereNull('deleted_at') + ->orderBy('id', 'asc') + ->first(); + + + $task_repo = new TaskRepository(); + + $quote_items->each(function($item) use($task_repo, $task_status){ + + $task = TaskFactory::create($this->quote->company_id, $this->quote->user_id); + $task->client_id = $this->quote->client_id; + $task->project_id = $this->quote->project_id; + $task->description = $item->notes; + $task->status_id = $task_status->id; + $task->rate = $item->unit_cost; + $task_repo->save([], $task); + + }); + + event('eloquent.created: App\Models\Project', $project); + + return $project->fresh(); + } +} \ No newline at end of file diff --git a/app/Services/Quote/QuoteService.php b/app/Services/Quote/QuoteService.php index 908cb257c151..e12168af52e1 100644 --- a/app/Services/Quote/QuoteService.php +++ b/app/Services/Quote/QuoteService.php @@ -13,13 +13,14 @@ namespace App\Services\Quote; use App\Utils\Ninja; use App\Models\Quote; -use App\Jobs\Util\UnlinkFile; +use App\Models\Project; use App\Utils\Traits\MakesHash; use App\Exceptions\QuoteConversion; use App\Jobs\Entity\CreateEntityPdf; use App\Repositories\QuoteRepository; use App\Events\Quote\QuoteWasApproved; use Illuminate\Support\Facades\Storage; +use App\Services\Quote\ConvertQuoteToProject; class QuoteService { @@ -41,6 +42,13 @@ class QuoteService return $this; } + public function convertToProject(): Project + { + $project = (new ConvertQuoteToProject($this->quote))->run(); + + return $project; + } + public function convert() :self { if ($this->quote->invoice_id) { diff --git a/app/Services/Quote/SendEmail.php b/app/Services/Quote/SendEmail.php index 445d321ff97c..d157792aa0c6 100644 --- a/app/Services/Quote/SendEmail.php +++ b/app/Services/Quote/SendEmail.php @@ -11,8 +11,10 @@ namespace App\Services\Quote; -use App\Jobs\Entity\EmailEntity; +use App\Utils\Ninja; use App\Models\ClientContact; +use App\Jobs\Entity\EmailEntity; +use App\Events\Quote\QuoteWasEmailed; class SendEmail { @@ -42,15 +44,15 @@ class SendEmail $this->reminder_template = $this->quote->calculateTemplate('quote'); } - $this->quote->service()->markSent()->save(); $this->quote->invitations->each(function ($invitation) { if (! $invitation->contact->trashed() && $invitation->contact->email) { EmailEntity::dispatch($invitation, $invitation->company, $this->reminder_template); - - // MailEntity::dispatch($invitation, $invitation->company->db, $mo); } }); + + + } } diff --git a/app/Utils/HtmlEngine.php b/app/Utils/HtmlEngine.php index 97b2e7ea46f5..058f9ed36731 100644 --- a/app/Utils/HtmlEngine.php +++ b/app/Utils/HtmlEngine.php @@ -663,6 +663,12 @@ class HtmlEngine $data['$payment.custom3'] = ['value' => '', 'label' => ctrans('texts.payment')]; $data['$payment.custom4'] = ['value' => '', 'label' => ctrans('texts.payment')]; + $data['$payment.amount'] = ['value' => '', 'label' => ctrans('texts.payment')]; + $data['$payment.date'] = ['value' => '', 'label' => ctrans('texts.payment_date')]; + $data['$payment.number'] = ['value' => '', 'label' => ctrans('texts.payment_number')]; + $data['$payment.transaction_reference'] = ['value' => '', 'label' => ctrans('texts.transaction_reference')]; + + if ($this->entity_string == 'invoice' && $this->entity->payments()->exists()) { $payment_list = '

'; @@ -672,7 +678,6 @@ class HtmlEngine $data['$payments'] = ['value' => $payment_list, 'label' => ctrans('texts.payments')]; - $payment = $this->entity->payments()->first(); $data['$payment.custom1'] = ['value' => $payment->custom_value1, 'label' => $this->helpers->makeCustomField($this->company->custom_fields, 'payment1')]; @@ -680,6 +685,11 @@ class HtmlEngine $data['$payment.custom3'] = ['value' => $payment->custom_value3, 'label' => $this->helpers->makeCustomField($this->company->custom_fields, 'payment3')]; $data['$payment.custom4'] = ['value' => $payment->custom_value4, 'label' => $this->helpers->makeCustomField($this->company->custom_fields, 'payment4')]; + $data['$payment.amount'] = ['value' => Number::formatMoney($payment->amount, $this->client), 'label' => ctrans('texts.payment')]; + $data['$payment.date'] = ['value' => $this->formatDate($payment->date, $this->client->date_format()), 'label' => ctrans('texts.payment_date')]; + $data['$payment.number'] = ['value' => $payment->number, 'label' => ctrans('texts.payment_number')]; + $data['$payment.transaction_reference'] = ['value' => $payment->transaction_reference, 'label' => ctrans('texts.transaction_reference')]; + } if (($this->entity_string == 'invoice' || $this->entity_string == 'recurring_invoice') && isset($this->company?->custom_fields?->company1)) { diff --git a/app/Utils/Traits/Pdf/PdfMaker.php b/app/Utils/Traits/Pdf/PdfMaker.php index 99f5038d9505..ebca89f47246 100644 --- a/app/Utils/Traits/Pdf/PdfMaker.php +++ b/app/Utils/Traits/Pdf/PdfMaker.php @@ -39,6 +39,8 @@ trait PdfMaker $pdf->addChromiumArguments(config('ninja.snappdf_chromium_arguments')); } + $html = str_replace(['file:/', 'iframe', '<object', 'setHtml($html) ->generate(); diff --git a/config/ninja.php b/config/ninja.php index bf67009c9408..1c509fec85e7 100644 --- a/config/ninja.php +++ b/config/ninja.php @@ -15,8 +15,8 @@ return [ 'require_https' => env('REQUIRE_HTTPS', true), 'app_url' => rtrim(env('APP_URL', ''), '/'), 'app_domain' => env('APP_DOMAIN', 'invoicing.co'), - 'app_version' => env('APP_VERSION','5.7.10'), - 'app_tag' => env('APP_TAG','5.7.10'), + 'app_version' => env('APP_VERSION','5.7.11'), + 'app_tag' => env('APP_TAG','5.7.11'), 'minimum_client_version' => '5.0.16', 'terms_version' => '1.0.1', 'api_secret' => env('API_SECRET', ''), diff --git a/tests/Feature/QuoteTest.php b/tests/Feature/QuoteTest.php index 320a4f1149d0..957286739fb0 100644 --- a/tests/Feature/QuoteTest.php +++ b/tests/Feature/QuoteTest.php @@ -13,10 +13,12 @@ namespace Tests\Feature; use Tests\TestCase; use App\Models\Quote; +use App\Models\Client; use App\Models\Project; use Tests\MockAccountData; use App\Models\ClientContact; use App\Utils\Traits\MakesHash; +use App\DataMapper\ClientSettings; use App\Exceptions\QuoteConversion; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Session; @@ -52,6 +54,72 @@ class QuoteTest extends TestCase ); } + public function testQuoteToProjectConversion2() + { + $settings = ClientSettings::defaults(); + $settings->default_task_rate = 41; + + $c = Client::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'settings' => $settings, + ]); + + $q = Quote::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $c->id, + 'status_id' => 2, + 'date' => now(), + 'line_items' =>[ + [ + 'type_id' => 2, + 'unit_cost' => 200, + 'quantity' => 2, + 'notes' => 'Test200', + ], + [ + 'type_id' => 2, + 'unit_cost' => 100, + 'quantity' => 1, + 'notes' => 'Test100', + ], + [ + 'type_id' => 1, + 'unit_cost' => 10, + 'quantity' => 1, + 'notes' => 'Test', + ], + + ], + ]); + + $q->calc()->getQuote(); + $q->fresh(); + + $p = $q->service()->convertToProject(); + + $this->assertEquals(3, $p->budgeted_hours); + $this->assertEquals(2, $p->tasks()->count()); + + $t = $p->tasks()->where('description', 'Test200')->first(); + + $this->assertEquals(200, $t->rate); + + $t = $p->tasks()->where('description', 'Test100')->first(); + + $this->assertEquals(100, $t->rate); + + + } + + public function testQuoteToProjectConversion() + { + $project = $this->quote->service()->convertToProject(); + + $this->assertInstanceOf('\App\Models\Project', $project); + } + public function testQuoteConversion() { $invoice = $this->quote->service()->convertToInvoice(); @@ -62,7 +130,6 @@ class QuoteTest extends TestCase $invoice = $this->quote->service()->convertToInvoice(); - } public function testQuoteDownloadPDF() diff --git a/tests/Feature/TaskApiTest.php b/tests/Feature/TaskApiTest.php index 1651954bb121..2d7d9e4363fe 100644 --- a/tests/Feature/TaskApiTest.php +++ b/tests/Feature/TaskApiTest.php @@ -11,8 +11,10 @@ namespace Tests\Feature; +use App\DataMapper\ClientSettings; use Tests\TestCase; use App\Models\Task; +use App\Models\Client; use App\Models\Project; use Tests\MockAccountData; use App\Utils\Traits\MakesHash; @@ -101,7 +103,88 @@ class TaskApiTest extends TestCase return true; } } - + + public function testTaskClientRateSet() + { + $settings = ClientSettings::defaults(); + $settings->default_task_rate = 41; + + $c = Client::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'settings' => $settings, + ]); + + $data = [ + 'client_id' => $c->hashed_id, + 'description' => 'Test Task', + 'time_log' => '[[1681165417,1681165432,"sumtin",true],[1681165446,0]]', + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(200); + $arr = $response->json(); + + $this->assertEquals(41, $arr['data']['rate']); + } + + public function testTaskProjectRateSet() + { + + $p = Project::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $this->client->id, + 'name' => 'proggy', + 'task_rate' => 101, + ]); + + $data = [ + 'project_id' => $p->hashed_id, + 'client_id' => $this->client->id, + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'description' => 'Test Task', + 'time_log' => '[[1681165417,1681165432,"sumtin",true],[1681165446,0]]', + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(200); + $arr = $response->json(); + + $this->assertEquals(101, $arr['data']['rate']); + } + + public function testStatusSet() + { + + $data = [ + 'client_id' => $this->client->id, + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'description' => 'Test Task', + 'time_log' => '[[1681165417,1681165432,"sumtin",true],[1681165446,0]]', + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks"); + + $response->assertStatus(200); + $arr = $response->json(); + + $this->assertNotEmpty($arr['data']['status_id']); + } + public function testStartDate() { $x = [];