Files
alrahma_sunday_school_api/app/Services/Billing/ChargeService.php
T
2026-06-08 23:45:55 -04:00

457 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 §910, §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,
];
}
}