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'] }}
|
{{ __('documents.date') }} |
{{ __('documents.description') }} |
- {{ __('documents.amount') }} |
+ {{ __('documents.amount') }} |
@foreach ($items as $item)
{{ $item['date'] }} |
{{ $item['description'] }} |
- {{ $item['amount'] }} |
+ {{ $item['amount'] }} |
@endforeach
+@if ($vat)
+
+ {{ __('documents.subtotal') }} |
+ {{ $subTotal }} |
+
+
+ {{ __('documents.vat', ['rate' => $vatRate]) }} |
+ {{ $totalVat }} |
+
+@endif
- {{ __('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));
}
}