Allowance check

This commit is contained in:
ISMAIL MASSERAN
2026-04-14 02:34:00 +00:00
parent 0c4d200839
commit 3775c4a876
100 changed files with 81493 additions and 3226 deletions
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\API\v1\Admin\ActivityLog;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Spatie\Activitylog\Models\Activity;
class IndexController extends Controller
{
public function __invoke(Request $request)
{
$perPage = (int) $request->query('per_page', 25);
if ($perPage < 1) $perPage = 25;
if ($perPage > 200) $perPage = 200;
$q = Activity::query()
->orderByDesc('id');
if ($request->filled('description')) {
$q->where('description', 'like', '%' . $request->query('description') . '%');
}
if ($request->filled('subject_type')) {
$q->where('subject_type', (string) $request->query('subject_type'));
}
if ($request->filled('causer_id')) {
$q->where('causer_id', (int) $request->query('causer_id'));
}
$p = $q->paginate($perPage);
$items = collect($p->items())->map(function (Activity $a) {
return [
'id' => $a->id,
'log_name' => $a->log_name,
'description' => $a->description,
'subject_type' => $a->subject_type,
'subject_id' => $a->subject_id,
'causer_type' => $a->causer_type,
'causer_id' => $a->causer_id,
'properties' => $a->properties,
'created_at' => optional($a->created_at)->toDateTimeString(),
];
})->values();
return response()->json([
'data' => $items,
'meta' => [
'current_page' => $p->currentPage(),
'per_page' => $p->perPage(),
'last_page' => $p->lastPage(),
'total' => $p->total(),
],
]);
}
}
@@ -11,7 +11,20 @@ class AddController extends Controller
public function __invoke (Request $request)
{
$this->validateRequest($request);
$this->insertAdmin($request);
$user = $this->insertAdmin($request);
activity()
->performedOn($user)
->withProperties([
'admin_id' => $user->id,
'admin_name' => $user->name,
'admin_email' => $user->email,
'admin_role' => $user->role,
'created_by_admin_id' => $request->user() ? (int) $request->user()->id : null,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("create admin: {$user->name}");
return response()->json([
'status' => 'success',
'message' => 'Admin added successfully'
@@ -22,7 +35,7 @@ class AddController extends Controller
{
$admin = $request->all();
$admin['password'] = bcrypt($request->password);
User::create($admin);
return User::create($admin);
}
private function validateRequest($request)
@@ -0,0 +1,171 @@
<?php
namespace App\Http\Controllers\API\v1\Admin\Allowance;
use App\AllowanceClaimCode;
use App\AllowancePayout;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use App\Result;
use App\Voter;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
class PayoutController extends Controller
{
public function __invoke(Request $request)
{
$this->validate($request, [
'voter_id' => 'required|integer',
'method' => 'nullable|string|in:cash,bank',
'amount_cents' => 'nullable|integer|min:0',
'reference' => 'nullable|string|max:80',
'note' => 'nullable|string|max:2000',
'claim_code' => 'nullable|string|min:4|max:20',
]);
$electionId = Util::getCurrentElection();
$method = $request->input('method', 'cash');
$voterId = (int) $request->input('voter_id');
$voter = Voter::where('election_id', $electionId)->where('id', $voterId)->first();
if (!$voter) {
return response()->json([
'status' => 'failed',
'message' => 'Voter not found for current election.',
], 404);
}
// Enforce method by attendance type (paperless replacement for coupon rules)
if ($method === 'cash' && (int) $voter->kehadiran !== 1) {
return response()->json([
'status' => 'failed',
'message' => 'Cash payout only allowed for Fizikal attendance.',
], 422);
}
if ($method === 'bank' && (int) $voter->kehadiran !== 2) {
return response()->json([
'status' => 'failed',
'message' => 'Bank payout only allowed for Maya attendance.',
], 422);
}
// Enforce "must have voted" (officer check)
$hasVoted = Result::where('election_id', $electionId)->where('voter_id', $voterId)->exists();
if (!$hasVoted) {
return response()->json([
'status' => 'failed',
'message' => 'Voter has not voted yet.',
], 422);
}
// Default amounts (can be overridden by passing amount_cents)
$defaultAmount = $method === 'cash' ? 30000 : 15000;
$amountCents = $request->filled('amount_cents') ? (int) $request->input('amount_cents') : $defaultAmount;
$now = Carbon::now();
$adminId = $request->user() ? (int) $request->user()->id : null;
$claimCode = $request->input('claim_code');
// For fizikal cash payout, require claim code (paperless coupon).
if ($method === 'cash' && (!$claimCode || trim($claimCode) === '')) {
return response()->json([
'status' => 'failed',
'message' => 'Sila masukkan kod tuntutan (claim code) sebelum bayaran tunai dibuat.',
], 422);
}
$payout = DB::transaction(function () use ($electionId, $voterId, $method, $claimCode, $amountCents, $adminId, $now, $request) {
// Consume claim code if provided
if ($claimCode && trim($claimCode) !== '') {
$row = AllowanceClaimCode::where('election_id', $electionId)
->where('voter_id', $voterId)
->where('method', $method)
->lockForUpdate()
->first();
if (!$row) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan tidak dijumpai.',
], 404);
}
if ($row->used_at) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan telah digunakan.',
], 422);
}
if ($row->expires_at && Carbon::now()->greaterThan($row->expires_at)) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan telah tamat tempoh.',
], 422);
}
if (!Hash::check(trim((string) $claimCode), $row->code_hash)) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan tidak sah.',
], 422);
}
$row->used_at = Carbon::now();
$row->used_by_admin_id = $adminId;
$row->save();
}
// Idempotent: if already exists (unique election_id+voter_id+method) we keep it as paid.
return AllowancePayout::updateOrCreate(
[
'election_id' => $electionId,
'voter_id' => $voterId,
'method' => $method,
],
[
'status' => 'paid',
'amount_cents' => $amountCents,
'currency' => 'MYR',
'paid_by_admin_id' => $adminId,
'paid_at' => $now,
'reference' => $request->input('reference'),
'note' => $request->input('note'),
]
);
});
// In case the transaction returned a JSON response (validation failure), return it directly.
if ($payout instanceof \Illuminate\Http\JsonResponse) {
return $payout;
}
activity()
->performedOn($payout)
->withProperties([
'election_id' => $electionId,
'voter_id' => $voterId,
'voter_name' => $voter->name,
'attendance' => (int) $voter->kehadiran,
'method' => $method,
'amount_cents' => (int) $payout->amount_cents,
'currency' => $payout->currency,
'paid_by_admin_id' => $adminId,
'reference' => $payout->reference,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("allowance payout ({$method}): {$voter->name}");
return response()->json([
'status' => 'success',
'message' => 'Allowance payout recorded.',
'payout' => $payout,
]);
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\API\v1\Admin\Allowance;
use App\AllowanceClaimCode;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
class VerifyCodeController extends Controller
{
public function __invoke(Request $request)
{
$this->validate($request, [
'voter_id' => 'required|integer',
'method' => 'nullable|string|in:cash,bank',
'code' => 'required|string|min:4|max:20',
]);
$electionId = Util::getCurrentElection();
$method = $request->input('method', 'cash');
$voterId = (int) $request->input('voter_id');
$code = trim((string) $request->input('code'));
$row = AllowanceClaimCode::where('election_id', $electionId)
->where('voter_id', $voterId)
->where('method', $method)
->first();
if (!$row) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan tidak dijumpai.',
], 404);
}
if ($row->used_at) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan telah digunakan.',
], 422);
}
if ($row->expires_at && Carbon::now()->greaterThan($row->expires_at)) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan telah tamat tempoh.',
], 422);
}
if (!Hash::check($code, $row->code_hash)) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan tidak sah.',
], 422);
}
// Mark used (consumed) here so it cannot be reused
$row->used_at = Carbon::now();
$row->used_by_admin_id = $request->user() ? (int) $request->user()->id : null;
$row->save();
return response()->json([
'status' => 'success',
'message' => 'Kod tuntutan disahkan.',
'claim' => [
'id' => $row->id,
'voter_id' => $row->voter_id,
'method' => $row->method,
'expires_at' => $row->expires_at,
'used_at' => $row->used_at,
],
]);
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\API\v1\Admin\Allowance;
use App\AllowancePayout;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
class VoidController extends Controller
{
public function __invoke(Request $request, $id)
{
$this->validate($request, [
'note' => 'nullable|string|max:2000',
]);
$payout = AllowancePayout::find($id);
if (!$payout) {
return response()->json([
'status' => 'failed',
'message' => 'Payout not found.',
], 404);
}
$note = trim((string) $request->input('note', ''));
if ($note !== '') {
$payout->note = $payout->note ? ($payout->note . "\n\nVOID NOTE: " . $note) : ("VOID NOTE: " . $note);
}
$payout->status = 'void';
$payout->save();
return response()->json([
'status' => 'success',
'message' => 'Payout voided.',
'payout' => $payout,
'voided_at' => Carbon::now(),
]);
}
}
@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\API\v1\Admin\Impersonate;
use App\Http\Controllers\Controller;
use App\User;
use Illuminate\Http\Request;
class LeaveController extends Controller
{
public function __invoke(Request $request)
{
$current = $request->user();
if (! $current) {
return response()->json(['error' => 'Unauthenticated.'], 401);
}
$token = null;
try {
if (method_exists($current, 'token')) {
$token = $current->token();
}
} catch (\Throwable $e) {
$token = null;
}
$tokenName = $token ? (string) $token->name : '';
if (strpos($tokenName, 'impersonate:') !== 0) {
return response()->json([
'status' => 'failed',
'message' => 'Anda tidak sedang impersonate.',
], 422);
}
$impersonatorId = (int) substr($tokenName, strlen('impersonate:'));
if ($impersonatorId < 1) {
return response()->json([
'status' => 'failed',
'message' => 'Token impersonate tidak sah.',
], 422);
}
$impersonator = User::find($impersonatorId);
if (! $impersonator) {
return response()->json([
'status' => 'failed',
'message' => 'Akaun admin asal tidak dijumpai.',
], 404);
}
// Revoke impersonated token.
try {
if ($token) {
$token->revoke();
}
} catch (\Throwable $e) {
// Best effort.
}
$accessToken = $impersonator->createToken('My app', ['admin'])->accessToken;
activity()
->performedOn($impersonator)
->withProperties([
'admin_id' => $impersonator->id,
'admin_name' => $impersonator->name,
'admin_email' => $impersonator->email,
'from_user_id' => $current->id,
'from_user_email' => $current->email,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("admin leave impersonation: {$impersonator->email}");
return response()->json([
'status' => 'success',
'message' => 'Berjaya kembali ke akaun asal.',
'user' => $impersonator,
'token' => $accessToken,
]);
}
}
@@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers\API\v1\Admin\Impersonate;
use App\Http\Controllers\Controller;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class StartController extends Controller
{
public function __invoke(Request $request)
{
$request->validate([
'user_id' => ['required', 'integer', 'exists:users,id'],
]);
$impersonator = Auth::user();
$target = User::findOrFail((int) $request->input('user_id'));
if (! $impersonator || ! method_exists($impersonator, 'tokenCan') || ! $impersonator->tokenCan('admin')) {
return response()->json(['error' => 'Unauthenticated.'], 401);
}
if ((int) $impersonator->id === (int) $target->id) {
return response()->json([
'status' => 'failed',
'message' => 'Tidak boleh impersonate akaun sendiri.',
], 422);
}
if (method_exists($impersonator, 'canImpersonate') && ! $impersonator->canImpersonate()) {
return response()->json([
'status' => 'failed',
'message' => 'Akses ditolak (impersonate).',
], 403);
}
if (method_exists($target, 'canBeImpersonated') && ! $target->canBeImpersonated()) {
return response()->json([
'status' => 'failed',
'message' => 'Akaun ini tidak boleh di-impersonate.',
], 403);
}
// Revoke current token to reduce risk of dual active sessions.
try {
if (method_exists($impersonator, 'token') && $impersonator->token()) {
$impersonator->token()->revoke();
}
} catch (\Throwable $e) {
// Best effort.
}
// Encode impersonator id in token name for later "leave" action.
$tokenName = 'impersonate:' . (int) $impersonator->id;
$accessToken = $target->createToken($tokenName, ['admin'])->accessToken;
activity()
->performedOn($target)
->withProperties([
'admin_id' => $impersonator->id,
'admin_name' => $impersonator->name,
'admin_email' => $impersonator->email,
'impersonated_user_id' => $target->id,
'impersonated_user_name' => $target->name,
'impersonated_user_email' => $target->email,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("admin impersonate: {$impersonator->email} -> {$target->email}");
return response()->json([
'status' => 'success',
'message' => 'Impersonate berjaya.',
'user' => $target,
'impersonator' => $impersonator,
'token' => $accessToken,
]);
}
}
@@ -26,9 +26,29 @@ class LoginController extends Controller
$result['user'] = $user;
$result['election_status'] = Util::getElectionStatus();
$result['token'] = $user->createToken('My app', ['admin'])->accessToken;
activity()
->performedOn($user)
->withProperties([
'admin_id' => $user->id,
'admin_name' => $user->name,
'admin_email' => $user->email,
'admin_role' => $user->role,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("admin login: {$user->email}");
} else {
$result['status'] = 'failed';
$result['message'] = 'Wrong email or password';
activity()
->withProperties([
'admin_email' => (string) $request->input('email'),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('admin login failed');
}
return response()->json($result);
@@ -8,8 +8,31 @@ use App\Http\Controllers\Controller;
class LogoutController extends Controller
{
public function __invoke()
public function __invoke(Request $request)
{
$user = Auth::user();
if ($user) {
activity()
->performedOn($user)
->withProperties([
'admin_id' => $user->id,
'admin_name' => $user->name,
'admin_email' => $user->email,
'admin_role' => $user->role,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("admin logout: {$user->email}");
} else {
activity()
->withProperties([
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('admin logout');
}
Auth::guard('web')->logout();
Auth::guard('voter')->logout();
return response()->json([