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 = $('
+ Here you can download receipts (in PDF format) for payments in specified period.
+ Select the period and press the Download button.
+
+
+
+
+
+
+
+
+ There are no receipts for payments in this account. Please, note that you can download
+ receipts after the month ends.
+
+
+
+
+
-
+
{{ paymentDialogTitle }}
Choose the amount by which you want to top up your wallet.
or
Add auto-payment, so you never run out.
Auto-payment is set to fill up your account by {{ mandate.amount }} CHF
every time your account balance gets under {{ mandate.balance }} CHF.
You will be charged via {{ mandate.method }}.
The configured auto-payment has been disabled. Top up your wallet or
raise the auto-payment amount.
You can cancel or change the auto-payment at any time.
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);
}
}