diff --git a/src/.env.example b/src/.env.example index 6d9bccc4..c6cd0d4c 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,109 +1,112 @@ 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= +VAT_COUNTRIES=CH,LI +VAT_RATE=7.7 + KB_ACCOUNT_DELETE= KB_ACCOUNT_SUSPENDED= diff --git a/src/app/Documents/Receipt.php b/src/app/Documents/Receipt.php index b00504a8..aa16f228 100644 --- a/src/app/Documents/Receipt.php +++ b/src/app/Documents/Receipt.php @@ -1,245 +1,266 @@ wallet = $wallet; $this->year = $year; $this->month = $month; } /** * Render the mail template with fake data * * @param string $type Output format ('html' or 'pdf') * * @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(); } elseif ($type !== 'html') { throw new \Exception("Unsupported output format"); } 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) { + $country = 'CH'; $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(); + $country = $this->wallet->owner->getSetting('country'); $items = $this->wallet->payments() ->where('status', PaymentProvider::STATUS_PAID) ->where('updated_at', '>=', $start) ->where('updated_at', '<', $end) ->where('amount', '>', 0) ->orderBy('updated_at') ->get(); } + $vatRate = \config('app.vat.rate'); + $vatCountries = explode(',', \config('app.vat.countries')); + $vatCountries = array_map('strtoupper', array_map('trim', $vatCountries)); + + if (!$country || !in_array(strtoupper($country), $vatCountries)) { + $vatRate = 0; + } + + $totalVat = 0; $total = 0; - $items = $items->map(function ($item) use (&$total, $appName) { - $total += $item->amount; + $items = $items->map(function ($item) use (&$total, &$totalVat, $appName, $vatRate) { + $amount = $item->amount; + + if ($vatRate > 0) { + $amount = round($amount * ((100 - $vatRate) / 100)); + $totalVat += $item->amount - $amount; + } + + $total += $amount; + return [ - 'amount' => sprintf('%.2f %s', $item->amount / 100, $this->wallet->currency), + 'amount' => $this->wallet->money($amount), '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, + 'subTotal' => $this->wallet->money($total), + 'total' => $this->wallet->money($total + $totalVat), + 'totalVat' => $this->wallet->money($totalVat), + 'vatRate' => preg_replace('/([.,]00|0|[.,])$/', '', sprintf('%.2f', $vatRate)), + 'vat' => $vatRate > 0, ]); 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/config/app.php b/src/config/app.php index ac9c24cb..e337a8c1 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -1,256 +1,261 @@ 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'), ], + + 'vat' => [ + 'countries' => env('VAT_COUNTRIES'), + 'rate' => (float) env('VAT_RATE'), + ], ]; diff --git a/src/resources/lang/en/documents.php b/src/resources/lang/en/documents.php index ac2e056b..ca8e3db4 100644 --- a/src/resources/lang/en/documents.php +++ b/src/resources/lang/en/documents.php @@ -1,36 +1,38 @@ "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-filename' => ":site Receipt for :id", 'receipt-title' => "Receipt for :month :year", 'receipt-item-desc' => ":site Services", + 'subtotal' => "Subtotal", + 'vat' => "VAT (:rate%)", ]; diff --git a/src/resources/sass/document.scss b/src/resources/sass/document.scss index dbd2c5ac..46836373 100644 --- a/src/resources/sass/document.scss +++ b/src/resources/sass/document.scss @@ -1,112 +1,119 @@ // 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; } + .price { + width: 150px; + text-align: right; + white-space: nowrap; + } + td.logo { width: 1%; } td.description { width: 98%; } tr.total { background-color: #f4f4f4; } + + tr.vat td, + tr.subtotal td { + border-bottom: 0; + } } #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 index bdded528..8c3d5684 100644 --- a/src/resources/views/documents/receipt.blade.php +++ b/src/resources/views/documents/receipt.blade.php @@ -1,56 +1,66 @@

{{ $title }}

{!! $customer['customer'] !!} {{ __('documents.account-id') }} {{ $customer['wallet_id'] }}
{{ __('documents.customer-no') }} {{ $customer['id'] }}
- + @foreach ($items as $item) - + @endforeach +@if ($vat) + + + + + + + + +@endif - - + +
{{ __('documents.date') }} {{ __('documents.description') }}{{ __('documents.amount') }}{{ __('documents.amount') }}
{{ $item['date'] }} {{ $item['description'] }}{{ $item['amount'] }}{{ $item['amount'] }}
{{ __('documents.subtotal') }}{{ $subTotal }}
{{ __('documents.vat', ['rate' => $vatRate]) }}{{ $totalVat }}
{{ __('documents.total') }}{{ $total }}{{ __('documents.total') }}{{ $total }}
diff --git a/src/tests/Feature/Documents/ReceiptTest.php b/src/tests/Feature/Documents/ReceiptTest.php index 5b91ad41..60e373c1 100644 --- a/src/tests/Feature/Documents/ReceiptTest.php +++ b/src/tests/Feature/Documents/ReceiptTest.php @@ -1,261 +1,312 @@ deleteTestUser('receipt-test@kolabnow.com'); parent::tearDown(); } /** - * Test receipt HTML output - * - * @return void + * Test receipt HTML output (without VAT) */ - public function testHtmlOutput() + public function testHtmlOutput(): void { $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])); + $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])); + $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])); + $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])); + $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 + // 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 HTML output (with VAT) + */ + public function testHtmlOutputVat(): void + { + \config(['app.vat.rate' => 7.7]); + \config(['app.vat.countries' => 'ch']); + + $appName = \config('app.name'); + $wallet = $this->getTestData('CH'); + $receipt = new Receipt($wallet, 2020, 5); + $html = $receipt->htmlOutput(); + + $this->assertStringStartsWith('', $html); + + $dom = new \DOMDocument('1.0', 'UTF-8'); + $dom->loadHTML($html); + + // The main table content + $content = $dom->getElementById('content'); + $records = $content->getElementsByTagName('tr'); + $this->assertCount(7, $records); + + $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('11,39 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('0,92 CHF', $this->getNodeContent($cells[2])); + $subtotalCells = $records[4]->getElementsByTagName('td'); + $this->assertCount(2, $subtotalCells); + $this->assertSame('Subtotal', $this->getNodeContent($subtotalCells[0])); + $this->assertSame('12,32 CHF', $this->getNodeContent($subtotalCells[1])); + $vatCells = $records[5]->getElementsByTagName('td'); + $this->assertCount(2, $vatCells); + $this->assertSame('VAT (7.7%)', $this->getNodeContent($vatCells[0])); + $this->assertSame('1,03 CHF', $this->getNodeContent($vatCells[1])); + $totalCells = $records[6]->getElementsByTagName('td'); + $this->assertCount(2, $totalCells); + $this->assertSame('Total', $this->getNodeContent($totalCells[0])); + $this->assertSame('13,35 CHF', $this->getNodeContent($totalCells[1])); + } + /** * Test receipt PDF output - * - * @return void */ - public function testPdfOutput() + public function testPdfOutput(): void { $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 * + * @param string $country User country code + * * @return \App\Wallet */ - protected function getTestData() + protected function getTestData(string $country = null): Wallet { 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", + 'country' => $country ]); $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)); } }