Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117382755
D1360.1774822201.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
52 KB
Referenced Files
None
Subscribers
None
D1360.1774822201.diff
View Options
diff --git a/src/app/Http/Controllers/API/V4/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php
--- a/src/app/Http/Controllers/API/V4/WalletsController.php
+++ b/src/app/Http/Controllers/API/V4/WalletsController.php
@@ -113,6 +113,7 @@
$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
@@ -144,14 +145,14 @@
}
}
- $result = $result->map(function ($item) {
+ $result = $result->map(function ($item) use ($isAdmin) {
$amount = $item->amount;
if (in_array($item->type, [Transaction::WALLET_PENALTY, Transaction::WALLET_DEBIT])) {
$amount *= -1;
}
- return [
+ $entry = [
'id' => $item->id,
'createdAt' => $item->created_at->format('Y-m-d H:i'),
'type' => $item->type,
@@ -159,6 +160,12 @@
'amount' => $amount,
'hasDetails' => !empty($item->cnt),
];
+
+ if ($isAdmin && $item->user_email) {
+ $entry['user'] = $item->user_email;
+ }
+
+ return $entry;
});
return response()->json([
diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss
--- a/src/resources/sass/app.scss
+++ b/src/resources/sass/app.scss
@@ -120,6 +120,7 @@
table {
td.buttons,
+ td.email,
td.price,
td.datetime,
td.selection {
diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue
--- a/src/resources/vue/Admin/User.vue
+++ b/src/resources/vue/Admin/User.vue
@@ -2,7 +2,7 @@
<div class="container">
<div class="card" id="user-info">
<div class="card-body">
- <div class="card-title">{{ user.email }}</div>
+ <h1 class="card-title">{{ user.email }}</h1>
<div class="card-text">
<form class="read-only">
<div v-if="user.wallet.user_id != user.id" class="form-group row">
@@ -74,9 +74,11 @@
<span class="form-control-plaintext" id="country">{{ user.country }}</span>
</div>
</div>
+ </form>
+ <div class="mt-2">
<button v-if="!user.isSuspended" id="button-suspend" class="btn btn-warning" type="button" @click="suspendUser">Suspend</button>
<button v-if="user.isSuspended" id="button-unsuspend" class="btn btn-warning" type="button" @click="unsuspendUser">Unsuspend</button>
- </form>
+ </div>
</div>
</div>
</div>
@@ -110,7 +112,7 @@
<div class="tab-content">
<div class="tab-pane show active" id="user-finances" role="tabpanel" aria-labelledby="tab-finances">
<div class="card-body">
- <div class="card-title">Account balance <span :class="wallet.balance < 0 ? 'text-danger' : 'text-success'"><strong>{{ $root.price(wallet.balance) }}</strong></span></div>
+ <h2 class="card-title">Account balance <span :class="wallet.balance < 0 ? 'text-danger' : 'text-success'"><strong>{{ $root.price(wallet.balance) }}</strong></span></h2>
<div class="card-text">
<form class="read-only">
<div class="form-group row">
@@ -138,10 +140,14 @@
<span class="form-control-plaintext" v-html="wallet.providerLink"></span>
</div>
</div>
+ </form>
+ <div class="mt-2">
<button id="button-award" class="btn btn-success" type="button" @click="awardDialog">Add bonus</button>
<button id="button-penalty" class="btn btn-danger" type="button" @click="penalizeDialog">Add penalty</button>
- </form>
+ </div>
</div>
+ <h2 class="card-title mt-4">Transactions</h2>
+ <transaction-log v-if="wallet.id && !walletReload" class="card-text" :wallet-id="wallet.id" :is-admin="true"></transaction-log>
</div>
</div>
<div class="tab-pane" id="user-aliases" role="tabpanel" aria-labelledby="tab-aliases">
@@ -340,6 +346,8 @@
</template>
<script>
+ import TransactionLog from '../Widgets/TransactionLog'
+
export default {
beforeRouteUpdate (to, from, next) {
// An event called when the route that renders this component has changed,
@@ -348,6 +356,9 @@
next()
this.$parent.routerReload()
},
+ components: {
+ TransactionLog
+ },
data() {
return {
oneoff_amount: '',
@@ -359,6 +370,7 @@
discounts: [],
external_email: '',
wallet: {},
+ walletReload: false,
domains: [],
skus: [],
users: [],
@@ -496,6 +508,11 @@
penalizeDialog() {
this.oneOffDialog(true)
},
+ reload() {
+ // this is to reload transaction log
+ this.walletReload = true
+ this.$nextTick(function() { this.walletReload = false })
+ },
submitDiscount() {
$('#discount-dialog').modal('hide')
@@ -553,6 +570,7 @@
this.wallet = Object.assign({}, this.wallet, {balance: response.data.balance})
this.oneoff_amount = ''
this.oneoff_description = ''
+ this.reload()
}
})
},
diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue
--- a/src/resources/vue/Dashboard.vue
+++ b/src/resources/vue/Dashboard.vue
@@ -1,6 +1,6 @@
<template>
<div class="container" dusk="dashboard-component">
- <status-component v-bind:status="status" @status-update="statusUpdate"></status-component>
+ <status-component :status="status" @status-update="statusUpdate"></status-component>
<div id="dashboard-nav">
<router-link class="card link-profile" :to="{ name: 'profile' }">
diff --git a/src/resources/vue/Domain/Info.vue b/src/resources/vue/Domain/Info.vue
--- a/src/resources/vue/Domain/Info.vue
+++ b/src/resources/vue/Domain/Info.vue
@@ -1,6 +1,6 @@
<template>
<div class="container">
- <status-component v-bind:status="status" @status-update="statusUpdate"></status-component>
+ <status-component :status="status" @status-update="statusUpdate"></status-component>
<div v-if="domain && !domain.isConfirmed" class="card" id="domain-verify">
<div class="card-body">
diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue
--- a/src/resources/vue/User/Info.vue
+++ b/src/resources/vue/User/Info.vue
@@ -1,6 +1,6 @@
<template>
<div class="container">
- <status-component v-if="user_id !== 'new'" v-bind:status="status" @status-update="statusUpdate"></status-component>
+ <status-component v-if="user_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
<div class="card" id="user-info">
<div class="card-body">
diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue
--- a/src/resources/vue/Wallet.vue
+++ b/src/resources/vue/Wallet.vue
@@ -22,41 +22,7 @@
<div class="tab-content">
<div class="tab-pane show active" id="wallet-history" role="tabpanel" aria-labelledby="tab-history">
<div class="card-body">
- <div class="card-text">
- <table class="table table-sm m-0">
- <thead class="thead-light">
- <tr>
- <th scope="col">Date</th>
- <th scope="col"></th>
- <th scope="col">Description</th>
- <th scope="col" class="price">Amount</th>
- </tr>
- </thead>
- <tbody>
- <tr v-for="transaction in transactions" :id="'log' + transaction.id" :key="transaction.id">
- <td class="datetime">{{ transaction.createdAt }}</td>
- <td class="selection">
- <button class="btn btn-lg btn-link btn-action" title="Details"
- v-if="transaction.hasDetails"
- @click="loadTransaction(transaction.id)"
- >
- <svg-icon icon="info-circle"></svg-icon>
- </button>
- </td>
- <td class="description">{{ transactionDescription(transaction) }}</td>
- <td :class="'price ' + transactionClass(transaction)">{{ transactionAmount(transaction) }}</td>
- </tr>
- </tbody>
- <tfoot class="table-fake-body">
- <tr>
- <td colspan="4">There are no transactions for this account.</td>
- </tr>
- </tfoot>
- </table>
- <div class="text-center p-3" id="transactions-loader">
- <button class="btn btn-secondary" v-if="transactions_more" @click="loadTransactions(true)">Load more</button>
- </div>
- </div>
+ <transaction-log v-if="walletId" class="card-text" :wallet-id="walletId"></transaction-log>
</div>
</div>
</div>
@@ -164,7 +130,12 @@
</template>
<script>
+ import TransactionLog from './Widgets/TransactionLog'
+
export default {
+ components: {
+ TransactionLog
+ },
data() {
return {
amount: '',
@@ -177,6 +148,7 @@
transactions: [],
transactions_more: false,
transactions_page: 1,
+ walletId: null,
wallet_currency: 'CHF'
}
},
@@ -188,58 +160,13 @@
this.provider = wallet.provider
})
- this.loadTransactions()
+ this.walletId = this.$store.state.authInfo.wallets[0].id
if (this.provider == 'stripe') {
this.stripeInit()
}
},
methods: {
- loadTransactions(more) {
- let loader = $('#wallet-history')
- let walletId = this.$store.state.authInfo.wallets[0].id
- let param = ''
-
- if (more) {
- param = '?page=' + (this.transactions_page + 1)
- loader = $('#transactions-loader')
- }
-
- this.$root.addLoader(loader)
- axios.get('/api/v4/wallets/' + walletId + '/transactions' + param)
- .then(response => {
- this.$root.removeLoader(loader)
- // Note: In Vue we can't just use .concat()
- for (let i in response.data.list) {
- this.$set(this.transactions, this.transactions.length, response.data.list[i])
- }
- this.transactions_more = response.data.hasMore
- this.transactions_page = response.data.page || 1
- })
- .catch(error => {
- this.$root.removeLoader(loader)
- })
- },
- loadTransaction(id) {
- let walletId = this.$store.state.authInfo.wallets[0].id
- let record = $('#log' + id)
- let cell = record.find('td.description')
- let details = $('<div class="list-details"><ul></ul><div>').appendTo(cell)
-
- this.$root.addLoader(cell)
- axios.get('/api/v4/wallets/' + walletId + '/transactions' + '?transaction=' + id)
- .then(response => {
- this.$root.removeLoader(cell)
- record.find('button').remove()
- let list = details.find('ul')
- response.data.list.forEach(elem => {
- list.append($('<li>').text(this.transactionDescription(elem)))
- })
- })
- .catch(error => {
- this.$root.removeLoader(cell)
- })
- },
paymentDialog() {
const dialog = $('#payment-dialog')
const mandate_form = $('#mandate-form')
@@ -354,19 +281,6 @@
this.$toast.error(result.error.message)
}
})
- },
- transactionAmount(transaction) {
- return this.$root.price(transaction.amount)
- },
- transactionClass(transaction) {
- return transaction.amount < 0 ? 'text-danger' : 'text-success';
- },
- transactionDescription(transaction) {
- let desc = transaction.description
- if (/^(billed|created|deleted)$/.test(transaction.type)) {
- desc += ' (' + this.$root.price(transaction.amount) + ')'
- }
- return desc
}
}
}
diff --git a/src/resources/vue/Widgets/TransactionLog.vue b/src/resources/vue/Widgets/TransactionLog.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Widgets/TransactionLog.vue
@@ -0,0 +1,122 @@
+<template>
+ <div>
+ <table class="table table-sm m-0 transactions">
+ <thead class="thead-light">
+ <tr>
+ <th scope="col">Date</th>
+ <th scope="col" v-if="isAdmin">User</th>
+ <th scope="col"></th>
+ <th scope="col">Description</th>
+ <th scope="col" class="price">Amount</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="transaction in transactions" :id="'log' + transaction.id" :key="transaction.id">
+ <td class="datetime">{{ transaction.createdAt }}</td>
+ <td class="email" v-if="isAdmin">{{ transaction.user }}</td>
+ <td class="selection">
+ <button class="btn btn-lg btn-link btn-action" title="Details"
+ v-if="transaction.hasDetails"
+ @click="loadTransaction(transaction.id)"
+ >
+ <svg-icon icon="info-circle"></svg-icon>
+ </button>
+ </td>
+ <td class="description">{{ description(transaction) }}</td>
+ <td :class="'price ' + className(transaction)">{{ amount(transaction) }}</td>
+ </tr>
+ </tbody>
+ <tfoot class="table-fake-body">
+ <tr>
+ <td :colspan="isAdmin ? 5 : 4">There are no transactions for this account.</td>
+ </tr>
+ </tfoot>
+ </table>
+ <div class="text-center p-3" id="transactions-loader" v-if="hasMore">
+ <button class="btn btn-secondary"@click="loadLog(true)">Load more</button>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ props: {
+ walletId: { type: String, default: () => { return null } },
+ isAdmin: { type: Boolean, default: () => { return false } },
+ },
+ data() {
+ return {
+ transactions: [],
+ hasMore: false,
+ page: 1
+ }
+ },
+ mounted() {
+ this.loadLog()
+ },
+ methods: {
+ loadLog(more) {
+ if (!this.walletId) {
+ return
+ }
+
+ let loader = $(this.$el)
+ let param = ''
+
+ if (more) {
+ param = '?page=' + (this.page + 1)
+ loader = $('#transactions-loader')
+ }
+
+ this.$root.addLoader(loader)
+ axios.get('/api/v4/wallets/' + this.walletId + '/transactions' + param)
+ .then(response => {
+ this.$root.removeLoader(loader)
+ // Note: In Vue we can't just use .concat()
+ for (let i in response.data.list) {
+ this.$set(this.transactions, this.transactions.length, response.data.list[i])
+ }
+ this.hasMore = response.data.hasMore
+ this.page = response.data.page || 1
+ })
+ .catch(error => {
+ this.$root.removeLoader(loader)
+ })
+ },
+ loadTransaction(id) {
+ let record = $('#log' + id)
+ let cell = record.find('td.description')
+ let details = $('<div class="list-details"><ul></ul><div>').appendTo(cell)
+
+ this.$root.addLoader(cell)
+ axios.get('/api/v4/wallets/' + this.walletId + '/transactions' + '?transaction=' + id)
+ .then(response => {
+ this.$root.removeLoader(cell)
+ record.find('button').remove()
+ let list = details.find('ul')
+ response.data.list.forEach(elem => {
+ list.append($('<li>').text(this.description(elem)))
+ })
+ })
+ .catch(error => {
+ this.$root.removeLoader(cell)
+ })
+ },
+ amount(transaction) {
+ return this.$root.price(transaction.amount)
+ },
+ className(transaction) {
+ return transaction.amount < 0 ? 'text-danger' : 'text-success';
+ },
+ description(transaction) {
+ let desc = transaction.description
+
+ if (/^(billed|created|deleted)$/.test(transaction.type)) {
+ desc += ' (' + this.$root.price(transaction.amount) + ')'
+ }
+
+ return desc
+ }
+ }
+ }
+</script>
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -105,6 +105,7 @@
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/Admin/UserFinancesTest.php b/src/tests/Browser/Admin/UserFinancesTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Admin/UserFinancesTest.php
@@ -0,0 +1,314 @@
+<?php
+
+namespace Tests\Browser\Admin;
+
+use App\Discount;
+use App\Transaction;
+use App\User;
+use App\Wallet;
+use Carbon\Carbon;
+use Tests\Browser;
+use Tests\Browser\Components\Dialog;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Admin\User as UserPage;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\TestCaseDusk;
+
+class UserTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useAdminUrl();
+
+ $john = $this->getTestUser('john@kolab.org');
+ $wallet = $john->wallets()->first();
+ $wallet->discount()->dissociate();
+ $wallet->balance = 0;
+ $wallet->save();
+ }
+
+ /**
+ * Test Finances tab (and transactions)
+ */
+ public function testFinances(): void
+ {
+ // Assert Jack's Finances tab
+ $this->browse(function (Browser $browser) {
+ $jack = $this->getTestUser('jack@kolab.org');
+ $jack->wallets()->first()->transactions()->delete();
+ $page = new UserPage($jack->id);
+
+ $browser->visit(new Home())
+ ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true)
+ ->on(new Dashboard())
+ ->visit($page)
+ ->on($page)
+ ->assertSeeIn('@nav #tab-finances', 'Finances')
+ ->with('@user-finances', function (Browser $browser) {
+ $browser->waitUntilMissing('.app-loader')
+ ->assertSeeIn('.card-title:first-child', 'Account balance')
+ ->assertSeeIn('.card-title:first-child .text-success', '0,00 CHF')
+ ->with('form', function (Browser $browser) {
+ $payment_provider = ucfirst(\config('services.payment_provider'));
+ $browser->assertElementsCount('.row', 2)
+ ->assertSeeIn('.row:nth-child(1) label', 'Discount')
+ ->assertSeeIn('.row:nth-child(1) #discount span', 'none')
+ ->assertSeeIn('.row:nth-child(2) label', $payment_provider . ' ID')
+ ->assertVisible('.row:nth-child(2) a');
+ })
+ ->assertSeeIn('h2:nth-of-type(2)', 'Transactions')
+ ->with('table', function (Browser $browser) {
+ $browser->assertMissing('tbody')
+ ->assertSeeIn('tfoot td', "There are no transactions for this account.");
+ })
+ ->assertMissing('table + button');
+ });
+ });
+
+ // Assert John's Finances tab (with discount, and debit)
+ $this->browse(function (Browser $browser) {
+ $john = $this->getTestUser('john@kolab.org');
+ $page = new UserPage($john->id);
+ $discount = Discount::where('code', 'TEST')->first();
+ $wallet = $john->wallet();
+ $wallet->transactions()->delete();
+ $wallet->discount()->associate($discount);
+ $wallet->debit(2010);
+ $wallet->save();
+
+ // Create test transactions
+ $transaction = Transaction::create([
+ 'user_email' => 'jeroen@jeroen.jeroen',
+ 'object_id' => $wallet->id,
+ 'object_type' => Wallet::class,
+ 'type' => Transaction::WALLET_CREDIT,
+ 'amount' => 100,
+ 'description' => 'Payment',
+ ]);
+ $transaction->updated_at = Carbon::now()->previous(Carbon::MONDAY);
+ $transaction->save();
+
+ // Click the managed-by link on Jack's page
+ $browser->click('@user-info #manager a')
+ ->on($page)
+ ->with('@user-finances', function (Browser $browser) use ($transaction) {
+ $browser->waitUntilMissing('.app-loader')
+ ->assertSeeIn('.card-title:first-child', 'Account balance')
+ ->assertSeeIn('.card-title:first-child .text-danger', '-20,10 CHF')
+ ->with('form', function (Browser $browser) {
+ $browser->assertElementsCount('.row', 2)
+ ->assertSeeIn('.row:nth-child(1) label', 'Discount')
+ ->assertSeeIn('.row:nth-child(1) #discount span', '10% - Test voucher');
+ })
+ ->assertSeeIn('h2:nth-of-type(2)', 'Transactions')
+ ->with('table', function (Browser $browser) use ($transaction) {
+ $browser->assertElementsCount('tbody tr', 2)
+ ->assertMissing('tfoot')
+ ->assertSeeIn('tbody tr:last-child td.email', 'jeroen@jeroen.jeroen');
+ });
+ });
+ });
+
+ // Now we go to Ned's info page, he's a controller on John's wallet
+ $this->browse(function (Browser $browser) {
+ $ned = $this->getTestUser('ned@kolab.org');
+ $page = new UserPage($ned->id);
+
+ $browser->click('@nav #tab-users')
+ ->click('@user-users tbody tr:nth-child(3) td:first-child a')
+ ->on($page)
+ ->with('@user-finances', function (Browser $browser) {
+ $browser->waitUntilMissing('.app-loader')
+ ->assertSeeIn('.card-title:first-child', 'Account balance')
+ ->assertSeeIn('.card-title:first-child .text-success', '0,00 CHF')
+ ->with('form', function (Browser $browser) {
+ $browser->assertElementsCount('.row', 2)
+ ->assertSeeIn('.row:nth-child(1) label', 'Discount')
+ ->assertSeeIn('.row:nth-child(1) #discount span', 'none');
+ })
+ ->assertSeeIn('h2:nth-of-type(2)', 'Transactions')
+ ->with('table', function (Browser $browser) {
+ $browser->assertMissing('tbody')
+ ->assertSeeIn('tfoot td', "There are no transactions for this account.");
+ })
+ ->assertMissing('table + button');
+ });
+ });
+ }
+
+ /**
+ * Test editing wallet discount
+ *
+ * @depends testFinances
+ */
+ public function testWalletDiscount(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $john = $this->getTestUser('john@kolab.org');
+
+ $browser->visit(new UserPage($john->id))
+ ->pause(100)
+ ->waitUntilMissing('@user-finances .app-loader')
+ ->click('@user-finances #discount button')
+ // Test dialog content, and closing it with Cancel button
+ ->with(new Dialog('#discount-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Account discount')
+ ->assertFocused('@body select')
+ ->assertSelected('@body select', '')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@button-action', 'Submit')
+ ->click('@button-cancel');
+ })
+ ->assertMissing('#discount-dialog')
+ ->click('@user-finances #discount button')
+ // Change the discount
+ ->with(new Dialog('#discount-dialog'), function (Browser $browser) {
+ $browser->click('@body select')
+ ->click('@body select option:nth-child(2)')
+ ->click('@button-action');
+ })
+ ->assertToast(Toast::TYPE_SUCCESS, 'User wallet updated successfully.')
+ ->assertSeeIn('#discount span', '10% - Test voucher')
+ ->click('@nav #tab-subscriptions')
+ ->with('@user-subscriptions', function (Browser $browser) {
+ $browser->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
+ ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹')
+ ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher');
+ })
+ // Change back to 'none'
+ ->click('@nav #tab-finances')
+ ->click('@user-finances #discount button')
+ ->with(new Dialog('#discount-dialog'), function (Browser $browser) {
+ $browser->click('@body select')
+ ->click('@body select option:nth-child(1)')
+ ->click('@button-action');
+ })
+ ->assertToast(Toast::TYPE_SUCCESS, 'User wallet updated successfully.')
+ ->assertSeeIn('#discount span', 'none')
+ ->click('@nav #tab-subscriptions')
+ ->with('@user-subscriptions', function (Browser $browser) {
+ $browser->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,44 CHF/month')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month')
+ ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '5,55 CHF/month')
+ ->assertMissing('table + .hint');
+ });
+ });
+ }
+
+ /**
+ * Test awarding/penalizing a wallet
+ *
+ * @depends testFinances
+ */
+ public function testBonusPenalty(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $john = $this->getTestUser('john@kolab.org');
+
+ $browser->visit(new UserPage($john->id))
+ ->waitFor('@user-finances #button-award')
+ ->click('@user-finances #button-award')
+ // Test dialog content, and closing it with Cancel button
+ ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Add a bonus to the wallet')
+ ->assertFocused('@body input#oneoff_amount')
+ ->assertSeeIn('@body label[for="oneoff_amount"]', 'Amount')
+ ->assertvalue('@body input#oneoff_amount', '')
+ ->assertSeeIn('@body label[for="oneoff_description"]', 'Description')
+ ->assertvalue('@body input#oneoff_description', '')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@button-action', 'Submit')
+ ->click('@button-cancel');
+ })
+ ->assertMissing('#oneoff-dialog');
+
+ // Test bonus
+ $browser->click('@user-finances #button-award')
+ ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) {
+ // Test input validation for a bonus
+ $browser->type('@body #oneoff_amount', 'aaa')
+ ->type('@body #oneoff_description', '')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ ->assertVisible('@body #oneoff_amount.is-invalid')
+ ->assertVisible('@body #oneoff_description.is-invalid')
+ ->assertSeeIn(
+ '@body #oneoff_amount + span + .invalid-feedback',
+ 'The amount must be a number.'
+ )
+ ->assertSeeIn(
+ '@body #oneoff_description + .invalid-feedback',
+ 'The description field is required.'
+ );
+
+ // Test adding a bonus
+ $browser->type('@body #oneoff_amount', '12.34')
+ ->type('@body #oneoff_description', 'Test bonus')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_SUCCESS, 'The bonus has been added to the wallet successfully.');
+ })
+ ->assertMissing('#oneoff-dialog')
+ ->assertSeeIn('@user-finances .card-title span.text-success', '12,34 CHF')
+ ->waitUntilMissing('.app-loader')
+ ->with('table', function (Browser $browser) {
+ $browser->assertElementsCount('tbody tr', 3)
+ ->assertMissing('tfoot')
+ ->assertSeeIn('tbody tr:first-child td.description', 'Bonus: Test bonus')
+ ->assertSeeIn('tbody tr:first-child td.email', 'jeroen@jeroen.jeroen')
+ ->assertSeeIn('tbody tr:first-child td.price', '12,34 CHF');
+ });
+
+ $this->assertSame(1234, $john->wallets()->first()->balance);
+
+ // Test penalty
+ $browser->click('@user-finances #button-penalty')
+ // Test dialog content, and closing it with Cancel button
+ ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Add a penalty to the wallet')
+ ->assertFocused('@body input#oneoff_amount')
+ ->assertSeeIn('@body label[for="oneoff_amount"]', 'Amount')
+ ->assertvalue('@body input#oneoff_amount', '')
+ ->assertSeeIn('@body label[for="oneoff_description"]', 'Description')
+ ->assertvalue('@body input#oneoff_description', '')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@button-action', 'Submit')
+ ->click('@button-cancel');
+ })
+ ->assertMissing('#oneoff-dialog')
+ ->click('@user-finances #button-penalty')
+ ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) {
+ // Test input validation for a penalty
+ $browser->type('@body #oneoff_amount', '')
+ ->type('@body #oneoff_description', '')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ ->assertVisible('@body #oneoff_amount.is-invalid')
+ ->assertVisible('@body #oneoff_description.is-invalid')
+ ->assertSeeIn(
+ '@body #oneoff_amount + span + .invalid-feedback',
+ 'The amount field is required.'
+ )
+ ->assertSeeIn(
+ '@body #oneoff_description + .invalid-feedback',
+ 'The description field is required.'
+ );
+
+ // Test adding a penalty
+ $browser->type('@body #oneoff_amount', '12.35')
+ ->type('@body #oneoff_description', 'Test penalty')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_SUCCESS, 'The penalty has been added to the wallet successfully.');
+ })
+ ->assertMissing('#oneoff-dialog')
+ ->assertSeeIn('@user-finances .card-title span.text-danger', '-0,01 CHF');
+
+ $this->assertSame(-1, $john->wallets()->first()->balance);
+ });
+ }
+}
diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php
--- a/src/tests/Browser/Admin/UserTest.php
+++ b/src/tests/Browser/Admin/UserTest.php
@@ -33,8 +33,6 @@
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
- $wallet->balance = 0;
- $wallet->save();
}
/**
@@ -52,8 +50,6 @@
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
- $wallet->balance = 0;
- $wallet->save();
parent::tearDown();
}
@@ -109,21 +105,8 @@
$browser->pause(500)
->assertElementsCount('@nav a', 5);
- // Assert Finances tab
- $browser->assertSeeIn('@nav #tab-finances', 'Finances')
- ->with('@user-finances', function (Browser $browser) {
- $browser->waitUntilMissing('.app-loader')
- ->assertSeeIn('.card-title', 'Account balance')
- ->assertSeeIn('.card-title .text-success', '0,00 CHF')
- ->with('form', function (Browser $browser) {
- $payment_provider = ucfirst(\config('services.payment_provider'));
- $browser->assertElementsCount('.row', 2)
- ->assertSeeIn('.row:nth-child(1) label', 'Discount')
- ->assertSeeIn('.row:nth-child(1) #discount span', 'none')
- ->assertSeeIn('.row:nth-child(2) label', $payment_provider . ' ID')
- ->assertVisible('.row:nth-child(2) a');
- });
- });
+ // Note: Finances tab is tested in UserFinancesTest.php
+ $browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
@@ -217,18 +200,8 @@
$browser->pause(500)
->assertElementsCount('@nav a', 5);
- // Assert Finances tab
- $browser->assertSeeIn('@nav #tab-finances', 'Finances')
- ->with('@user-finances', function (Browser $browser) {
- $browser->waitUntilMissing('.app-loader')
- ->assertSeeIn('.card-title', 'Account balance')
- ->assertSeeIn('.card-title .text-danger', '-20,10 CHF')
- ->with('form', function (Browser $browser) {
- $browser->assertElementsCount('.row', 2)
- ->assertSeeIn('.row:nth-child(1) label', 'Discount')
- ->assertSeeIn('.row:nth-child(1) #discount span', '10% - Test voucher');
- });
- });
+ // Note: Finances tab is tested in UserFinancesTest.php
+ $browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
@@ -298,18 +271,8 @@
$browser->pause(500)
->assertElementsCount('@nav a', 5);
- // Assert Finances tab
- $browser->assertSeeIn('@nav #tab-finances', 'Finances')
- ->with('@user-finances', function (Browser $browser) {
- $browser->waitUntilMissing('.app-loader')
- ->assertSeeIn('.card-title', 'Account balance')
- ->assertSeeIn('.card-title .text-success', '0,00 CHF')
- ->with('form', function (Browser $browser) {
- $browser->assertElementsCount('.row', 2)
- ->assertSeeIn('.row:nth-child(1) label', 'Discount')
- ->assertSeeIn('.row:nth-child(1) #discount span', 'none');
- });
- });
+ // Note: Finances tab is tested in UserFinancesTest.php
+ $browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (0)')
@@ -433,165 +396,4 @@
->assertMissing('@user-info #button-unsuspend');
});
}
-
- /**
- * Test editing wallet discount
- *
- * @depends testUserInfo2
- */
- public function testWalletDiscount(): void
- {
- $this->browse(function (Browser $browser) {
- $john = $this->getTestUser('john@kolab.org');
-
- $browser->visit(new UserPage($john->id))
- ->pause(100)
- ->waitUntilMissing('@user-finances .app-loader')
- ->click('@user-finances #discount button')
- // Test dialog content, and closing it with Cancel button
- ->with(new Dialog('#discount-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@title', 'Account discount')
- ->assertFocused('@body select')
- ->assertSelected('@body select', '')
- ->assertSeeIn('@button-cancel', 'Cancel')
- ->assertSeeIn('@button-action', 'Submit')
- ->click('@button-cancel');
- })
- ->assertMissing('#discount-dialog')
- ->click('@user-finances #discount button')
- // Change the discount
- ->with(new Dialog('#discount-dialog'), function (Browser $browser) {
- $browser->click('@body select')
- ->click('@body select option:nth-child(2)')
- ->click('@button-action');
- })
- ->assertToast(Toast::TYPE_SUCCESS, 'User wallet updated successfully.')
- ->assertSeeIn('#discount span', '10% - Test voucher')
- ->click('@nav #tab-subscriptions')
- ->with('@user-subscriptions', function (Browser $browser) {
- $browser->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹')
- ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
- ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹')
- ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher');
- })
- // Change back to 'none'
- ->click('@nav #tab-finances')
- ->click('@user-finances #discount button')
- ->with(new Dialog('#discount-dialog'), function (Browser $browser) {
- $browser->click('@body select')
- ->click('@body select option:nth-child(1)')
- ->click('@button-action');
- })
- ->assertToast(Toast::TYPE_SUCCESS, 'User wallet updated successfully.')
- ->assertSeeIn('#discount span', 'none')
- ->click('@nav #tab-subscriptions')
- ->with('@user-subscriptions', function (Browser $browser) {
- $browser->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,44 CHF/month')
- ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month')
- ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '5,55 CHF/month')
- ->assertMissing('table + .hint');
- });
- });
- }
-
- /**
- * Test awarding/penalizing a wallet
- */
- public function testBonusPenalty(): void
- {
- $this->browse(function (Browser $browser) {
- $john = $this->getTestUser('john@kolab.org');
-
- $browser->visit(new UserPage($john->id))
- ->waitFor('@user-finances #button-award')
- ->click('@user-finances #button-award')
- // Test dialog content, and closing it with Cancel button
- ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@title', 'Add a bonus to the wallet')
- ->assertFocused('@body input#oneoff_amount')
- ->assertSeeIn('@body label[for="oneoff_amount"]', 'Amount')
- ->assertvalue('@body input#oneoff_amount', '')
- ->assertSeeIn('@body label[for="oneoff_description"]', 'Description')
- ->assertvalue('@body input#oneoff_description', '')
- ->assertSeeIn('@button-cancel', 'Cancel')
- ->assertSeeIn('@button-action', 'Submit')
- ->click('@button-cancel');
- })
- ->assertMissing('#oneoff-dialog');
-
- // Test bonus
- $browser->click('@user-finances #button-award')
- ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) {
- // Test input validation for a bonus
- $browser->type('@body #oneoff_amount', 'aaa')
- ->type('@body #oneoff_description', '')
- ->click('@button-action')
- ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
- ->assertVisible('@body #oneoff_amount.is-invalid')
- ->assertVisible('@body #oneoff_description.is-invalid')
- ->assertSeeIn(
- '@body #oneoff_amount + span + .invalid-feedback',
- 'The amount must be a number.'
- )
- ->assertSeeIn(
- '@body #oneoff_description + .invalid-feedback',
- 'The description field is required.'
- );
-
- // Test adding a bonus
- $browser->type('@body #oneoff_amount', '12.34')
- ->type('@body #oneoff_description', 'Test bonus')
- ->click('@button-action')
- ->assertToast(Toast::TYPE_SUCCESS, 'The bonus has been added to the wallet successfully.');
- })
- ->assertMissing('#oneoff-dialog')
- ->assertSeeIn('@user-finances .card-title span.text-success', '12,34 CHF');
-
- $this->assertSame(1234, $john->wallets()->first()->balance);
-
- // Test penalty
- $browser->click('@user-finances #button-penalty')
- // Test dialog content, and closing it with Cancel button
- ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@title', 'Add a penalty to the wallet')
- ->assertFocused('@body input#oneoff_amount')
- ->assertSeeIn('@body label[for="oneoff_amount"]', 'Amount')
- ->assertvalue('@body input#oneoff_amount', '')
- ->assertSeeIn('@body label[for="oneoff_description"]', 'Description')
- ->assertvalue('@body input#oneoff_description', '')
- ->assertSeeIn('@button-cancel', 'Cancel')
- ->assertSeeIn('@button-action', 'Submit')
- ->click('@button-cancel');
- })
- ->assertMissing('#oneoff-dialog')
- ->click('@user-finances #button-penalty')
- ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) {
- // Test input validation for a penalty
- $browser->type('@body #oneoff_amount', '')
- ->type('@body #oneoff_description', '')
- ->click('@button-action')
- ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
- ->assertVisible('@body #oneoff_amount.is-invalid')
- ->assertVisible('@body #oneoff_description.is-invalid')
- ->assertSeeIn(
- '@body #oneoff_amount + span + .invalid-feedback',
- 'The amount field is required.'
- )
- ->assertSeeIn(
- '@body #oneoff_description + .invalid-feedback',
- 'The description field is required.'
- );
-
- // Test adding a penalty
- $browser->type('@body #oneoff_amount', '12.35')
- ->type('@body #oneoff_description', 'Test penalty')
- ->click('@button-action')
- ->assertToast(Toast::TYPE_SUCCESS, 'The penalty has been added to the wallet successfully.');
- })
- ->assertMissing('#oneoff-dialog')
- ->assertSeeIn('@user-finances .card-title span.text-danger', '-0,01 CHF');
-
- $this->assertSame(-1, $john->wallets()->first()->balance);
- });
- }
}
diff --git a/src/tests/Browser/WalletTest.php b/src/tests/Browser/WalletTest.php
--- a/src/tests/Browser/WalletTest.php
+++ b/src/tests/Browser/WalletTest.php
@@ -111,6 +111,7 @@
->assertSeeIn('@nav #tab-history', 'History')
->with('@history-tab', function (Browser $browser) use ($pages, $wallet) {
$browser->assertElementsCount('table tbody tr', 10)
+ ->assertMissing('table td.email')
->assertSeeIn('#transactions-loader button', 'Load more');
foreach ($pages[0] as $idx => $transaction) {
diff --git a/src/tests/Feature/Controller/Admin/WalletsTest.php b/src/tests/Feature/Controller/Admin/WalletsTest.php
--- a/src/tests/Feature/Controller/Admin/WalletsTest.php
+++ b/src/tests/Feature/Controller/Admin/WalletsTest.php
@@ -136,6 +136,51 @@
}
/**
+ * Test fetching wallet transactions (GET /api/v4/wallets/:id/transactions)
+ */
+ public function testTransactions(): void
+ {
+ // Note: Here we're testing only that the end-point works,
+ // and admin can get the transaction log, response details
+ // are tested in Feature/Controller/WalletsTest.php
+ $this->deleteTestUser('wallets-controller@kolabnow.com');
+ $user = $this->getTestUser('wallets-controller@kolabnow.com');
+ $wallet = $user->wallets()->first();
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+
+ // Non-admin
+ $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions");
+ $response->assertStatus(403);
+
+ // Create some sample transactions
+ $transactions = $this->createTestTransactions($wallet);
+ $transactions = array_reverse($transactions);
+ $pages = array_chunk($transactions, 10 /* page size*/);
+
+ // Get the 2nd page
+ $response = $this->actingAs($admin)->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->assertFalse($json['list'][$idx]['hasDetails']);
+ }
+
+ // The 'user' key is set only on the admin end-point
+ $this->assertSame('jeroen@jeroen.jeroen', $json['list'][1]['user']);
+ }
+
+ /**
* Test updating a wallet (PUT /api/v4/wallets/:id)
*/
public function testUpdate(): void
diff --git a/src/tests/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php
--- a/src/tests/Feature/Controller/WalletsTest.php
+++ b/src/tests/Feature/Controller/WalletsTest.php
@@ -80,6 +80,7 @@
$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;
@@ -104,6 +105,7 @@
$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;
diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php
--- a/src/tests/TestCaseTrait.php
+++ b/src/tests/TestCaseTrait.php
@@ -60,7 +60,7 @@
}
$transaction = Transaction::create([
- 'user_email' => null,
+ 'user_email' => 'jeroen@jeroen.jeroen',
'object_id' => $wallet->id,
'object_type' => \App\Wallet::class,
'type' => Transaction::WALLET_DEBIT,
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sun, Mar 29, 10:10 PM (3 d, 20 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18777156
Default Alt Text
D1360.1774822201.diff (52 KB)
Attached To
Mode
D1360: Transaction log in admin UI
Attached
Detach File
Event Timeline