diff --git a/app/Http/Controllers/ClientStatementController.php b/app/Http/Controllers/ClientStatementController.php index 13d4a81ff06d..375234c6d016 100644 --- a/app/Http/Controllers/ClientStatementController.php +++ b/app/Http/Controllers/ClientStatementController.php @@ -43,7 +43,7 @@ class ClientStatementController extends BaseController } $pdf = $request->client()->service()->statement( - $request->only(['start_date', 'end_date', 'show_payments_table', 'show_aging_table', 'status', 'show_credits_table']), + $request->only(['start_date', 'end_date', 'show_payments_table', 'show_aging_table', 'status', 'show_credits_table', 'template']), $send_email ); diff --git a/app/Http/Requests/CompanyGateway/BulkCompanyGatewayRequest.php b/app/Http/Requests/CompanyGateway/BulkCompanyGatewayRequest.php index 444bf89291fe..397734aed5ac 100644 --- a/app/Http/Requests/CompanyGateway/BulkCompanyGatewayRequest.php +++ b/app/Http/Requests/CompanyGateway/BulkCompanyGatewayRequest.php @@ -26,13 +26,20 @@ class BulkCompanyGatewayRequest extends Request */ public function authorize() : bool { - return auth()->user()->isAdmin(); + /** @var \App\Models\User $user */ + $user = auth()->user(); + + return $user->isAdmin(); } public function rules() { + + /** @var \App\Models\User $user */ + $user = auth()->user(); + return [ - 'ids' => ['required','bail','array',Rule::exists('company_gateways', 'id')->where('company_id', auth()->user()->company()->id)], + 'ids' => ['required','bail','array',Rule::exists('company_gateways', 'id')->where('company_id', $user->company()->id)], 'action' => 'required|bail|in:archive,restore,delete' ]; } diff --git a/app/Http/Requests/Design/StoreDesignRequest.php b/app/Http/Requests/Design/StoreDesignRequest.php index c1ab381badd1..af0fbb8a84bf 100644 --- a/app/Http/Requests/Design/StoreDesignRequest.php +++ b/app/Http/Requests/Design/StoreDesignRequest.php @@ -45,7 +45,7 @@ class StoreDesignRequest extends Request 'design.footer' => 'required|min:1', 'design.includes' => 'required|min:1', 'is_template' => 'sometimes|boolean', - 'entities' => 'sometimes|string' + 'entities' => 'sometimes|string|nullable' ]; } diff --git a/app/Http/Requests/Design/UpdateDesignRequest.php b/app/Http/Requests/Design/UpdateDesignRequest.php index 9c6c48fa510b..62b1b4a97562 100644 --- a/app/Http/Requests/Design/UpdateDesignRequest.php +++ b/app/Http/Requests/Design/UpdateDesignRequest.php @@ -35,7 +35,7 @@ class UpdateDesignRequest extends Request { return [ 'is_template' => 'sometimes|boolean', - 'entities' => 'sometimes|string' + 'entities' => 'sometimes|string|nullable' ]; } diff --git a/app/Http/Requests/Invoice/UpdateInvoiceRequest.php b/app/Http/Requests/Invoice/UpdateInvoiceRequest.php index 1237c0030831..a21c98a988ea 100644 --- a/app/Http/Requests/Invoice/UpdateInvoiceRequest.php +++ b/app/Http/Requests/Invoice/UpdateInvoiceRequest.php @@ -65,8 +65,6 @@ class UpdateInvoiceRequest extends Request $rules['is_amount_discount'] = ['boolean']; - nlog($this->partial); - $rules['line_items'] = 'array'; $rules['discount'] = 'sometimes|numeric'; $rules['project_id'] = ['bail', 'sometimes', new ValidProjectForClient($this->all())]; diff --git a/app/Http/Requests/Statements/CreateStatementRequest.php b/app/Http/Requests/Statements/CreateStatementRequest.php index 3406be9468e5..2cf38a3a58d2 100644 --- a/app/Http/Requests/Statements/CreateStatementRequest.php +++ b/app/Http/Requests/Statements/CreateStatementRequest.php @@ -17,9 +17,10 @@ class CreateStatementRequest extends Request */ public function authorize(): bool { - // return auth()->user()->isAdmin(); + /** @var \App\Models\User $user */ + $user = auth()->user(); - return auth()->user()->can('view', $this->client()); + return $user->can('view', $this->client()); } /** @@ -29,14 +30,18 @@ class CreateStatementRequest extends Request */ public function rules() { + /** @var \App\Models\User $user */ + $user = auth()->user(); + return [ 'start_date' => 'required|date_format:Y-m-d', 'end_date' => 'required|date_format:Y-m-d', - 'client_id' => 'bail|required|exists:clients,id,company_id,'.auth()->user()->company()->id, + 'client_id' => 'bail|required|exists:clients,id,company_id,'.$user->company()->id, 'show_payments_table' => 'boolean', 'show_aging_table' => 'boolean', 'show_credits_table' => 'boolean', 'status' => 'string', + 'template' => 'sometimes|string|nullable', ]; } diff --git a/app/PaymentDrivers/PayFastPaymentDriver.php b/app/PaymentDrivers/PayFastPaymentDriver.php index 8e1ce921ac40..707aabd999db 100644 --- a/app/PaymentDrivers/PayFastPaymentDriver.php +++ b/app/PaymentDrivers/PayFastPaymentDriver.php @@ -53,7 +53,7 @@ class PayFastPaymentDriver extends BaseDriver if ($this->client->currency()->code == 'ZAR') { $types[] = GatewayType::CREDIT_CARD; } - + return $types; } diff --git a/app/Services/Client/PaymentMethod.php b/app/Services/Client/PaymentMethod.php index aa535e916a4e..e9cd452dc9b8 100644 --- a/app/Services/Client/PaymentMethod.php +++ b/app/Services/Client/PaymentMethod.php @@ -78,6 +78,19 @@ class PaymentMethod ->sortby(function ($model) use ($transformed_ids) { //company gateways are sorted in order of priority return array_search($model->id, $transformed_ids); // this closure sorts for us }); + + if($this->gateways->count() == 0 && count($transformed_ids) >=1) { + + /** This is a fallback in case a user archives some gateways that have been ordered preferentially. */ + $this->gateways = CompanyGateway::query() + ->with('gateway') + ->where('company_id', $this->client->company_id) + ->where('gateway_key', '!=', '54faab2ab6e3223dbe848b1686490baa') + ->whereNull('deleted_at') + ->where('is_deleted', false)->get(); + + } + } else { $this->gateways = CompanyGateway::query() ->with('gateway') diff --git a/app/Services/Client/Statement.php b/app/Services/Client/Statement.php index dae872c774bb..68d1f29aa531 100644 --- a/app/Services/Client/Statement.php +++ b/app/Services/Client/Statement.php @@ -29,11 +29,12 @@ use App\Utils\Traits\Pdf\PdfMaker as PdfMakerTrait; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use App\Models\Credit; +use App\Utils\Traits\MakesHash; class Statement { use PdfMakerTrait; - + use MakesHash; /** * @var Invoice|Payment|null */ @@ -88,21 +89,44 @@ class Statement 'process_markdown' => $this->entity->client->company->markdown_enabled, ]; - $maker = new PdfMaker($state); + if($this->options['template'] ?? false){ - $maker - ->design($template) - ->build(); + $template = Design::where('id', $this->decodePrimaryKey($this->options['template'])) + ->where('company_id', $this->client->company_id) + ->first(); - $pdf = null; + $ts = $template->service()->build([ + 'client' => $this->client, + 'entity' => $this->entity, + 'variables' => $variables, + 'invoices' => $this->getInvoices(), + 'payments' => $this->getPayments(), + 'credits' => $this->getCredits(), + 'aging' => $this->getAging(), + ]); + + $html = $ts->getHtml(); + + } + else { + + $maker = new PdfMaker($state); + + $maker + ->design($template) + ->build(); + + $pdf = null; + $html = $maker->getCompiledHTML(true); + } try { if (config('ninja.phantomjs_pdf_generation') || config('ninja.pdf_generator') == 'phantom') { - $pdf = (new Phantom)->convertHtmlToPdf($maker->getCompiledHTML(true)); + $pdf = (new Phantom)->convertHtmlToPdf($html); } elseif (config('ninja.invoiceninja_hosted_pdf_generation') || config('ninja.pdf_generator') == 'hosted_ninja') { - $pdf = (new NinjaPdf())->build($maker->getCompiledHTML(true)); + $pdf = (new NinjaPdf())->build($html); } else { - $pdf = $this->makePdf(null, null, $maker->getCompiledHTML(true)); + $pdf = $this->makePdf(null, null, $html); } } catch (\Exception $e) { nlog(print_r($e->getMessage(), 1)); diff --git a/app/Services/Template/TemplateService.php b/app/Services/Template/TemplateService.php index d2dbd94e688d..84f758882101 100644 --- a/app/Services/Template/TemplateService.php +++ b/app/Services/Template/TemplateService.php @@ -20,10 +20,12 @@ use App\Models\Payment; use App\Models\Project; use App\Utils\HtmlEngine; use League\Fractal\Manager; +use App\Models\ClientContact; use App\Models\PurchaseOrder; use App\Utils\VendorHtmlEngine; use App\Utils\PaymentHtmlEngine; use Illuminate\Support\Collection; +use Twig\Extra\Intl\IntlExtension; use App\Transformers\TaskTransformer; use App\Transformers\QuoteTransformer; use App\Transformers\CreditTransformer; @@ -91,7 +93,7 @@ class TemplateService { $data = $this->preProcessDataBlocks($data); $replacements = []; - +nlog($data); $contents = $this->document->getElementsByTagName('ninja'); foreach ($contents as $content) { @@ -103,10 +105,13 @@ class TemplateService $string_extension = new \Twig\Extension\StringLoaderExtension(); $twig->addExtension($string_extension); - + $twig->addExtension(new IntlExtension()); + $template = $twig->createTemplate(html_entity_decode($template)); $template = $template->render($data); + nlog($template); + $f = $this->document->createDocumentFragment(); $f->appendXML($template); $replacements[] = $f; @@ -228,67 +233,92 @@ class TemplateService private function processInvoices($invoices): array { $it = new InvoiceTransformer(); - $it->setDefaultIncludes(['client']); + $it->setDefaultIncludes(['client','payments']); $manager = new Manager(); - // $manager->setSerializer(new JsonApiSerializer()); - $resource = new \League\Fractal\Resource\Collection($invoices, $it, Invoice::class); - $i = $manager->createData($resource)->toArray(); - return $i['data']; + $manager->parseIncludes(['client','payments','payments.type']); + $resource = new \League\Fractal\Resource\Collection($invoices, $it, null); + $invoices = $manager->createData($resource)->toArray(); + + // nlog($invoices); + + foreach($invoices['data'] as $key => $invoice) + { + + $invoices['data'][$key]['client'] = $invoice['client']['data'] ?? []; + $invoices['data'][$key]['client']['contacts'] = $invoice['client']['data']['contacts']['data'] ?? []; + $invoices['data'][$key]['payments'] = $invoice['payments']['data'] ?? []; + + if($invoice['payments']['data'] ?? false) { + foreach($invoice['payments']['data'] as $keyx => $payment) { + $invoices['data'][$key]['payments'][$keyx]['paymentables']= $payment['paymentables']['data'] ?? []; + } + } + + } + + return $invoices['data']; } - private function processQuotes($quotes): Collection + private function processQuotes($quotes): array { $it = new QuoteTransformer(); $it->setDefaultIncludes(['client']); $manager = new Manager(); + $manager->setSerializer(new ArraySerializer()); $resource = new \League\Fractal\Resource\Collection($quotes, $it, Quote::class); $i = $manager->createData($resource)->toArray(); - return $i['data']; + + $i['client']['contacts'] = $i['client']['contacts'][ClientContact::class]; + return $i[Quote::class]; } - private function processCredits($credits): Collection + private function processCredits($credits): array { $it = new CreditTransformer(); $it->setDefaultIncludes(['client']); $manager = new Manager(); + $manager->setSerializer(new ArraySerializer()); $resource = new \League\Fractal\Resource\Collection($credits, $it, Credit::class); $i = $manager->createData($resource)->toArray(); - return $i['data']; + return $i[Credit::class]; } - private function processPayments($payments): Collection + private function processPayments($payments): array { $it = new PaymentTransformer(); $it->setDefaultIncludes(['client','invoices','paymentables']); $manager = new Manager(); + $manager->setSerializer(new ArraySerializer()); $resource = new \League\Fractal\Resource\Collection($payments, $it, Payment::class); $i = $manager->createData($resource)->toArray(); - return $i['data']; + return $i[Payment::class]; } - private function processTasks($tasks): Collection + private function processTasks($tasks): array { $it = new TaskTransformer(); $it->setDefaultIncludes(['client','tasks','project','invoice']); $manager = new Manager(); + $manager->setSerializer(new ArraySerializer()); $resource = new \League\Fractal\Resource\Collection($tasks, $it, Task::class); $i = $manager->createData($resource)->toArray(); - return $i['data']; + return $i[Task::class]; } - private function processProjects($projects): Collection + private function processProjects($projects): array { $it = new ProjectTransformer(); $it->setDefaultIncludes(['client','tasks']); $manager = new Manager(); + $manager->setSerializer(new ArraySerializer()); $resource = new \League\Fractal\Resource\Collection($projects, $it, Project::class); $i = $manager->createData($resource)->toArray(); - return $i['data']; + return $i[Project::class]; } @@ -298,9 +328,10 @@ class TemplateService $it = new PurchaseOrderTransformer(); $it->setDefaultIncludes(['vendor','expense']); $manager = new Manager(); + $manager->setSerializer(new ArraySerializer()); $resource = new \League\Fractal\Resource\Collection($purchase_orders, $it, PurchaseOrder::class); $i = $manager->createData($resource)->toArray(); - return $i['data']; + return $i[PurchaseOrder::class]; } } \ No newline at end of file diff --git a/app/Transformers/PaymentTransformer.php b/app/Transformers/PaymentTransformer.php index 2c9df5a4073c..cb7d038e6941 100644 --- a/app/Transformers/PaymentTransformer.php +++ b/app/Transformers/PaymentTransformer.php @@ -32,6 +32,7 @@ class PaymentTransformer extends EntityTransformer protected array $availableIncludes = [ 'client', 'invoices', + 'type', ]; public function __construct($serializer = null) @@ -69,6 +70,13 @@ class PaymentTransformer extends EntityTransformer return $this->includeCollection($payment->documents, $transformer, Document::class); } + public function includeType(Payment $payment) + { + return [ + 'type' => $payment->type->translatedType() ?? '', + ]; + } + public function transform(Payment $payment) { return [ diff --git a/composer.json b/composer.json index 5520a24c5d38..86c90466fead 100644 --- a/composer.json +++ b/composer.json @@ -94,6 +94,7 @@ "symfony/mailgun-mailer": "^6.1", "symfony/postmark-mailer": "^6.1", "turbo124/beacon": "^1.5", + "twig/intl-extra": "^3.7", "twig/twig": "^3", "twilio/sdk": "^6.40", "webpatser/laravel-countries": "dev-master#75992ad", diff --git a/composer.lock b/composer.lock index 6de1b9d36ffd..200e1a53a515 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "08bc4729962b495b68162a069269f74f", + "content-hash": "0e0f7606a875b132577ee735309b1247", "packages": [ { "name": "afosto/yaac", @@ -13864,6 +13864,70 @@ }, "time": "2023-09-24T07:20:04+00:00" }, + { + "name": "twig/intl-extra", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/twigphp/intl-extra.git", + "reference": "4f4fe572f635534649cc069e1dafe4a8ad63774d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/intl-extra/zipball/4f4fe572f635534649cc069e1dafe4a8ad63774d", + "reference": "4f4fe572f635534649cc069e1dafe4a8ad63774d", + "shasum": "" + }, + "require": { + "php": ">=7.1.3", + "symfony/intl": "^5.4|^6.0", + "twig/twig": "^2.7|^3.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^5.4|^6.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Twig\\Extra\\Intl\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + } + ], + "description": "A Twig extension for Intl", + "homepage": "https://twig.symfony.com", + "keywords": [ + "intl", + "twig" + ], + "support": { + "source": "https://github.com/twigphp/intl-extra/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2023-07-29T15:34:56+00:00" + }, { "name": "twig/twig", "version": "v3.7.1", diff --git a/tests/Feature/Template/TemplateTest.php b/tests/Feature/Template/TemplateTest.php index aa391d69d747..1f6dc241f5e7 100644 --- a/tests/Feature/Template/TemplateTest.php +++ b/tests/Feature/Template/TemplateTest.php @@ -101,6 +101,59 @@ class TemplateTest extends TestCase '; + private string $payments_body = ' + + + + + + + + + + + + + + + {% for invoice in invoices %} + + + + + + + + + + {% for payment in invoice.payments|filter(payment => payment.is_deleted == false) %} + + {% for pivot in payment.paymentables %} + + + + + + + + + + + {% endfor %} + {% endfor %} + {% endfor%} + +
Invoice #DateDue DateTotalTransactionOutstanding
{{ invoice.number }}{{ invoice.date }}{{ invoice.due_date }}{{ invoice.amount|format_currency("EUR") }}{{ invoice.balance|format_currency("EUR") }}
{{ payment.number }}{{ payment.date }} + {% if pivot.amount > 0 %} + {{ pivot.amount|format_currency("EUR") }} - {{ payment.type.name }} + {% else %} + ({{ pivot.refunded|format_currency("EUR") }}) + {% endif %} +
+ +
+ '; + protected function setUp() :void { parent::setUp(); @@ -113,6 +166,67 @@ class TemplateTest extends TestCase } + public function testVariableResolutionViaTransformersForPaymentsInStatements() + { + Invoice::factory()->count(20)->create([ + 'company_id' => $this->company->id, + 'user_id' => $this->user->id, + 'client_id' => $this->client->id, + 'status_id' => Invoice::STATUS_SENT, + 'amount' => 100, + 'balance' => 100, + ]); + + $i = Invoice::orderBy('id','desc') + ->where('client_id', $this->client->id) + ->where('status_id', 2) + ->cursor() + ->each(function ($i){ + $i->service()->applyPaymentAmount(random_int(1,100)); + }); + + $invoices = Invoice::withTrashed() + ->with('payments.type') + ->where('is_deleted', false) + ->where('company_id', $this->client->company_id) + ->where('client_id', $this->client->id) + ->whereIn('status_id', [2,3,4]) + ->orderBy('due_date', 'ASC') + ->orderBy('date', 'ASC') + ->cursor(); + + $invoices->each(function ($i){ + + $rand = [1,2,4,5,6,7,8,9,10,11,12,13,14,15,16,17,24,25,32,49,50]; + + $i->payments()->each(function ($p) use ($rand){ + shuffle($rand); + $p->type_id = $rand[0]; + $p->save(); + + }); + }); + + $design_model = Design::find(2); + + $replicated_design = $design_model->replicate(); + $design = $replicated_design->design; + $design->body .= $this->payments_body; + $replicated_design->design = $design; + $replicated_design->is_custom = true; + $replicated_design->is_template =true; + $replicated_design->entities = 'client'; + $replicated_design->save(); + + $data['invoices'] = $invoices; + $ts = $replicated_design->service()->build($data); + + nlog("results = "); + nlog($ts->getHtml()); + $this->assertNotNull($ts->getHtml()); + + } + public function testDoubleEntityNestedDataTemplateServiceBuild() { $design_model = Design::find(2);