diff --git a/src/.env.example b/src/.env.example --- a/src/.env.example +++ b/src/.env.example @@ -99,5 +99,11 @@ JWT_SECRET= +COMPANY_NAME= +COMPANY_ADDRESS= +COMPANY_DETAILS= +COMPANY_EMAIL= +COMPANY_LOGO= + KB_ACCOUNT_DELETE= KB_ACCOUNT_SUSPENDED= diff --git a/src/app/Console/Development/TemplateRender.php b/src/app/Console/Development/TemplateRender.php --- a/src/app/Console/Development/TemplateRender.php +++ b/src/app/Console/Development/TemplateRender.php @@ -11,7 +11,7 @@ * * @var string */ - protected $signature = 'template:render {template}'; + protected $signature = 'template:render {template} {--html} {--pdf}'; /** * The console command description. @@ -28,10 +28,32 @@ public function handle() { $template = $this->argument('template'); - $template = str_replace('/', '\\', $template); + $template = str_replace("/", "\\", $template); - $class = '\\App\\' . $template; + $class = "\\App\\{$template}"; - echo $class::fakeRender(); + // Invalid template, list all templates + if (!class_exists($class)) { + $this->info("Invalid template name. Available templates:"); + + foreach (glob(app_path() . '/Documents/*.php') as $file) { + $file = basename($file, '.php'); + $this->info("Documents/$file"); + } + + foreach (glob(app_path() . '/Mail/*.php') as $file) { + $file = basename($file, '.php'); + $this->info("Mail/$file"); + } + + return 1; + } + + $mode = 'html'; + if (!empty($this->option('pdf'))) { + $mode = 'pdf'; + } + + echo $class::fakeRender($mode); } } diff --git a/src/app/Documents/Receipt.php b/src/app/Documents/Receipt.php new file mode 100644 --- /dev/null +++ b/src/app/Documents/Receipt.php @@ -0,0 +1,241 @@ +wallet = $wallet; + $this->year = $year; + $this->month = $month; + } + + /** + * Render the mail template with fake data + * + * @return string HTML or PDF output + */ + public static function fakeRender(string $type = 'html'): string + { + $wallet = new Wallet(); + $wallet->id = \App\Utils::uuidStr(); + $wallet->owner = new User(['id' => 123456789]); // @phpstan-ignore-line + + $receipt = new self($wallet, date('Y'), date('n')); + + self::$fakeMode = true; + + if ($type == 'pdf') { + return $receipt->pdfOutput(); + } + + return $receipt->htmlOutput(); + } + + /** + * Render the receipt in HTML format. + * + * @return string HTML content + */ + public function htmlOutput(): string + { + return $this->build()->render(); + } + + /** + * Render the receipt in PDF format. + * + * @return string PDF content + */ + public function pdfOutput(): string + { + // Parse ther HTML template + $html = $this->build()->render(); + + // Link fonts from public/fonts to storage/fonts so DomPdf can find them + if (!is_link(storage_path('fonts/Roboto-Regular.ttf'))) { + symlink( + public_path('fonts/Roboto-Regular.ttf'), + storage_path('fonts/Roboto-Regular.ttf') + ); + symlink( + public_path('fonts/Roboto-Bold.ttf'), + storage_path('fonts/Roboto-Bold.ttf') + ); + } + + // Fix font and image paths + $html = str_replace('url(/fonts/', 'url(fonts/', $html); + $html = str_replace('src="/images/', 'src="images/', $html); + + // TODO: The output file is about ~200KB, we could probably slim it down + // by using separate font files with small subset of languages when + // there are no Unicode characters used, e.g. only ASCII or Latin. + + // Load PDF generator + $pdf = \PDF::loadHTML($html)->setPaper('a4', 'portrait'); + + return $pdf->output(); + } + + /** + * Build the document + * + * @return \Illuminate\View\View The template object + */ + protected function build() + { + $appName = \config('app.name'); + $start = Carbon::create($this->year, $this->month, 1, 0, 0, 0); + $end = $start->copy()->endOfMonth(); + + $month = \trans('documents.month' . intval($this->month)); + $title = \trans('documents.receipt-title', ['year' => $this->year, 'month' => $month]); + $company = $this->companyData(); + + if (self::$fakeMode) { + $customer = [ + 'id' => $this->wallet->owner->id, + 'wallet_id' => $this->wallet->id, + 'customer' => 'Freddie Krüger
7252 Westminster Lane
Forest Hills, NY 11375', + ]; + + $items = collect([ + (object) [ + 'amount' => 1234, + 'updated_at' => $start->copy()->next(Carbon::MONDAY), + ], + (object) [ + 'amount' => 10000, + // @phpstan-ignore-next-line + 'updated_at' => $start->copy()->next()->next(), + ], + (object) [ + 'amount' => 1234, + // @phpstan-ignore-next-line + 'updated_at' => $start->copy()->next()->next()->next(Carbon::MONDAY), + ], + (object) [ + 'amount' => 99, + // @phpstan-ignore-next-line + 'updated_at' => $start->copy()->next()->next()->next(), + ], + ]); + } else { + $customer = $this->customerData(); + + $items = $this->wallet->payments() + ->where('status', PaymentProvider::STATUS_PAID) + ->where('updated_at', '>=', $start) + ->where('updated_at', '<', $end) + ->where('amount', '>', 0) + ->orderBy('updated_at') + ->get(); + } + + $total = 0; + $items = $items->map(function ($item) use (&$total, $appName) { + $total += $item->amount; + return [ + 'amount' => sprintf('%.2f %s', $item->amount / 100, $this->wallet->currency), + 'description' => \trans('documents.receipt-item-desc', ['site' => $appName]), + 'date' => $item->updated_at->toDateString(), + ]; + }); + + $total = sprintf('%.2f %s', $total / 100, $this->wallet->currency); + + // Load the template + $view = view('documents.receipt') + ->with([ + 'site' => $appName, + 'title' => $title, + 'company' => $company, + 'customer' => $customer, + 'items' => $items, + 'total' => $total, + ]); + + return $view; + } + + /** + * Prepare customer data for the template + * + * @return array Customer data for the template + */ + protected function customerData(): array + { + $user = $this->wallet->owner; + $name = $user->name(); + $organization = $user->getSetting('organization'); + $address = $user->getSetting('billing_address'); + + $customer = trim(($organization ?: $name) . "\n$address"); + $customer = str_replace("\n", '
', htmlentities($customer)); + + return [ + 'id' => $this->wallet->owner->id, + 'wallet_id' => $this->wallet->id, + 'customer' => $customer, + ]; + } + + /** + * Prepare company data for the template + * + * @return array Company data for the template + */ + protected function companyData(): array + { + $header = \config('app.company.name') . "\n" . \config('app.company.address'); + $header = str_replace("\n", '
', htmlentities($header)); + + $footerLineLength = 110; + $footer = \config('app.company.details'); + $contact = \config('app.company.email'); + $logo = \config('app.company.logo'); + + if ($contact) { + $length = strlen($footer) + strlen($contact) + 3; + $contact = htmlentities($contact); + $footer .= ($length > $footerLineLength ? "\n" : ' | ') + . sprintf('%s', $contact, $contact); + } + + return [ + 'logo' => $logo ? "" : '', + 'header' => $header, + 'footer' => $footer, + ]; + } +} diff --git a/src/app/Payment.php b/src/app/Payment.php --- a/src/app/Payment.php +++ b/src/app/Payment.php @@ -22,6 +22,16 @@ 'amount' => 'integer' ]; + protected $fillable = [ + 'id', + 'wallet_id', + 'amount', + 'description', + 'provider', + 'status', + 'type', + ]; + /** * The wallet to which this payment belongs. * diff --git a/src/composer.json b/src/composer.json --- a/src/composer.json +++ b/src/composer.json @@ -15,6 +15,7 @@ ], "require": { "php": "^7.1.3", + "barryvdh/laravel-dompdf": "^0.8.6", "doctrine/dbal": "^2.9", "fideloper/proxy": "^4.0", "geoip2/geoip2": "^2.9", diff --git a/src/config/app.php b/src/config/app.php --- a/src/config/app.php +++ b/src/config/app.php @@ -176,6 +176,7 @@ /* * Package Service Providers... */ + Barryvdh\DomPDF\ServiceProvider::class, /* * Application Service Providers... @@ -221,6 +222,7 @@ 'Mail' => Illuminate\Support\Facades\Mail::class, 'Notification' => Illuminate\Support\Facades\Notification::class, 'Password' => Illuminate\Support\Facades\Password::class, + 'PDF' => Barryvdh\DomPDF\Facade::class, 'Queue' => Illuminate\Support\Facades\Queue::class, 'Redirect' => Illuminate\Support\Facades\Redirect::class, 'Redis' => Illuminate\Support\Facades\Redis::class, @@ -243,4 +245,12 @@ // An article about a way to delete an owned account 'account_delete' => env('KB_ACCOUNT_DELETE'), ], + + 'company' => [ + 'name' => env('COMPANY_NAME'), + 'address' => env('COMPANY_ADDRESS'), + 'details' => env('COMPANY_DETAILS'), + 'email' => env('COMPANY_EMAIL'), + 'logo' => env('COMPANY_LOGO'), + ], ]; diff --git a/src/config/dompdf.php b/src/config/dompdf.php new file mode 100644 --- /dev/null +++ b/src/config/dompdf.php @@ -0,0 +1,243 @@ + false, // Throw an Exception on warnings from dompdf + 'orientation' => 'portrait', + 'defines' => [ + /** + * The location of the DOMPDF font directory + * + * The location of the directory where DOMPDF will store fonts and font metrics + * Note: This directory must exist and be writable by the webserver process. + * *Please note the trailing slash.* + * + * Notes regarding fonts: + * Additional .afm font metrics can be added by executing load_font.php from command line. + * + * Only the original "Base 14 fonts" are present on all pdf viewers. Additional fonts must + * be embedded in the pdf file or the PDF may not display correctly. This can significantly + * increase file size unless font subsetting is enabled. Before embedding a font please + * review your rights under the font license. + * + * Any font specification in the source HTML is translated to the closest font available + * in the font directory. + * + * The pdf standard "Base 14 fonts" are: + * Courier, Courier-Bold, Courier-BoldOblique, Courier-Oblique, + * Helvetica, Helvetica-Bold, Helvetica-BoldOblique, Helvetica-Oblique, + * Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic, + * Symbol, ZapfDingbats. + */ + "font_dir" => storage_path('fonts/'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782) + + /** + * The location of the DOMPDF font cache directory + * + * This directory contains the cached font metrics for the fonts used by DOMPDF. + * This directory can be the same as DOMPDF_FONT_DIR + * + * Note: This directory must exist and be writable by the webserver process. + */ + "font_cache" => storage_path('fonts/'), + + /** + * The location of a temporary directory. + * + * The directory specified must be writeable by the webserver process. + * The temporary directory is required to download remote images and when + * using the PFDLib back end. + */ + "temp_dir" => sys_get_temp_dir(), + + /** + * ==== IMPORTANT ==== + * + * dompdf's "chroot": Prevents dompdf from accessing system files or other + * files on the webserver. All local files opened by dompdf must be in a + * subdirectory of this directory. DO NOT set it to '/' since this could + * allow an attacker to use dompdf to read any files on the server. This + * should be an absolute path. + * This is only checked on command line call by dompdf.php, but not by + * direct class use like: + * $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output(); + */ + "chroot" => realpath(base_path()), + + /** + * Whether to enable font subsetting or not. + */ + "enable_font_subsetting" => false, + + /** + * The PDF rendering backend to use + * + * Valid settings are 'PDFLib', 'CPDF' (the bundled R&OS PDF class), 'GD' and + * 'auto'. 'auto' will look for PDFLib and use it if found, or if not it will + * fall back on CPDF. 'GD' renders PDFs to graphic files. {@link + * Canvas_Factory} ultimately determines which rendering class to instantiate + * based on this setting. + * + * Both PDFLib & CPDF rendering backends provide sufficient rendering + * capabilities for dompdf, however additional features (e.g. object, + * image and font support, etc.) differ between backends. Please see + * {@link PDFLib_Adapter} for more information on the PDFLib backend + * and {@link CPDF_Adapter} and lib/class.pdf.php for more information + * on CPDF. Also see the documentation for each backend at the links + * below. + * + * The GD rendering backend is a little different than PDFLib and + * CPDF. Several features of CPDF and PDFLib are not supported or do + * not make any sense when creating image files. For example, + * multiple pages are not supported, nor are PDF 'objects'. Have a + * look at {@link GD_Adapter} for more information. GD support is + * experimental, so use it at your own risk. + * + * @link http://www.pdflib.com + * @link http://www.ros.co.nz/pdf + * @link http://www.php.net/image + */ + "pdf_backend" => "CPDF", + + /** + * PDFlib license key + * + * If you are using a licensed, commercial version of PDFlib, specify + * your license key here. If you are using PDFlib-Lite or are evaluating + * the commercial version of PDFlib, comment out this setting. + * + * @link http://www.pdflib.com + * + * If pdflib present in web server and auto or selected explicitely above, + * a real license code must exist! + */ + //"DOMPDF_PDFLIB_LICENSE" => "your license key here", + + /** + * html target media view which should be rendered into pdf. + * List of types and parsing rules for future extensions: + * http://www.w3.org/TR/REC-html40/types.html + * screen, tty, tv, projection, handheld, print, braille, aural, all + * Note: aural is deprecated in CSS 2.1 because it is replaced by speech in CSS 3. + * Note, even though the generated pdf file is intended for print output, + * the desired content might be different (e.g. screen or projection view of html file). + * Therefore allow specification of content here. + */ + "default_media_type" => "print", + + /** + * The default paper size. + * + * North America standard is "letter"; other countries generally "a4" + * + * @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.) + */ + "default_paper_size" => "a4", + + /** + * The default font family + * + * Used if no suitable fonts can be found. This must exist in the font folder. + * @var string + */ + "default_font" => "serif", + + /** + * Image DPI setting + * + * This setting determines the default DPI setting for images and fonts. The + * DPI may be overridden for inline images by explictly setting the + * image's width & height style attributes (i.e. if the image's native + * width is 600 pixels and you specify the image's width as 72 points, + * the image will have a DPI of 600 in the rendered PDF. The DPI of + * background images can not be overridden and is controlled entirely + * via this parameter. + * + * For the purposes of DOMPDF, pixels per inch (PPI) = dots per inch (DPI). + * If a size in html is given as px (or without unit as image size), + * this tells the corresponding size in pt. + * This adjusts the relative sizes to be similar to the rendering of the + * html page in a reference browser. + * + * In pdf, always 1 pt = 1/72 inch + * + * Rendering resolution of various browsers in px per inch: + * Windows Firefox and Internet Explorer: + * SystemControl->Display properties->FontResolution: Default:96, largefonts:120, custom:? + * Linux Firefox: + * about:config *resolution: Default:96 + * (xorg screen dimension in mm and Desktop font dpi settings are ignored) + * + * Take care about extra font/image zoom factor of browser. + * + * In images, size in pixel attribute, img css style, are overriding + * the real image dimension in px for rendering. + * + * @var int + */ + "dpi" => 96, + + /** + * Enable inline PHP + * + * If this setting is set to true then DOMPDF will automatically evaluate + * inline PHP contained within tags. + * + * Enabling this for documents you do not trust (e.g. arbitrary remote html + * pages) is a security risk. Set this option to false if you wish to process + * untrusted documents. + * + * @var bool + */ + "enable_php" => false, + + /** + * Enable inline Javascript + * + * If this setting is set to true then DOMPDF will automatically insert + * JavaScript code contained within tags. + * + * @var bool + */ + "enable_javascript" => false, + + /** + * Enable remote file access + * + * If this setting is set to true, DOMPDF will access remote sites for + * images and CSS files as required. + * This is required for part of test case www/test/image_variants.html through www/examples.php + * + * Attention! + * This can be a security risk, in particular in combination with DOMPDF_ENABLE_PHP and + * allowing remote access to dompdf.php or on allowing remote html code to be passed to + * $dompdf = new DOMPDF(, $dompdf->load_html(..., + * This allows anonymous users to download legally doubtful internet content which on + * tracing back appears to being downloaded by your server, or allows malicious php code + * in remote html pages to be executed by your server with your account privileges. + * + * @var bool + */ + "enable_remote" => false, + + /** + * A ratio applied to the fonts height to be more like browsers' line height + */ + "font_height_ratio" => 1.1, + + /** + * Use the more-than-experimental HTML5 Lib parser + */ + "enable_html5_parser" => false, + ], + +]; diff --git a/src/public/fonts/Roboto-Bold.ttf b/src/public/fonts/Roboto-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 GIT binary patch literal 0 Hc$@ + + + + + + + + + + + diff --git a/src/public/mix-manifest.json b/src/public/mix-manifest.json --- a/src/public/mix-manifest.json +++ b/src/public/mix-manifest.json @@ -1,5 +1,6 @@ { "/js/admin.js": "/js/admin.js", "/js/user.js": "/js/user.js", - "/css/app.css": "/css/app.css" + "/css/app.css": "/css/app.css", + "/css/document.css": "/css/document.css" } diff --git a/src/resources/lang/en/documents.php b/src/resources/lang/en/documents.php new file mode 100644 --- /dev/null +++ b/src/resources/lang/en/documents.php @@ -0,0 +1,35 @@ + "Account ID", + 'amount' => "Amount", + 'customer-no' => "Customer No.", + 'date' => "Date", + 'description' => "Description", + 'period' => "Period", + 'total' => "Total", + + 'month1' => "January", + 'month2' => "February", + 'month3' => "March", + 'month4' => "April", + 'month5' => "May", + 'month6' => "June", + 'month7' => "July", + 'month8' => "August", + 'month9' => "September", + 'month10' => "October", + 'month11' => "November", + 'month12' => "December", + + 'receipt-title' => "Receipt for :month :year", + 'receipt-item-desc' => ":site Services", + +]; diff --git a/src/resources/sass/document.scss b/src/resources/sass/document.scss new file mode 100644 --- /dev/null +++ b/src/resources/sass/document.scss @@ -0,0 +1,112 @@ +// Variables +@import 'variables'; + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: normal; + src: url('../fonts/Roboto-Regular.ttf') format('truetype'); +} + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 700; + src: url('../fonts/Roboto-Bold.ttf') format('truetype'); +} + +body { + font-family: Roboto, sans-serif; + margin: 20pt; +} + +a { + text-decoration: none; + color: $main-color; +} + +h1 { + text-align: center; + margin: 30pt; + font-size: 20pt; +} + +table { + width: 100%; + + &.content { + border-spacing: initial; + + th { + padding: 5px; + background-color: #f4f4f4; + border-top: 1px solid #eee; + } + + td { + padding: 5px; + border-bottom: 1px solid #eee; + } + } + + &.head { + margin-bottom: 1em; + } + + + td.idents { + white-space: nowrap; + width: 1%; + text-align: right; + vertical-align: top; + font-size: 10pt; + } + + td.logo { + width: 1%; + } + + td.description { + width: 98%; + } + + tr.total { + background-color: #f4f4f4; + } +} + +#footer { + font-size: 10pt; + color: gray; + text-align: center; + padding-top: 5pt; + position: absolute; + margin: 20pt; + bottom: 0; + left: 0; + right: 0; + + @media print { + margin-bottom: 0; + } +} + +.bold { + font-weight: bold; +} + +.gray { + color: gray; +} + +.align-right { + text-align: right; +} + +.align-left { + text-align: left; +} + +.amount { + white-space: nowrap; +} diff --git a/src/resources/views/documents/receipt.blade.php b/src/resources/views/documents/receipt.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/documents/receipt.blade.php @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + +

{{ $title }}

+ + + + + + +
+ {!! $customer['customer'] !!} + + {{ __('documents.account-id') }} {{ $customer['wallet_id'] }}
+ {{ __('documents.customer-no') }} {{ $customer['id'] }} +
+ + + + + + + +@foreach ($items as $item) + + + + + +@endforeach + + + + +
{{ __('documents.date') }}{{ __('documents.description') }}{{ __('documents.amount') }}
{{ $item['date'] }}{{ $item['description'] }}{{ $item['amount'] }}
{{ __('documents.total') }}{{ $total }}
+ + + + diff --git a/src/storage/fonts/.gitignore b/src/storage/fonts/.gitignore new file mode 100644 --- /dev/null +++ b/src/storage/fonts/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/tests/Feature/Documents/ReceiptTest.php b/src/tests/Feature/Documents/ReceiptTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Documents/ReceiptTest.php @@ -0,0 +1,261 @@ +deleteTestUser('receipt-test@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test receipt HTML output + * + * @return void + */ + public function testHtmlOutput() + { + $appName = \config('app.name'); + $wallet = $this->getTestData(); + $receipt = new Receipt($wallet, 2020, 5); + $html = $receipt->htmlOutput(); + + $this->assertStringStartsWith('', $html); + + $dom = new \DOMDocument('1.0', 'UTF-8'); + $dom->loadHTML($html); + + // Title + $title = $dom->getElementById('title'); + $this->assertSame("Receipt for May 2020", $title->textContent); + + // Company name/address + $header = $dom->getElementById('header'); + $companyOutput = $this->getNodeContent($header->getElementsByTagName('td')[0]); + $companyExpected = \config('app.company.name') . "\n" . \config('app.company.address'); + $this->assertSame($companyExpected, $companyOutput); + + // The main table content + $content = $dom->getElementById('content'); + $records = $content->getElementsByTagName('tr'); + $this->assertCount(5, $records); + + $headerCells = $records[0]->getElementsByTagName('th'); + $this->assertCount(3, $headerCells); + $this->assertSame('Date', $this->getNodeContent($headerCells[0])); + $this->assertSame('Description', $this->getNodeContent($headerCells[1])); + $this->assertSame('Amount', $this->getNodeContent($headerCells[2])); + $cells = $records[1]->getElementsByTagName('td'); + $this->assertCount(3, $cells); + $this->assertSame('2020-05-01', $this->getNodeContent($cells[0])); + $this->assertSame("$appName Services", $this->getNodeContent($cells[1])); + $this->assertSame('12.34 CHF', $this->getNodeContent($cells[2])); + $cells = $records[2]->getElementsByTagName('td'); + $this->assertCount(3, $cells); + $this->assertSame('2020-05-10', $this->getNodeContent($cells[0])); + $this->assertSame("$appName Services", $this->getNodeContent($cells[1])); + $this->assertSame('0.01 CHF', $this->getNodeContent($cells[2])); + $cells = $records[3]->getElementsByTagName('td'); + $this->assertCount(3, $cells); + $this->assertSame('2020-05-31', $this->getNodeContent($cells[0])); + $this->assertSame("$appName Services", $this->getNodeContent($cells[1])); + $this->assertSame('1.00 CHF', $this->getNodeContent($cells[2])); + $summaryCells = $records[4]->getElementsByTagName('td'); + $this->assertCount(2, $summaryCells); + $this->assertSame('Total', $this->getNodeContent($summaryCells[0])); + $this->assertSame('13.35 CHF', $this->getNodeContent($summaryCells[1])); + + // Customer data + $customer = $dom->getElementById('customer'); + $customerCells = $customer->getElementsByTagName('td'); + $customerOutput = $this->getNodeContent($customerCells[0]); + $customerExpected = "Firstname Lastname\nTest Unicode Straße 150\n10115 Berlin"; + $this->assertSame($customerExpected, $this->getNodeContent($customerCells[0])); + $customerIdents = $this->getNodeContent($customerCells[1]); + $this->assertTrue(strpos($customerIdents, "Account ID {$wallet->id}") !== false); + $this->assertTrue(strpos($customerIdents, "Customer No. {$wallet->owner->id}") !== false); + + // TODO: Company details in the footer + $footer = $dom->getElementById('footer'); + $footerOutput = $footer->textContent; + $this->assertStringStartsWith(\config('app.company.details'), $footerOutput); + $this->assertTrue(strpos($footerOutput, \config('app.company.email')) !== false); + } + + /** + * Test receipt PDF output + * + * @return void + */ + public function testPdfOutput() + { + $wallet = $this->getTestData(); + $receipt = new Receipt($wallet, 2020, 5); + $pdf = $receipt->PdfOutput(); + + $this->assertStringStartsWith("%PDF-1.3\n", $pdf); + $this->assertTrue(strlen($pdf) > 5000); + + // TODO: Test the content somehow + } + + /** + * Prepare data for a test + * + * @return \App\Wallet + */ + protected function getTestData() + { + Bus::fake(); + + $user = $this->getTestUser('receipt-test@kolabnow.com'); + $user->setSettings([ + 'first_name' => 'Firstname', + 'last_name' => 'Lastname', + 'billing_address' => "Test Unicode Straße 150\n10115 Berlin", + ]); + + $wallet = $user->wallets()->first(); + + // Create two payments out of the 2020-05 period + // and three in it, plus one in the period but unpaid, + // and one with amount 0 + + $payment = Payment::create([ + 'id' => 'AAA1', + 'status' => PaymentProvider::STATUS_PAID, + 'type' => PaymentProvider::TYPE_ONEOFF, + 'description' => 'Paid in April', + 'wallet_id' => $wallet->id, + 'provider' => 'stripe', + 'amount' => 1111, + ]); + $payment->updated_at = Carbon::create(2020, 4, 30, 12, 0, 0); + $payment->save(); + + $payment = Payment::create([ + 'id' => 'AAA2', + 'status' => PaymentProvider::STATUS_PAID, + 'type' => PaymentProvider::TYPE_ONEOFF, + 'description' => 'Paid in June', + 'wallet_id' => $wallet->id, + 'provider' => 'stripe', + 'amount' => 2222, + ]); + $payment->updated_at = Carbon::create(2020, 6, 1, 0, 0, 0); + $payment->save(); + + $payment = Payment::create([ + 'id' => 'AAA3', + 'status' => PaymentProvider::STATUS_PAID, + 'type' => PaymentProvider::TYPE_ONEOFF, + 'description' => 'Auto-Payment Setup', + 'wallet_id' => $wallet->id, + 'provider' => 'stripe', + 'amount' => 0, + ]); + $payment->updated_at = Carbon::create(2020, 5, 1, 0, 0, 0); + $payment->save(); + + $payment = Payment::create([ + 'id' => 'AAA4', + 'status' => PaymentProvider::STATUS_OPEN, + 'type' => PaymentProvider::TYPE_ONEOFF, + 'description' => 'Payment not yet paid', + 'wallet_id' => $wallet->id, + 'provider' => 'stripe', + 'amount' => 999, + ]); + $payment->updated_at = Carbon::create(2020, 5, 1, 0, 0, 0); + $payment->save(); + + // ... so we expect the last three on the receipt + $payment = Payment::create([ + 'id' => 'AAA5', + 'status' => PaymentProvider::STATUS_PAID, + 'type' => PaymentProvider::TYPE_ONEOFF, + 'description' => 'Payment OK', + 'wallet_id' => $wallet->id, + 'provider' => 'stripe', + 'amount' => 1234, + ]); + $payment->updated_at = Carbon::create(2020, 5, 1, 0, 0, 0); + $payment->save(); + + $payment = Payment::create([ + 'id' => 'AAA6', + 'status' => PaymentProvider::STATUS_PAID, + 'type' => PaymentProvider::TYPE_ONEOFF, + 'description' => 'Payment OK', + 'wallet_id' => $wallet->id, + 'provider' => 'stripe', + 'amount' => 1, + ]); + $payment->updated_at = Carbon::create(2020, 5, 10, 0, 0, 0); + $payment->save(); + + $payment = Payment::create([ + 'id' => 'AAA7', + 'status' => PaymentProvider::STATUS_PAID, + 'type' => PaymentProvider::TYPE_RECURRING, + 'description' => 'Payment OK', + 'wallet_id' => $wallet->id, + 'provider' => 'stripe', + 'amount' => 100, + ]); + $payment->updated_at = Carbon::create(2020, 5, 31, 23, 59, 0); + $payment->save(); + + // Make sure some config is set so we can test it's put into the receipt + if (empty(\config('app.company.name'))) { + \config(['app.company.name' => 'Company Co.']); + } + if (empty(\config('app.company.email'))) { + \config(['app.company.email' => 'email@domina.tld']); + } + if (empty(\config('app.company.details'))) { + \config(['app.company.details' => 'VAT No. 123456789']); + } + if (empty(\config('app.company.address'))) { + \config(['app.company.address' => "Test Street 12\n12345 Some Place"]); + } + + return $wallet; + } + + /** + * Extract text from a HTML element replacing
with \n + * + * @param \DOMElement $node The HTML element + * + * @return string The content + */ + protected function getNodeContent(\DOMElement $node) + { + $content = []; + foreach ($node->childNodes as $child) { + if ($child->nodeName == 'br') { + $content[] = "\n"; + } else { + $content[] = $child->textContent; + } + } + + return trim(implode($content)); + } +} diff --git a/src/webpack.mix.js b/src/webpack.mix.js --- a/src/webpack.mix.js +++ b/src/webpack.mix.js @@ -13,4 +13,5 @@ mix.js('resources/js/user.js', 'public/js') .js('resources/js/admin.js', 'public/js') - .sass('resources/sass/app.scss', 'public/css'); + .sass('resources/sass/app.scss', 'public/css') + .sass('resources/sass/document.scss', 'public/css');