diff --git a/app/DataMapper/CompanySettings.php b/app/DataMapper/CompanySettings.php index b5afc076f092..7342e628a0eb 100644 --- a/app/DataMapper/CompanySettings.php +++ b/app/DataMapper/CompanySettings.php @@ -71,7 +71,7 @@ class CompanySettings extends BaseSettings public $inclusive_taxes = false; //@implemented public $quote_footer = ''; //@implmented - public $translations; + public $translations; public $counter_number_applied = 'when_saved'; // when_saved , when_sent //@implemented public $quote_number_applied = 'when_saved'; // when_saved , when_sent //@implemented @@ -594,7 +594,7 @@ class CompanySettings extends BaseSettings * * @return stdClass The stdClass of PDF variables */ - private static function getEntityVariableDefaults() :stdClass + public static function getEntityVariableDefaults() :stdClass { $variables = [ 'client_details' => [ @@ -676,6 +676,19 @@ class CompanySettings extends BaseSettings '$paid_to_date', '$outstanding', ], + 'statement_invoice_columns' => [ + '$invoice.number', + '$invoice.date', + '$due_date', + '$total', + '$outstanding', + ], + 'statement_payment_columns' => [ + '$invoice.number', + '$payment.date', + '$method', + '$outstanding', + ], ]; return json_decode(json_encode($variables)); diff --git a/app/Http/Controllers/ClientController.php b/app/Http/Controllers/ClientController.php index bcca4e6368a9..9d3d272e65bd 100644 --- a/app/Http/Controllers/ClientController.php +++ b/app/Http/Controllers/ClientController.php @@ -521,16 +521,6 @@ class ClientController extends BaseController return $this->listResponse(Client::withTrashed()->whereIn('id', $this->transformKeys($ids))); } - /** - * Returns a client statement. - * - * @return void [type] [description] - */ - public function statement() - { - //todo - } - /** * Update the specified resource in storage. * diff --git a/app/Http/Controllers/ClientStatementController.php b/app/Http/Controllers/ClientStatementController.php index 002e45b9c1e8..5d9dfb758a18 100644 --- a/app/Http/Controllers/ClientStatementController.php +++ b/app/Http/Controllers/ClientStatementController.php @@ -11,31 +11,118 @@ namespace App\Http\Controllers; -/** - * Class ClientStatementController. - */ +use App\Http\Requests\Statements\CreateStatementRequest; +use App\Models\Design; +use App\Models\InvoiceInvitation; +use App\Services\PdfMaker\Design as PdfDesignModel; +use App\Services\PdfMaker\Design as PdfMakerDesign; +use App\Services\PdfMaker\PdfMaker as PdfMakerService; +use App\Utils\HostedPDF\NinjaPdf; +use App\Utils\HtmlEngine; +use App\Utils\Traits\MakesHash; +use App\Utils\Traits\Pdf\PdfMaker; + class ClientStatementController extends BaseController { + use MakesHash, PdfMaker; + + /** @var \App\Models\Invoice|\App\Models\Payment */ + protected $entity; + public function __construct() { parent::__construct(); } - /** - * Displays a client statement view for a given - * client_id. - * @return void - */ - public function show() + public function statement(CreateStatementRequest $request) { + $pdf = $this->createStatement($request); + + if ($pdf) { + return response()->streamDownload(function () use ($pdf) { + echo $pdf; + }, 'statement.pdf', ['Content-Type' => 'application/pdf']); + } + + return response()->json(['message' => 'Something went wrong. Please check logs.']); } - /** - * Updates the show view data dependent on - * configured variables. - * @return void - */ - public function update() + protected function createStatement(CreateStatementRequest $request): ?string { + $invitation = InvoiceInvitation::first(); + + if (count($request->getInvoices()) >= 1) { + $this->entity = $request->getInvoices()->first(); + } + + if (count($request->getPayments()) >= 1) { + $this->entity = $request->getPayments()->first(); + } + + $entity_design_id = 1; + + $entity_design_id = $this->entity->design_id + ? $this->entity->design_id + : $this->decodePrimaryKey($this->entity->client->getSetting($entity_design_id)); + + $design = Design::find($entity_design_id); + + if (!$design) { + $design = Design::find($entity_design_id); + } + + $html = new HtmlEngine($invitation); + + $options = [ + 'start_date' => $request->start_date, + 'end_date' => $request->end_date, + 'show_payments_table' => $request->show_payments_table, + 'show_aging_table' => $request->show_aging_table, + ]; + + if ($design->is_custom) { + $options['custom_partials'] = \json_decode(\json_encode($design->design), true); + $template = new PdfMakerDesign(PdfDesignModel::CUSTOM, $options); + } else { + $template = new PdfMakerDesign(strtolower($design->name), $options); + } + + $variables = $html->generateLabelsAndValues(); + + $state = [ + 'template' => $template->elements([ + 'client' => $this->entity->client, + 'entity' => $this->entity, + 'pdf_variables' => (array)$this->entity->company->settings->pdf_variables, + '$product' => $design->design->product, + 'variables' => $variables, + 'invoices' => $request->getInvoices(), + 'payments' => $request->getPayments(), + 'aging' => $request->getAging(), + ], \App\Services\PdfMaker\Design::STATEMENT), + 'variables' => $variables, + 'options' => [], + 'process_markdown' => $this->entity->client->company->markdown_enabled, + ]; + + $maker = new PdfMakerService($state); + + $maker + ->design($template) + ->build(); + + $pdf = null; + + try { + if (config('ninja.invoiceninja_hosted_pdf_generation') || config('ninja.pdf_generator') == 'hosted_ninja') { + $pdf = (new NinjaPdf())->build($maker->getCompiledHTML(true)); + } else { + $pdf = $this->makePdf(null, null, $maker->getCompiledHTML(true)); + } + } catch (\Exception $e) { + nlog(print_r($e->getMessage(), 1)); + } + + return $pdf; } } diff --git a/app/Http/Requests/Statements/CreateStatementRequest.php b/app/Http/Requests/Statements/CreateStatementRequest.php new file mode 100644 index 000000000000..a8577c538367 --- /dev/null +++ b/app/Http/Requests/Statements/CreateStatementRequest.php @@ -0,0 +1,71 @@ +user()->isAdmin(); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + 'start_date' => ['required'], + 'end_date' => ['required'], + ]; + } + + /** + * The collection of invoices for the statement. + * + * @return Invoice[]|\Illuminate\Database\Eloquent\Collection + */ + public function getInvoices() + { + // $this->request->start_date & $this->request->end_date are available. + + return Invoice::all(); + } + + /** + * The collection of payments for the statement. + * + * @return Payment[]|\Illuminate\Database\Eloquent\Collection + */ + public function getPayments() + { + // $this->request->start_date & $this->request->end_date are available. + + return Payment::all(); + } + + /** + * The array of aging data. + */ + public function getAging(): array + { + return [ + '0-30' => 1000, + '30-60' => 2000, + '60-90' => 3000, + '90-120' => 4000, + '120+' => 5000, + ]; + } +} diff --git a/app/Services/PdfMaker/Design.php b/app/Services/PdfMaker/Design.php index 76595fc02a2f..88ee420bf970 100644 --- a/app/Services/PdfMaker/Design.php +++ b/app/Services/PdfMaker/Design.php @@ -13,17 +13,21 @@ namespace App\Services\PdfMaker; use App\Models\Credit; +use App\Models\GatewayType; +use App\Models\Invoice; +use App\Models\Payment; use App\Models\Quote; use App\Services\PdfMaker\Designs\Utilities\BaseDesign; use App\Services\PdfMaker\Designs\Utilities\DesignHelpers; use App\Utils\Number; +use App\Utils\Traits\MakesDates; use App\Utils\Traits\MakesInvoiceValues; use DOMDocument; use Illuminate\Support\Str; class Design extends BaseDesign { - use MakesInvoiceValues, DesignHelpers; + use MakesInvoiceValues, DesignHelpers, MakesDates; /** @var App\Models\Invoice || @var App\Models\Quote */ public $entity; @@ -43,6 +47,15 @@ class Design extends BaseDesign /** Construct options */ public $options; + /** @var Invoice[] */ + public $invoices; + + /** @var Payment[] */ + public $payments; + + /** @var array */ + public $aging = []; + const BOLD = 'bold'; const BUSINESS = 'business'; const CLEAN = 'clean'; @@ -54,6 +67,9 @@ class Design extends BaseDesign const PLAYFUL = 'playful'; const CUSTOM = 'custom'; + const DELIVERY_NOTE = 'delivery_note'; + const STATEMENT = 'statement'; + public function __construct(string $design = null, array $options = []) { Str::endsWith('.html', $design) ? $this->design = $design : $this->design = "{$design}.html"; @@ -69,9 +85,7 @@ class Design extends BaseDesign ); } - $path = isset($this->options['custom_path']) - ? $this->options['custom_path'] - : config('ninja.designs.base_path'); + $path = $this->options['custom_path'] ?? config('ninja.designs.base_path'); return file_get_contents( $path . $this->design @@ -115,6 +129,26 @@ class Design extends BaseDesign 'id' => 'task-table', 'elements' => $this->taskTable(), ], + 'statement-invoice-table' => [ + 'id' => 'statement-invoice-table', + 'elements' => $this->statementInvoiceTable(), + ], + 'statement-invoice-table-totals' => [ + 'id' => 'statement-invoice-table-totals', + 'elements' => $this->statementInvoiceTableTotals(), + ], + 'statement-payment-table' => [ + 'id' => 'statement-payment-table', + 'elements' => $this->statementPaymentTable(), + ], + 'statement-payment-table-totals' => [ + 'id' => 'statement-payment-table-totals', + 'elements' => $this->statementPaymentTableTotals(), + ], + 'statement-aging-table' => [ + 'id' => 'statement-aging-table', + 'elements' => $this->statementAgingTable(), + ], 'table-totals' => [ 'id' => 'table-totals', 'elements' => $this->tableTotals(), @@ -158,7 +192,7 @@ class Design extends BaseDesign { $elements = []; - if ($this->type == 'delivery_note') { + if ($this->type == self::DELIVERY_NOTE) { $elements = [ ['element' => 'p', 'content' => ctrans('texts.delivery_note'), 'properties' => ['data-ref' => 'delivery_note-label', 'style' => 'font-weight: bold; text-transform: uppercase']], ['element' => 'p', 'content' => $this->entity->client->name, 'show_empty' => false, 'properties' => ['data-ref' => 'delivery_note-client.name']], @@ -190,6 +224,19 @@ class Design extends BaseDesign public function entityDetails(): array { + if ($this->type === 'statement') { + return [ + ['element' => 'tr', 'properties' => [], 'elements' => [ + ['element' => 'th', 'properties' => [], 'content' => ctrans('texts.statement_date')], + ['element' => 'th', 'properties' => [], 'content' => $this->options['end_date'] ?? ''], + ]], + ['element' => 'tr', 'properties' => [], 'elements' => [ + ['element' => 'th', 'properties' => [], 'content' => '$balance_due_label'], + ['element' => 'th', 'properties' => [], 'content' => '$balance_due'], + ]], + ]; + } + $variables = $this->context['pdf_variables']['invoice_details']; if ($this->entity instanceof Quote) { @@ -203,7 +250,7 @@ class Design extends BaseDesign $elements = []; // We don't want to show account balance or invoice total on PDF.. or any amount with currency. - if ($this->type == 'delivery_note') { + if ($this->type == self::DELIVERY_NOTE) { $variables = array_filter($variables, function ($m) { return !in_array($m, ['$invoice.balance_due', '$invoice.total']); }); @@ -231,7 +278,7 @@ class Design extends BaseDesign public function deliveryNoteTable(): array { - if ($this->type !== 'delivery_note') { + if ($this->type !== self::DELIVERY_NOTE) { return []; } @@ -241,7 +288,7 @@ class Design extends BaseDesign ['element' => 'th', 'content' => '$description_label', 'properties' => ['data-ref' => 'delivery_note-description_label']], ['element' => 'th', 'content' => '$product.quantity_label', 'properties' => ['data-ref' => 'delivery_note-product.quantity_label']], ]], - ['element' => 'tbody', 'elements' => $this->buildTableBody('delivery_note')], + ['element' => 'tbody', 'elements' => $this->buildTableBody(self::DELIVERY_NOTE)], ]; } @@ -260,7 +307,7 @@ class Design extends BaseDesign return []; } - if ($this->type == 'delivery_note') { + if ($this->type === self::DELIVERY_NOTE || $this->type === self::STATEMENT) { return []; } @@ -285,7 +332,7 @@ class Design extends BaseDesign return []; } - if ($this->type == 'delivery_note') { + if ($this->type === self::DELIVERY_NOTE || $this->type === self::STATEMENT) { return []; } @@ -295,6 +342,120 @@ class Design extends BaseDesign ]; } + /** + * Parent method for building invoices table within statement. + * + * @return array + */ + public function statementInvoiceTable(): array + { + if (is_null($this->invoices) || $this->type !== self::STATEMENT) { + return []; + } + + $tbody = []; + + foreach ($this->invoices as $invoice) { + $element = ['element' => 'tr', 'elements' => []]; + + $element['elements'][] = ['element' => 'td', 'content' => $invoice->number]; + $element['elements'][] = ['element' => 'td', 'content' => $this->translateDate($invoice->date, $invoice->client->date_format(), $invoice->client->locale()) ?: ' ']; + $element['elements'][] = ['element' => 'td', 'content' => $this->translateDate($invoice->due_date, $invoice->client->date_format(), $invoice->client->locale()) ?: ' ']; + $element['elements'][] = ['element' => 'td', 'content' => Number::formatMoney($invoice->calc()->getTotal(), $invoice->client) ?: ' ']; + $element['elements'][] = ['element' => 'td', 'content' => Number::formatMoney($invoice->partial, $invoice->client) ?: ' ']; + + $tbody[] = $element; + } + + return [ + ['element' => 'thead', 'elements' => $this->buildTableHeader('statement_invoice')], + ['element' => 'tbody', 'elements' => $tbody], + ]; + } + + public function statementInvoiceTableTotals(): array + { + if ($this->type !== self::STATEMENT) { + return []; + } + + return [ + ['element' => 'p', 'content' => '$outstanding_label: $outstanding'], + ]; + } + + /** + * Parent method for building payments table within statement. + * + * @return array + */ + public function statementPaymentTable(): array + { + if (is_null($this->payments) && $this->type !== self::STATEMENT) { + return []; + } + + if (\array_key_exists('show_payment_table', $this->options) && $this->options['show_payment_table'] === false) { + return []; + } + + $tbody = []; + + foreach ($this->payments as $payment) { + foreach ($payment->invoices as $invoice) { + $element = ['element' => 'tr', 'elements' => []]; + + $element['elements'][] = ['element' => 'td', 'content' => $invoice->number]; + $element['elements'][] = ['element' => 'td', 'content' => $this->translateDate($payment->date, $payment->client->date_format(), $payment->client->locale()) ?: ' ']; + $element['elements'][] = ['element' => 'td', 'content' => GatewayType::getAlias($payment->gateway_type_id) ?: ' ']; + $element['elements'][] = ['element' => 'td', 'content' => Number::formatMoney($payment->amount, $payment->client) ?: ' ']; + + $tbody[] = $element; + } + } + + return [ + ['element' => 'thead', 'elements' => $this->buildTableHeader('statement_payment')], + ['element' => 'tbody', 'elements' => $tbody], + ]; + } + + public function statementPaymentTableTotals(): array + { + if ($this->type !== self::STATEMENT) { + return []; + } + + return [ + ['element' => 'p', 'content' => \sprintf('%s: %s', ctrans('texts.amount_paid'), 1000)], + ]; + } + + public function statementAgingTable(): array + { + if ($this->type !== self::STATEMENT) { + return []; + } + + if (\array_key_exists('show_aging_table', $this->options) && $this->options['show_aging_table'] === false) { + return []; + } + + $elements = [ + ['element' => 'thead', 'elements' => []], + ['element' => 'tbody', 'elements' => [ + ['element' => 'tr', 'elements' => []], + ]], + ]; + + foreach ($this->aging as $column => $value) { + $elements[0]['elements'][] = ['element' => 'th', 'content' => $column]; + $elements[1]['elements'][] = ['element' => 'td', 'content' => $value]; + } + + return $elements; + } + /** * Generate the structure of table headers. () * @@ -354,7 +515,7 @@ class Design extends BaseDesign return []; } - if ($type == 'delivery_note') { + if ($type == self::DELIVERY_NOTE) { foreach ($items as $row) { $element = ['element' => 'tr', 'elements' => []]; @@ -453,7 +614,7 @@ class Design extends BaseDesign ['element' => 'div', 'properties' => ['class' => 'totals-table-right-side', 'dir' => '$dir'], 'elements' => []], ]; - if ($this->type == 'delivery_note') { + if ($this->type == self::DELIVERY_NOTE || $this->type == self::STATEMENT) { return $elements; } diff --git a/app/Services/PdfMaker/Designs/Utilities/DesignHelpers.php b/app/Services/PdfMaker/Designs/Utilities/DesignHelpers.php index d43465d0ed60..b9aea8184d42 100644 --- a/app/Services/PdfMaker/Designs/Utilities/DesignHelpers.php +++ b/app/Services/PdfMaker/Designs/Utilities/DesignHelpers.php @@ -28,6 +28,8 @@ trait DesignHelpers public function setup(): self { + $this->syncPdfVariables(); + if (isset($this->context['client'])) { $this->client = $this->context['client']; } @@ -36,11 +38,38 @@ trait DesignHelpers $this->entity = $this->context['entity']; } + if (isset($this->context['invoices'])) { + $this->invoices = $this->context['invoices']; + $this->entity = $this->invoices->first(); + } + + if (isset($this->context['payments'])) { + $this->payments = $this->context['payments']; + } + + if (isset($this->context['aging'])) { + $this->aging = $this->context['aging']; + } + $this->document(); return $this; } + protected function syncPdfVariables(): void + { + $default = (array) \App\DataMapper\CompanySettings::getEntityVariableDefaults(); + $variables = $this->context['pdf_variables']; + + foreach ($default as $property => $value) { + if (array_key_exists($property, $variables)) { + continue; + } + + $variables[$property] = $value; + } + } + /** * Initialize local dom document instance. Used for getting raw HTML out of template. * diff --git a/app/Utils/HtmlEngine.php b/app/Utils/HtmlEngine.php index c3f0f81c6d25..0f6b81bf3b22 100644 --- a/app/Utils/HtmlEngine.php +++ b/app/Utils/HtmlEngine.php @@ -190,7 +190,7 @@ class HtmlEngine } else{ $data['$balance_due'] = ['value' => Number::formatMoney($this->entity->balance, $this->client) ?: ' ', 'label' => ctrans('texts.balance_due')]; - $data['$balance_due_raw'] = ['value' => $this->entity->balance, 'label' => ctrans('texts.balance_due')]; + $data['$balance_due_raw'] = ['value' => $this->entity->balance, 'label' => ctrans('texts.balance_due')]; } } @@ -302,7 +302,7 @@ class HtmlEngine $data['$contact.full_name'] = ['value' => $this->contact->present()->name(), 'label' => ctrans('texts.name')]; $data['$contact'] = &$data['$contact.full_name']; - + $data['$contact.email'] = ['value' => $this->contact->email, 'label' => ctrans('texts.email')]; $data['$contact.phone'] = ['value' => $this->contact->phone, 'label' => ctrans('texts.phone')]; @@ -439,6 +439,9 @@ class HtmlEngine $data['$dir'] = ['value' => optional($this->client->language())->locale === 'ar' ? 'rtl' : 'ltr', 'label' => '']; $data['$dir_text_align'] = ['value' => optional($this->client->language())->locale === 'ar' ? 'right' : 'left', 'label' => '']; + $data['$payment.date'] = ['value' => ' ', 'label' => ctrans('texts.payment_date')]; + $data['$method'] = ['value' => ' ', 'label' => ctrans('texts.method')]; + $arrKeysLength = array_map('strlen', array_keys($data)); array_multisort($arrKeysLength, SORT_DESC, $data); diff --git a/database/migrations/2021_08_19_115919_update_designs.php b/database/migrations/2021_08_24_115919_update_designs.php similarity index 100% rename from database/migrations/2021_08_19_115919_update_designs.php rename to database/migrations/2021_08_24_115919_update_designs.php diff --git a/resources/views/pdf-designs/bold.html b/resources/views/pdf-designs/bold.html index 46a89373ee33..0e669bb2ecb6 100644 --- a/resources/views/pdf-designs/bold.html +++ b/resources/views/pdf-designs/bold.html @@ -80,9 +80,7 @@ padding-bottom: 0.5rem; } - #product-table, - #task-table, - #delivery-note-table { + [data-ref="table"] { min-width: 100%; table-layout: fixed; overflow-wrap: break-word; @@ -96,46 +94,32 @@ color: grey; } - #product-table > thead, - #delivery-note-table > thead, - #task-table > thead { + [data-ref="table"] > thead { text-align: left; } - #product-table > thead > tr > th, - #delivery-note-table > thead > tr > th, - #task-table > thead > tr > th { + [data-ref="table"] > thead > tr > th { padding: 1.5rem 3rem; font-size: 1rem; } - #product-table > thead > tr > th:last-child, - #delivery-note-table > thead > tr > th:last-child, - #task-table > thead > tr > th:last-child { + [data-ref="table"] > thead > tr > th:last-child { text-align: right; } - #product-table > tbody > tr > td, - #delivery-note-table > tbody > tr > td, - #task-table > tbody > tr > td { + [data-ref="table"] > tbody > tr > td { padding: 1.5rem 3rem; } - #product-table > tbody > tr > td:last-child, - #delivery-note-table > tbody > tr > td:last-child, - #task-table > tbody > tr > td:last-child { + [data-ref="table"] > tbody > tr > td:last-child { text-align: right; } - #product-table > tbody > tr > td:first-child, - #delivery-note-table > tbody > tr > td:first-child, - #task-table > tbody > tr > td:first-child { + [data-ref="table"] > tbody > tr > td:first-child { font-weight: bold; } - #product-table > tbody > tr:nth-child(odd), - #delivery-note-table > tbody > tr:nth-child(odd), - #task-table > tbody > tr:nth-child(odd) { + [data-ref="table"] > tbody > tr:nth-child(odd) { background-color: #ebebeb; } @@ -254,12 +238,18 @@ margin-bottom: 0; } - [data-ref="product_table-product.description-th"] { + [data-ref="product_table-product.description-th"] { width: 23%; } + [data-ref="statement-totals"] { + margin-top: 1rem; + text-align: right; + margin-right: .75rem; + } + /** Useful snippets, uncomment to enable. **/ - + /** Hide company logo **/ /* .company-logo { display: none } */ @@ -318,11 +308,20 @@ - + - + - + + + +
+ + +
+ + +
@@ -350,7 +349,12 @@