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)
*/