diff --git a/app/Console/Commands/PostUpdate.php b/app/Console/Commands/PostUpdate.php index 92c896281027..f3892d921d96 100644 --- a/app/Console/Commands/PostUpdate.php +++ b/app/Console/Commands/PostUpdate.php @@ -52,7 +52,7 @@ class PostUpdate extends Command nlog("finished migrating"); - exec('vendor/bin/composer install --no-dev:'); + exec('vendor/bin/composer install --no-dev'); nlog("finished running composer install "); diff --git a/app/Helpers/Generic.php b/app/Helpers/Generic.php index fc3444176fed..4ea60eeb8de7 100644 --- a/app/Helpers/Generic.php +++ b/app/Helpers/Generic.php @@ -21,17 +21,18 @@ */ function nlog($output, $context = []): void { - $trace = debug_backtrace(); - \Illuminate\Support\Facades\Log::channel('invoiceninja')->info(print_r($trace[1]['class'],1), []); + if (!config('ninja.expanded_logging')) + return; - if (config('ninja.expanded_logging')) { if (gettype($output) == 'object') { $output = print_r($output, 1); } + $trace = debug_backtrace(); + \Illuminate\Support\Facades\Log::channel('invoiceninja')->info(print_r($trace[1]['class'],1), []); \Illuminate\Support\Facades\Log::channel('invoiceninja')->info($output, $context); - } + } if (!function_exists('ray')) { diff --git a/app/Http/Controllers/ClientPortal/DashboardController.php b/app/Http/Controllers/ClientPortal/DashboardController.php index 7074b8189f39..4c7bd6894d30 100644 --- a/app/Http/Controllers/ClientPortal/DashboardController.php +++ b/app/Http/Controllers/ClientPortal/DashboardController.php @@ -22,6 +22,7 @@ class DashboardController extends Controller */ public function index() { - return $this->render('dashboard.index'); + return redirect()->route('client.invoices.index'); + //return $this->render('dashboard.index'); } } diff --git a/app/Http/Controllers/RecurringInvoiceController.php b/app/Http/Controllers/RecurringInvoiceController.php index 2c1b5ea290c8..96d32cc3a698 100644 --- a/app/Http/Controllers/RecurringInvoiceController.php +++ b/app/Http/Controllers/RecurringInvoiceController.php @@ -431,6 +431,62 @@ class RecurringInvoiceController extends BaseController return response()->json([], 200); } + + /** + * @OA\Get( + * path="/api/v1/recurring_invoice/{invitation_key}/download", + * operationId="downloadInvoice", + * tags={"invoices"}, + * summary="Download a specific invoice by invitation key", + * description="Downloads a specific invoice", + * @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="invitation_key", + * in="path", + * description="The Recurring Invoice Invitation Key", + * example="D2J234DFA", + * required=true, + * @OA\Schema( + * type="string", + * format="string", + * ), + * ), + * @OA\Response( + * response=200, + * description="Returns the recurring invoice pdf", + * @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"), + * ), + * ) + * @param $invitation_key + * @return \Symfony\Component\HttpFoundation\BinaryFileResponse + */ + public function downloadPdf($invitation_key) + { + $invitation = $this->recurring_invoice_repo->getInvitationByKey($invitation_key); + $contact = $invitation->contact; + $recurring_invoice = $invitation->recurring_invoice; + + $file_path = $recurring_invoice->service()->getInvoicePdf($contact); + + return response()->download($file_path, basename($file_path)); + } + /** * Perform bulk actions on the list view. * diff --git a/app/Http/ViewComposers/PortalComposer.php b/app/Http/ViewComposers/PortalComposer.php index d7d7ae179d9b..ea8383f6e589 100644 --- a/app/Http/ViewComposers/PortalComposer.php +++ b/app/Http/ViewComposers/PortalComposer.php @@ -67,8 +67,9 @@ class PortalComposer { $data = []; - if($this->settings->enable_client_portal_dashboard == TRUE) - $data[] = [ 'title' => ctrans('texts.dashboard'), 'url' => 'client.dashboard', 'icon' => 'activity']; + //@todo wire this back in when we are happy with dashboard. + // if($this->settings->enable_client_portal_dashboard == TRUE) + // $data[] = [ 'title' => ctrans('texts.dashboard'), 'url' => 'client.dashboard', 'icon' => 'activity']; $data[] = ['title' => ctrans('texts.invoices'), 'url' => 'client.invoices.index', 'icon' => 'file-text']; $data[] = ['title' => ctrans('texts.recurring_invoices'), 'url' => 'client.recurring_invoices.index', 'icon' => 'file']; diff --git a/app/Jobs/Entity/CreateEntityPdf.php b/app/Jobs/Entity/CreateEntityPdf.php index 04ca27461698..ce3e37103a20 100644 --- a/app/Jobs/Entity/CreateEntityPdf.php +++ b/app/Jobs/Entity/CreateEntityPdf.php @@ -19,6 +19,7 @@ use App\Models\Invoice; use App\Models\InvoiceInvitation; use App\Models\Quote; use App\Models\QuoteInvitation; +use App\Models\RecurringInvoice; use App\Models\RecurringInvoiceInvitation; use App\Services\PdfMaker\Design as PdfDesignModel; use App\Services\PdfMaker\Design as PdfMakerDesign; @@ -106,6 +107,9 @@ class CreateEntityPdf implements ShouldQueue } elseif ($this->entity instanceof Credit) { $path = $this->entity->client->credit_filepath(); $entity_design_id = 'credit_design_id'; + } elseif ($this->entity instanceof RecurringInvoice) { + $path = $this->entity->client->recurring_invoice_filepath(); + $entity_design_id = 'invoice_design_id'; } $file_path = $path.$this->entity->number.'.pdf'; diff --git a/app/Models/Client.php b/app/Models/Client.php index 8066fa4bd986..6f079cd56860 100644 --- a/app/Models/Client.php +++ b/app/Models/Client.php @@ -605,6 +605,11 @@ class Client extends BaseModel implements HasLocalePreference return $this->company->company_key.'/'.$this->client_hash.'/credits/'; } + public function recurring_invoice_filepath() + { + return $this->company->company_key.'/'.$this->client_hash.'/recurring_invoices/'; + } + public function company_filepath() { return $this->company->company_key.'/'; diff --git a/app/Models/Presenters/RecurringInvoicePresenter.php b/app/Models/Presenters/RecurringInvoicePresenter.php new file mode 100644 index 000000000000..99a3eafbab91 --- /dev/null +++ b/app/Models/Presenters/RecurringInvoicePresenter.php @@ -0,0 +1,31 @@ +first(); @@ -144,8 +143,24 @@ class BaseRepository return $invitation; } + /* Clean return of a key rather than butchering the model*/ + private function resolveEntityKey($model) + { + switch ($model) { + case ($model instanceof RecurringInvoice): + return 'recurring_invoice_id'; + case ($model instanceof Invoice): + return 'invoice_id'; + case ($model instanceof Quote): + return 'quote_id'; + case ($model instanceof Credit): + return 'credit_id'; + } + } + /** - * Alternative save used for Invoices, Quotes & Credits. + * Alternative save used for Invoices, Recurring Invoices, Quotes & Credits. + * * @param $data * @param $model * @return mixed @@ -153,20 +168,17 @@ class BaseRepository */ protected function alternativeSave($data, $model) { - $class = new ReflectionClass($model); - if (array_key_exists('client_id', $data)) { - $client = Client::where('id', $data['client_id'])->withTrashed()->first(); - } else { - $client = Client::where('id', $model->client_id)->withTrashed()->first(); - } + if (array_key_exists('client_id', $data)) //forces the client_id if it doesn't exist + $model->client_id = $data['client_id']; + + $client = Client::where('id', $model->client_id)->withTrashed()->first(); $state = []; - $resource = explode('\\', $class->name)[2]; /** This will extract 'Invoice' from App\Models\Invoice */ - $lcfirst_resource_id = lcfirst($resource).'_id'; //doesn't work for recurring. - if($class->name == RecurringInvoice::class) - $lcfirst_resource_id = 'recurring_invoice_id'; + $resource = class_basename($model); //ie Invoice + + $lcfirst_resource_id = $this->resolveEntityKey($model); //ie invoice_id $state['starting_amount'] = $model->amount; @@ -176,26 +188,24 @@ class BaseRepository $data = array_merge($company_defaults, $data); } - $tmp_data = $data; + $tmp_data = $data; //preserves the $data arrayss /* We need to unset some variable as we sometimes unguard the model */ - if (isset($tmp_data['invitations'])) { + if (isset($tmp_data['invitations'])) unset($tmp_data['invitations']); - } - - if (isset($tmp_data['client_contacts'])) { + + if (isset($tmp_data['client_contacts'])) unset($tmp_data['client_contacts']); - } - + $model->fill($tmp_data); $model->save(); - if (array_key_exists('documents', $data)) { + /* Model now persisted, now lets do some child tasks */ + + if (array_key_exists('documents', $data)) $this->saveDocuments($data['documents'], $model); - } - - $invitation_factory_class = sprintf('App\\Factory\\%sInvitationFactory', $resource); + /* Marks whether the client contact should receive emails based on the send_email property */ if (isset($data['client_contacts'])) { foreach ($data['client_contacts'] as $contact) { if ($contact['send_email'] == 1 && is_string($contact['id'])) { @@ -206,6 +216,7 @@ class BaseRepository } } + /* If invitations are present we need to filter existing invitations with the new ones */ if (isset($data['invitations'])) { $invitations = collect($data['invitations']); @@ -214,23 +225,24 @@ class BaseRepository $invitation_class = sprintf('App\\Models\\%sInvitation', $resource); $invitation = $invitation_class::whereRaw('BINARY `key`= ?', [$invitation])->first(); - if ($invitation) { + if ($invitation) $invitation->delete(); - } + }); foreach ($data['invitations'] as $invitation) { //if no invitations are present - create one. if (! $this->getInvitation($invitation, $resource)) { - if (isset($invitation['id'])) { + + if (isset($invitation['id'])) unset($invitation['id']); - } //make sure we are creating an invite for a contact who belongs to the client only! $contact = ClientContact::find($invitation['client_contact_id']); if ($contact && $model->client_id == $contact->client_id) { + $invitation_class = sprintf('App\\Models\\%sInvitation', $resource); $new_invitation = $invitation_class::withTrashed() @@ -239,12 +251,17 @@ class BaseRepository ->first(); if ($new_invitation && $new_invitation->trashed()) { + $new_invitation->restore(); + } else { + + $invitation_factory_class = sprintf('App\\Factory\\%sInvitationFactory', $resource); $new_invitation = $invitation_factory_class::create($model->company_id, $model->user_id); $new_invitation->{$lcfirst_resource_id} = $model->id; $new_invitation->client_contact_id = $contact->id; $new_invitation->save(); + } } } @@ -254,50 +271,61 @@ class BaseRepository $model->load('invitations'); /* If no invitations have been created, this is our fail safe to maintain state*/ - if ($model->invitations->count() == 0) { + if ($model->invitations->count() == 0) $model->service()->createInvitations(); - } + /* Recalculate invoice amounts */ $model = $model->calc()->getInvoice(); + /* We use this to compare to our starting amount */ $state['finished_amount'] = $model->amount; + /* Apply entity number */ $model = $model->service()->applyNumber()->save(); - if ($model->company->update_products !== false) { + /* Update product details if necessary */ + if ($model->company->update_products !== false) UpdateOrCreateProduct::dispatch($model->line_items, $model, $model->company); - } - if ($class->name == Invoice::class) { + /* Perform model specific tasks */ + if ($model instanceof Invoice) { + if (($state['finished_amount'] != $state['starting_amount']) && ($model->status_id != Invoice::STATUS_DRAFT)) { + $model->ledger()->updateInvoiceBalance(($state['finished_amount'] - $state['starting_amount'])); $model->client->service()->updateBalance(($state['finished_amount'] - $state['starting_amount']))->save(); + } - if (! $model->design_id) { + if (! $model->design_id) $model->design_id = $this->decodePrimaryKey($client->getSetting('invoice_design_id')); - } //links tasks and expenses back to the invoice. $model->service()->linkEntities()->save(); + } - if ($class->name == Credit::class) { + if ($model instanceof Credit) { + $model = $model->calc()->getCredit(); $model->ledger()->updateCreditBalance(($state['finished_amount'] - $state['starting_amount'])); - if (! $model->design_id) { + if (! $model->design_id) $model->design_id = $this->decodePrimaryKey($client->getSetting('credit_design_id')); - } + } - if ($class->name == Quote::class) { + if ($model instanceof Quote) { + $model = $model->calc()->getQuote(); + } - if($class->name == RecurringInvoice::class) { + if ($model instanceof RecurringInvoice) { + $model = $model->calc()->getRecurringInvoice(); + } $model->save(); diff --git a/app/Repositories/RecurringInvoiceRepository.php b/app/Repositories/RecurringInvoiceRepository.php index aad82a4e6c23..b74560bc1b1e 100644 --- a/app/Repositories/RecurringInvoiceRepository.php +++ b/app/Repositories/RecurringInvoiceRepository.php @@ -13,6 +13,7 @@ namespace App\Repositories; use App\Helpers\Invoice\InvoiceSum; use App\Models\RecurringInvoice; +use App\Models\RecurringInvoiceInvitation; /** * RecurringInvoiceRepository. @@ -38,4 +39,9 @@ class RecurringInvoiceRepository extends BaseRepository return $invoice; } + + public function getInvitationByKey($key) :?RecurringInvoiceInvitation + { + return RecurringInvoiceInvitation::whereRaw('BINARY `key`= ?', [$key])->first(); + } } diff --git a/app/Services/Recurring/GetInvoicePdf.php b/app/Services/Recurring/GetInvoicePdf.php new file mode 100644 index 000000000000..336e57635b55 --- /dev/null +++ b/app/Services/Recurring/GetInvoicePdf.php @@ -0,0 +1,60 @@ +entity = $entity; + + $this->contact = $contact; + } + + public function run() + { + if (! $this->contact) { + $this->contact = $this->entity->client->primary_contact()->first(); + } + + $invitation = $this->entity->invitations->where('client_contact_id', $this->contact->id)->first(); + + $path = $this->entity->client->recurring_invoice_filepath(); + + $file_path = $path.$this->entity->hashed_id.'.pdf'; + + $disk = config('filesystems.default'); + + $file = Storage::disk($disk)->exists($file_path); + + if (! $file) { + $file_path = CreateEntityPdf::dispatchNow($invitation); + } + + + /* Copy from remote disk to local when using cloud file storage. */ + if(config('filesystems.default') == 's3') + return TempFile::path(Storage::disk($disk)->url($file_path)); + + // return Storage::disk($disk)->url($file_path); + return Storage::disk($disk)->path($file_path); + } +} diff --git a/app/Services/Recurring/RecurringService.php b/app/Services/Recurring/RecurringService.php index 8f6bbe32ac3b..a84c229514f3 100644 --- a/app/Services/Recurring/RecurringService.php +++ b/app/Services/Recurring/RecurringService.php @@ -12,6 +12,7 @@ namespace App\Services\Recurring; use App\Models\RecurringInvoice; +use App\Services\Recurring\GetInvoicePdf; use Illuminate\Support\Carbon; class RecurringService @@ -77,6 +78,11 @@ class RecurringService return $this; } + public function getInvoicePdf($contact = null) + { + return (new GetInvoicePdf($this->recurring_entity, $contact))->run(); + } + public function save() { $this->recurring_entity->save(); diff --git a/app/Transformers/TaskTransformer.php b/app/Transformers/TaskTransformer.php index b00bccbe5a33..8d669c33bd92 100644 --- a/app/Transformers/TaskTransformer.php +++ b/app/Transformers/TaskTransformer.php @@ -59,7 +59,7 @@ class TaskTransformer extends EntityTransformer 'project_id' => $this->encodePrimaryKey($task->project_id) ?: '', 'is_deleted' => (bool) $task->is_deleted, 'time_log' => $task->time_log ?: '', - 'is_running' => (bool) $task->is_running, + 'is_running' => (bool) $task->is_running, //@deprecate 'custom_value1' => $task->custom_value1 ?: '', 'custom_value2' => $task->custom_value2 ?: '', 'custom_value3' => $task->custom_value3 ?: '', diff --git a/app/Utils/HtmlEngine.php b/app/Utils/HtmlEngine.php index ff1020d0b2e8..c9f20182c57b 100644 --- a/app/Utils/HtmlEngine.php +++ b/app/Utils/HtmlEngine.php @@ -118,7 +118,7 @@ class HtmlEngine $data['$quote.datetime'] = &$data['$entity.datetime']; $data['$credit.datetime'] = &$data['$entity.datetime']; - if ($this->entity_string == 'invoice') { + if ($this->entity_string == 'invoice' || $this->entity_string == 'recurring_invoice') { $data['$entity'] = ['value' => '', 'label' => ctrans('texts.invoice')]; $data['$number'] = ['value' => $this->entity->number ?: ' ', 'label' => ctrans('texts.invoice_number')]; $data['$entity.terms'] = ['value' => $this->entity->terms ?: ' ', 'label' => ctrans('texts.invoice_terms')]; diff --git a/resources/lang/en/texts.php b/resources/lang/en/texts.php index 2fec4cb8a87d..f89dd1350aa9 100644 --- a/resources/lang/en/texts.php +++ b/resources/lang/en/texts.php @@ -3259,7 +3259,7 @@ return [ 'enable_only_for_development' => 'Enable only for development', 'test_pdf' => 'Test PDF', - 'status_cancelled' => 'Cancelled', + 'cancelled' => 'Cancelled', 'checkout_authorize_label' => 'Checkout.com can be can saved as payment method for future use, once you complete your first transaction. Don\'t forget to check "Store credit card details" during payment process.', diff --git a/routes/client.php b/routes/client.php index e3cb1285ee26..fb886e9d0b0e 100644 --- a/routes/client.php +++ b/routes/client.php @@ -80,6 +80,7 @@ Route::group(['middleware' => ['invite_db'], 'prefix' => 'client', 'as' => 'clie /*Invitation catches*/ Route::get('recurring_invoice/{invitation_key}', 'ClientPortal\InvitationController@recurringRouter'); Route::get('{entity}/{invitation_key}', 'ClientPortal\InvitationController@router'); + Route::get('recurring_invoice/{invitation_key}/download_pdf', 'RecurringInvoiceController@downloadPdf')->name('recurring_invoice.download_invitation_key'); Route::get('invoice/{invitation_key}/download_pdf', 'InvoiceController@downloadPdf')->name('invoice.download_invitation_key'); Route::get('quote/{invitation_key}/download_pdf', 'QuoteController@downloadPdf')->name('quote.download_invitation_key'); Route::get('credit/{invitation_key}/download_pdf', 'CreditController@downloadPdf')->name('credit.download_invitation_key');