diff --git a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
--- a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
@@ -5,8 +5,11 @@
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
{
@@ -46,6 +49,65 @@
}
/**
+ * 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.
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -41,5 +41,7 @@
'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
--- a/src/resources/vue/Admin/User.vue
+++ b/src/resources/vue/Admin/User.vue
@@ -138,6 +138,8 @@
+
+
@@ -298,6 +300,42 @@
+
+
@@ -312,6 +350,11 @@
},
data() {
return {
+ oneoff_amount: '',
+ oneoff_currency: 'CHF',
+ oneoff_description: '',
+ oneoff_negative: false,
+ balance: 0,
discount: 0,
discount_description: '',
discounts: [],
@@ -414,6 +457,9 @@
capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
},
+ awardDialog() {
+ this.oneOffDialog(false)
+ },
discountEdit() {
$('#discount-dialog')
.on('shown.bs.modal', e => {
@@ -441,6 +487,16 @@
})
.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')
@@ -473,6 +529,34 @@
}
})
},
+ 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 => {
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -103,6 +103,7 @@
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
--- a/src/tests/Browser/Admin/UserTest.php
+++ b/src/tests/Browser/Admin/UserTest.php
@@ -493,4 +493,105 @@
});
});
}
+
+ /**
+ * 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
--- a/src/tests/Feature/Controller/Admin/WalletsTest.php
+++ b/src/tests/Feature/Controller/Admin/WalletsTest.php
@@ -3,6 +3,7 @@
namespace Tests\Feature\Controller\Admin;
use App\Discount;
+use App\Transaction;
use Tests\TestCase;
class WalletsTest extends TestCase
@@ -55,7 +56,7 @@
$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']));
@@ -65,6 +66,76 @@
}
/**
+ * 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