Files
alrahma_sunday_school_api/app/Http/Controllers/Api/DiscountController.php
T
2026-03-05 12:29:37 -05:00

648 lines
24 KiB
PHP
Executable File

<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\DiscountUsage;
use App\Models\DiscountVoucher;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\Response;
class DiscountController extends BaseApiController
{
protected DiscountVoucher $discount;
protected DiscountUsage $discountUsage;
protected Invoice $invoice;
protected User $user;
protected Payment $payment;
protected Configuration $config;
protected string $schoolYear;
protected ?string $semester;
public function __construct()
{
parent::__construct();
$this->discount = model(DiscountVoucher::class);
$this->discountUsage = model(DiscountUsage::class);
$this->invoice = model(Invoice::class);
$this->user = model(User::class);
$this->payment = model(Payment::class);
$this->config = model(Configuration::class);
$this->schoolYear = $this->config->getConfig('school_year') ?? '';
$sem = $this->config->getConfig('semester');
$this->semester = is_string($sem) && $sem !== '' ? $sem : null;
}
public function index()
{
$activeOnly = $this->request->getGet('active') === 'true';
$query = $this->discount->newQuery();
if ($activeOnly) {
$today = now()->toDateString();
$query->where('is_active', 1)
->where(function ($q) use ($today) {
$q->whereNull('valid_from')->orWhere('valid_from', '<=', $today);
})
->where(function ($q) use ($today) {
$q->whereNull('valid_until')->orWhere('valid_until', '>=', $today);
});
}
$discounts = $query->orderBy('created_at', 'DESC')->get()->toArray();
return $this->success($discounts, 'Discount vouchers retrieved successfully');
}
public function getByCode($code = null)
{
$discount = $this->findActiveDiscount($code);
if (!$discount) {
return $this->respondError('Discount voucher not found or expired', Response::HTTP_NOT_FOUND);
}
if ($this->isUsageLimitReached($discount)) {
return $this->respondError('Discount voucher has reached maximum usage limit', Response::HTTP_BAD_REQUEST);
}
return $this->success($discount->toArray(), 'Discount voucher retrieved successfully');
}
public function apply()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Unauthorized', Response::HTTP_UNAUTHORIZED);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'code' => 'required',
'invoice_id' => 'required|integer',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$code = strtoupper(trim((string) ($data['code'] ?? '')));
$discount = $this->findActiveDiscount($code);
if (!$discount) {
return $this->respondError('Invalid or expired discount voucher', Response::HTTP_BAD_REQUEST);
}
if ($this->isUsageLimitReached($discount)) {
return $this->respondError('Discount voucher has reached maximum usage limit', Response::HTTP_BAD_REQUEST);
}
$exists = $this->discountUsage->newQuery()
->where('voucher_id', $discount->id)
->where('invoice_id', $data['invoice_id'])
->exists();
if ($exists) {
return $this->respondError('Discount voucher already applied to this invoice', Response::HTTP_BAD_REQUEST);
}
try {
$usage = $this->discountUsage->create([
'voucher_id' => $discount->id,
'invoice_id' => $data['invoice_id'],
'used_by' => $user->id,
'used_at' => now()->toDateTimeString(),
]);
$discount->increment('times_used');
return $this->success([
'discount' => $discount->fresh()->toArray(),
'applied' => true,
], 'Discount voucher applied successfully');
} catch (\Throwable $e) {
log_message('error', 'Discount apply error: ' . $e->getMessage());
return $this->respondError('Failed to apply discount voucher', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function parents()
{
$parents = $this->user->getParents();
$result = [];
foreach ($parents as $parent) {
$discount = DB::table('discount_usages as du')
->selectRaw('COALESCE(SUM(du.discount_amount),0) AS total_discount')
->join('invoices as i', 'i.id', '=', 'du.invoice_id')
->where('i.parent_id', $parent['id'])
->where('i.school_year', $this->schoolYear)
->first();
$totalDiscount = (float) ($discount->total_discount ?? 0);
$result[] = [
'id' => $parent['id'],
'firstname' => $parent['firstname'],
'lastname' => $parent['lastname'],
'email' => $parent['email'],
'school_id' => $parent['school_id'] ?? null,
'total_discount' => $totalDiscount,
'has_discount' => $totalDiscount > 0 ? 1 : 0,
];
}
return $this->success($result, 'Parent discounts retrieved');
}
public function applyVoucher()
{
$data = $this->payloadData();
$rules = [
'voucher_id' => 'required|integer',
'parent_ids' => 'required',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$voucherId = (int) $data['voucher_id'];
$parentIds = $this->normalizeParentIds($data['parent_ids']);
if (empty($parentIds)) {
return $this->respondError('At least one parent is required', Response::HTTP_BAD_REQUEST);
}
$voucher = $this->discount->find($voucherId);
if (!$voucher) {
return $this->respondError('Voucher not found', Response::HTTP_NOT_FOUND);
}
$remainingUses = max(0, (int) ($voucher['max_uses'] ?? 1) - (int) ($voucher['times_used'] ?? 0));
if ($remainingUses <= 0) {
return $this->respondError('Voucher has reached its maximum allowed uses', Response::HTTP_BAD_REQUEST);
}
$description = $this->normalizeDescription($voucher['description'] ?? '');
$now = utc_now();
$fullyCoveredInvoiceIds = [];
$appliedInvoices = [];
$errors = [];
$usedCount = 0;
DB::beginTransaction();
try {
foreach ($parentIds as $parentId) {
$invoices = $this->invoice->getInvoicesByUserId($parentId, $this->schoolYear);
if (empty($invoices)) {
$errors[] = "Parent {$parentId} has no invoices for {$this->schoolYear}.";
continue;
}
foreach ($invoices as $invoice) {
if ($invoice['balance'] <= 0) {
continue;
}
if ($remainingUses <= 0) {
break 2;
}
$initialPreBalance = $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
$already = $this->discountUsage->newQuery()
->where('voucher_id', $voucherId)
->where('invoice_id', $invoice['id'])
->exists();
if ($already) {
continue;
}
$rawDiscount = ($voucher['discount_type'] === 'percent')
? round(((float) $invoice['total_amount'] * (float) $voucher['discount_value']) / 100, 2)
: (float) $voucher['discount_value'];
$discountAmount = min($rawDiscount, $initialPreBalance);
if ($discountAmount <= 0) {
continue;
}
$this->db->table('discount_usages')->insert([
'voucher_id' => $voucherId,
'invoice_id' => $invoice['id'],
'parent_id' => $parentId,
'discount_amount' => $discountAmount,
'description' => $description,
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'updated_by' => $this->getCurrentUserId() ?? 0,
'used_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
$newBalance = max(0.0, round(((float) $invoice['balance']) - $discountAmount, 2));
$this->invoice->update($invoice['id'], [
'balance' => $newBalance,
'has_discount' => 1,
'updated_at' => $now,
]);
$postBalance = max(0.0, round($initialPreBalance - $discountAmount, 2));
$currentBalance = $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
$this->discount->newQuery()
->where('id', $voucherId)
->set('times_used', 'times_used + 1', false)
->update();
[$eventData, $studentData] = $this->buildPaymentEventData(
$invoice['id'],
(string) $voucherId,
$discountAmount,
'discount',
$now,
'',
0,
$initialPreBalance,
$postBalance
);
event('paymentReceived', [$eventData, $studentData]);
$epsilon = 0.01;
$fullyCovered = (abs($discountAmount - $initialPreBalance) <= $epsilon)
|| ($postBalance <= $epsilon)
|| ($currentBalance <= $epsilon);
if ($fullyCovered) {
$fullyCoveredInvoiceIds[] = (int) $invoice['id'];
}
$appliedInvoices[] = (int) $invoice['id'];
$remainingUses--;
$usedCount++;
if ($remainingUses <= 0) {
$this->discount->update($voucherId, [
'is_active' => 0,
'updated_at' => $now,
]);
break 2;
}
}
}
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
log_message('error', 'Voucher application failed: ' . $e->getMessage());
return $this->respondError('Voucher application failed. Transaction rolled back.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
$enrollmentUpdates = 0;
foreach (array_unique($fullyCoveredInvoiceIds) as $invoiceId) {
try {
$enrollmentUpdates += $this->updateEnrollmentStatusIfPaid($invoiceId);
} catch (\Throwable $e) {
log_message('error', 'updateEnrollmentStatusIfPaid failed for invoice {iid}: {err}', [
'iid' => $invoiceId,
'err' => $e->getMessage(),
]);
}
}
return $this->success([
'applied_invoices' => array_values(array_unique($appliedInvoices)),
'errors' => $errors,
'enrollments' => $enrollmentUpdates,
'used_count' => $usedCount,
], 'Voucher applied successfully');
}
public function createVoucher()
{
$data = $this->payloadData();
$rules = [
'code' => 'required|string',
'discount_type' => 'required|in:percent,fixed',
'discount_value' => 'required|numeric',
];
$errors = $this->validateRequest($data, $rules);
if (!empty($errors)) {
return $this->respondValidationError($errors);
}
$code = strtoupper(preg_replace('/[^A-Z0-9\-]/i', '', trim((string) $data['code'])));
if ($code === '') {
return $this->respondError('Voucher code is required', Response::HTTP_BAD_REQUEST);
}
$payload = [
'code' => $code,
'discount_type' => strtolower((string) $data['discount_type']),
'discount_value' => (float) $data['discount_value'],
'max_uses' => isset($data['max_uses']) && $data['max_uses'] !== '' ? (int) $data['max_uses'] : null,
'valid_from' => $this->normalizeDate($data['valid_from'] ?? null),
'valid_until' => $this->normalizeDate($data['valid_until'] ?? null),
'is_active' => isset($data['is_active']) ? (bool) $data['is_active'] : true,
'description' => trim((string) ($data['description'] ?? '')),
];
if ($payload['valid_from'] && $payload['valid_until'] && $payload['valid_from'] > $payload['valid_until']) {
return $this->respondError('Valid until must be the same as or after valid from', Response::HTTP_BAD_REQUEST);
}
try {
$voucher = $this->discount->create($payload);
} catch (\Throwable $e) {
log_message('error', 'Voucher creation failed: ' . $e->getMessage());
return $this->respondError('Failed to save voucher: ' . $e->getMessage());
}
return $this->respondCreated($voucher->toArray(), 'Voucher created successfully');
}
public function editVoucher($id)
{
$voucher = $this->discount->find($id);
if (!$voucher) {
return $this->respondError('Voucher not found', Response::HTTP_NOT_FOUND);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$update = [];
foreach (['code', 'discount_type', 'discount_value', 'max_uses', 'valid_from', 'valid_until', 'is_active', 'description'] as $field) {
if (array_key_exists($field, $data)) {
$value = $data[$field];
if (in_array($field, ['valid_from', 'valid_until'], true)) {
$value = $this->normalizeDate($value);
}
$update[$field] = $value;
}
}
if (!empty($update)) {
$this->discount->update($id, $update);
}
return $this->success($this->discount->find($id), 'Voucher updated successfully');
}
protected function findActiveDiscount(?string $code)
{
if (empty($code)) {
return null;
}
$today = now()->toDateString();
return $this->discount->newQuery()
->where('code', $code)
->where('is_active', 1)
->where(function ($q) use ($today) {
$q->whereNull('valid_from')->orWhere('valid_from', '<=', $today);
})
->where(function ($q) use ($today) {
$q->whereNull('valid_until')->orWhere('valid_until', '>=', $today);
})
->first();
}
protected function isUsageLimitReached($discount): bool
{
if (!$discount->max_uses) {
return false;
}
return isset($discount->times_used) && $discount->times_used >= $discount->max_uses;
}
private function normalizeParentIds($input): array
{
$values = [];
if (is_string($input)) {
$decoded = json_decode($input, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
$values = $decoded;
} else {
$values = [$input];
}
} elseif (is_array($input)) {
$values = $input;
}
return array_values(array_filter(array_map('intval', $values), fn($id) => $id > 0));
}
private function normalizeDescription(?string $description): string
{
if ($description === null) {
return '';
}
$description = trim($description);
return preg_replace(
'/^Auto-generated on \d{4}-\d{2}-\d{2}\s*[—-]\s*reason:\s*/i',
'',
$description
);
}
private function normalizeDate($value): ?string
{
if ($value === null || $value === '') {
return null;
}
$date = preg_replace('/[^0-9\-]/', '', (string) $value);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) ? $date : null;
}
private function getCurrentInvoiceBalance($invoiceId, $schoolYear): float
{
$invoice = $this->invoice->find($invoiceId);
if (!$invoice) {
return 0.0;
}
$qb = $this->payment
->select('COALESCE(SUM(paid_amount),0) AS total_paid')
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear);
$table = $this->payment->table;
if ($this->db->getSchemaBuilder()->hasColumn($table, 'status')) {
$qb->where(function ($q) {
$q->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'])
->orWhereNull('status');
});
}
if ($this->db->getSchemaBuilder()->hasColumn($table, 'is_void')) {
$qb->where(function ($q) {
$q->where('is_void', 0)
->orWhereNull('is_void');
});
}
$rowPaid = $qb->first();
$totalPaid = (float) ($rowPaid['total_paid'] ?? 0);
$rowDisc = DB::table('discount_usages as du')
->selectRaw('COALESCE(SUM(du.discount_amount),0) AS total_disc')
->join('invoices as i', 'i.id', '=', 'du.invoice_id')
->where('du.invoice_id', $invoiceId)
->where('i.school_year', $schoolYear)
->first();
$totalDisc = (float) ($rowDisc->total_disc ?? 0);
$rowRefund = DB::table('refunds')
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear)
->whereIn('status', ['Partial', 'Paid'])
->first();
$totalRefundPaid = (float) ($rowRefund->total_refund_paid ?? 0);
$total = (float) ($invoice['total_amount'] ?? 0);
return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2));
}
private function updateEnrollmentStatusIfPaid(int $invoiceId): int
{
$invoice = $this->invoice->find($invoiceId);
if (!$invoice) {
log_message('warning', 'Invoice not found: {id}', ['id' => $invoiceId]);
return 0;
}
$total = (float) ($invoice['total_amount'] ?? 0);
$balance = (float) ($invoice['balance'] ?? 0);
if (!($total > 0 && $balance < $total)) {
log_message('info', 'No payment yet. Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]);
return 0;
}
$parentId = (int) ($invoice['parent_id'] ?? 0);
if ($parentId <= 0 || $this->schoolYear === '') {
log_message('warning', 'Missing parent_id/school_year for invoice #{id}', ['id' => $invoiceId]);
return 0;
}
$paidEnrollmentIds = [];
try {
$rows = DB::table('invoice_items')
->select('enrollment_id')
->where('invoice_id', $invoiceId)
->whereNotNull('enrollment_id')
->get()
->pluck('enrollment_id')
->map('intval')
->filter(fn($eid) => $eid > 0)
->unique()
->values()
->toArray();
$paidEnrollmentIds = $rows;
} catch (\Throwable $e) {
// ignore
}
$builder = DB::table('enrollments')
->where('parent_id', $parentId)
->where('school_year', $this->schoolYear)
->where(function ($query) {
$query->where('enrollment_status', 'payment pending')
->orWhere('enrollment_status', 'Payment pending')
->orWhere('enrollment_status', 'Payment Pending')
->orWhere('enrollment_status', 'PAYMENT PENDING')
->orWhereRaw("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'");
});
if ($this->semester !== null) {
$builder->where('semester', $this->semester);
}
if (!empty($paidEnrollmentIds)) {
$builder->whereIn('id', $paidEnrollmentIds);
}
$toUpdateIds = $builder->pluck('id')->map('intval')->toArray();
if (empty($toUpdateIds)) {
return 0;
}
$updated = DB::table('enrollments')
->whereIn('id', $toUpdateIds)
->update([
'enrollment_status' => 'enrolled',
'enrollment_date' => local_date(utc_now(), 'Y-m-d'),
]);
return $updated;
}
private function buildPaymentEventData(
int $invoiceId,
string $transactionId,
float $amount,
string $paymentMethod,
string $paymentDate,
?string $checkNumber,
int $installmentSeq,
float $preBalance,
float $postBalance
): array {
$invoice = $this->invoice->find($invoiceId);
if (!$invoice) {
throw new \RuntimeException("Invoice {$invoiceId} not found");
}
$parentRow = DB::table('users')
->select('id', 'firstname', 'lastname', 'email')
->where('id', $invoice['parent_id'])
->first();
if (!$parentRow) {
throw new \RuntimeException("Parent not found for invoice {$invoiceId}");
}
$studentRows = DB::table('students as s')
->select('s.id', 's.firstname', 's.lastname', 'sc.class_section_id')
->join('student_class as sc', 'sc.student_id', '=', 's.id')
->where('s.parent_id', $invoice['parent_id'])
->where('sc.school_year', $this->schoolYear)
->when($this->semester !== null, fn($q) => $q->where('sc.semester', $this->semester))
->get()
->toArray();
$data = [
'user_id' => (int) $parentRow->id,
'email' => $parentRow->email,
'firstname' => $parentRow->firstname,
'lastname' => $parentRow->lastname,
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'invoice_id' => $invoiceId,
'transaction_id' => $transactionId,
'payment_date' => $paymentDate,
'amount' => $amount,
'method' => $paymentMethod,
'check_number' => $checkNumber,
'installment_seq' => $installmentSeq,
'invoice_total' => (float) $invoice['total_amount'],
'pre_balance' => $preBalance,
'post_balance' => $postBalance,
'portalLink' => URL::to('/parent/invoices/' . $invoiceId),
];
return [$data, $studentRows];
}
}