update api and add more features
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\DiscountUsage;
|
||||
use App\Models\Payment;
|
||||
use App\Models\Refund;
|
||||
use App\Services\Fees\FeeConfigService;
|
||||
use App\Services\Fees\TuitionCalculationService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* BalanceCalculationService (plan §6, §13 Phase 2)
|
||||
*
|
||||
* Combines tuition, extra charges, and event charges, then nets payments,
|
||||
* credits, and refunds to produce family- and student-level balances.
|
||||
*/
|
||||
class BalanceCalculationService
|
||||
{
|
||||
public function __construct(
|
||||
private FeeConfigService $config,
|
||||
private TuitionCalculationService $tuition,
|
||||
private BillingTotalsService $billingTotals
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $students
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function calculateFamilyAccount(array $students, int $parentId, ?string $schoolYear = null): array
|
||||
{
|
||||
$schoolYear = $schoolYear ?? $this->config->getSchoolYear();
|
||||
|
||||
$tuitionBreakdown = $this->tuition->calculate($students);
|
||||
$tuitionTotal = (float) ($tuitionBreakdown['family_total'] ?? 0);
|
||||
$extraCharges = $this->billingTotals->additionalSubtotalByParent($parentId, $schoolYear);
|
||||
$eventByStudent = $this->billingTotals->eventSubtotalsByStudent($parentId, $schoolYear);
|
||||
$eventCharges = round(array_sum($eventByStudent), 2);
|
||||
$lateFees = 0.0;
|
||||
|
||||
$totalCharges = round($tuitionTotal + $extraCharges + $eventCharges + $lateFees, 2);
|
||||
|
||||
$totalPayments = round(Payment::getTotalPaidByParentId($parentId, $schoolYear), 2);
|
||||
$totalCredits = round(
|
||||
DiscountUsage::getTotalDiscountByParentIdAndSchoolYear($parentId, $schoolYear),
|
||||
2
|
||||
);
|
||||
$refundsApplied = round(
|
||||
Refund::totalApprovedPaidOutForParentAndYear($parentId, $schoolYear),
|
||||
2
|
||||
);
|
||||
|
||||
$netting = round($totalPayments + $totalCredits + $refundsApplied, 2);
|
||||
$rawRemaining = round($totalCharges - $netting, 2);
|
||||
$remainingBalance = max(0.0, $rawRemaining);
|
||||
$accountCredit = $rawRemaining < 0 ? round(abs($rawRemaining), 2) : 0.0;
|
||||
|
||||
$studentRows = $this->buildStudentBalances(
|
||||
$tuitionBreakdown['students'] ?? [],
|
||||
$eventByStudent,
|
||||
$totalCharges,
|
||||
$totalPayments,
|
||||
$totalCredits,
|
||||
$refundsApplied
|
||||
);
|
||||
|
||||
Log::info('Family account balance calculated', [
|
||||
'parent_id' => $parentId,
|
||||
'school_year' => $schoolYear,
|
||||
'total_charges' => $totalCharges,
|
||||
'remaining_balance' => $remainingBalance,
|
||||
'account_credit' => $accountCredit,
|
||||
]);
|
||||
|
||||
return [
|
||||
'parent_id' => $parentId,
|
||||
'school_year' => $schoolYear,
|
||||
'charges' => [
|
||||
'tuition' => $tuitionTotal,
|
||||
'extra_charges' => $extraCharges,
|
||||
'event_charges' => $eventCharges,
|
||||
'late_fees' => $lateFees,
|
||||
'total' => $totalCharges,
|
||||
],
|
||||
'credits' => [
|
||||
'discounts' => $totalCredits,
|
||||
'total' => $totalCredits,
|
||||
],
|
||||
'payments' => $totalPayments,
|
||||
'refunds_applied' => $refundsApplied,
|
||||
'remaining_balance' => $remainingBalance,
|
||||
'account_credit' => $accountCredit,
|
||||
'tuition' => $tuitionBreakdown,
|
||||
'students' => $studentRows,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $tuitionStudents
|
||||
* @param array<int, float> $eventByStudent
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function buildStudentBalances(
|
||||
array $tuitionStudents,
|
||||
array $eventByStudent,
|
||||
float $familyTotalCharges,
|
||||
float $totalPayments,
|
||||
float $totalCredits,
|
||||
float $refundsApplied
|
||||
): array {
|
||||
$familyNetting = $totalPayments + $totalCredits + $refundsApplied;
|
||||
$rows = [];
|
||||
|
||||
foreach ($tuitionStudents as $student) {
|
||||
$studentId = (int) ($student['student_id'] ?? 0);
|
||||
$tuitionAmount = (float) ($student['final_tuition_amount'] ?? $student['tuition_fee'] ?? 0);
|
||||
$eventAmount = (float) ($eventByStudent[$studentId] ?? 0);
|
||||
$extraAmount = 0.0;
|
||||
$studentTotalCharges = round($tuitionAmount + $eventAmount + $extraAmount, 2);
|
||||
|
||||
$share = $familyTotalCharges > 0
|
||||
? ($studentTotalCharges / $familyTotalCharges)
|
||||
: 0.0;
|
||||
|
||||
$paymentsApplied = round($totalPayments * $share, 2);
|
||||
$creditsApplied = round($totalCredits * $share, 2);
|
||||
$refundsAppliedShare = round($refundsApplied * $share, 2);
|
||||
$allocatedNetting = round($familyNetting * $share, 2);
|
||||
$rawRemaining = round($studentTotalCharges - $allocatedNetting, 2);
|
||||
|
||||
$rows[] = [
|
||||
'student_id' => $studentId > 0 ? $studentId : ($student['student_id'] ?? null),
|
||||
'student_name' => $student['student_name'] ?? null,
|
||||
'grade' => $student['grade'] ?? null,
|
||||
'enrollment_status' => $student['enrollment_status'] ?? null,
|
||||
'tuition' => round($tuitionAmount, 2),
|
||||
'extra_charges' => $extraAmount,
|
||||
'event_charges' => $eventAmount,
|
||||
'total_charges' => $studentTotalCharges,
|
||||
'payments_applied' => $paymentsApplied,
|
||||
'credits_applied' => $creditsApplied,
|
||||
'refunds_applied' => $refundsAppliedShare,
|
||||
'remaining_balance' => max(0.0, $rawRemaining),
|
||||
'account_credit' => $rawRemaining < 0 ? round(abs($rawRemaining), 2) : 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
$unassignedEvent = (float) ($eventByStudent[0] ?? 0);
|
||||
if ($unassignedEvent > 0) {
|
||||
$rows[] = [
|
||||
'student_id' => null,
|
||||
'student_name' => null,
|
||||
'grade' => null,
|
||||
'enrollment_status' => null,
|
||||
'tuition' => 0.0,
|
||||
'extra_charges' => 0.0,
|
||||
'event_charges' => $unassignedEvent,
|
||||
'total_charges' => $unassignedEvent,
|
||||
'payments_applied' => 0.0,
|
||||
'credits_applied' => 0.0,
|
||||
'refunds_applied' => 0.0,
|
||||
'remaining_balance' => $unassignedEvent,
|
||||
'account_credit' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
@@ -10,41 +10,109 @@ class BillingTotalsService
|
||||
{
|
||||
public function eventSubtotal(int $parentId, string $schoolYear): float
|
||||
{
|
||||
$eventSubtotal = 0.0;
|
||||
return round(array_sum($this->eventSubtotalsByStudent($parentId, $schoolYear)), 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, float> student_id => amount
|
||||
*/
|
||||
public function eventSubtotalsByStudent(int $parentId, string $schoolYear): array
|
||||
{
|
||||
$byStudent = [];
|
||||
|
||||
try {
|
||||
$events = EventCharges::getChargesWithEventInfo($parentId, $schoolYear) ?? [];
|
||||
foreach ($events as $event) {
|
||||
$eventSubtotal += (float) ($event['charged'] ?? 0.0);
|
||||
$amount = (float) ($event['charged'] ?? 0);
|
||||
if ($amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$studentId = (int) ($event['student_id'] ?? 0);
|
||||
if ($studentId > 0) {
|
||||
$byStudent[$studentId] = ($byStudent[$studentId] ?? 0.0) + $amount;
|
||||
} else {
|
||||
$byStudent[0] = ($byStudent[0] ?? 0.0) + $amount;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to sum event charges.', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return round($eventSubtotal, 2);
|
||||
foreach ($byStudent as $studentId => $amount) {
|
||||
$byStudent[$studentId] = round($amount, 2);
|
||||
}
|
||||
|
||||
return $byStudent;
|
||||
}
|
||||
|
||||
public function additionalSubtotal(int $invoiceId, ?string $schoolYear = null): float
|
||||
{
|
||||
$additionalSubtotal = 0.0;
|
||||
return $this->sumAdditionalChargeRows(
|
||||
$this->fetchAdditionalChargeRows(invoiceId: $invoiceId, schoolYear: $schoolYear)
|
||||
);
|
||||
}
|
||||
|
||||
public function additionalSubtotalByParent(int $parentId, string $schoolYear): float
|
||||
{
|
||||
return $this->sumAdditionalChargeRows(
|
||||
$this->fetchAdditionalChargeRows(parentId: $parentId, schoolYear: $schoolYear)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{charge_type: string, amount: float}>
|
||||
*/
|
||||
private function fetchAdditionalChargeRows(
|
||||
?int $parentId = null,
|
||||
?int $invoiceId = null,
|
||||
?string $schoolYear = null
|
||||
): array {
|
||||
$rows = [];
|
||||
|
||||
try {
|
||||
$builder = AdditionalCharge::query()
|
||||
->select('charge_type', 'amount')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('status', 'applied');
|
||||
|
||||
if ($parentId !== null) {
|
||||
$builder->where('parent_id', $parentId);
|
||||
}
|
||||
|
||||
if ($invoiceId !== null) {
|
||||
$builder->where('invoice_id', $invoiceId);
|
||||
}
|
||||
|
||||
if ($schoolYear !== null && $schoolYear !== '') {
|
||||
$builder->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$rows = $builder->get()->map(fn ($row) => (array) $row)->all();
|
||||
foreach ($rows as $row) {
|
||||
$amount = (float) ($row['amount'] ?? 0);
|
||||
$type = strtolower((string) ($row['charge_type'] ?? 'add'));
|
||||
$amount = $type === 'deduct' ? -abs($amount) : abs($amount);
|
||||
$additionalSubtotal += $amount;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to sum additional charges.', ['error' => $e->getMessage()]);
|
||||
Log::warning('Failed to load additional charges.', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{charge_type?: string, amount?: float|int|string}> $rows
|
||||
*/
|
||||
private function sumAdditionalChargeRows(array $rows): float
|
||||
{
|
||||
$additionalSubtotal = 0.0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$amount = (float) ($row['amount'] ?? 0);
|
||||
$type = strtolower((string) ($row['charge_type'] ?? 'add'));
|
||||
|
||||
if (in_array($type, ['deduct', 'previous'], true)) {
|
||||
$amount = -abs($amount);
|
||||
} else {
|
||||
$amount = abs($amount);
|
||||
}
|
||||
|
||||
$additionalSubtotal += $amount;
|
||||
}
|
||||
|
||||
return round($additionalSubtotal, 2);
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\AdditionalCharge;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventCharges;
|
||||
use App\Services\ExtraCharges\ExtraChargesChargeService;
|
||||
use App\Services\ExtraCharges\ExtraChargesContextService;
|
||||
use App\Services\ExtraCharges\ExtraChargesInvoiceService;
|
||||
use App\Services\Invoices\InvoiceGenerationService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* ChargeService (plan §9–10, §13 Phase 3)
|
||||
*
|
||||
* Unified entry point for extra and event charges: create, cancel, apply,
|
||||
* mark paid/unpaid, list by family, and prevent duplicate charges.
|
||||
*/
|
||||
class ChargeService
|
||||
{
|
||||
public function __construct(
|
||||
private ExtraChargesChargeService $extraCharges,
|
||||
private ExtraChargesInvoiceService $invoices,
|
||||
private ExtraChargesContextService $context,
|
||||
private InvoiceGenerationService $invoiceGeneration
|
||||
) {
|
||||
}
|
||||
|
||||
public function createExtraCharge(array $payload): array
|
||||
{
|
||||
$parentId = (int) ($payload['parent_id'] ?? 0);
|
||||
$schoolYear = (string) ($payload['school_year'] ?? $this->context->getSchoolYear());
|
||||
$title = trim((string) ($payload['title'] ?? ''));
|
||||
$chargeType = (string) ($payload['charge_type'] ?? 'add');
|
||||
$amountAbs = round(abs((float) ($payload['amount'] ?? 0)), 2);
|
||||
|
||||
if ($parentId <= 0 || $title === '' || $amountAbs <= 0) {
|
||||
return ['ok' => false, 'message' => 'Invalid extra charge payload.'];
|
||||
}
|
||||
|
||||
if ($this->findDuplicateExtraCharge($parentId, $schoolYear, $title, $amountAbs, $chargeType)) {
|
||||
Log::warning('Duplicate extra charge blocked', [
|
||||
'parent_id' => $parentId,
|
||||
'title' => $title,
|
||||
'school_year' => $schoolYear,
|
||||
]);
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'A matching extra charge already exists for this parent.',
|
||||
'error' => 'duplicate_charge',
|
||||
];
|
||||
}
|
||||
|
||||
$result = $this->extraCharges->createCharge(array_merge($payload, [
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $payload['semester'] ?? $this->context->getSemester(),
|
||||
]));
|
||||
|
||||
if (!($result['ok'] ?? false)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'charge_type' => 'extra',
|
||||
'id' => (int) ($result['id'] ?? 0),
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $result['invoice_id'] ?? null,
|
||||
'status' => !empty($payload['invoice_id']) ? 'applied' : 'pending',
|
||||
];
|
||||
}
|
||||
|
||||
public function cancelExtraCharge(int $chargeId): array
|
||||
{
|
||||
$charge = AdditionalCharge::query()->find($chargeId);
|
||||
if (!$charge) {
|
||||
return ['ok' => false, 'message' => 'Extra charge not found.'];
|
||||
}
|
||||
|
||||
if ((string) $charge->status === 'void') {
|
||||
return ['ok' => false, 'message' => 'Charge is already void.', 'error' => 'already_canceled'];
|
||||
}
|
||||
|
||||
$ok = $this->extraCharges->voidCharge($charge);
|
||||
|
||||
return [
|
||||
'ok' => $ok,
|
||||
'charge_type' => 'extra',
|
||||
'id' => $chargeId,
|
||||
'status' => $ok ? 'void' : (string) $charge->status,
|
||||
'message' => $ok ? null : 'Unable to void extra charge.',
|
||||
];
|
||||
}
|
||||
|
||||
public function applyExtraCharge(int $chargeId, int $invoiceId): array
|
||||
{
|
||||
$charge = AdditionalCharge::query()->find($chargeId);
|
||||
if (!$charge) {
|
||||
return ['ok' => false, 'message' => 'Extra charge not found.'];
|
||||
}
|
||||
|
||||
if ((string) $charge->status === 'void') {
|
||||
return ['ok' => false, 'message' => 'Cannot apply a void charge.'];
|
||||
}
|
||||
|
||||
if ((string) $charge->status === 'applied' && (int) $charge->invoice_id === $invoiceId) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'charge_type' => 'extra',
|
||||
'id' => $chargeId,
|
||||
'status' => 'applied',
|
||||
'invoice_id' => $invoiceId,
|
||||
];
|
||||
}
|
||||
|
||||
if ((string) $charge->status === 'applied') {
|
||||
return ['ok' => false, 'message' => 'Charge is already applied to another invoice.'];
|
||||
}
|
||||
|
||||
$chargeType = (string) ($charge->charge_type ?? 'add');
|
||||
$amountAbs = round(abs((float) $charge->amount), 2);
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
$this->invoices->applyToInvoice($invoiceId, $chargeType, $amountAbs);
|
||||
$charge->invoice_id = $invoiceId;
|
||||
$charge->status = 'applied';
|
||||
$charge->save();
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
Log::error('Apply extra charge failed', ['charge_id' => $chargeId, 'error' => $e->getMessage()]);
|
||||
|
||||
return ['ok' => false, 'message' => 'Unable to apply charge to invoice.'];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'charge_type' => 'extra',
|
||||
'id' => $chargeId,
|
||||
'status' => 'applied',
|
||||
'invoice_id' => $invoiceId,
|
||||
];
|
||||
}
|
||||
|
||||
public function createEventCharge(array $payload): array
|
||||
{
|
||||
$parentId = (int) ($payload['parent_id'] ?? 0);
|
||||
$studentId = (int) ($payload['student_id'] ?? 0);
|
||||
$schoolYear = (string) ($payload['school_year'] ?? $this->context->getSchoolYear());
|
||||
$semester = (string) ($payload['semester'] ?? $this->context->getSemester());
|
||||
$eventId = isset($payload['event_id']) ? (int) $payload['event_id'] : null;
|
||||
$eventName = trim((string) ($payload['event_name'] ?? ''));
|
||||
$actorId = (int) ($payload['created_by'] ?? $payload['updated_by'] ?? 0);
|
||||
|
||||
if ($parentId <= 0 || $studentId <= 0) {
|
||||
return ['ok' => false, 'message' => 'Parent and student are required.'];
|
||||
}
|
||||
|
||||
$amount = isset($payload['amount']) ? (float) $payload['amount'] : null;
|
||||
if ($eventId > 0) {
|
||||
$event = Event::getEvent($eventId, $schoolYear);
|
||||
if (!$event) {
|
||||
return ['ok' => false, 'message' => 'Event not found.'];
|
||||
}
|
||||
$eventName = (string) ($event->event_name ?? $eventName);
|
||||
$amount = $amount ?? (float) ($event->amount ?? 0);
|
||||
}
|
||||
|
||||
if ($eventName === '' || $amount === null || $amount < 0) {
|
||||
return ['ok' => false, 'message' => 'Event name and amount are required.'];
|
||||
}
|
||||
|
||||
if ($this->findDuplicateEventCharge($parentId, $studentId, $schoolYear, $semester, $eventId, $eventName)) {
|
||||
Log::warning('Duplicate event charge blocked', [
|
||||
'parent_id' => $parentId,
|
||||
'student_id' => $studentId,
|
||||
'event_id' => $eventId,
|
||||
'event_name' => $eventName,
|
||||
]);
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'This student already has an active charge for this event.',
|
||||
'error' => 'duplicate_charge',
|
||||
];
|
||||
}
|
||||
|
||||
$charge = EventCharges::query()->create([
|
||||
'event_id' => $eventId > 0 ? $eventId : null,
|
||||
'event_name' => $eventId > 0 ? null : $eventName,
|
||||
'parent_id' => $parentId,
|
||||
'student_id' => $studentId,
|
||||
'participation' => 'yes',
|
||||
'charged' => round($amount, 2),
|
||||
'amount' => round($amount, 2),
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'created_by' => $actorId ?: null,
|
||||
'updated_by' => $actorId ?: null,
|
||||
]);
|
||||
|
||||
$this->invoiceGeneration->generateInvoice($parentId, $schoolYear);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'charge_type' => 'event',
|
||||
'id' => (int) $charge->id,
|
||||
'parent_id' => $parentId,
|
||||
'student_id' => $studentId,
|
||||
'event_id' => $eventId > 0 ? $eventId : null,
|
||||
'event_name' => $eventName,
|
||||
'amount' => round($amount, 2),
|
||||
'participation_status' => 'yes',
|
||||
'payment_status' => 'unpaid',
|
||||
'cancellation_status' => 'active',
|
||||
];
|
||||
}
|
||||
|
||||
public function cancelEventCharge(int $chargeId, bool $issueCredit = true): array
|
||||
{
|
||||
$charge = EventCharges::query()->find($chargeId);
|
||||
if (!$charge) {
|
||||
return ['ok' => false, 'message' => 'Event charge not found.'];
|
||||
}
|
||||
|
||||
if ($this->isEventChargeCanceled($charge)) {
|
||||
return ['ok' => false, 'message' => 'Event charge is already canceled.', 'error' => 'already_canceled'];
|
||||
}
|
||||
|
||||
$amount = round(abs((float) ($charge->charged ?? $charge->amount ?? 0)), 2);
|
||||
$eventName = $this->resolveEventName($charge);
|
||||
$parentId = (int) $charge->parent_id;
|
||||
$schoolYear = (string) $charge->school_year;
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
$charge->participation = 'no';
|
||||
$charge->charged = 0;
|
||||
if (Schema::hasColumn('event_charges', 'amount')) {
|
||||
$charge->amount = 0;
|
||||
}
|
||||
$charge->save();
|
||||
|
||||
$creditId = null;
|
||||
if ($issueCredit && $amount > 0 && $parentId > 0) {
|
||||
$credit = $this->createExtraCharge([
|
||||
'parent_id' => $parentId,
|
||||
'title' => 'Event credit: ' . $eventName,
|
||||
'description' => 'Account credit for canceled event charge #' . $chargeId,
|
||||
'amount' => $amount,
|
||||
'charge_type' => 'deduct',
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => (string) ($charge->semester ?? $this->context->getSemester()),
|
||||
'created_by' => (int) ($charge->updated_by ?? 0),
|
||||
]);
|
||||
if ($credit['ok'] ?? false) {
|
||||
$creditId = (int) ($credit['id'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$this->invoiceGeneration->generateInvoice($parentId, $schoolYear);
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
Log::error('Cancel event charge failed', ['charge_id' => $chargeId, 'error' => $e->getMessage()]);
|
||||
|
||||
return ['ok' => false, 'message' => 'Unable to cancel event charge.'];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'charge_type' => 'event',
|
||||
'id' => $chargeId,
|
||||
'cancellation_status' => 'canceled',
|
||||
'participation_status' => 'no',
|
||||
'account_credit_charge_id' => $creditId,
|
||||
];
|
||||
}
|
||||
|
||||
public function markEventChargePaid(int $chargeId, bool $paid, ?int $paymentId = null): array
|
||||
{
|
||||
$charge = EventCharges::query()->find($chargeId);
|
||||
if (!$charge) {
|
||||
return ['ok' => false, 'message' => 'Event charge not found.'];
|
||||
}
|
||||
|
||||
if ($this->isEventChargeCanceled($charge)) {
|
||||
return ['ok' => false, 'message' => 'Cannot update payment on a canceled charge.'];
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('event_charges', 'event_paid')) {
|
||||
$charge->event_paid = $paid ? 1 : 0;
|
||||
}
|
||||
if (Schema::hasColumn('event_charges', 'event_payment_id') && $paymentId !== null) {
|
||||
$charge->event_payment_id = $paymentId;
|
||||
}
|
||||
$charge->save();
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'charge_type' => 'event',
|
||||
'id' => $chargeId,
|
||||
'payment_status' => $paid ? 'paid' : 'unpaid',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{extra_charges: array<int, array<string, mixed>>, event_charges: array<int, array<string, mixed>>}
|
||||
*/
|
||||
public function listForParent(int $parentId, ?string $schoolYear = null): array
|
||||
{
|
||||
$schoolYear = $schoolYear ?? $this->context->getSchoolYear();
|
||||
|
||||
$extraRows = AdditionalCharge::query()
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
|
||||
$eventRows = EventCharges::getChargesWithEventInfo($parentId, $schoolYear);
|
||||
|
||||
return [
|
||||
'parent_id' => $parentId,
|
||||
'school_year' => $schoolYear,
|
||||
'extra_charges' => $extraRows->map(fn (AdditionalCharge $row) => $this->formatExtraCharge($row))->all(),
|
||||
'event_charges' => array_map(fn (array $row) => $this->formatEventCharge($row), $eventRows),
|
||||
];
|
||||
}
|
||||
|
||||
private function findDuplicateExtraCharge(
|
||||
int $parentId,
|
||||
string $schoolYear,
|
||||
string $title,
|
||||
float $amountAbs,
|
||||
string $chargeType
|
||||
): bool {
|
||||
$normalizedTitle = strtolower($title);
|
||||
$signedAmount = $chargeType === 'deduct' ? -$amountAbs : $amountAbs;
|
||||
|
||||
return AdditionalCharge::query()
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('status', ['pending', 'applied'])
|
||||
->whereRaw('LOWER(title) = ?', [$normalizedTitle])
|
||||
->where('amount', $signedAmount)
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function findDuplicateEventCharge(
|
||||
int $parentId,
|
||||
int $studentId,
|
||||
string $schoolYear,
|
||||
string $semester,
|
||||
?int $eventId,
|
||||
string $eventName
|
||||
): bool {
|
||||
$query = EventCharges::query()
|
||||
->where('parent_id', $parentId)
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->where('participation', 'yes')
|
||||
->where('charged', '>', 0);
|
||||
|
||||
if ($eventId > 0) {
|
||||
$query->where('event_id', $eventId);
|
||||
} else {
|
||||
$query->where('event_name', $eventName);
|
||||
}
|
||||
|
||||
return $query->exists();
|
||||
}
|
||||
|
||||
private function isEventChargeCanceled(EventCharges $charge): bool
|
||||
{
|
||||
if (strtolower((string) $charge->participation) === 'no') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (float) ($charge->charged ?? 0) <= 0;
|
||||
}
|
||||
|
||||
private function resolveEventName(EventCharges $charge): string
|
||||
{
|
||||
if (!empty($charge->event_name)) {
|
||||
return (string) $charge->event_name;
|
||||
}
|
||||
|
||||
if ($charge->event_id) {
|
||||
$event = Event::query()->find($charge->event_id);
|
||||
if ($event) {
|
||||
return (string) $event->event_name;
|
||||
}
|
||||
}
|
||||
|
||||
return 'Event';
|
||||
}
|
||||
|
||||
private function formatExtraCharge(AdditionalCharge $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $row->id,
|
||||
'charge_type' => 'extra',
|
||||
'parent_id' => (int) $row->parent_id,
|
||||
'invoice_id' => $row->invoice_id ? (int) $row->invoice_id : null,
|
||||
'school_year' => $row->school_year,
|
||||
'semester' => $row->semester,
|
||||
'charge_name' => $row->title,
|
||||
'charge_description' => $row->description,
|
||||
'amount' => round(abs((float) $row->amount), 2),
|
||||
'signed_amount' => round((float) $row->amount, 2),
|
||||
'fee_type' => $row->charge_type,
|
||||
'due_date' => $row->due_date,
|
||||
'status' => $row->status,
|
||||
'approval_status' => $row->status,
|
||||
'created_by' => $row->created_by,
|
||||
'created_at' => $row->created_at,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function formatEventCharge(array $row): array
|
||||
{
|
||||
$charged = (float) ($row['charged'] ?? 0);
|
||||
$canceled = strtolower((string) ($row['participation'] ?? '')) === 'no' || $charged <= 0;
|
||||
$paid = false;
|
||||
if (isset($row['event_paid'])) {
|
||||
$paid = (bool) $row['event_paid'];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'charge_type' => 'event',
|
||||
'event_id' => isset($row['event_id']) ? (int) $row['event_id'] : null,
|
||||
'event_name' => $row['event_name'] ?? null,
|
||||
'parent_id' => isset($row['parent_id']) ? (int) $row['parent_id'] : null,
|
||||
'student_id' => isset($row['student_id']) ? (int) $row['student_id'] : null,
|
||||
'school_year' => $row['school_year'] ?? null,
|
||||
'semester' => $row['semester'] ?? null,
|
||||
'participation_status' => $row['participation'] ?? null,
|
||||
'charge_amount' => $charged,
|
||||
'payment_status' => $paid ? 'paid' : ($canceled ? 'canceled' : 'unpaid'),
|
||||
'cancellation_status' => $canceled ? 'canceled' : 'active',
|
||||
'event_date' => $row['updated_at'] ?? $row['created_at'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user