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