Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117926780
D1267.1775452651.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
17 KB
Referenced Files
None
Subscribers
None
D1267.1775452651.diff
View Options
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,9 +5,59 @@
use App\Discount;
use App\Wallet;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Validator;
class WalletsController extends \App\Http\Controllers\API\V4\WalletsController
{
+ /**
+ * 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/js/app.js b/src/resources/js/app.js
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -212,7 +212,7 @@
form = $(form)
$.each(error.response.data.errors || {}, (idx, msg) => {
- const input_name = (form.data('validation-prefix') || '') + idx
+ const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx
let input = form.find('#' + input_name)
if (!input.length) {
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
@@ -35,5 +35,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
@@ -120,6 +120,8 @@
</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>
@@ -280,6 +282,42 @@
</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">×</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>
@@ -294,6 +332,10 @@
},
data() {
return {
+ oneoff_amount: '',
+ oneoff_currency: 'CHF',
+ oneoff_description: '',
+ oneoff_negative: false,
balance: 0,
discount: 0,
discount_description: '',
@@ -392,6 +434,9 @@
})
},
methods: {
+ awardDialog() {
+ this.oneOffDialog(false)
+ },
discountEdit() {
$('#discount-dialog')
.on('shown.bs.modal', e => {
@@ -417,6 +462,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')
@@ -450,6 +505,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
@@ -97,6 +97,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
@@ -458,4 +458,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
@@ -24,6 +24,56 @@
parent::tearDown();
}
+ /**
+ * 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)
*/
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Mon, Apr 6, 5:17 AM (6 h, 41 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18835734
Default Alt Text
D1267.1775452651.diff (17 KB)
Attached To
Mode
D1267: Support: Awarding a bonus or penalty (Bifrost#T315896)
Attached
Detach File
Event Timeline