Page MenuHomePhorge

No OneTemporary

diff --git a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
index 00f90d27..0ef93f0b 100644
--- a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
@@ -1,86 +1,148 @@
<?php
namespace App\Http\Controllers\API\V4\Admin;
use App\Discount;
use App\Http\Controllers\API\V4\PaymentsController;
use App\Providers\PaymentProvider;
+use App\Transaction;
use App\Wallet;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
class WalletsController extends \App\Http\Controllers\API\V4\WalletsController
{
/**
* Return data of the specified wallet.
*
* @param string $id A wallet identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function show($id)
{
$wallet = Wallet::find($id);
if (empty($wallet)) {
return $this->errorResponse(404);
}
$result = $wallet->toArray();
$result['discount'] = 0;
$result['discount_description'] = '';
if ($wallet->discount) {
$result['discount'] = $wallet->discount->discount;
$result['discount_description'] = $wallet->discount->description;
}
$result['mandate'] = PaymentsController::walletMandate($wallet);
$provider = PaymentProvider::factory($wallet);
$result['provider'] = $provider->name();
$result['providerLink'] = $provider->customerLink($wallet);
return response()->json($result);
}
+ /**
+ * Award/penalize a wallet.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ * @params string $id Wallet identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function oneOff(Request $request, $id)
+ {
+ $wallet = Wallet::find($id);
+
+ if (empty($wallet)) {
+ return $this->errorResponse(404);
+ }
+
+ // Check required fields
+ $v = Validator::make(
+ $request->all(),
+ [
+ 'amount' => 'required|numeric',
+ 'description' => 'required|string|max:1024',
+ ]
+ );
+
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
+ }
+
+ $amount = (int) ($request->amount * 100);
+ $type = $amount > 0 ? Transaction::WALLET_AWARD : Transaction::WALLET_PENALTY;
+
+ DB::beginTransaction();
+
+ $wallet->balance += $amount;
+ $wallet->save();
+
+ Transaction::create(
+ [
+ 'user_email' => \App\Utils::userEmailOrNull(),
+ 'object_id' => $wallet->id,
+ 'object_type' => Wallet::class,
+ 'type' => $type,
+ 'amount' => $amount < 0 ? $amount * -1 : $amount,
+ 'description' => $request->description
+ ]
+ );
+
+ DB::commit();
+
+ $response = [
+ 'status' => 'success',
+ 'message' => \trans("app.wallet-{$type}-success"),
+ 'balance' => $wallet->balance
+ ];
+
+ return response()->json($response);
+ }
+
/**
* Update wallet data.
*
* @param \Illuminate\Http\Request $request The API request.
* @params string $id Wallet identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function update(Request $request, $id)
{
$wallet = Wallet::find($id);
if (empty($wallet)) {
return $this->errorResponse(404);
}
if (array_key_exists('discount', $request->input())) {
if (empty($request->discount)) {
$wallet->discount()->dissociate();
$wallet->save();
} elseif ($discount = Discount::find($request->discount)) {
$wallet->discount()->associate($discount);
$wallet->save();
}
}
$response = $wallet->toArray();
if ($wallet->discount) {
$response['discount'] = $wallet->discount->discount;
$response['discount_description'] = $wallet->discount->description;
}
$response['status'] = 'success';
$response['message'] = \trans('app.wallet-update-success');
return response()->json($response);
}
}
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
index 64ce4dcd..f68f231e 100644
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -1,45 +1,47 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used in the application.
*/
'mandate-delete-success' => 'The auto-payment has been removed.',
'mandate-update-success' => 'The auto-payment has been updated.',
'planbutton' => 'Choose :plan',
'process-user-new' => 'Registering a user...',
'process-user-ldap-ready' => 'Creating a user...',
'process-user-imap-ready' => 'Creating a mailbox...',
'process-domain-new' => 'Registering a custom domain...',
'process-domain-ldap-ready' => 'Creating a custom domain...',
'process-domain-verified' => 'Verifying a custom domain...',
'process-domain-confirmed' => 'Verifying an ownership of a custom domain...',
'process-success' => 'Setup process finished successfully.',
'process-error-user-ldap-ready' => 'Failed to create a user.',
'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.',
'process-error-domain-ldap-ready' => 'Failed to create a domain.',
'process-error-domain-verified' => 'Failed to verify a domain.',
'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.',
'domain-verify-success' => 'Domain verified successfully.',
'domain-verify-error' => 'Domain ownership verification failed.',
'user-update-success' => 'User data updated successfully.',
'user-create-success' => 'User created successfully.',
'user-delete-success' => 'User deleted successfully.',
'user-suspend-success' => 'User suspended successfully.',
'user-unsuspend-success' => 'User unsuspended successfully.',
'search-foundxdomains' => ':x domains have been found.',
'search-foundxusers' => ':x user accounts have been found.',
+ 'wallet-award-success' => 'The bonus has been added to the wallet successfully.',
+ 'wallet-penalty-success' => 'The penalty has been added to the wallet successfully.',
'wallet-update-success' => 'User wallet updated successfully.',
];
diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue
index c8559cce..758f8a95 100644
--- a/src/resources/vue/Admin/User.vue
+++ b/src/resources/vue/Admin/User.vue
@@ -1,496 +1,580 @@
<template>
<div class="container">
<div class="card" id="user-info">
<div class="card-body">
<div class="card-title">{{ user.email }}</div>
<div class="card-text">
<form class="read-only">
<div v-if="user.wallet.user_id != user.id" class="form-group row">
<label for="manager" class="col-sm-4 col-form-label">Managed by</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="manager">
<router-link :to="{ path: '/user/' + user.wallet.user_id }">{{ user.wallet.user_email }}</router-link>
</span>
</div>
</div>
<div class="form-group row">
<label for="userid" class="col-sm-4 col-form-label">ID <span class="text-muted">(Created at)</span></label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="userid">
{{ user.id }} <span class="text-muted">({{ user.created_at }})</span>
</span>
</div>
</div>
<div class="form-group row">
<label for="status" class="col-sm-4 col-form-label">Status</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="status">
<span :class="$root.userStatusClass(user)">{{ $root.userStatusText(user) }}</span>
</span>
</div>
</div>
<div class="form-group row" v-if="user.first_name">
<label for="first_name" class="col-sm-4 col-form-label">First name</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="first_name">{{ user.first_name }}</span>
</div>
</div>
<div class="form-group row" v-if="user.last_name">
<label for="last_name" class="col-sm-4 col-form-label">Last name</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="last_name">{{ user.last_name }}</span>
</div>
</div>
<div class="form-group row" v-if="user.organization">
<label for="organization" class="col-sm-4 col-form-label">Organization</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="organization">{{ user.organization }}</span>
</div>
</div>
<div class="form-group row" v-if="user.phone">
<label for="phone" class="col-sm-4 col-form-label">Phone</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="phone">{{ user.phone }}</span>
</div>
</div>
<div class="form-group row">
<label for="external_email" class="col-sm-4 col-form-label">External email</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="external_email">
<a v-if="user.external_email" :href="'mailto:' + user.external_email">{{ user.external_email }}</a>
<button type="button" class="btn btn-secondary btn-sm" @click="emailEdit">Edit</button>
</span>
</div>
</div>
<div class="form-group row" v-if="user.billing_address">
<label for="billing_address" class="col-sm-4 col-form-label">Address</label>
<div class="col-sm-8">
<span class="form-control-plaintext" style="white-space:pre" id="billing_address">{{ user.billing_address }}</span>
</div>
</div>
<div class="form-group row">
<label for="country" class="col-sm-4 col-form-label">Country</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="country">{{ user.country }}</span>
</div>
</div>
<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>
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-finances" href="#user-finances" role="tab" aria-controls="user-finances" aria-selected="true">
Finances
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-aliases" href="#user-aliases" role="tab" aria-controls="user-aliases" aria-selected="false">
Aliases ({{ user.aliases.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-subscriptions" href="#user-subscriptions" role="tab" aria-controls="user-subscriptions" aria-selected="false">
Subscriptions ({{ skus.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-domains" href="#user-domains" role="tab" aria-controls="user-domains" aria-selected="false">
Domains ({{ domains.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-users" href="#user-users" role="tab" aria-controls="user-users" aria-selected="false">
Users ({{ users.length }})
</a>
</li>
</ul>
<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>
<div class="card-text">
<form class="read-only">
<div class="form-group row">
<label class="col-sm-4 col-form-label">Discount</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="discount">
<span>{{ wallet.discount ? (wallet.discount + '% - ' + wallet.discount_description) : 'none' }}</span>
<button type="button" class="btn btn-secondary btn-sm" @click="discountEdit">Edit</button>
</span>
</div>
</div>
<div class="form-group row" v-if="wallet.mandate && wallet.mandate.id">
<label class="col-sm-4 col-form-label">Auto-payment</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="autopayment">
Fill up by <b>{{ wallet.mandate.amount }} CHF</b>
when under <b>{{ wallet.mandate.balance }} CHF</b>
using {{ wallet.mandate.method }}<span v-if="wallet.mandate.isDisabled"> (disabled)</span>.
</span>
</div>
</div>
<div class="form-group row" v-if="wallet.providerLink">
<label class="col-sm-4 col-form-label">{{ capitalize(wallet.provider) }} ID</label>
<div class="col-sm-8">
<span class="form-control-plaintext" v-html="wallet.providerLink"></span>
</div>
</div>
+ <button id="button-award" class="btn btn-success" type="button" @click="awardDialog">Award</button>
+ <button id="button-penalty" class="btn btn-danger" type="button" @click="penalizeDialog">Penalize</button>
</form>
</div>
</div>
</div>
<div class="tab-pane" id="user-aliases" role="tabpanel" aria-labelledby="tab-aliases">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover">
<thead class="thead-light">
<tr>
<th scope="col">Email address</th>
</tr>
</thead>
<tbody>
<tr v-for="(alias, index) in user.aliases" :id="'alias' + index" :key="index">
<td>{{ alias }}</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td>This user has no email aliases.</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-subscriptions" role="tabpanel" aria-labelledby="tab-subscriptions">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover">
<thead class="thead-light">
<tr>
<th scope="col">Subscription</th>
<th scope="col">Price</th>
</tr>
</thead>
<tbody>
<tr v-for="(sku, sku_id) in skus" :id="'sku' + sku_id" :key="sku_id">
<td>{{ sku.name }}</td>
<td>{{ sku.price }}</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td colspan="2">This user has no subscriptions.</td>
</tr>
</tfoot>
</table>
<small v-if="discount > 0" class="hint">
<hr class="m-0">
&sup1; applied discount: {{ discount }}% - {{ discount_description }}
</small>
</div>
</div>
</div>
<div class="tab-pane" id="user-domains" role="tabpanel" aria-labelledby="tab-domains">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover">
<thead class="thead-light">
<tr>
<th scope="col">Name</th>
</tr>
</thead>
<tbody>
<tr v-for="domain in domains" :id="'domain' + domain.id" :key="domain.id">
<td>
<svg-icon icon="globe" :class="$root.domainStatusClass(domain)" :title="$root.domainStatusText(domain)"></svg-icon>
<router-link :to="{ path: '/domain/' + domain.id }">{{ domain.namespace }}</router-link>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td>There are no domains in this account.</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-users" role="tabpanel" aria-labelledby="tab-users">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover">
<thead class="thead-light">
<tr>
<th scope="col">Primary Email</th>
</tr>
</thead>
<tbody>
<tr v-for="item in users" :id="'user' + item.id" :key="item.id">
<td>
<svg-icon icon="user" :class="$root.userStatusClass(item)" :title="$root.userStatusText(item)"></svg-icon>
<router-link :to="{ path: '/user/' + item.id }">{{ item.email }}</router-link>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td>There are no users in this account.</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
<div id="discount-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Account discount</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p class="form-group">
<select v-model="wallet.discount_id" class="custom-select">
<option value="">- none -</option>
<option v-for="item in discounts" :value="item.id" :key="item.id">{{ item.label }}</option>
</select>
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary modal-action" @click="submitDiscount()">
<svg-icon icon="check"></svg-icon> Submit
</button>
</div>
</div>
</div>
</div>
<div id="email-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">External email</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p class="form-group">
<input v-model="external_email" name="external_email" class="form-control">
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary modal-action" @click="submitEmail()">
<svg-icon icon="check"></svg-icon> Submit
</button>
</div>
</div>
</div>
</div>
+
+ <div id="oneoff-dialog" class="modal" tabindex="-1" role="dialog">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">{{ oneoff_negative ? 'Add a penalty to the wallet' : 'Add a bonus to the wallet' }}</h5>
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+ <span aria-hidden="true">&times;</span>
+ </button>
+ </div>
+ <div class="modal-body">
+ <form data-validation-prefix="oneoff_">
+ <div class="form-group">
+ <label for="oneoff_amount">Amount</label>
+ <div class="input-group">
+ <input type="text" class="form-control" id="oneoff_amount" v-model="oneoff_amount" required>
+ <span class="input-group-append">
+ <span class="input-group-text">{{ oneoff_currency }}</span>
+ </span>
+ </div>
+ </div>
+ <div class="form-group">
+ <label for="oneoff_description">Description</label>
+ <input class="form-control" id="oneoff_description" v-model="oneoff_description" required>
+ </div>
+ </form>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">Cancel</button>
+ <button type="button" class="btn btn-primary modal-action" @click="submitOneOff()">
+ <svg-icon icon="check"></svg-icon> Submit
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
</div>
</template>
<script>
export default {
beforeRouteUpdate (to, from, next) {
// An event called when the route that renders this component has changed,
// but this component is reused in the new route.
// Required to handle links from /user/XXX to /user/YYY
next()
this.$parent.routerReload()
},
data() {
return {
+ oneoff_amount: '',
+ oneoff_currency: 'CHF',
+ oneoff_description: '',
+ oneoff_negative: false,
+ balance: 0,
discount: 0,
discount_description: '',
discounts: [],
external_email: '',
wallet: {},
domains: [],
skus: [],
users: [],
user: {
aliases: [],
wallet: {},
skus: {},
}
}
},
created() {
const user_id = this.$route.params.user
this.$root.startLoading()
axios.get('/api/v4/users/' + user_id)
.then(response => {
this.$root.stopLoading()
this.user = response.data
const financesTab = '#user-finances'
const keys = ['first_name', 'last_name', 'external_email', 'billing_address', 'phone', 'organization']
const country = this.user.settings.country
if (country) {
this.user.country = window.config.countries[country][1]
}
keys.forEach(key => { this.user[key] = this.user.settings[key] })
this.discount = this.user.wallet.discount
this.discount_description = this.user.wallet.discount_description
// TODO: currencies, multi-wallets, accounts
// Get more info about the wallet (e.g. payment provider related)
this.$root.addLoader(financesTab)
axios.get('/api/v4/wallets/' + this.user.wallets[0].id)
.then(response => {
this.$root.removeLoader(financesTab)
this.wallet = response.data
})
.catch(error => {
this.$root.removeLoader(financesTab)
})
// Create subscriptions list
axios.get('/api/v4/skus')
.then(response => {
// "merge" SKUs with user entitlement-SKUs
response.data.forEach(sku => {
if (sku.id in this.user.skus) {
let count = this.user.skus[sku.id].count
let item = {
id: sku.id,
name: sku.name,
cost: sku.cost,
units: count - sku.units_free,
price: this.$root.priceLabel(sku.cost, count - sku.units_free, this.discount)
}
if (sku.range) {
item.name += ' ' + count + ' ' + sku.range.unit
}
this.skus.push(item)
}
})
})
// Fetch users
// TODO: Multiple wallets
axios.get('/api/v4/users?owner=' + user_id)
.then(response => {
this.users = response.data.list.filter(user => {
return user.id != user_id;
})
})
// Fetch domains
axios.get('/api/v4/domains?owner=' + user_id)
.then(response => {
this.domains = response.data.list
})
})
.catch(this.$root.errorHandler)
},
mounted() {
$(this.$el).find('ul.nav-tabs a').on('click', e => {
e.preventDefault()
$(e.target).tab('show')
})
},
methods: {
capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
},
+ awardDialog() {
+ this.oneOffDialog(false)
+ },
discountEdit() {
$('#discount-dialog')
.on('shown.bs.modal', e => {
$(e.target).find('select').focus()
// Note: Vue v-model is strict, convert null to a string
this.wallet.discount_id = this.wallet_discount_id || ''
})
.modal()
if (!this.discounts.length) {
// Fetch discounts
axios.get('/api/v4/discounts')
.then(response => {
this.discounts = response.data.list
})
}
},
emailEdit() {
this.external_email = this.user.external_email
this.$root.clearFormValidation($('#email-dialog'))
$('#email-dialog')
.on('shown.bs.modal', e => {
$(e.target).find('input').focus()
})
.modal()
},
+ oneOffDialog(negative) {
+ this.oneoff_negative = negative
+ this.dialog = $('#oneoff-dialog').on('shown.bs.modal', event => {
+ this.$root.clearFormValidation(event.target)
+ $(event.target).find('#oneoff_amount').focus()
+ }).modal()
+ },
+ penalizeDialog() {
+ this.oneOffDialog(true)
+ },
submitDiscount() {
$('#discount-dialog').modal('hide')
axios.put('/api/v4/wallets/' + this.user.wallets[0].id, { discount: this.wallet.discount_id })
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.wallet = Object.assign({}, this.wallet, response.data)
// Update prices in Subscriptions tab
if (this.user.wallet.id == response.data.id) {
this.discount = this.wallet.discount
this.discount_description = this.wallet.discount_description
this.skus.forEach(sku => {
sku.price = this.$root.priceLabel(sku.cost, sku.units, this.discount)
})
}
}
})
},
submitEmail() {
axios.put('/api/v4/users/' + this.user.id, { external_email: this.external_email })
.then(response => {
if (response.data.status == 'success') {
$('#email-dialog').modal('hide')
this.$toast.success(response.data.message)
this.user.external_email = this.external_email
this.external_email = null // required because of Vue
}
})
},
+ submitOneOff() {
+ let wallet_id = this.user.wallets[0].id
+ let post = {
+ amount: this.oneoff_amount,
+ description: this.oneoff_description
+ }
+
+ if (this.oneoff_negative && /^\d+(\.?\d+)?$/.test(post.amount)) {
+ post.amount *= -1
+ }
+
+ // TODO: We maybe should use system currency not wallet currency,
+ // or have a selector so the operator does not have to calculate
+ // exchange rates
+
+ this.$root.clearFormValidation(this.dialog)
+
+ axios.post('/api/v4/wallets/' + wallet_id + '/one-off', post)
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.dialog.modal('hide')
+ this.$toast.success(response.data.message)
+ this.balance = response.data.balance
+ this.oneoff_amount = ''
+ this.oneoff_description = ''
+ }
+ })
+ },
suspendUser() {
axios.post('/api/v4/users/' + this.user.id + '/suspend', {})
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.user = Object.assign({}, this.user, { isSuspended: true })
}
})
},
unsuspendUser() {
axios.post('/api/v4/users/' + this.user.id + '/unsuspend', {})
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.user = Object.assign({}, this.user, { isSuspended: false })
}
})
}
}
}
</script>
diff --git a/src/routes/api.php b/src/routes/api.php
index fa4ef0eb..68143fc2 100644
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -1,108 +1,109 @@
<?php
use Illuminate\Http\Request;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/
Route::group(
[
'middleware' => '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::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::apiResource('discounts', API\V4\Admin\DiscountsController::class);
}
);
diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php
index 50738e41..5e79c686 100644
--- a/src/tests/Browser/Admin/UserTest.php
+++ b/src/tests/Browser/Admin/UserTest.php
@@ -1,496 +1,597 @@
<?php
namespace Tests\Browser\Admin;
use App\Discount;
use App\User;
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;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class UserTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useAdminUrl();
$john = $this->getTestUser('john@kolab.org');
$john->setSettings([
'phone' => '+48123123123',
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->balance = 0;
$wallet->save();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->setSettings([
'phone' => null,
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->balance = 0;
$wallet->save();
parent::tearDown();
}
/**
* Test user info page (unauthenticated)
*/
public function testUserUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$browser->visit('/user/' . $jack->id)->on(new Home());
});
}
/**
* Test user info page
*/
public function testUserInfo(): void
{
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$page = new UserPage($jack->id);
$browser->visit(new Home())
->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true)
->on(new Dashboard())
->visit($page)
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $jack->email)
->with('@user-info form', function (Browser $browser) use ($jack) {
$browser->assertElementsCount('.row', 7)
->assertSeeIn('.row:nth-child(1) label', 'Managed by')
->assertSeeIn('.row:nth-child(1) #manager a', 'john@kolab.org')
->assertSeeIn('.row:nth-child(2) label', 'ID (Created at)')
->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})")
->assertSeeIn('.row:nth-child(3) label', 'Status')
->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active')
->assertSeeIn('.row:nth-child(4) label', 'First name')
->assertSeeIn('.row:nth-child(4) #first_name', 'Jack')
->assertSeeIn('.row:nth-child(5) label', 'Last name')
->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels')
->assertSeeIn('.row:nth-child(6) label', 'External email')
->assertMissing('.row:nth-child(6) #external_email a')
->assertSeeIn('.row:nth-child(7) label', 'Country')
->assertSeeIn('.row:nth-child(7) #country', 'United States of America');
});
// Some tabs are loaded in background, wait a second
$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');
});
});
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'jack.daniels@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,44 CHF')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '5,55 CHF')
->assertMissing('table tfoot');
});
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
});
}
/**
* Test user info page (continue)
*
* @depends testUserInfo
*/
public function testUserInfo2(): void
{
$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->discount()->associate($discount);
$wallet->debit(2010);
$wallet->save();
// Click the managed-by link on Jack's page
$browser->click('@user-info #manager a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $john->email)
->with('@user-info form', function (Browser $browser) use ($john) {
$ext_email = $john->getSetting('external_email');
$browser->assertElementsCount('.row', 9)
->assertSeeIn('.row:nth-child(1) label', 'ID (Created at)')
->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})")
->assertSeeIn('.row:nth-child(2) label', 'Status')
->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active')
->assertSeeIn('.row:nth-child(3) label', 'First name')
->assertSeeIn('.row:nth-child(3) #first_name', 'John')
->assertSeeIn('.row:nth-child(4) label', 'Last name')
->assertSeeIn('.row:nth-child(4) #last_name', 'Doe')
->assertSeeIn('.row:nth-child(5) label', 'Organization')
->assertSeeIn('.row:nth-child(5) #organization', 'Kolab Developers')
->assertSeeIn('.row:nth-child(6) label', 'Phone')
->assertSeeIn('.row:nth-child(6) #phone', $john->getSetting('phone'))
->assertSeeIn('.row:nth-child(7) label', 'External email')
->assertSeeIn('.row:nth-child(7) #external_email a', $ext_email)
->assertAttribute('.row:nth-child(7) #external_email a', 'href', "mailto:$ext_email")
->assertSeeIn('.row:nth-child(8) label', 'Address')
->assertSeeIn('.row:nth-child(8) #billing_address', $john->getSetting('billing_address'))
->assertSeeIn('.row:nth-child(9) label', 'Country')
->assertSeeIn('.row:nth-child(9) #country', 'United States of America');
});
// Some tabs are loaded in background, wait a second
$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');
});
});
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'john.doe@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher');
});
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (1)')
->click('@nav #tab-domains')
->with('@user-domains table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 1)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
->assertMissing('tfoot');
});
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (3)')
->click('@nav #tab-users')
->with('@user-users table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 3)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org')
->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(3) td:first-child a', 'ned@kolab.org')
->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success')
->assertMissing('tfoot');
});
});
// 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('@user-users tbody tr:nth-child(3) td:first-child a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $ned->email)
->with('@user-info form', function (Browser $browser) use ($ned) {
$browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created at)')
->assertSeeIn('.row:nth-child(2) #userid', "{$ned->id} ({$ned->created_at})");
});
// Some tabs are loaded in background, wait a second
$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');
});
});
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (0)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'This user has no email aliases.');
});
// Assert Subscriptions tab, we expect John's discount here
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (5)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 5)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync')
->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,90 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication')
->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher');
});
// We don't expect John's domains here
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// We don't expect John's users here
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
});
}
/**
* Test editing an external email
*
* @depends testUserInfo2
*/
public function testExternalEmail(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->waitFor('@user-info #external_email button')
->click('@user-info #external_email button')
// Test dialog content, and closing it with Cancel button
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'External email')
->assertFocused('@body input')
->assertValue('@body input', 'john.doe.external@gmail.com')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Submit')
->click('@button-cancel');
})
->assertMissing('#email-dialog')
->click('@user-info #external_email button')
// Test email validation error handling, and email update
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->type('@body input', 'test')
->click('@button-action')
->waitFor('@body input.is-invalid')
->assertSeeIn(
'@body input + .invalid-feedback',
'The external email must be a valid email address.'
)
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->type('@body input', 'test@test.com')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.')
->assertSeeIn('@user-info #external_email a', 'test@test.com')
->click('@user-info #external_email button')
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->assertValue('@body input', 'test@test.com')
->assertMissing('@body input.is-invalid')
->assertMissing('@body input + .invalid-feedback')
->click('@button-cancel');
})
->assertSeeIn('@user-info #external_email a', 'test@test.com');
// $john->getSetting() may not work here as it uses internal cache
// read the value form database
$current_ext_email = $john->settings()->where('key', 'external_email')->first()->value;
$this->assertSame('test@test.com', $current_ext_email);
});
}
/**
* Test suspending/unsuspending the user
*/
public function testSuspendAndUnsuspend(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->assertVisible('@user-info #button-suspend')
->assertMissing('@user-info #button-unsuspend')
->click('@user-info #button-suspend')
->assertToast(Toast::TYPE_SUCCESS, 'User suspended successfully.')
->assertSeeIn('@user-info #status span.text-warning', 'Suspended')
->assertMissing('@user-info #button-suspend')
->click('@user-info #button-unsuspend')
->assertToast(Toast::TYPE_SUCCESS, 'User unsuspended successfully.')
->assertSeeIn('@user-info #status span.text-success', 'Active')
->assertVisible('@user-info #button-suspend')
->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/Feature/Controller/Admin/WalletsTest.php b/src/tests/Feature/Controller/Admin/WalletsTest.php
index 5cacb275..6ca6ac44 100644
--- a/src/tests/Feature/Controller/Admin/WalletsTest.php
+++ b/src/tests/Feature/Controller/Admin/WalletsTest.php
@@ -1,110 +1,181 @@
<?php
namespace Tests\Feature\Controller\Admin;
use App\Discount;
+use App\Transaction;
use Tests\TestCase;
class WalletsTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useAdminUrl();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
parent::tearDown();
}
/**
* Test fetching a wallet (GET /api/v4/wallets/:id)
*
* @group stripe
*/
public function testShow(): void
{
\config(['services.payment_provider' => 'stripe']);
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$wallet = $user->wallets()->first();
// Make sure there's no stripe/mollie identifiers
$wallet->setSetting('stripe_id', null);
$wallet->setSetting('stripe_mandate_id', null);
$wallet->setSetting('mollie_id', null);
$wallet->setSetting('mollie_mandate_id', null);
// Non-admin user
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}");
$response->assertStatus(403);
// Admin user
$response = $this->actingAs($admin)->get("api/v4/wallets/{$wallet->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame($wallet->id, $json['id']);
$this->assertSame('CHF', $json['currency']);
- $this->assertSame(0, $json['balance']);
+ $this->assertSame($wallet->balance, $json['balance']);
$this->assertSame(0, $json['discount']);
$this->assertTrue(empty($json['description']));
$this->assertTrue(empty($json['discount_description']));
$this->assertTrue(!empty($json['provider']));
$this->assertTrue(!empty($json['providerLink']));
$this->assertTrue(!empty($json['mandate']));
}
+ /**
+ * Test awarding/penalizing a wallet (POST /api/v4/wallets/:id/one-off)
+ */
+ public function testOneOff(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $wallet = $user->wallets()->first();
+ $balance = $wallet->balance;
+
+ Transaction::where('object_id', $wallet->id)
+ ->whereIn('type', [Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY])
+ ->delete();
+
+ // Non-admin user
+ $response = $this->actingAs($user)->post("api/v4/wallets/{$wallet->id}/one-off", []);
+ $response->assertStatus(403);
+
+ // Admin user - invalid input
+ $post = ['amount' => 'aaaa'];
+ $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame('The amount must be a number.', $json['errors']['amount'][0]);
+ $this->assertSame('The description field is required.', $json['errors']['description'][0]);
+ $this->assertCount(2, $json);
+ $this->assertCount(2, $json['errors']);
+
+ // Admin user - a valid bonus
+ $post = ['amount' => '50', 'description' => 'A bonus'];
+ $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('The bonus has been added to the wallet successfully.', $json['message']);
+ $this->assertSame($balance += 5000, $json['balance']);
+ $this->assertSame($balance, $wallet->fresh()->balance);
+
+ $transaction = Transaction::where('object_id', $wallet->id)
+ ->where('type', Transaction::WALLET_AWARD)->first();
+
+ $this->assertSame($post['description'], $transaction->description);
+ $this->assertSame(5000, $transaction->amount);
+ $this->assertSame($admin->email, $transaction->user_email);
+
+ // Admin user - a valid penalty
+ $post = ['amount' => '-40', 'description' => 'A penalty'];
+ $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('The penalty has been added to the wallet successfully.', $json['message']);
+ $this->assertSame($balance -= 4000, $json['balance']);
+ $this->assertSame($balance, $wallet->fresh()->balance);
+
+ $transaction = Transaction::where('object_id', $wallet->id)
+ ->where('type', Transaction::WALLET_PENALTY)->first();
+
+ $this->assertSame($post['description'], $transaction->description);
+ $this->assertSame(4000, $transaction->amount);
+ $this->assertSame($admin->email, $transaction->user_email);
+ }
+
/**
* Test updating a wallet (PUT /api/v4/wallets/:id)
*/
public function testUpdate(): void
{
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$wallet = $user->wallets()->first();
$discount = Discount::where('code', 'TEST')->first();
// Non-admin user
$response = $this->actingAs($user)->put("api/v4/wallets/{$wallet->id}", []);
$response->assertStatus(403);
// Admin user - setting a discount
$post = ['discount' => $discount->id];
$response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('User wallet updated successfully.', $json['message']);
$this->assertSame($wallet->id, $json['id']);
$this->assertSame($discount->discount, $json['discount']);
$this->assertSame($discount->id, $json['discount_id']);
$this->assertSame($discount->description, $json['discount_description']);
$this->assertSame($discount->id, $wallet->fresh()->discount->id);
// Admin user - removing a discount
$post = ['discount' => null];
$response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('User wallet updated successfully.', $json['message']);
$this->assertSame($wallet->id, $json['id']);
$this->assertSame(null, $json['discount_id']);
$this->assertTrue(empty($json['discount_description']));
$this->assertSame(null, $wallet->fresh()->discount);
}
}

File Metadata

Mime Type
text/x-diff
Expires
Mon, Sep 15, 10:35 AM (1 d, 16 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
287528
Default Alt Text
(79 KB)

Event Timeline