diff --git a/src/app/Http/Controllers/API/V4/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php index d58f2833..19098382 100644 --- a/src/app/Http/Controllers/API/V4/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/WalletsController.php @@ -1,179 +1,260 @@ errorResponse(404); } /** * Show the form for creating a new resource. * * @return \Illuminate\Http\JsonResponse */ public function create() { return $this->errorResponse(404); } /** * Store a newly created resource in storage. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ public function store(Request $request) { return $this->errorResponse(404); } /** * Display the specified resource. * * @param string $id * * @return \Illuminate\Http\JsonResponse */ public function show($id) { return $this->errorResponse(404); } /** * Show the form for editing the specified resource. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function edit($id) { return $this->errorResponse(404); } /** * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function update(Request $request, $id) { return $this->errorResponse(404); } /** * Remove the specified resource from storage. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { return $this->errorResponse(404); } + /** + * Download a receipt in pdf format. + * + * @param string $id Wallet identifier + * @param string $receipt Receipt identifier (YYYY-MM) + * + * @return \Illuminate\Http\Response|void + */ + public function receiptDownload($id, $receipt) + { + $wallet = Wallet::find($id); + + // Only owner (or admin) has access to the wallet + if (!Auth::guard()->user()->canRead($wallet)) { + return abort(403); + } + + list ($year, $month) = explode('-', $receipt); + + if (empty($year) || empty($month) || $year < 2000 || $month < 1 || $month > 12) { + return abort(404); + } + + if ($receipt >= date('Y-m')) { + return abort(404); + } + + $params = [ + 'id' => sprintf('%04d-%02d', $year, $month), + 'site' => \config('app.name') + ]; + + $filename = \trans('documents.receipt-filename', $params); + + $receipt = new \App\Documents\Receipt($wallet, (int) $year, (int) $month); + + $content = $receipt->pdfOutput(); + + return response($content) + ->withHeaders([ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + 'Content-Length' => strlen($content), + ]); + } + + /** + * Fetch wallet receipts list. + * + * @param string $id Wallet identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function receipts($id) + { + $wallet = Wallet::find($id); + + // Only owner (or admin) has access to the wallet + if (!Auth::guard()->user()->canRead($wallet)) { + return $this->errorResponse(403); + } + + $result = $wallet->payments() + ->selectRaw('distinct date_format(updated_at, "%Y-%m") as ident') + ->where('status', PaymentProvider::STATUS_PAID) + ->where('amount', '>', 0) + ->orderBy('ident', 'desc') + ->get() + ->whereNotIn('ident', [date('Y-m')]) // exclude current month + ->pluck('ident'); + + return response()->json([ + 'status' => 'success', + 'list' => $result, + 'count' => count($result), + 'hasMore' => false, + 'page' => 1, + ]); + } + /** * Fetch wallet transactions. * * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse */ public function transactions($id) { $wallet = Wallet::find($id); // Only owner (or admin) has access to the wallet if (!Auth::guard()->user()->canRead($wallet)) { return $this->errorResponse(403); } $pageSize = 10; $page = intval(request()->input('page')) ?: 1; $hasMore = false; $isAdmin = $this instanceof Admin\WalletsController; if ($transaction = request()->input('transaction')) { // Get sub-transactions for the specified transaction ID, first // check access rights to the transaction's wallet $transaction = $wallet->transactions()->where('id', $transaction)->first(); if (!$transaction) { return $this->errorResponse(404); } $result = Transaction::where('transaction_id', $transaction->id)->get(); } else { // Get main transactions (paged) $result = $wallet->transactions() // FIXME: Do we know which (type of) transaction has sub-transactions // without the sub-query? ->selectRaw("*, (SELECT count(*) FROM transactions sub " . "WHERE sub.transaction_id = transactions.id) AS cnt") ->whereNull('transaction_id') ->latest() ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } } $result = $result->map(function ($item) use ($isAdmin) { $amount = $item->amount; if (in_array($item->type, [Transaction::WALLET_PENALTY, Transaction::WALLET_DEBIT])) { $amount *= -1; } $entry = [ 'id' => $item->id, 'createdAt' => $item->created_at->format('Y-m-d H:i'), 'type' => $item->type, 'description' => $item->shortDescription(), 'amount' => $amount, 'hasDetails' => !empty($item->cnt), ]; if ($isAdmin && $item->user_email) { $entry['user'] = $item->user_email; } return $entry; }); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, 'page' => $page, ]); } } diff --git a/src/resources/js/app.js b/src/resources/js/app.js index 0f2e6942..fd1c69cc 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,296 +1,319 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') import AppComponent from '../vue/App' import MenuComponent from '../vue/Widgets/Menu' import store from './store' const loader = '
Loading
' const app = new Vue({ el: '#app', components: { AppComponent, MenuComponent, }, store, router: window.router, data() { return { isLoading: true, isAdmin: window.isAdmin } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, isController(wallet_id) { if (wallet_id && store.state.authInfo) { let i for (i = 0; i < store.state.authInfo.wallets.length; i++) { if (wallet_id == store.state.authInfo.wallets[i].id) { return true } } for (i = 0; i < store.state.authInfo.accounts.length; i++) { if (wallet_id == store.state.authInfo.accounts[i].id) { return true } } } return false }, // Set user state to "logged in" loginUser(token, dashboard) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') localStorage.setItem('token', token) axios.defaults.headers.common.Authorization = 'Bearer ' + token if (dashboard !== false) { this.$router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null }, // Set user state to "not logged in" logoutUser() { store.commit('logoutUser') localStorage.setItem('token', '') delete axios.defaults.headers.common.Authorization this.$router.push({ name: 'login' }) }, // Display "loading" overlay inside of the specified element addLoader(elem) { $(elem).css({position: 'relative'}).append($(loader).addClass('small')) }, // Remove loader element added in addLoader() removeLoader(elem) { $(elem).find('.app-loader').remove() }, startLoading() { this.isLoading = true // Lock the UI with the 'loading...' element let loading = $('#app > .app-loader').show() if (!loading.length) { $('#app').append($(loader)) } }, // Hide "loading" overlay stopLoading() { $('#app > .app-loader').fadeOut() this.isLoading = false }, errorPage(code, msg) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". const map = { 400: "Bad request", 401: "Unauthorized", 403: "Access denied", 404: "Not found", 405: "Method not allowed", 500: "Internal server error" } if (!msg) msg = map[code] || "Unknown Error" const error_page = `
${code}
${msg}
` $('#app').children(':not(nav)').remove() $('#app').append(error_page) }, errorHandler(error) { this.stopLoading() if (!error.response) { // TODO: probably network connection error } else if (error.response.status === 401) { this.logoutUser() } else { this.errorPage(error.response.status, error.response.statusText) } }, + downloadFile(url) { + // TODO: This might not be a best way for big files as the content + // will be stored (temporarily) in browser memory + // TODO: This method does not show the download progress in the browser + // but it could be implemented in the UI, axios has 'progress' property + axios.get(url, { responseType: 'blob' }) + .then (response => { + const link = document.createElement('a') + const contentDisposition = response.headers['content-disposition'] + let filename = 'unknown' + + if (contentDisposition) { + const match = contentDisposition.match(/filename="(.+)"/); + if (match.length === 2) { + filename = match[1]; + } + } + + link.href = window.URL.createObjectURL(response.data) + link.download = filename + link.click() + }) + }, price(price) { return (price/100).toLocaleString('de-DE', { style: 'currency', currency: 'CHF' }) }, priceLabel(cost, units = 1, discount) { let index = '' if (units < 0) { units = 1 } if (discount) { cost = Math.floor(cost * ((100 - discount) / 100)) index = '\u00B9' } return this.price(cost * units) + '/month' + index }, domainStatusClass(domain) { if (domain.isDeleted) { return 'text-muted' } if (domain.isSuspended) { return 'text-warning' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'text-danger' } return 'text-success' }, domainStatusText(domain) { if (domain.isDeleted) { return 'Deleted' } if (domain.isSuspended) { return 'Suspended' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'Not Ready' } return 'Active' }, userStatusClass(user) { if (user.isDeleted) { return 'text-muted' } if (user.isSuspended) { return 'text-warning' } if (!user.isImapReady || !user.isLdapReady) { return 'text-danger' } return 'text-success' }, userStatusText(user) { if (user.isDeleted) { return 'Deleted' } if (user.isSuspended) { return 'Suspended' } if (!user.isImapReady || !user.isLdapReady) { return 'Not Ready' } return 'Active' } } }) // Add a axios request interceptor window.axios.interceptors.request.use( config => { // This is the only way I found to change configuration options // on a running application. We need this for browser testing. config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider return config }, error => { // Do something with request error return Promise.reject(error) } ) // Add a axios response interceptor for general/validation error handler window.axios.interceptors.response.use( response => { // Do nothing return response }, error => { let error_msg let status = error.response ? error.response.status : 200 if (error.response && status == 422) { error_msg = "Form validation error" const modal = $('div.modal.show') $(modal.length ? modal : 'form').each((i, form) => { form = $(form) $.each(error.response.data.errors || {}, (idx, msg) => { const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx let input = form.find('#' + input_name) if (!input.length) { input = form.find('[name="' + input_name + '"]'); } if (input.length) { // Create an error message\ // API responses can use a string, array or object let msg_text = '' if ($.type(msg) !== 'string') { $.each(msg, (index, str) => { msg_text += str + ' ' }) } else { msg_text = msg } let feedback = $('
').text(msg_text) if (input.is('.list-input')) { // List input widget input.children(':not(:first-child)').each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) } else { // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } } }) form.find('.is-invalid:not(.listinput-widget)').first().focus() }) } else if (error.response && error.response.data) { error_msg = error.response.data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toast.error(error_msg || "Server Error") // Pass the error as-is return Promise.reject(error) } ) diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js index c4ce8672..318a8da5 100644 --- a/src/resources/js/fontawesome.js +++ b/src/resources/js/fontawesome.js @@ -1,53 +1,55 @@ import { library } from '@fortawesome/fontawesome-svg-core' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' //import { } from '@fortawesome/free-brands-svg-icons' import { faCheckSquare, faCreditCard, faSquare, } from '@fortawesome/free-regular-svg-icons' import { faCheck, faCheckCircle, + faDownload, faGlobe, faExclamationCircle, faInfoCircle, faLock, faKey, faPlus, faSearch, faSignInAlt, faSyncAlt, faTrashAlt, faUser, faUserCog, faUsers, faWallet } from '@fortawesome/free-solid-svg-icons' // Register only these icons we need library.add( faCheck, faCheckCircle, faCheckSquare, faCreditCard, + faDownload, faExclamationCircle, faGlobe, faInfoCircle, faLock, faKey, faPlus, faSearch, faSignInAlt, faSquare, faSyncAlt, faTrashAlt, faUser, faUserCog, faUsers, faWallet ) export default FontAwesomeIcon diff --git a/src/resources/lang/en/documents.php b/src/resources/lang/en/documents.php index d7a444d5..ac2e056b 100644 --- a/src/resources/lang/en/documents.php +++ b/src/resources/lang/en/documents.php @@ -1,35 +1,36 @@ "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", ]; diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue index fda396ab..f2d4cb68 100644 --- a/src/resources/vue/Wallet.vue +++ b/src/resources/vue/Wallet.vue @@ -1,287 +1,341 @@ diff --git a/src/routes/api.php b/src/routes/api.php index ae5dfdac..d53906d2 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,111 +1,113 @@ 'api', 'prefix' => 'auth' ], function ($router) { Route::post('login', 'API\AuthController@login'); Route::group( ['middleware' => 'auth:api'], function ($router) { Route::get('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => 'auth' ], function ($router) { Route::post('password-reset/init', 'API\PasswordResetController@init'); Route::post('password-reset/verify', 'API\PasswordResetController@verify'); Route::post('password-reset', 'API\PasswordResetController@reset'); Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/init', 'API\SignupController@init'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'auth:api', 'prefix' => 'v4' ], function () { Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::apiResource('entitlements', API\V4\EntitlementsController::class); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::get('users/{id}/status', 'API\V4\UsersController@status'); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions'); + Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts'); + Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload'); Route::post('payments', 'API\V4\PaymentsController@store'); Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); } ); Route::group( [ 'domain' => \config('app.domain'), ], function () { Route::post('webhooks/payment/{provider}', 'API\V4\PaymentsController@webhook'); } ); Route::group( [ 'domain' => 'admin.' . \config('app.domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => 'v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\Admin\DomainsController@confirm'); Route::apiResource('entitlements', API\V4\Admin\EntitlementsController::class); Route::apiResource('packages', API\V4\Admin\PackagesController::class); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions'); Route::apiResource('discounts', API\V4\Admin\DiscountsController::class); } ); diff --git a/src/tests/Browser.php b/src/tests/Browser.php index 54c6e365..830b2202 100644 --- a/src/tests/Browser.php +++ b/src/tests/Browser.php @@ -1,207 +1,207 @@ elements($selector); $count = count($elements); if ($visible) { foreach ($elements as $element) { if (!$element->isDisplayed()) { $count--; } } } Assert::assertEquals($expected_count, $count, "Count of [$selector] elements is not $expected_count"); return $this; } /** * Assert Tip element content */ public function assertTip($selector, $content) { return $this->click($selector) ->withinBody(function ($browser) use ($content) { $browser->waitFor('div.tooltip .tooltip-inner') ->assertSeeIn('div.tooltip .tooltip-inner', $content); }) ->click($selector); } /** * Assert Toast element content (and close it) */ public function assertToast(string $type, string $message, $title = null) { return $this->withinBody(function ($browser) use ($type, $title, $message) { $browser->with(new Toast($type), function (Browser $browser) use ($title, $message) { $browser->assertToastTitle($title) ->assertToastMessage($message) ->closeToast(); }); }); } /** * Assert specified error page is displayed. */ public function assertErrorPage(int $error_code) { $this->with(new Error($error_code), function ($browser) { // empty, assertions will be made by the Error component itself }); return $this; } /** * Assert that the given element has specified class assigned. */ public function assertHasClass($selector, $class_name) { $element = $this->resolver->findOrFail($selector); $classes = explode(' ', (string) $element->getAttribute('class')); Assert::assertContains($class_name, $classes, "[$selector] has no class '{$class_name}'"); return $this; } /** * Assert that the given element is readonly */ public function assertReadonly($selector) { $element = $this->resolver->findOrFail($selector); $value = $element->getAttribute('readonly'); Assert::assertTrue($value == 'true', "Element [$selector] is not readonly"); return $this; } /** * Assert that the given element is not readonly */ public function assertNotReadonly($selector) { $element = $this->resolver->findOrFail($selector); $value = $element->getAttribute('readonly'); Assert::assertTrue($value != 'true', "Element [$selector] is not readonly"); return $this; } /** * Assert that the given element contains specified text, * no matter it's displayed or not. */ public function assertText($selector, $text) { $element = $this->resolver->findOrFail($selector); Assert::assertTrue(strpos($element->getText(), $text) !== false, "No expected text in [$selector]"); return $this; } /** * Remove all toast messages */ public function clearToasts() { $this->script("jQuery('.toast-container > *').remove()"); return $this; } /** * Check if in Phone mode */ public static function isPhone() { return getenv('TESTS_MODE') == 'phone'; } /** * Check if in Tablet mode */ public static function isTablet() { return getenv('TESTS_MODE') == 'tablet'; } /** * Check if in Desktop mode */ public static function isDesktop() { return !self::isPhone() && !self::isTablet(); } /** * Returns content of a downloaded file */ - public function readDownloadedFile($filename) + public function readDownloadedFile($filename, $sleep = 5) { $filename = __DIR__ . "/Browser/downloads/$filename"; // Give the browser a chance to finish download - if (!file_exists($filename)) { - sleep(2); + if (!file_exists($filename) && $sleep) { + sleep($sleep); } Assert::assertFileExists($filename); return file_get_contents($filename); } /** * Removes downloaded file */ public function removeDownloadedFile($filename) { @unlink(__DIR__ . "/Browser/downloads/$filename"); return $this; } /** * Execute code within body context. * Useful to execute code that selects elements outside of a component context */ public function withinBody($callback) { if ($this->resolver->prefix != 'body') { $orig_prefix = $this->resolver->prefix; $this->resolver->prefix = 'body'; } call_user_func($callback, $this); if (isset($orig_prefix)) { $this->resolver->prefix = $orig_prefix; } return $this; } } diff --git a/src/tests/Browser/Pages/Wallet.php b/src/tests/Browser/Pages/Wallet.php index b9a56f6d..4f28786d 100644 --- a/src/tests/Browser/Pages/Wallet.php +++ b/src/tests/Browser/Pages/Wallet.php @@ -1,48 +1,49 @@ assertPathIs($this->url()) ->waitUntilMissing('@app .app-loader') ->assertSeeIn('#wallet .card-title', 'Account balance'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', '@main' => '#wallet', '@payment-dialog' => '#payment-dialog', '@nav' => 'ul.nav-tabs', '@history-tab' => '#wallet-history', + '@receipts-tab' => '#wallet-receipts', ]; } } diff --git a/src/tests/Browser/WalletTest.php b/src/tests/Browser/WalletTest.php index b271210d..e048cc7a 100644 --- a/src/tests/Browser/WalletTest.php +++ b/src/tests/Browser/WalletTest.php @@ -1,153 +1,252 @@ deleteTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@kolab.org'); Wallet::where('user_id', $john->id)->update(['balance' => -1234]); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@kolab.org'); Wallet::where('user_id', $john->id)->update(['balance' => 0]); parent::tearDown(); } /** * Test wallet page (unauthenticated) */ public function testWalletUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/wallet')->on(new Home()); }); } /** * Test wallet "box" on Dashboard */ public function testDashboard(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-wallet .name', 'Wallet') ->assertSeeIn('@links .link-wallet .badge', '-12,34 CHF'); }); } /** * Test wallet page * * @depends testDashboard */ public function testWallet(): void { $this->browse(function (Browser $browser) { $browser->click('@links .link-wallet') ->on(new WalletPage()) ->assertSeeIn('#wallet .card-title', 'Account balance') ->assertSeeIn('#wallet .card-text', 'Current account balance is -12,34 CHF'); }); } + /** + * Test Receipts tab + */ + public function testReceipts(): void + { + $user = $this->getTestUser('wallets-controller@kolabnow.com', ['password' => 'simple123']); + $wallet = $user->wallets()->first(); + $wallet->payments()->delete(); + + // Log out John and log in the test user + $this->browse(function (Browser $browser) { + $browser->visit('/logout') + ->waitForLocation('/login') + ->on(new Home()) + ->submitLogon('wallets-controller@kolabnow.com', 'simple123', true); + }); + + // Assert Receipts tab content when there's no receipts available + $this->browse(function (Browser $browser) { + $browser->on(new Dashboard()) + ->click('@links .link-wallet') + ->on(new WalletPage()) + ->assertSeeIn('@nav #tab-receipts', 'Receipts') + ->with('@receipts-tab', function (Browser $browser) { + $browser->waitUntilMissing('.app-loader') + ->assertSeeIn('p', 'There are no receipts for payments') + ->assertDontSeeIn('p', 'Here you can download') + ->assertMissing('select') + ->assertMissing('button'); + }); + }); + + // Create some sample payments + $receipts = []; + $date = Carbon::create(intval(date('Y')) - 1, 3, 30); + $payment = Payment::create([ + 'id' => 'AAA1', + 'status' => PaymentProvider::STATUS_PAID, + 'type' => PaymentProvider::TYPE_ONEOFF, + 'description' => 'Paid in March', + 'wallet_id' => $wallet->id, + 'provider' => 'stripe', + 'amount' => 1111, + ]); + $payment->updated_at = $date; + $payment->save(); + $receipts[] = $date->format('Y-m'); + + $date = Carbon::create(intval(date('Y')) - 1, 4, 30); + $payment = Payment::create([ + 'id' => 'AAA2', + 'status' => PaymentProvider::STATUS_PAID, + 'type' => PaymentProvider::TYPE_ONEOFF, + 'description' => 'Paid in April', + 'wallet_id' => $wallet->id, + 'provider' => 'stripe', + 'amount' => 1111, + ]); + $payment->updated_at = $date; + $payment->save(); + $receipts[] = $date->format('Y-m'); + + // Assert Receipts tab with receipts available + $this->browse(function (Browser $browser) use ($receipts) { + $browser->refresh() + ->on(new WalletPage()) + ->assertSeeIn('@nav #tab-receipts', 'Receipts') + ->with('@receipts-tab', function (Browser $browser) use ($receipts) { + $browser->waitUntilMissing('.app-loader') + ->assertDontSeeIn('p', 'There are no receipts for payments') + ->assertSeeIn('p', 'Here you can download') + ->assertSeeIn('button', 'Download') + ->assertElementsCount('select > option', 2) + ->assertSeeIn('select > option:nth-child(1)', $receipts[1]) + ->assertSeeIn('select > option:nth-child(2)', $receipts[0]); + + // Download a receipt file + $browser->select('select', $receipts[0]) + ->click('button') + ->pause(2000); + + $files = glob(__DIR__ . '/downloads/*.pdf'); + + $filename = pathinfo($files[0], PATHINFO_BASENAME); + $this->assertTrue(strpos($filename, $receipts[0]) !== false); + + $content = $browser->readDownloadedFile($filename, 0); + $this->assertStringStartsWith("%PDF-1.3\n", $content); + + $browser->removeDownloadedFile($filename); + }); + }); + } + /** * Test History tab */ public function testHistory(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com', ['password' => 'simple123']); // Log out John and log in the test user $this->browse(function (Browser $browser) { $browser->visit('/logout') ->waitForLocation('/login') ->on(new Home()) ->submitLogon('wallets-controller@kolabnow.com', 'simple123', true); }); $package_kolab = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package_kolab); $wallet = $user->wallets()->first(); // Create some sample transactions $transactions = $this->createTestTransactions($wallet); $transactions = array_reverse($transactions); $pages = array_chunk($transactions, 10 /* page size*/); $this->browse(function (Browser $browser) use ($pages, $wallet) { $browser->on(new Dashboard()) ->click('@links .link-wallet') ->on(new WalletPage()) ->assertSeeIn('@nav #tab-history', 'History') + ->click('@nav #tab-history') ->with('@history-tab', function (Browser $browser) use ($pages, $wallet) { - $browser->assertElementsCount('table tbody tr', 10) + $browser->waitUntilMissing('.app-loader') + ->assertElementsCount('table tbody tr', 10) ->assertMissing('table td.email') ->assertSeeIn('#transactions-loader button', 'Load more'); foreach ($pages[0] as $idx => $transaction) { $selector = 'table tbody tr:nth-child(' . ($idx + 1) . ')'; $priceStyle = $transaction->type == Transaction::WALLET_AWARD ? 'text-success' : 'text-danger'; $browser->assertSeeIn("$selector td.description", $transaction->shortDescription()) ->assertMissing("$selector td.selection button") ->assertVisible("$selector td.price.{$priceStyle}"); // TODO: Test more transaction details } // Load the next page $browser->click('#transactions-loader button') ->waitUntilMissing('.app-loader') ->assertElementsCount('table tbody tr', 12) ->assertMissing('#transactions-loader button'); $debitEntry = null; foreach ($pages[1] as $idx => $transaction) { $selector = 'table tbody tr:nth-child(' . ($idx + 1 + 10) . ')'; $priceStyle = $transaction->type == Transaction::WALLET_CREDIT ? 'text-success' : 'text-danger'; $browser->assertSeeIn("$selector td.description", $transaction->shortDescription()); if ($transaction->type == Transaction::WALLET_DEBIT) { $debitEntry = $selector; } else { $browser->assertMissing("$selector td.selection button"); } } // Load sub-transactions $browser->click("$debitEntry td.selection button") ->waitUntilMissing('.app-loader') ->assertElementsCount("$debitEntry td.description ul li", 2) ->assertMissing("$debitEntry td.selection button"); }); }); } } diff --git a/src/tests/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php index 332c3d6d..4bc32866 100644 --- a/src/tests/Feature/Controller/WalletsTest.php +++ b/src/tests/Feature/Controller/WalletsTest.php @@ -1,152 +1,254 @@ deleteTestUser('wallets-controller@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('wallets-controller@kolabnow.com'); parent::tearDown(); } + /** + * Test fetching pdf receipt + */ + public function testReceiptDownload(): void + { + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $john = $this->getTestUser('john@klab.org'); + $wallet = $user->wallets()->first(); + + // Unauth access not allowed + $response = $this->get("api/v4/wallets/{$wallet->id}/receipts/2020-05"); + $response->assertStatus(401); + $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts/2020-05"); + $response->assertStatus(403); + + // Invalid receipt id (current month) + $receiptId = date('Y-m'); + $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); + $response->assertStatus(404); + + // Invalid receipt id + $receiptId = '1000-03'; + $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); + $response->assertStatus(404); + + // Valid receipt id + $year = intval(date('Y')) - 1; + $receiptId = "$year-12"; + $filename = \config('app.name') . " Receipt for $year-12"; + + $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); + + $response->assertStatus(200); + $response->assertHeader('content-type', 'application/pdf'); + $response->assertHeader('content-disposition', 'attachment; filename="' . $filename . '"'); + $response->assertHeader('content-length'); + + $length = $response->headers->get('content-length'); + $content = $response->content(); + $this->assertStringStartsWith("%PDF-1.3\n", $content); + $this->assertEquals(strlen($content), $length); + } + + /** + * Test fetching list of receipts + */ + public function testReceipts(): void + { + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $john = $this->getTestUser('john@klab.org'); + $wallet = $user->wallets()->first(); + $wallet->payments()->delete(); + + // Unauth access not allowed + $response = $this->get("api/v4/wallets/{$wallet->id}/receipts"); + $response->assertStatus(401); + $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts"); + $response->assertStatus(403); + + // Empty list expected + $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(5, $json); + $this->assertSame('success', $json['status']); + $this->assertSame([], $json['list']); + $this->assertSame(1, $json['page']); + $this->assertSame(0, $json['count']); + $this->assertSame(false, $json['hasMore']); + + // Insert a payment to the database + $date = Carbon::create(intval(date('Y')) - 1, 4, 30); + $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 = $date; + $payment->save(); + + $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(5, $json); + $this->assertSame('success', $json['status']); + $this->assertSame([$date->format('Y-m')], $json['list']); + $this->assertSame(1, $json['page']); + $this->assertSame(1, $json['count']); + $this->assertSame(false, $json['hasMore']); + } + /** * Test fetching wallet transactions */ public function testTransactions(): void { $package_kolab = \App\Package::where('title', 'kolab')->first(); $user = $this->getTestUser('wallets-controller@kolabnow.com'); $user->assignPackage($package_kolab); $john = $this->getTestUser('john@klab.org'); $wallet = $user->wallets()->first(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(403); // Expect empty list $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); // Create some sample transactions $transactions = $this->createTestTransactions($wallet); $transactions = array_reverse($transactions); $pages = array_chunk($transactions, 10 /* page size*/); // Get the first page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['page']); $this->assertSame(10, $json['count']); $this->assertSame(true, $json['hasMore']); $this->assertCount(10, $json['list']); foreach ($pages[0] as $idx => $transaction) { $this->assertSame($transaction->id, $json['list'][$idx]['id']); $this->assertSame($transaction->type, $json['list'][$idx]['type']); $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); $this->assertFalse($json['list'][$idx]['hasDetails']); $this->assertFalse(array_key_exists('user', $json['list'][$idx])); } $search = null; // Get the second page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=2"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(2, $json['page']); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); foreach ($pages[1] as $idx => $transaction) { $this->assertSame($transaction->id, $json['list'][$idx]['id']); $this->assertSame($transaction->type, $json['list'][$idx]['type']); $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); $this->assertSame( $transaction->type == Transaction::WALLET_DEBIT, $json['list'][$idx]['hasDetails'] ); $this->assertFalse(array_key_exists('user', $json['list'][$idx])); if ($transaction->type == Transaction::WALLET_DEBIT) { $search = $transaction->id; } } // Get a non-existing page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=3"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(3, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(0, $json['list']); // Sub-transaction searching $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction=123"); $response->assertStatus(404); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['page']); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][0]['type']); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][1]['type']); // Test that John gets 404 if he tries to access // someone else's transaction ID on his wallet's endpoint $wallet = $john->wallets()->first(); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}"); $response->assertStatus(404); } }