refactor: replace UpdateCashTransactionRequest with CashTransactionRequest in CashController; add ensureEditable method in CashTransaction model for validation; update CashService to use transaction handling methods; enhance CashTest descriptions for clarity

This commit is contained in:
Yoga Pangestu 2026-07-03 23:44:42 +07:00
parent 143448e6e0
commit c685f45bcf
7 changed files with 92 additions and 157 deletions

View File

@ -5,13 +5,14 @@
use App\Http\Controllers\Concerns\FlashesEntityMessage; use App\Http\Controllers\Concerns\FlashesEntityMessage;
use App\Http\Controllers\Concerns\ParsesDataTableQuery; use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Finance\Cash\CashTransactionRequest;
use App\Http\Requests\Admin\Finance\Cash\DepositRequest; use App\Http\Requests\Admin\Finance\Cash\DepositRequest;
use App\Http\Requests\Admin\Finance\Cash\WithdrawRequest; use App\Http\Requests\Admin\Finance\Cash\WithdrawRequest;
use App\Http\Requests\Admin\Finance\UpdateCashTransactionRequest;
use App\Models\CashTransaction; use App\Models\CashTransaction;
use App\Services\Finance\CashService; use App\Services\Finance\CashService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -38,8 +39,14 @@ public function index(Request $request): Response
]); ]);
} }
public function update(UpdateCashTransactionRequest $request, CashTransaction $cashTransaction): RedirectResponse public function update(CashTransactionRequest $request, CashTransaction $cashTransaction): RedirectResponse
{ {
try {
$cashTransaction->ensureEditable();
} catch (ValidationException) {
abort(403);
}
$this->cashService->updateDeposit($cashTransaction, $request->validated()); $this->cashService->updateDeposit($cashTransaction, $request->validated());
$this->flashUpdated('Setor kas'); $this->flashUpdated('Setor kas');
@ -49,6 +56,12 @@ public function update(UpdateCashTransactionRequest $request, CashTransaction $c
public function destroy(CashTransaction $cashTransaction): RedirectResponse public function destroy(CashTransaction $cashTransaction): RedirectResponse
{ {
try {
$cashTransaction->ensureEditable();
} catch (ValidationException) {
abort(403);
}
$this->cashService->deleteTransaction($cashTransaction); $this->cashService->deleteTransaction($cashTransaction);
$this->flashDeleted('Setor kas'); $this->flashDeleted('Setor kas');

View File

@ -1,12 +1,12 @@
<?php <?php
namespace App\Http\Requests\Admin\Finance; namespace App\Http\Requests\Admin\Finance\Cash;
use App\Enums\Permission; use App\Enums\Permission;
use App\Http\Requests\Concerns\ValidatesMediaUploads; use App\Http\Requests\Concerns\ValidatesMediaUploads;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
class UpdateCashTransactionRequest extends FormRequest class CashTransactionRequest extends FormRequest
{ {
use ValidatesMediaUploads; use ValidatesMediaUploads;

View File

@ -16,6 +16,7 @@
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Validation\ValidationException;
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
#[Guarded(['id'])] #[Guarded(['id'])]
@ -127,6 +128,15 @@ public function sourceBadgeClass(): Attribute
} }
// 5. Other Methods // 5. Other Methods
public function ensureEditable(): void
{
if ($this->reference_type !== null) {
throw ValidationException::withMessages([
'transaction' => 'Transaksi ini tidak dapat diubah dari halaman kas.',
]);
}
}
public function getEmployeeAdvanceLabel(): string public function getEmployeeAdvanceLabel(): string
{ {
return $this->isEmployeeAdvanceRepayment() ? 'Pelunasan Kasbon' : 'Pencairan Kasbon'; return $this->isEmployeeAdvanceRepayment() ? 'Pelunasan Kasbon' : 'Pencairan Kasbon';

View File

@ -6,18 +6,20 @@
use App\Models\CashAccount; use App\Models\CashAccount;
use App\Models\CashTransaction; use App\Models\CashTransaction;
use App\Models\User; use App\Models\User;
use App\Services\Concerns\RunsInTransaction;
use App\Services\Concerns\SyncsPhotos;
use App\Services\Media\MediaService; use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter; use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class CashService class CashService
{ {
use RunsInTransaction, SyncsPhotos;
private const MAX_PHOTOS = 1; private const MAX_PHOTOS = 1;
public function __construct( public function __construct(
@ -66,8 +68,8 @@ public function paginateForIndex(CashAccount $cashAccount, array $tableQuery, st
public function deposit(CashAccount $cashAccount, array $validated, User $user): CashTransaction public function deposit(CashAccount $cashAccount, array $validated, User $user): CashTransaction
{ {
try { $transaction = $this->runInTransaction(
$transaction = DB::transaction(function () use ($cashAccount, $validated, $user): CashTransaction { function () use ($cashAccount, $validated, $user): CashTransaction {
$account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id); $account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id);
$amount = (int) $validated['amount']; $amount = (int) $validated['amount'];
$newBalance = $account->balance + $amount; $newBalance = $account->balance + $amount;
@ -83,21 +85,12 @@ public function deposit(CashAccount $cashAccount, array $validated, User $user):
'created_by_id' => $user->id, 'created_by_id' => $user->id,
]); ]);
$this->syncPhotos($transaction, $validated); $this->syncPhotos($transaction, $validated, self::MAX_PHOTOS);
return $transaction; return $transaction;
}); },
} catch (ValidationException $e) { 'Gagal melakukan setoran kas',
throw $e; );
} catch (\Throwable $e) {
Log::error('Gagal melakukan setoran kas: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'💰 Setoran Kas', '💰 Setoran Kas',
@ -111,8 +104,8 @@ public function deposit(CashAccount $cashAccount, array $validated, User $user):
public function withdraw(CashAccount $cashAccount, array $validated, User $user): CashTransaction public function withdraw(CashAccount $cashAccount, array $validated, User $user): CashTransaction
{ {
try { $transaction = $this->runInTransaction(
$transaction = DB::transaction(function () use ($cashAccount, $validated, $user): CashTransaction { function () use ($cashAccount, $validated, $user): CashTransaction {
$account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id); $account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id);
$amount = (int) $validated['amount']; $amount = (int) $validated['amount'];
@ -135,21 +128,12 @@ public function withdraw(CashAccount $cashAccount, array $validated, User $user)
'created_by_id' => $user->id, 'created_by_id' => $user->id,
]); ]);
$this->syncPhotos($transaction, $validated); $this->syncPhotos($transaction, $validated, self::MAX_PHOTOS);
return $transaction; return $transaction;
}); },
} catch (ValidationException $e) { 'Gagal melakukan tarik kas',
throw $e; );
} catch (\Throwable $e) {
Log::error('Gagal melakukan tarik kas: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'🏦 Tarik Kas', '🏦 Tarik Kas',
@ -168,8 +152,8 @@ public function recordOutgoing(
User $user, User $user,
?CashAccount $cashAccount = null, ?CashAccount $cashAccount = null,
): CashTransaction { ): CashTransaction {
try { return $this->runInTransaction(
return DB::transaction(function () use ($reference, $amount, $description, $user, $cashAccount): CashTransaction { function () use ($reference, $amount, $description, $user, $cashAccount): CashTransaction {
$account = CashAccount::query()->lockForUpdate()->findOrFail( $account = CashAccount::query()->lockForUpdate()->findOrFail(
($cashAccount ?? $this->getDefaultAccount())->id, ($cashAccount ?? $this->getDefaultAccount())->id,
); );
@ -197,18 +181,9 @@ public function recordOutgoing(
$transaction->save(); $transaction->save();
return $transaction; return $transaction;
}); },
} catch (ValidationException $e) { 'Gagal mencatat transaksi keluar kas',
throw $e; );
} catch (\Throwable $e) {
Log::error('Gagal mencatat transaksi keluar kas: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
} }
public function recordIncoming( public function recordIncoming(
@ -218,8 +193,8 @@ public function recordIncoming(
User $user, User $user,
?CashAccount $cashAccount = null, ?CashAccount $cashAccount = null,
): CashTransaction { ): CashTransaction {
try { return $this->runInTransaction(
return DB::transaction(function () use ($reference, $amount, $description, $user, $cashAccount): CashTransaction { function () use ($reference, $amount, $description, $user, $cashAccount): CashTransaction {
$account = CashAccount::query()->lockForUpdate()->findOrFail( $account = CashAccount::query()->lockForUpdate()->findOrFail(
($cashAccount ?? $this->getDefaultAccount())->id, ($cashAccount ?? $this->getDefaultAccount())->id,
); );
@ -241,26 +216,15 @@ public function recordIncoming(
$transaction->save(); $transaction->save();
return $transaction; return $transaction;
}); },
} catch (ValidationException $e) { 'Gagal mencatat transaksi masuk kas',
throw $e; );
} catch (\Throwable $e) {
Log::error('Gagal mencatat transaksi masuk kas: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
} }
public function updateDeposit(CashTransaction $transaction, array $validated): void public function updateDeposit(CashTransaction $transaction, array $validated): void
{ {
$this->ensureEditable($transaction); $this->runInTransaction(
function () use ($transaction, $validated): void {
try {
DB::transaction(function () use ($transaction, $validated): void {
CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id); CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id);
$transaction->update([ $transaction->update([
@ -268,7 +232,7 @@ public function updateDeposit(CashTransaction $transaction, array $validated): v
'description' => $validated['description'], 'description' => $validated['description'],
]); ]);
$this->syncPhotos($transaction, $validated); $this->syncPhotos($transaction, $validated, self::MAX_PHOTOS);
$this->recalculateBalances($transaction->cashAccount); $this->recalculateBalances($transaction->cashAccount);
@ -279,18 +243,9 @@ public function updateDeposit(CashTransaction $transaction, array $validated): v
'amount' => 'Saldo kas tidak mencukupi.', 'amount' => 'Saldo kas tidak mencukupi.',
]); ]);
} }
}); },
} catch (ValidationException $e) { 'Gagal memperbarui setoran kas',
throw $e; );
} catch (\Throwable $e) {
Log::error('Gagal memperbarui setoran kas: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'✏️ Transaksi Kas Diperbarui', '✏️ Transaksi Kas Diperbarui',
@ -302,10 +257,11 @@ public function updateDeposit(CashTransaction $transaction, array $validated): v
public function deleteTransaction(CashTransaction $transaction): void public function deleteTransaction(CashTransaction $transaction): void
{ {
$this->ensureEditable($transaction); $amountFormatted = $transaction->amount_formatted;
$description = $transaction->description;
try { $this->runInTransaction(
DB::transaction(function () use ($transaction): void { function () use ($transaction): void {
CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id); CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id);
$account = $transaction->cashAccount; $account = $transaction->cashAccount;
@ -320,22 +276,13 @@ public function deleteTransaction(CashTransaction $transaction): void
'transaction' => 'Saldo kas tidak mencukupi jika transaksi ini dihapus.', 'transaction' => 'Saldo kas tidak mencukupi jika transaksi ini dihapus.',
]); ]);
} }
}); },
} catch (ValidationException $e) { 'Gagal menghapus transaksi kas',
throw $e; );
} catch (\Throwable $e) {
Log::error('Gagal menghapus transaksi kas: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'🗑️ Transaksi Kas Dihapus', '🗑️ Transaksi Kas Dihapus',
"Transaksi kas senilai {$transaction->amount_formatted} dengan keterangan {$transaction->description} telah dihapus.", "Transaksi kas senilai {$amountFormatted} dengan keterangan {$description} telah dihapus.",
['owner', 'developer', 'admin-toko'], ['owner', 'developer', 'admin-toko'],
route('admin.finance.cash.index'), route('admin.finance.cash.index'),
); );
@ -346,8 +293,8 @@ public function updateReferencedTransaction(
int $amount, int $amount,
string $description, string $description,
): void { ): void {
try { $this->runInTransaction(
DB::transaction(function () use ($transaction, $amount, $description): void { function () use ($transaction, $amount, $description): void {
CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id); CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id);
$transaction->update([ $transaction->update([
@ -364,24 +311,15 @@ public function updateReferencedTransaction(
'amount' => 'Saldo kas tidak mencukupi.', 'amount' => 'Saldo kas tidak mencukupi.',
]); ]);
} }
}); },
} catch (ValidationException $e) { 'Gagal memperbarui transaksi kas referensi',
throw $e; );
} catch (\Throwable $e) {
Log::error('Gagal memperbarui transaksi kas referensi: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
} }
public function deleteReferencedTransaction(CashTransaction $transaction): void public function deleteReferencedTransaction(CashTransaction $transaction): void
{ {
try { $this->runInTransaction(
DB::transaction(function () use ($transaction): void { function () use ($transaction): void {
CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id); CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id);
$account = $transaction->cashAccount; $account = $transaction->cashAccount;
@ -394,43 +332,11 @@ public function deleteReferencedTransaction(CashTransaction $transaction): void
'transaction' => 'Saldo kas tidak mencukupi jika transaksi ini dihapus.', 'transaction' => 'Saldo kas tidak mencukupi jika transaksi ini dihapus.',
]); ]);
} }
}); },
} catch (ValidationException $e) { 'Gagal menghapus transaksi kas referensi',
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menghapus transaksi kas referensi: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
}
private function syncPhotos(CashTransaction $transaction, array $validated): void
{
$this->mediaService->syncCollection(
$transaction,
'photos',
$validated['photos'] ?? null,
$validated['remove_media_ids'] ?? null,
self::MAX_PHOTOS,
required: true,
errorKey: 'photos',
s3Keys: $validated['s3_keys'] ?? null,
); );
} }
private function ensureEditable(CashTransaction $transaction): void
{
if ($transaction->reference_type !== null) {
throw ValidationException::withMessages([
'transaction' => 'Transaksi ini tidak dapat diubah dari halaman kas.',
]);
}
}
private function recalculateBalances(CashAccount $cashAccount): void private function recalculateBalances(CashAccount $cashAccount): void
{ {
$account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id); $account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id);

View File

@ -6,15 +6,19 @@ import { DataTable } from '@/components/data-table';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import {
useDataTableQuery,
useDataTableQuerySync,
} from '@/composables/useDataTableQuery';
import { CashReferenceType } from '@/constants/cash-reference-type'; import { CashReferenceType } from '@/constants/cash-reference-type';
import { CashTransactionType } from '@/constants/cash-transaction-type'; import { CashTransactionType } from '@/constants/cash-transaction-type';
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index } from '@/routes/admin/finance/cash';
import type { CashAccount, CashTransactionListItem, PaginatedCashTransactions } from '@/types/cash'; import type { CashAccount, CashTransactionListItem, PaginatedCashTransactions } from '@/types/cash';
import type { DataTableFilterDef, DataTableSort } from '@/types/data-table'; import type { DataTableFilterDef, DataTableSort } from '@/types/data-table';
import CashTransactionFormModal from './form/CashTransactionFormModal.vue'; import CashTransactionFormModal from './form/CashTransactionFormModal.vue';
import { createColumns } from './table/columns'; import { createColumns } from './table/columns';
import { index } from '@/routes/admin/finance/cash';
const props = defineProps<{ const props = defineProps<{
cashAccount: CashAccount; cashAccount: CashAccount;
@ -116,11 +120,13 @@ watch(
</div> </div>
<div class="flex shrink-0 items-center gap-2 self-start sm:self-center"> <div class="flex shrink-0 items-center gap-2 self-start sm:self-center">
<Button v-if="can('cash.deposit')" variant="outline" @click="openCreateModal(CashTransactionType.DEPOSIT)"> <Button v-if="can('cash.deposit')" variant="outline"
@click="openCreateModal(CashTransactionType.DEPOSIT)">
<ArrowDownCircle class="size-4" /> <ArrowDownCircle class="size-4" />
Setor Kas Setor Kas
</Button> </Button>
<Button v-if="can('cash.withdraw')" variant="outline" @click="openCreateModal(CashTransactionType.WITHDRAWAL)"> <Button v-if="can('cash.withdraw')" variant="outline"
@click="openCreateModal(CashTransactionType.WITHDRAWAL)">
<ArrowUpCircle class="size-4" /> <ArrowUpCircle class="size-4" />
Tarik Kas Tarik Kas
</Button> </Button>
@ -142,7 +148,7 @@ watch(
</Card> </Card>
<Card class="min-w-0"> <Card class="min-w-0">
<CardContent class="min-w-0 pt-6"> <CardContent class="min-w-0">
<DataTable v-model:search="search" :columns="columns" :data="transactions.data" :pagination="pagination" <DataTable v-model:search="search" :columns="columns" :data="transactions.data" :pagination="pagination"
:pagination-links="transactions.links" :sort="currentSort" :filter-defs="filterDefs" :pagination-links="transactions.links" :sort="currentSort" :filter-defs="filterDefs"
:filter-values="filterValues" @sort-change="setSort" @filter-change="setFilter" :filter-values="filterValues" @sort-change="setSort" @filter-change="setFilter"

View File

@ -1,5 +1,5 @@
import type { MediaItem } from '@/types/media';
import type { Paginated } from '@/types/common'; import type { Paginated } from '@/types/common';
import type { MediaItem } from '@/types/media';
export type CashAccount = { export type CashAccount = {
id: number; id: number;

View File

@ -307,9 +307,9 @@ function withdrawPayload(?int $amount = null, ?string $description = null): arra
}); });
}); });
// ─── Update Transaction ─────────────────────────────────── // ─── Update ───────────────────────────────────────────────
describe('Cash Update Transaction', function () { describe('Cash Update', function () {
test('authenticated user with permission can update a manual transaction', function () { test('authenticated user with permission can update a manual transaction', function () {
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_UPDATE); $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_UPDATE);
@ -380,9 +380,9 @@ function withdrawPayload(?int $amount = null, ?string $description = null): arra
}); });
}); });
// ─── Destroy Transaction ────────────────────────────────── // ─── Destroy ──────────────────────────────────────────────
describe('Cash Destroy Transaction', function () { describe('Cash Destroy', function () {
test('authenticated user with permission can delete a manual transaction', function () { test('authenticated user with permission can delete a manual transaction', function () {
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_DELETE); $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_DELETE);