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 @@ -7,6 +7,7 @@ use App\Providers\PaymentProvider; use App\Wallet; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Validator; class WalletsController extends \App\Http\Controllers\API\V4\WalletsController { @@ -45,6 +46,55 @@ return $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 ? 'award' : 'penalty'; + + // TODO: create the transaction log entry + + if ($type == 'penalty') { + $wallet->debit($amount * -1); + } else { + $wallet->credit($amount); + } + + $response = [ + 'status' => 'success', + 'message' => \trans("app.wallet-{$type}-success"), + 'balance' => $wallet->balance + ]; + + return response()->json($response); + } + /** * Update wallet data. * 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 @@ -38,5 +38,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 @@ -136,6 +136,8 @@ + + @@ -296,6 +298,42 @@ + + @@ -310,6 +348,11 @@ }, data() { return { + oneoff_amount: '', + oneoff_currency: 'CHF', + oneoff_description: '', + oneoff_negative: false, + balance: 0, discount: 0, discount_description: '', discounts: [], @@ -412,6 +455,9 @@ capitalize(str) { return str.charAt(0).toUpperCase() + str.slice(1) }, + awardDialog() { + this.oneOffDialog(false) + }, discountEdit() { $('#discount-dialog') .on('shown.bs.modal', e => { @@ -439,6 +485,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') @@ -470,6 +526,34 @@ 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 = '' + } + }) } } } diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -101,6 +101,7 @@ Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); 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 @@ -465,4 +465,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 @@ -64,6 +64,56 @@ $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; + + // 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']); + + // 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']); + } + /** * Test updating a wallet (PUT /api/v4/wallets/:id) */