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;