context = $context; $this->invoiceService = $invoiceService; } public function applyVoucher(int $voucherId, array $parentIds, bool $allowAdditional, int $actorId): array { $schoolYear = $this->context->getSchoolYear(); $semester = $this->context->getSemester(); $voucher = DiscountVoucher::query()->find($voucherId); if (!$voucher) { return ['ok' => false, 'message' => 'Voucher not found.']; } $voucherDescription = trim((string) ($voucher->description ?? '')); $voucherDescription = preg_replace( '/^Auto-generated on \\d{4}-\\d{2}-\\d{2}\\s*[—-]\\s*reason:\\s*/i', '', $voucherDescription ); $maxUsesRaw = $voucher->max_uses; $maxUses = ($maxUsesRaw === null || $maxUsesRaw === '') ? null : (int) $maxUsesRaw; $timesUsed = (int) ($voucher->times_used ?? 0); $remainingUses = ($maxUses === null) ? PHP_INT_MAX : ($maxUses - $timesUsed); if ($remainingUses <= 0) { return ['ok' => false, 'message' => 'This voucher has reached its maximum allowed uses.']; } if (!$allowAdditional) { $rows = DB::table('discount_usages as du') ->select('i.parent_id') ->join('invoices as i', 'i.id', '=', 'du.invoice_id') ->whereIn('i.parent_id', $parentIds) ->where('i.school_year', $schoolYear) ->groupBy('i.parent_id') ->get() ->map(fn ($row) => (array) $row) ->all(); $blockedParentIds = array_map(static fn ($r) => (int) ($r['parent_id'] ?? 0), $rows); if (!empty($blockedParentIds)) { $parentIds = array_values(array_diff(array_map('intval', $parentIds), $blockedParentIds)); } if (empty($parentIds)) { return [ 'ok' => false, 'message' => 'All selected parents already have discounts. Enable "Allow additional discounts" to apply another.', ]; } } $paymentDate = function_exists('utc_now') ? utc_now() : now('UTC')->toDateTimeString(); $fullyCoveredInvoiceIds = []; $touchedInvoiceIds = []; $appliedCount = 0; DB::beginTransaction(); try { foreach ($parentIds as $parentId) { $invoices = Invoice::getInvoicesByUserId((int) $parentId, $schoolYear); if (empty($invoices)) { Log::warning('Discount apply: parent has no invoices', [ 'parent_id' => (int) $parentId, 'voucher_id' => $voucherId, ]); continue; } foreach ($invoices as $invoice) { if ($remainingUses <= 0) { break 2; } $invoiceId = (int) ($invoice['id'] ?? 0); if ($invoiceId <= 0) { continue; } $initialPreBalance = (float) $this->invoiceService->getCurrentInvoiceBalance($invoiceId, $schoolYear); if ($initialPreBalance <= 0) { continue; } if (!$allowAdditional) { $exists = DB::table('discount_usages as du') ->join('invoices as i', 'du.invoice_id', '=', 'i.id') ->where('du.voucher_id', $voucherId) ->where('du.invoice_id', $invoiceId) ->where('i.school_year', $schoolYear) ->count(); if ($exists) { continue; } } $rawDiscount = $voucher->discount_type === 'percent' ? round(((float) ($invoice['total_amount'] ?? 0) * (float) $voucher->discount_value) / 100, 2) : (float) $voucher->discount_value; $discount = min($rawDiscount, $initialPreBalance); if ($discount <= 0) { continue; } $now = function_exists('utc_now') ? utc_now() : now('UTC')->toDateTimeString(); DB::table('discount_usages')->insert([ 'voucher_id' => $voucherId, 'invoice_id' => $invoiceId, 'parent_id' => (int) $parentId, 'discount_amount' => $discount, 'description' => $voucherDescription, 'school_year' => $schoolYear, 'updated_by' => $actorId, 'used_at' => $now, 'created_at' => $now, 'updated_at' => $now, 'semester' => $semester, ]); $newBalance = max(0.0, round($initialPreBalance - $discount, 2)); DB::table('invoices') ->where('id', $invoiceId) ->update([ 'balance' => $newBalance, 'has_discount' => 1, 'updated_at' => $now, ]); $postBalance = round($initialPreBalance - $discount, 2); if ($postBalance < 0) { $postBalance = 0.0; } $currentBalance = (float) $this->invoiceService->getCurrentInvoiceBalance($invoiceId, $schoolYear); DB::table('discount_vouchers') ->where('id', $voucherId) ->update([ 'times_used' => DB::raw('COALESCE(times_used,0) + 1'), ]); $this->triggerPaymentReceived( $invoiceId, (string) $voucherId, $discount, $paymentDate, $initialPreBalance, $postBalance, $schoolYear, $semester ); $touchedInvoiceIds[] = $invoiceId; $appliedCount++; $epsilon = 0.01; $fullyCovered = (abs($discount - $initialPreBalance) <= $epsilon) || ($postBalance <= $epsilon) || ($currentBalance <= $epsilon); if ($fullyCovered) { $fullyCoveredInvoiceIds[] = $invoiceId; } $remainingUses--; if ($remainingUses <= 0 && $maxUses !== null) { DB::table('discount_vouchers') ->where('id', $voucherId) ->update([ 'is_active' => 0, 'updated_at' => $now, ]); break 2; } } } DB::commit(); } catch (\Throwable $e) { DB::rollBack(); return ['ok' => false, 'message' => 'Voucher application failed. Transaction rolled back.']; } if ($appliedCount === 0) { return [ 'ok' => false, 'message' => 'No discounts were applied. The voucher may already be used for these invoices or balances are zero.', ]; } if ($maxUses !== null) { $row = (array) DB::table('discount_vouchers') ->select('times_used') ->where('id', $voucherId) ->first(); $usedNow = (int) ($row['times_used'] ?? 0); if ($usedNow >= $maxUses) { DB::table('discount_vouchers') ->where('id', $voucherId) ->update([ 'is_active' => 0, 'updated_at' => function_exists('utc_now') ? utc_now() : now('UTC')->toDateTimeString(), ]); } } foreach (array_unique($touchedInvoiceIds) as $iid) { try { $this->invoiceService->recalculateInvoice($iid, $schoolYear); } catch (\Throwable $e) { Log::error('recalculateInvoice failed for invoice {id}: {err}', [ 'id' => $iid, 'err' => $e->getMessage(), ]); } } $updatedTotal = 0; foreach (array_unique($fullyCoveredInvoiceIds) as $iid) { try { $updatedTotal += $this->invoiceService->updateEnrollmentStatusIfPaid($iid, $schoolYear); } catch (\Throwable $e) { Log::error('updateEnrollmentStatusIfPaid failed for invoice {id}: {err}', [ 'id' => $iid, 'err' => $e->getMessage(), ]); } } return [ 'ok' => true, 'message' => $updatedTotal > 0 ? 'Voucher applied successfully and enrollments updated.' : 'Voucher applied successfully.', 'appliedCount' => $appliedCount, 'updatedEnrollments' => $updatedTotal, 'touchedInvoices' => array_values(array_unique($touchedInvoiceIds)), ]; } private function triggerPaymentReceived( int $invoiceId, string $voucherId, float $amount, string $paymentDate, float $preBalance, float $postBalance, string $schoolYear, string $semester ): void { try { [$eventData, $studentData] = $this->invoiceService->buildPaymentEventData( $invoiceId, $voucherId, $amount, 'discount', $paymentDate, '', 0, $preBalance, $postBalance, $schoolYear, $semester ); if (function_exists('event')) { event('paymentReceived', [$eventData, $studentData]); } } catch (\Throwable $e) { Log::warning('paymentReceived hook failed: ' . $e->getMessage()); } } }