Page MenuHomePhorge

D1309.1775401744.diff
No OneTemporary

Authored By
Unknown
Size
40 KB
Referenced Files
None
Subscribers
None

D1309.1775401744.diff

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 @@
+<?php
+
+namespace App\Documents;
+
+use App\Payment;
+use App\Providers\PaymentProvider;
+use App\User;
+use App\Wallet;
+use Carbon\Carbon;
+
+class Receipt
+{
+ /** @var \App\Wallet The wallet */
+ protected $wallet;
+
+ /** @var int Transactions date year */
+ protected $year;
+
+ /** @var int Transactions date month */
+ protected $month;
+
+ /** @var bool Enable fake data mode */
+ protected static $fakeMode = false;
+
+
+ /**
+ * Document constructor.
+ *
+ * @param \App\Wallet $wallet A wallet containing transactions
+ * @param int $year A year to list transactions from
+ * @param int $month A month to list transactions from
+ *
+ * @return void
+ */
+ public function __construct(Wallet $wallet, int $year, int $month)
+ {
+ $this->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<br>7252 Westminster Lane<br>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", '<br>', 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", '<br>', 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('<a href="mailto:%s">%s</a>', $contact, $contact);
+ }
+
+ return [
+ 'logo' => $logo ? "<img src=\"/images/$logo\" width=300>" : '',
+ '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 @@
+<?php
+
+return [
+
+ /*
+ |--------------------------------------------------------------------------
+ | Settings
+ |--------------------------------------------------------------------------
+ |
+ | Set some default values. It is possible to add all defines that can be set
+ | in dompdf_config.inc.php. You can also override the entire config file.
+ |
+ */
+ 'show_warnings' => 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, <img> 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 <script type="text/php"> ... </script> 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 <script type="text/javascript"> ... </script> 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$@<O00001
literal 0
Hc$@<O00001
diff --git a/src/public/fonts/Roboto-Regular.ttf b/src/public/fonts/Roboto-Regular.ttf
new file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
diff --git a/src/public/images/logo_print.png b/src/public/images/logo_print.png
deleted file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
diff --git a/src/public/images/logo_print.svg b/src/public/images/logo_print.svg
new file mode 100644
--- /dev/null
+++ b/src/public/images/logo_print.svg
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
+ viewBox="0 0 1700.8 566.9" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:#F3A628;}
+ .st1{fill:#575756;}
+</style>
+<path class="st0" d="M97.8,197.2c0-3.4,2.6-6.4,6.4-6.4h7.7c3.4,0,6.4,2.9,6.4,6.4v74.9l82.6-78.6c1.1-1.3,3.4-2.6,5-2.6h13
+ c4.8,0,7.4,5.3,3.2,9.3l-83.6,78.1l87.6,89.7c1.9,1.9,1.3,8.2-4.5,8.2h-13.5c-1.9,0-4.2-1.1-4.8-1.9l-85-88.7v84.2
+ c0,3.4-2.9,6.4-6.4,6.4h-7.7c-3.7,0-6.4-2.9-6.4-6.4V197.2z"/>
+<path class="st0" d="M469.1,195.8c0-2.6,2.1-5,5-5h10.3c2.6,0,5,2.4,5,5v162.5h76.2c2.9,0,5,2.4,5,5v7.7c0,2.6-2.1,5-5,5H474
+ c-2.9,0-5-2.4-5-5V195.8H469.1z"/>
+<path class="st0" d="M581.9,369.2l80.5-178.1c0.8-1.6,2.1-2.9,4.5-2.9h2.6c2.4,0,3.7,1.3,4.5,2.9l79.9,178.1
+ c1.6,3.4-0.5,6.9-4.5,6.9h-10.6c-2.4,0-4-1.6-4.5-2.9l-19.6-43.7h-93.9l-19.3,43.7c-0.5,1.3-2.1,2.9-4.5,2.9h-10.6
+ C582.4,376.1,580.3,372.6,581.9,369.2z M707.6,313.1c-13-28.8-25.7-58-38.6-86.8h-2.1l-38.6,86.8H707.6z"/>
+<path class="st0" d="M782.7,195.8c0-2.6,2.1-5,5-5h58c32.3,0,54.3,20.9,54.3,48.7c0,20.4-13.5,35.2-25.9,42.3
+ c14,5.8,31.8,18.8,31.8,43.1c0,29.6-23.6,51.1-57.7,51.1h-60.3c-2.9,0-5-2.4-5-5V195.8H782.7z M850.2,358.3
+ c19.3,0,33.3-14.6,33.3-33.6c0-18.8-17.2-32.6-37.8-32.6H802v66.2H850.2z M845.7,273.9c20.4,0,31.8-14.6,31.8-33.1
+ c0-19.1-11.4-31.8-31.8-31.8h-43.1v64.8h43.1V273.9z"/>
+<path class="st1" d="M976.8,192.9c0-2.6,2.4-4.8,5-4.8h6.6l119.4,148.7c0.3,0,0.3,0,0.5,0v-141c0-2.6,2.1-5,5-5h9.3c2.6,0,5,2.4,5,5
+ v178.1c0,2.6-2.4,4.8-5,4.8h-4.8L996.4,227.1h-0.3v144c0,2.6-2.1,5-5,5h-9.3c-2.6,0-5-2.4-5-5L976.8,192.9L976.8,192.9z"/>
+<path class="st1" d="M1263.2,188.2c52.9,0,95.3,42.6,95.3,95.5s-42.3,95-95.3,95s-95-42.1-95-95S1210.2,188.2,1263.2,188.2z
+ M1263.2,360.2c42.1,0,76.7-34.4,76.7-76.5s-34.7-77-76.7-77s-76.5,34.9-76.5,77S1221.1,360.2,1263.2,360.2z"/>
+<path class="st1" d="M1380.7,197.2c-1.1-3.7,1.1-6.4,4.8-6.4h11.1c2.1,0,4.2,1.9,4.8,3.7l36.8,137.1h1.1l44.2-140.5
+ c0.5-1.6,2.1-2.9,4.5-2.9h4.8c2.1,0,4,1.3,4.5,2.9l45,140.5h1.1l36-137.1c0.5-1.9,2.6-3.7,4.8-3.7h11.1c3.7,0,5.8,2.6,4.8,6.4
+ l-50,177.8c-0.5,2.1-2.6,3.7-4.8,3.7h-4.2c-1.9,0-3.7-1.3-4.5-2.9l-45.8-143.2h-1.3l-45,143.2c-0.8,1.6-2.6,2.9-4.5,2.9h-4.2
+ c-2.1,0-4.2-1.6-4.8-3.7L1380.7,197.2z"/>
+<path class="st0" d="M355,374.8c-4,0-7.7-2.7-8.7-6.8c-1.2-4.8,1.7-9.7,6.6-10.9c34.2-8.5,58.1-39.2,58.1-74.4
+ c0-42.3-34.4-76.7-76.7-76.7s-76.7,34.4-76.7,76.7c0,35.3,23.9,65.9,58.1,74.4c4.8,1.2,7.8,6.1,6.6,10.9c-1.2,4.8-6.1,7.8-10.9,6.6
+ c-42.2-10.5-71.7-48.4-71.7-91.9c0-52.2,42.5-94.8,94.8-94.8s94.8,42.5,94.8,94.8c0,43.6-29.5,81.4-71.7,91.9
+ C356.5,374.7,355.8,374.8,355,374.8z"/>
+</svg>
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 @@
+<?php
+
+return [
+
+ /*
+ |--------------------------------------------------------------------------
+ | Language files for document templates
+ |--------------------------------------------------------------------------
+ */
+
+ 'account-id' => "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 @@
+<!DOCTYPE html>
+<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
+ <head>
+ <meta charset="utf-8">
+ <style>
+{!! include('public/css/document.css') !!}
+ </style>
+ </head>
+ <body>
+ <table id="header">
+ <tr>
+ <td>
+ {!! $company['header'] !!}
+ </td>
+ <td class="logo">
+ {!! $company['logo'] !!}
+ </td>
+ </tr>
+ </table>
+
+ <h1 id="title">{{ $title }}</h1>
+
+ <table id="customer" class="head">
+ <tr>
+ <td>
+ {!! $customer['customer'] !!}
+ </td>
+ <td class="idents">
+ <span class="gray">{{ __('documents.account-id') }}</span> {{ $customer['wallet_id'] }}<br>
+ <span class="gray">{{ __('documents.customer-no') }}</span> {{ $customer['id'] }}
+ </td>
+ </tr>
+ </table>
+
+ <table id="content" class="content">
+ <tr>
+ <th class="align-left">{{ __('documents.date') }}</th>
+ <th class="align-left description">{{ __('documents.description') }}</th>
+ <th class="align-right">{{ __('documents.amount') }}</th>
+ </tr>
+@foreach ($items as $item)
+ <tr>
+ <td class="align-left">{{ $item['date'] }}</td>
+ <td class="align-left">{{ $item['description'] }}</td>
+ <td class="align-right">{{ $item['amount'] }}</td>
+ </tr>
+@endforeach
+ <tr class="total">
+ <td colspan="2" class="align-left bold">{{ __('documents.total') }}</td>
+ <td class="align-right bold">{{ $total }}</td>
+ </tr>
+ </table>
+
+ <div id="footer">{!! $company['footer'] !!}</div>
+ </body>
+</html>
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 @@
+<?php
+
+namespace Tests\Feature\Documents;
+
+use App\Documents\Receipt;
+use App\Payment;
+use App\Providers\PaymentProvider;
+use App\User;
+use App\Wallet;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\Bus;
+use Tests\TestCase;
+
+class ReceiptTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->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('<!DOCTYPE html>', $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 <br> 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');

File Metadata

Mime Type
text/plain
Expires
Sun, Apr 5, 3:09 PM (1 h, 15 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18832484
Default Alt Text
D1309.1775401744.diff (40 KB)

Event Timeline