diff --git a/app/Enums/PaymentMethod.php b/app/Enums/PaymentMethod.php
new file mode 100644
index 0000000..ff3ec71
--- /dev/null
+++ b/app/Enums/PaymentMethod.php
@@ -0,0 +1,21 @@
+ 'Transfer',
+ self::Cash => 'Tunai',
+ };
+ }
+}
diff --git a/app/Http/Controllers/Admin/Finances/TuitionInvoiceController.php b/app/Http/Controllers/Admin/Finances/TuitionInvoiceController.php
index 37ba9a1..651d963 100644
--- a/app/Http/Controllers/Admin/Finances/TuitionInvoiceController.php
+++ b/app/Http/Controllers/Admin/Finances/TuitionInvoiceController.php
@@ -36,10 +36,12 @@ public function index(PaginatedRequest $request): Response
'invoices' => $this->service->paginated(
...$request->validatedWithDefaults(),
academicTermId: $request->validated('academic_term_id'),
+ status: $request->validated('status'),
+ paymentMethod: $request->validated('payment_method'),
),
'students' => $students,
'academicTerms' => $this->academicTermService->getAllForSelect(),
- 'filters' => $request->only(['academic_term_id']),
+ 'filters' => $request->only(['academic_term_id', 'status', 'payment_method']),
]);
}
diff --git a/app/Http/Requests/Admin/Finances/TuitionPaymentRequest.php b/app/Http/Requests/Admin/Finances/TuitionPaymentRequest.php
index 41976f2..265b87d 100644
--- a/app/Http/Requests/Admin/Finances/TuitionPaymentRequest.php
+++ b/app/Http/Requests/Admin/Finances/TuitionPaymentRequest.php
@@ -2,7 +2,7 @@
namespace App\Http\Requests\Admin\Finances;
-use App\Enums\PaymentStatus;
+use App\Enums\PaymentMethod;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -15,13 +15,29 @@ public function authorize(): bool
public function rules(): array
{
+ $invoice = $this->route('tuition_invoice');
+ $payment = $this->route('payment');
+
+ $otherPaidTotal = $invoice->payments()
+ ->when($payment, fn ($query) => $query->where('id', '!=', $payment->id))
+ ->sum('amount_paid');
+
+ $remaining = max(0, round($invoice->amount_due - $otherPaidTotal, 2));
+
return [
- 'amount_paid' => ['required', 'numeric', 'min:0'],
- 'paid_at' => ['required', 'date'],
- 'payment_method' => ['nullable', 'string', 'max:30'],
- 'status' => ['required', Rule::enum(PaymentStatus::class)],
+ 'amount_paid' => ['required', 'numeric', 'min:0.01', "max:{$remaining}"],
+ 'paid_at' => ['required', 'date', 'before_or_equal:now'],
+ 'payment_method' => ['required', Rule::enum(PaymentMethod::class)],
'notes' => ['nullable', 'string'],
'proof' => ['nullable', 'file', 'max:10240', 'mimes:pdf,jpg,jpeg,png'],
];
}
+
+ public function messages(): array
+ {
+ return [
+ 'amount_paid.max' => 'Jumlah dibayar tidak boleh melebihi sisa tagihan.',
+ 'paid_at.before_or_equal' => 'Tanggal & waktu bayar tidak boleh melebihi waktu saat ini.',
+ ];
+ }
}
diff --git a/app/Http/Requests/PaginatedRequest.php b/app/Http/Requests/PaginatedRequest.php
index 9e89f92..fe0c2cf 100644
--- a/app/Http/Requests/PaginatedRequest.php
+++ b/app/Http/Requests/PaginatedRequest.php
@@ -4,6 +4,7 @@
use App\Enums\ClassMethod;
use App\Enums\Gender;
+use App\Enums\PaymentMethod;
use App\Enums\Semester;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -33,6 +34,7 @@ public function rules(): array
'course_class_id' => ['nullable', 'integer'],
'lecturer_id' => ['nullable', 'integer'],
'type' => ['nullable', 'string'],
+ 'payment_method' => ['nullable', 'string', Rule::in(PaymentMethod::values())],
];
}
diff --git a/app/Services/Admin/Finances/TuitionInvoiceService.php b/app/Services/Admin/Finances/TuitionInvoiceService.php
index aa32bd4..cc67ec3 100644
--- a/app/Services/Admin/Finances/TuitionInvoiceService.php
+++ b/app/Services/Admin/Finances/TuitionInvoiceService.php
@@ -22,8 +22,13 @@ public function getInvoicedTermIdsByStudent(array $studentIds): Collection
->map(fn ($rows) => $rows->pluck('academic_term_id')->values()->all());
}
- public function paginated(int $perPage = 25, string $search = '', ?int $academicTermId = null): LengthAwarePaginator
- {
+ public function paginated(
+ int $perPage = 25,
+ string $search = '',
+ ?int $academicTermId = null,
+ ?string $status = null,
+ ?string $paymentMethod = null,
+ ): LengthAwarePaginator {
return TuitionInvoice::query()
->select(['id', 'student_id', 'academic_term_id', 'amount_due', 'due_date'])
->withSum('payments as paid_total', 'amount_paid')
@@ -32,6 +37,13 @@ public function paginated(int $perPage = 25, string $search = '', ?int $academic
->when($search, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%")
->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))))
->when($academicTermId, fn ($q) => $q->where('academic_term_id', $academicTermId))
+ ->when($status, fn ($q) => match ($status) {
+ 'paid' => $q->havingRaw('COALESCE(paid_total, 0) >= amount_due'),
+ 'partial' => $q->havingRaw('COALESCE(paid_total, 0) > 0 AND COALESCE(paid_total, 0) < amount_due'),
+ 'unpaid' => $q->havingRaw('COALESCE(paid_total, 0) <= 0'),
+ default => $q,
+ })
+ ->when($paymentMethod, fn ($q) => $q->whereHas('payments', fn ($q) => $q->where('payment_method', $paymentMethod)))
->latest()
->paginate($perPage);
}
diff --git a/app/Services/Admin/Finances/TuitionPaymentService.php b/app/Services/Admin/Finances/TuitionPaymentService.php
index a355f12..771473f 100644
--- a/app/Services/Admin/Finances/TuitionPaymentService.php
+++ b/app/Services/Admin/Finances/TuitionPaymentService.php
@@ -2,6 +2,7 @@
namespace App\Services\Admin\Finances;
+use App\Enums\PaymentStatus;
use App\Models\TuitionInvoice;
use App\Models\TuitionPayment;
use Illuminate\Database\Eloquent\Collection;
@@ -23,7 +24,7 @@ public function create(TuitionInvoice $invoice, array $data, ?UploadedFile $file
'amount_paid' => $data['amount_paid'],
'paid_at' => $data['paid_at'],
'payment_method' => $data['payment_method'] ?? null,
- 'status' => $data['status'],
+ 'status' => $this->resolveStatus($invoice, $data['amount_paid']),
'recorded_by' => auth()->id(),
'notes' => $data['notes'] ?? null,
]);
@@ -40,7 +41,7 @@ public function update(TuitionPayment $payment, array $data, ?UploadedFile $file
$payment->amount_paid = $data['amount_paid'];
$payment->paid_at = $data['paid_at'];
$payment->payment_method = $data['payment_method'] ?? null;
- $payment->status = $data['status'];
+ $payment->status = $this->resolveStatus($payment->invoice, $data['amount_paid'], excludePaymentId: $payment->id);
$payment->notes = $data['notes'] ?? null;
$payment->update();
@@ -55,4 +56,16 @@ public function delete(TuitionPayment $payment): bool
{
return $payment->delete();
}
+
+ private function resolveStatus(TuitionInvoice $invoice, float|string $amountPaid, ?int $excludePaymentId = null): PaymentStatus
+ {
+ $otherPaidTotal = $invoice->payments()
+ ->when($excludePaymentId, fn ($query) => $query->where('id', '!=', $excludePaymentId))
+ ->sum('amount_paid');
+
+ $totalPaid = round($otherPaidTotal + (float) $amountPaid, 2);
+ $amountDue = round((float) $invoice->amount_due, 2);
+
+ return $totalPaid >= $amountDue ? PaymentStatus::Paid : PaymentStatus::Partial;
+ }
}
diff --git a/database/migrations/2026_08_24_000006_create_tuition_payments_table.php b/database/migrations/2026_08_24_000006_create_tuition_payments_table.php
index 0fb3176..871f4c3 100644
--- a/database/migrations/2026_08_24_000006_create_tuition_payments_table.php
+++ b/database/migrations/2026_08_24_000006_create_tuition_payments_table.php
@@ -1,5 +1,6 @@
foreignId('invoice_id')->constrained('tuition_invoices')->cascadeOnDelete();
$table->decimal('amount_paid', 15, 2);
$table->timestamp('paid_at');
- $table->string('payment_method', 30)->nullable();
+ $table->enum('payment_method', PaymentMethod::values())->nullable();
$table->enum('status', PaymentStatus::values())->nullable()->default(PaymentStatus::Unpaid->value);
$table->foreignId('recorded_by')->nullable()->constrained('users')->nullOnDelete();
$table->text('notes')->nullable();
diff --git a/resources/js/components/date-picker.tsx b/resources/js/components/date-picker.tsx
index e049319..a50ee21 100644
--- a/resources/js/components/date-picker.tsx
+++ b/resources/js/components/date-picker.tsx
@@ -16,6 +16,7 @@ type DatePickerProps = {
placeholder?: string;
className?: string;
disabled?: boolean;
+ maxDate?: Date;
};
export function DatePicker({
@@ -24,6 +25,7 @@ export function DatePicker({
placeholder = 'Pilih tanggal',
className,
disabled,
+ maxDate,
}: DatePickerProps) {
return (
Total Terbayar: {formatRupiah(paidTotal)}{' '}
- {paidTotal >= Number(invoice.amount_due) ? (
+ {isFullyPaid ? (
+ Sisa tagihan: {formatRupiah(remaining)} +