From 2212498136a24cf25073c670d603063e08118c17 Mon Sep 17 00:00:00 2001
From: Yoga Pangestu
Date: Sun, 30 Aug 2026 16:06:05 +0700
Subject: [PATCH] feat: implement PaymentMethod enum and integrate payment
method handling in tuition payment processes
---
app/Enums/PaymentMethod.php | 21 ++++
.../Finances/TuitionInvoiceController.php | 4 +-
.../Admin/Finances/TuitionPaymentRequest.php | 26 ++++-
app/Http/Requests/PaginatedRequest.php | 2 +
.../Admin/Finances/TuitionInvoiceService.php | 16 ++-
.../Admin/Finances/TuitionPaymentService.php | 17 ++-
...4_000006_create_tuition_payments_table.php | 3 +-
resources/js/components/date-picker.tsx | 3 +
resources/js/components/datetime-field.tsx | 12 +-
.../finances/tuition-invoices/columns.tsx | 24 +++-
.../admin/finances/tuition-invoices/index.tsx | 24 ++++
.../finances/tuition-invoices/payments.tsx | 110 +++++++++++-------
resources/js/types/tuition-payment.ts | 11 +-
13 files changed, 214 insertions(+), 59 deletions(-)
create mode 100644 app/Enums/PaymentMethod.php
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 (
@@ -48,6 +50,7 @@ export function DatePicker({
defaultMonth={value ?? undefined}
captionLayout="dropdown"
onSelect={onChange}
+ disabled={maxDate ? { after: maxDate } : undefined}
/>
diff --git a/resources/js/components/datetime-field.tsx b/resources/js/components/datetime-field.tsx
index 48a9839..697aac2 100644
--- a/resources/js/components/datetime-field.tsx
+++ b/resources/js/components/datetime-field.tsx
@@ -1,4 +1,4 @@
-import { format } from 'date-fns';
+import { format, isSameDay } from 'date-fns';
import { useState } from 'react';
import { DatePicker } from '@/components/date-picker';
import InputError from '@/components/input-error';
@@ -12,6 +12,7 @@ type DateTimeFieldProps = {
defaultValue?: string | null;
error?: string;
placeholder?: string;
+ maxNow?: boolean;
};
function parseDefault(value?: string | null): Date | undefined {
@@ -31,6 +32,7 @@ export function DateTimeField({
defaultValue,
error,
placeholder = 'Pilih tanggal',
+ maxNow,
}: DateTimeFieldProps) {
const initial = parseDefault(defaultValue);
const [date, setDate] = useState(initial);
@@ -40,6 +42,12 @@ export function DateTimeField({
? `${format(date, 'yyyy-MM-dd')} ${time || '00:00'}:00`
: '';
+ const now = new Date();
+ const maxTime =
+ maxNow && date && isSameDay(date, now)
+ ? format(now, 'HH:mm')
+ : undefined;
+
return (
diff --git a/resources/js/pages/admin/finances/tuition-invoices/columns.tsx b/resources/js/pages/admin/finances/tuition-invoices/columns.tsx
index 31b82c2..29abff7 100644
--- a/resources/js/pages/admin/finances/tuition-invoices/columns.tsx
+++ b/resources/js/pages/admin/finances/tuition-invoices/columns.tsx
@@ -1,9 +1,10 @@
import type { ColumnDef } from '@tanstack/react-table';
-import { format } from 'date-fns';
+import { format, isBefore, startOfDay } from 'date-fns';
import { CreditCard, Pencil, Trash2 } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import { formatRupiah } from '@/lib/currency';
+import { cn } from '@/lib/utils';
import { index as paymentsIndex } from '@/routes/admin/finances/tuition-invoices/payments';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type { TuitionInvoice } from '@/types/tuition-invoice';
@@ -92,9 +93,28 @@ export function createTuitionInvoiceColumns(
accessorKey: 'due_date',
header: () => Jatuh Tempo,
cell: ({ row }) => {
+ const invoice = row.original;
const dueDate = row.getValue('due_date') as string | null;
- return dueDate ? format(new Date(dueDate), 'd MMM yyyy') : '-';
+ if (!dueDate) {
+ return '-';
+ }
+
+ const paidTotal = Number(invoice.paid_total ?? 0);
+ const amountDue = Number(invoice.amount_due);
+ const isOverdue =
+ paidTotal < amountDue &&
+ isBefore(new Date(dueDate), startOfDay(new Date()));
+
+ return (
+
+ {format(new Date(dueDate), 'd MMM yyyy')}
+
+ );
},
},
{
diff --git a/resources/js/pages/admin/finances/tuition-invoices/index.tsx b/resources/js/pages/admin/finances/tuition-invoices/index.tsx
index 044d726..27ce003 100644
--- a/resources/js/pages/admin/finances/tuition-invoices/index.tsx
+++ b/resources/js/pages/admin/finances/tuition-invoices/index.tsx
@@ -45,6 +45,12 @@ import type {
TuitionInvoice,
TuitionInvoiceStudent,
} from '@/types/tuition-invoice';
+import {
+ PaymentMethodLabels,
+ PaymentMethods,
+ PaymentStatusLabels,
+ PaymentStatuses,
+} from '@/types/tuition-payment';
import { createTuitionInvoiceColumns } from './columns';
type AcademicTermOption = { id: number; name: string; semester: string };
@@ -62,6 +68,8 @@ type Props = {
highlight?: number;
filters: {
academic_term_id?: string;
+ status?: string;
+ payment_method?: string;
};
};
@@ -89,6 +97,22 @@ export default function TuitionInvoiceIndex({
label: formatAcademicTermLabel(term),
})),
},
+ {
+ key: 'status',
+ label: 'Status Bayar',
+ options: PaymentStatuses.map((status) => ({
+ value: status,
+ label: PaymentStatusLabels[status],
+ })),
+ },
+ {
+ key: 'payment_method',
+ label: 'Metode Pembayaran',
+ options: PaymentMethods.map((method) => ({
+ value: method,
+ label: PaymentMethodLabels[method],
+ })),
+ },
];
const pagination: PaginationState = {
diff --git a/resources/js/pages/admin/finances/tuition-invoices/payments.tsx b/resources/js/pages/admin/finances/tuition-invoices/payments.tsx
index 30c86f5..6b2e563 100644
--- a/resources/js/pages/admin/finances/tuition-invoices/payments.tsx
+++ b/resources/js/pages/admin/finances/tuition-invoices/payments.tsx
@@ -15,15 +15,8 @@ import { RupiahInput } from '@/components/rupiah-input';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
-import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select';
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Textarea } from '@/components/ui/textarea';
import { formatRupiah } from '@/lib/currency';
import { index as tuitionInvoiceIndex } from '@/routes/admin/finances/tuition-invoices';
@@ -35,7 +28,11 @@ import {
import { formatAcademicTermLabel } from '@/types/academic-term';
import type { TuitionInvoice } from '@/types/tuition-invoice';
import type { TuitionPayment } from '@/types/tuition-payment';
-import { PaymentStatusLabels, PaymentStatuses } from '@/types/tuition-payment';
+import {
+ PaymentMethodLabels,
+ PaymentMethods,
+ PaymentStatusLabels,
+} from '@/types/tuition-payment';
type Props = {
invoice: TuitionInvoice;
@@ -61,6 +58,8 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
(sum, payment) => sum + Number(payment.amount_paid),
0,
);
+ const amountDue = Number(invoice.amount_due);
+ const isFullyPaid = paidTotal >= amountDue;
const columns: ColumnDef[] = [
{
@@ -77,8 +76,12 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
{
accessorKey: 'payment_method',
header: () => Metode,
- cell: ({ row }) =>
- (row.getValue('payment_method') as string | null) ?? '-',
+ cell: ({ row }) => {
+ const method = row.getValue('payment_method') as
+ keyof typeof PaymentMethodLabels | null;
+
+ return method ? PaymentMethodLabels[method] : '-';
+ },
},
{
accessorKey: 'status',
@@ -207,7 +210,7 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
Total Terbayar: {formatRupiah(paidTotal)}{' '}
- {paidTotal >= Number(invoice.amount_due) ? (
+ {isFullyPaid ? (
Lunas
@@ -228,10 +231,12 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
Riwayat Pembayaran
-
+ {!isFullyPaid && (
+
+ )}
void;
invoiceId: number;
+ remaining: number;
}) {
return (
)}
@@ -311,11 +326,13 @@ function EditForm({
onOpenChange,
invoiceId,
editing,
+ remaining,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
invoiceId: number;
editing: TuitionPayment | null;
+ remaining: number;
}) {
return (
editing && (
)
}
@@ -341,10 +362,12 @@ function PaymentFields({
errors,
editing,
resetKey,
+ remaining,
}: {
errors: Record;
editing?: TuitionPayment;
resetKey?: string;
+ remaining: number;
}) {
return (
<>
@@ -359,6 +382,9 @@ function PaymentFields({
defaultValue={editing?.amount_paid}
ariaInvalid={!!errors.amount_paid}
/>
+
+ Sisa tagihan: {formatRupiah(remaining)}
+
-
-
-
-
-
-
-
-
+ {PaymentMethods.map((method) => (
+
+
+
+
+ ))}
+
+
diff --git a/resources/js/types/tuition-payment.ts b/resources/js/types/tuition-payment.ts
index 4ecd0a7..b758e05 100644
--- a/resources/js/types/tuition-payment.ts
+++ b/resources/js/types/tuition-payment.ts
@@ -8,12 +8,21 @@ export const PaymentStatusLabels: Record
= {
paid: 'Lunas',
};
+export const PaymentMethods = ['transfer', 'cash'] as const;
+
+export type PaymentMethod = (typeof PaymentMethods)[number];
+
+export const PaymentMethodLabels: Record = {
+ transfer: 'Transfer',
+ cash: 'Tunai',
+};
+
export type TuitionPayment = {
id: number;
invoice_id: number;
amount_paid: string;
paid_at: string;
- payment_method: string | null;
+ payment_method: PaymentMethod | null;
status: PaymentStatus | null;
recorded_by: number | null;
recorder: { id: number; full_name: string } | null;