diff --git a/src/.env.example b/src/.env.example index 0a00d8c8..6d9bccc4 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,103 +1,109 @@ APP_NAME=Kolab APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://127.0.0.1:8000 APP_PUBLIC_URL= APP_DOMAIN=kolabnow.com SUPPORT_URL= LOG_CHANNEL=stack DB_CONNECTION=mysql DB_DATABASE=kolabdev DB_HOST=127.0.0.1 DB_PASSWORD=kolab DB_PORT=3306 DB_USERNAME=kolabdev BROADCAST_DRIVER=log CACHE_DRIVER=redis QUEUE_CONNECTION=redis SESSION_DRIVER=file SESSION_LIFETIME=120 2FA_DSN=mysql://roundcube:Welcome2KolabSystems@127.0.0.1/roundcube 2FA_TOTP_DIGITS=6 2FA_TOTP_INTERVAL=30 2FA_TOTP_DIGEST=sha1 IMAP_URI=ssl://127.0.0.1:993 IMAP_ADMIN_LOGIN=cyrus-admin IMAP_ADMIN_PASSWORD=Welcome2KolabSystems IMAP_VERIFY_HOST=false IMAP_VERIFY_PEER=false LDAP_BASE_DN="dc=mgmt,dc=com" LDAP_DOMAIN_BASE_DN="ou=Domains,dc=mgmt,dc=com" LDAP_HOSTS=127.0.0.1 LDAP_PORT=389 LDAP_SERVICE_BIND_DN="uid=kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_SERVICE_BIND_PW="Welcome2KolabSystems" LDAP_USE_SSL=false LDAP_USE_TLS=false # Administrative LDAP_ADMIN_BIND_DN="cn=Directory Manager" LDAP_ADMIN_BIND_PW="Welcome2KolabSystems" LDAP_ADMIN_ROOT_DN="dc=mgmt,dc=com" # Hosted (public registration) LDAP_HOSTED_BIND_DN="uid=hosted-kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_HOSTED_BIND_PW="Welcome2KolabSystems" LDAP_HOSTED_ROOT_DN="dc=hosted,dc=com" REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 SWOOLE_HTTP_HOST=127.0.0.1 SWOOLE_HTTP_PORT=8000 PAYMENT_PROVIDER= MOLLIE_KEY= STRIPE_KEY= STRIPE_PUBLIC_KEY= STRIPE_WEBHOOK_SECRET= MAIL_DRIVER=smtp MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="noreply@example.com" MAIL_FROM_NAME="Example.com" MAIL_REPLYTO_ADDRESS=null MAIL_REPLYTO_NAME=null DNS_TTL=3600 DNS_SPF="v=spf1 mx -all" DNS_STATIC="%s. MX 10 ext-mx01.mykolab.com." DNS_COPY_FROM=null AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= PUSHER_APP_ID= PUSHER_APP_KEY= PUSHER_APP_SECRET= PUSHER_APP_CLUSTER=mt1 MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" 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 index 50893431..2e9f5856 100644 --- a/src/app/Console/Development/TemplateRender.php +++ b/src/app/Console/Development/TemplateRender.php @@ -1,37 +1,59 @@ 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 index 00000000..4fc11e4d --- /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 index fc17fa12..f501decc 100644 --- a/src/app/Payment.php +++ b/src/app/Payment.php @@ -1,38 +1,48 @@ 'integer' ]; + protected $fillable = [ + 'id', + 'wallet_id', + 'amount', + 'description', + 'provider', + 'status', + 'type', + ]; + /** * The wallet to which this payment belongs. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function wallet() { return $this->belongsTo( '\App\Wallet', 'wallet_id', /* local */ 'id' /* remote */ ); } } diff --git a/src/composer.json b/src/composer.json index 84a46ae6..6a50c33e 100644 --- a/src/composer.json +++ b/src/composer.json @@ -1,86 +1,87 @@ { "name": "laravel/laravel", "type": "project", "description": "The Laravel Framework.", "keywords": [ "framework", "laravel" ], "license": "MIT", "repositories": [ { "type": "vcs", "url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git" } ], "require": { "php": "^7.1.3", + "barryvdh/laravel-dompdf": "^0.8.6", "doctrine/dbal": "^2.9", "fideloper/proxy": "^4.0", "geoip2/geoip2": "^2.9", "iatstuti/laravel-nullable-fields": "*", "kolab/net_ldap3": "dev-master", "laravel/framework": "6.*", "laravel/tinker": "^1.0", "mollie/laravel-mollie": "^2.9", "morrislaptop/laravel-queue-clear": "^1.2", "silviolleite/laravelpwa": "^1.0", "spatie/laravel-translatable": "^4.2", "spomky-labs/otphp": "~4.0.0", "stripe/stripe-php": "^7.29", "swooletw/laravel-swoole": "^2.6", "torann/currency": "^1.0", "torann/geoip": "^1.0", "tymon/jwt-auth": "^1.0" }, "require-dev": { "beyondcode/laravel-dump-server": "^1.0", "beyondcode/laravel-er-diagram-generator": "^1.3", "filp/whoops": "^2.0", "fzaninotto/faker": "^1.4", "laravel/dusk": "~5.11.0", "mockery/mockery": "^1.0", "nunomaduro/larastan": "^0.5", "phpstan/phpstan": "^0.12", "phpunit/phpunit": "^8" }, "config": { "optimize-autoloader": true, "preferred-install": "dist", "sort-packages": true }, "extra": { "laravel": { "dont-discover": [] } }, "autoload": { "psr-4": { "App\\": "app/" }, "classmap": [ "database/seeds", "database/factories", "include" ] }, "autoload-dev": { "psr-4": { "Tests\\": "tests/" } }, "minimum-stability": "dev", "prefer-stable": true, "scripts": { "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" ], "post-root-package-install": [ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" ], "post-create-project-cmd": [ "@php artisan key:generate --ansi" ] } } diff --git a/src/config/app.php b/src/config/app.php index b7d1baf7..ac9c24cb 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -1,246 +1,256 @@ env('APP_NAME', 'Laravel'), /* |-------------------------------------------------------------------------- | Application Environment |-------------------------------------------------------------------------- | | This value determines the "environment" your application is currently | running in. This may determine how you prefer to configure various | services the application utilizes. Set this in your ".env" file. | */ 'env' => env('APP_ENV', 'production'), /* |-------------------------------------------------------------------------- | Application Debug Mode |-------------------------------------------------------------------------- | | When your application is in debug mode, detailed error messages with | stack traces will be shown on every error that occurs within your | application. If disabled, a simple generic error page is shown. | */ 'debug' => env('APP_DEBUG', false), /* |-------------------------------------------------------------------------- | Application URL |-------------------------------------------------------------------------- | | This URL is used by the console to properly generate URLs when using | the Artisan command line tool. You should set this to the root of | your application so that it is used when running Artisan tasks. */ 'url' => env('APP_URL', 'http://localhost'), 'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')), 'asset_url' => env('ASSET_URL', null), 'support_url' => env('SUPPORT_URL', null), /* |-------------------------------------------------------------------------- | Application Domain |-------------------------------------------------------------------------- | | System domain used for user signup (kolab identity) */ 'domain' => env('APP_DOMAIN', 'domain.tld'), /* |-------------------------------------------------------------------------- | Application Timezone |-------------------------------------------------------------------------- | | Here you may specify the default timezone for your application, which | will be used by the PHP date and date-time functions. We have gone | ahead and set this to a sensible default for you out of the box. | */ 'timezone' => 'UTC', /* |-------------------------------------------------------------------------- | Application Locale Configuration |-------------------------------------------------------------------------- | | The application locale determines the default locale that will be used | by the translation service provider. You are free to set this value | to any of the locales which will be supported by the application. | */ 'locale' => 'en', /* |-------------------------------------------------------------------------- | Application Fallback Locale |-------------------------------------------------------------------------- | | The fallback locale determines the locale to use when the current one | is not available. You may change the value to correspond to any of | the language folders that are provided through your application. | */ 'fallback_locale' => 'en', /* |-------------------------------------------------------------------------- | Faker Locale |-------------------------------------------------------------------------- | | This locale will be used by the Faker PHP library when generating fake | data for your database seeds. For example, this will be used to get | localized telephone numbers, street address information and more. | */ 'faker_locale' => 'en_US', /* |-------------------------------------------------------------------------- | Encryption Key |-------------------------------------------------------------------------- | | This key is used by the Illuminate encrypter service and should be set | to a random, 32 character string, otherwise these encrypted strings | will not be safe. Please do this before deploying an application! | */ 'key' => env('APP_KEY'), 'cipher' => 'AES-256-CBC', /* |-------------------------------------------------------------------------- | Autoloaded Service Providers |-------------------------------------------------------------------------- | | The service providers listed here will be automatically loaded on the | request to your application. Feel free to add your own services to | this array to grant expanded functionality to your applications. | */ 'providers' => [ /* * Laravel Framework Service Providers... */ Illuminate\Auth\AuthServiceProvider::class, Illuminate\Broadcasting\BroadcastServiceProvider::class, Illuminate\Bus\BusServiceProvider::class, Illuminate\Cache\CacheServiceProvider::class, Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, Illuminate\Cookie\CookieServiceProvider::class, Illuminate\Database\DatabaseServiceProvider::class, Illuminate\Encryption\EncryptionServiceProvider::class, Illuminate\Filesystem\FilesystemServiceProvider::class, Illuminate\Foundation\Providers\FoundationServiceProvider::class, Illuminate\Hashing\HashServiceProvider::class, Illuminate\Mail\MailServiceProvider::class, Illuminate\Notifications\NotificationServiceProvider::class, Illuminate\Pagination\PaginationServiceProvider::class, Illuminate\Pipeline\PipelineServiceProvider::class, Illuminate\Queue\QueueServiceProvider::class, Illuminate\Redis\RedisServiceProvider::class, Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, Illuminate\Session\SessionServiceProvider::class, Illuminate\Translation\TranslationServiceProvider::class, Illuminate\Validation\ValidationServiceProvider::class, Illuminate\View\ViewServiceProvider::class, /* * Package Service Providers... */ + Barryvdh\DomPDF\ServiceProvider::class, /* * Application Service Providers... */ App\Providers\AppServiceProvider::class, App\Providers\AuthServiceProvider::class, // App\Providers\BroadcastServiceProvider::class, App\Providers\EventServiceProvider::class, App\Providers\RouteServiceProvider::class, ], /* |-------------------------------------------------------------------------- | Class Aliases |-------------------------------------------------------------------------- | | This array of class aliases will be registered when this application | is started. However, feel free to register as many as you wish as | the aliases are "lazy" loaded so they don't hinder performance. | */ 'aliases' => [ 'App' => Illuminate\Support\Facades\App::class, 'Arr' => Illuminate\Support\Arr::class, 'Artisan' => Illuminate\Support\Facades\Artisan::class, 'Auth' => Illuminate\Support\Facades\Auth::class, 'Blade' => Illuminate\Support\Facades\Blade::class, 'Broadcast' => Illuminate\Support\Facades\Broadcast::class, 'Bus' => Illuminate\Support\Facades\Bus::class, 'Cache' => Illuminate\Support\Facades\Cache::class, 'Config' => Illuminate\Support\Facades\Config::class, 'Cookie' => Illuminate\Support\Facades\Cookie::class, 'Crypt' => Illuminate\Support\Facades\Crypt::class, 'DB' => Illuminate\Support\Facades\DB::class, 'Eloquent' => Illuminate\Database\Eloquent\Model::class, 'Event' => Illuminate\Support\Facades\Event::class, 'File' => Illuminate\Support\Facades\File::class, 'Gate' => Illuminate\Support\Facades\Gate::class, 'Hash' => Illuminate\Support\Facades\Hash::class, 'Lang' => Illuminate\Support\Facades\Lang::class, 'Log' => Illuminate\Support\Facades\Log::class, '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, 'Request' => Illuminate\Support\Facades\Request::class, 'Response' => Illuminate\Support\Facades\Response::class, 'Route' => Illuminate\Support\Facades\Route::class, 'Schema' => Illuminate\Support\Facades\Schema::class, 'Session' => Illuminate\Support\Facades\Session::class, 'Storage' => Illuminate\Support\Facades\Storage::class, 'Str' => Illuminate\Support\Str::class, 'URL' => Illuminate\Support\Facades\URL::class, 'Validator' => Illuminate\Support\Facades\Validator::class, 'View' => Illuminate\Support\Facades\View::class, ], // Locations of knowledge base articles 'kb' => [ // An article about suspended accounts 'account_suspended' => env('KB_ACCOUNT_SUSPENDED'), // 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 index 00000000..1fc929d0 --- /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 00000000..d998cf5b Binary files /dev/null and b/src/public/fonts/Roboto-Bold.ttf differ diff --git a/src/public/fonts/Roboto-Regular.ttf b/src/public/fonts/Roboto-Regular.ttf new file mode 100644 index 00000000..2b6392ff Binary files /dev/null and b/src/public/fonts/Roboto-Regular.ttf differ diff --git a/src/public/images/logo_print.png b/src/public/images/logo_print.png deleted file mode 100644 index bd0ff6d8..00000000 Binary files a/src/public/images/logo_print.png and /dev/null differ diff --git a/src/public/images/logo_print.svg b/src/public/images/logo_print.svg new file mode 100644 index 00000000..8acf94a7 --- /dev/null +++ b/src/public/images/logo_print.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + diff --git a/src/public/mix-manifest.json b/src/public/mix-manifest.json index df1588cc..f452e347 100644 --- 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 index 00000000..d7a444d5 --- /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 index 00000000..dbd2c5ac --- /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 index 00000000..f920df64 --- /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 index 00000000..d6b7ef32 --- /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 index 00000000..5b91ad41 --- /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 index 257371a0..77922033 100644 --- a/src/webpack.mix.js +++ b/src/webpack.mix.js @@ -1,16 +1,17 @@ const mix = require('laravel-mix'); /* |-------------------------------------------------------------------------- | Mix Asset Management |-------------------------------------------------------------------------- | | Mix provides a clean, fluent API for defining some Webpack build steps | for your Laravel application. By default, we are compiling the Sass | file for the application as well as bundling up all the JS files. | */ 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');