feat: implement PaymentMethod enum and integrate payment method handling in tuition payment processes
Some checks failed
tests / ci (pull_request) Has been cancelled

This commit is contained in:
Yoga Pangestu 2026-08-30 16:06:05 +07:00
parent d0793b739a
commit 2212498136
13 changed files with 214 additions and 59 deletions

View File

@ -0,0 +1,21 @@
<?php
namespace App\Enums;
use App\Enums\Concerns\HasValues;
enum PaymentMethod: string
{
use HasValues;
case Transfer = 'transfer';
case Cash = 'cash';
public function label(): string
{
return match ($this) {
self::Transfer => 'Transfer',
self::Cash => 'Tunai',
};
}
}

View File

@ -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']),
]);
}

View File

@ -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.',
];
}
}

View File

@ -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())],
];
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -1,5 +1,6 @@
<?php
use App\Enums\PaymentMethod;
use App\Enums\PaymentStatus;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
@ -14,7 +15,7 @@ public function up(): void
$table->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();

View File

@ -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 (
<Popover>
@ -48,6 +50,7 @@ export function DatePicker({
defaultMonth={value ?? undefined}
captionLayout="dropdown"
onSelect={onChange}
disabled={maxDate ? { after: maxDate } : undefined}
/>
</PopoverContent>
</Popover>

View File

@ -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<Date | undefined>(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 (
<div className="grid gap-2">
<Label>
@ -53,11 +61,13 @@ export function DateTimeField({
onChange={setDate}
placeholder={placeholder}
className="flex-1"
maxDate={maxNow ? now : undefined}
/>
<Input
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
max={maxTime}
className="w-28"
/>
</div>

View File

@ -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: () => <span>Jatuh Tempo</span>,
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 (
<span
className={cn(
isOverdue && 'font-medium text-destructive',
)}
>
{format(new Date(dueDate), 'd MMM yyyy')}
</span>
);
},
},
{

View File

@ -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 = {

View File

@ -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<TuitionPayment>[] = [
{
@ -77,8 +76,12 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
{
accessorKey: 'payment_method',
header: () => <span>Metode</span>,
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) {
</p>
<p>
Total Terbayar: {formatRupiah(paidTotal)}{' '}
{paidTotal >= Number(invoice.amount_due) ? (
{isFullyPaid ? (
<Badge variant="default" className="ml-1">
Lunas
</Badge>
@ -228,10 +231,12 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
<h2 className="text-lg font-semibold">
Riwayat Pembayaran
</h2>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
Tambah Pembayaran
</Button>
{!isFullyPaid && (
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
Tambah Pembayaran
</Button>
)}
</div>
<DataTable
@ -244,6 +249,7 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
open={createOpen}
onOpenChange={setCreateOpen}
invoiceId={invoice.id}
remaining={amountDue - paidTotal}
/>
<EditForm
@ -256,6 +262,12 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
}}
invoiceId={invoice.id}
editing={editing}
remaining={
editing
? amountDue -
(paidTotal - Number(editing.amount_paid))
: 0
}
/>
<DeleteConfirmDialog
@ -280,10 +292,12 @@ function CreateForm({
open,
onOpenChange,
invoiceId,
remaining,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
invoiceId: number;
remaining: number;
}) {
return (
<FormDialog
@ -299,6 +313,7 @@ function CreateForm({
<PaymentFields
errors={errors}
resetKey={open ? 'open' : 'closed'}
remaining={remaining}
/>
</div>
)}
@ -311,11 +326,13 @@ function EditForm({
onOpenChange,
invoiceId,
editing,
remaining,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
invoiceId: number;
editing: TuitionPayment | null;
remaining: number;
}) {
return (
<FormDialog
@ -329,7 +346,11 @@ function EditForm({
{({ errors }) =>
editing && (
<div className="grid gap-4">
<PaymentFields errors={errors} editing={editing} />
<PaymentFields
errors={errors}
editing={editing}
remaining={remaining}
/>
</div>
)
}
@ -341,10 +362,12 @@ function PaymentFields({
errors,
editing,
resetKey,
remaining,
}: {
errors: Record<string, string>;
editing?: TuitionPayment;
resetKey?: string;
remaining: number;
}) {
return (
<>
@ -359,6 +382,9 @@ function PaymentFields({
defaultValue={editing?.amount_paid}
ariaInvalid={!!errors.amount_paid}
/>
<p className="text-xs text-muted-foreground">
Sisa tagihan: {formatRupiah(remaining)}
</p>
<InputError message={errors.amount_paid} />
</div>
<DateTimeField
@ -368,38 +394,34 @@ function PaymentFields({
defaultValue={editing?.paid_at}
placeholder="Pilih tanggal bayar"
error={errors.paid_at}
maxNow
/>
<div className="grid gap-2">
<Label htmlFor="payment_method">Metode Pembayaran</Label>
<Input
id="payment_method"
name="payment_method"
placeholder="Contoh: transfer"
defaultValue={editing?.payment_method ?? ''}
/>
<InputError message={errors.payment_method} />
</div>
<div className="grid gap-2">
<Label>
Status <span className="text-destructive">*</span>
Metode Pembayaran{' '}
<span className="text-destructive">*</span>
</Label>
<input type="hidden" name="status" />
<Select
name="status"
defaultValue={editing?.status ?? 'unpaid'}
<RadioGroup
name="payment_method"
defaultValue={editing?.payment_method ?? ''}
className="flex flex-row gap-4"
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih status" />
</SelectTrigger>
<SelectContent>
{PaymentStatuses.map((status) => (
<SelectItem key={status} value={status}>
{PaymentStatusLabels[status]}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.status} />
{PaymentMethods.map((method) => (
<div key={method} className="flex items-center gap-2">
<RadioGroupItem
value={method}
id={`payment_method-${method}`}
/>
<Label
htmlFor={`payment_method-${method}`}
className="font-normal"
>
{PaymentMethodLabels[method]}
</Label>
</div>
))}
</RadioGroup>
<InputError message={errors.payment_method} />
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Catatan</Label>

View File

@ -8,12 +8,21 @@ export const PaymentStatusLabels: Record<PaymentStatus, string> = {
paid: 'Lunas',
};
export const PaymentMethods = ['transfer', 'cash'] as const;
export type PaymentMethod = (typeof PaymentMethods)[number];
export const PaymentMethodLabels: Record<PaymentMethod, string> = {
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;