Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117913525
D1309.1775401744.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
40 KB
Referenced Files
None
Subscribers
None
D1309.1775401744.diff
View Options
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
Details
Attached
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)
Attached To
Mode
D1309: Receipt document (html and pdf)
Attached
Detach File
Event Timeline