{"path":"/public_html/app/Core/RentalService.php","name":"RentalService.php","size":8756,"extension":".php","modified":"2026-09-05T03:49:32.346618981Z","mode":420,"isDir":false,"isSymlink":false,"type":"text","content":"\u003c?php\n/**\n * PHASE 2 - RENTAL FOUNDATION\n * Transactional core for creating and cancelling Rentals. Deliberately\n * does NOT reserve equipment, create a Check-Out, or start billing --\n * those remain entirely separate systems (Reservation / Check-Out /\n * future Payments), per the approved Phase 2 scope. A Rental created\n * here always starts as 'draft' because nothing in Phase 2 can move\n * equipment yet (see createDirect()/createFromRentalRequest()).\n */\n\nfinal class RentalService\n{\n /**\n * Admin creates a Rental directly, selecting an existing Customer\n * (customerId) or supplying newCustomerData to create one first via a\n * single deliberate flow (never automatic deduplication). Snapshots\n * the resolved Customer's identity fields onto the Rental at creation\n * time -- a later edit to the Customer profile never rewrites this\n * snapshot (Section 8).\n *\n * @throws InvalidArgumentException on validation failure (caller shows the message)\n */\n public static function createDirect(\n ?int $customerId,\n ?array $newCustomerData,\n int $createdBy,\n ?string $notificationEmail,\n bool $emailNotificationsEnabled,\n string $rentalCurrency\n ): array {\n $pdo = db();\n $pdo-\u003ebeginTransaction();\n try {\n $customer = self::resolveCustomer($customerId, $newCustomerData, $createdBy);\n\n $businessNumber = RentalNumberService::nextRentalNumber();\n\n $rentalId = Rental::create([\n 'business_number' =\u003e $businessNumber,\n 'customer_id' =\u003e (int) $customer['id'],\n 'source_rental_request_id' =\u003e null,\n 'operational_status' =\u003e 'draft',\n 'notification_email' =\u003e $notificationEmail ?: null,\n 'email_notifications_enabled' =\u003e $emailNotificationsEnabled,\n 'rental_currency' =\u003e $rentalCurrency ?: Rental::DEFAULT_CURRENCY,\n 'snapshot_customer_type' =\u003e $customer['customer_type'],\n 'snapshot_full_name' =\u003e $customer['full_name'],\n 'snapshot_company_name' =\u003e $customer['company_name'],\n 'snapshot_contact_person' =\u003e $customer['contact_person'],\n 'snapshot_phone' =\u003e $customer['phone'],\n 'snapshot_email' =\u003e $customer['email'],\n 'snapshot_address' =\u003e $customer['address'],\n 'created_by' =\u003e $createdBy,\n ]);\n\n $pdo-\u003ecommit();\n\n inventory_log_change('rental', $rentalId, 'created', null, null, $businessNumber, null, $createdBy);\n\n return Rental::find($rentalId);\n } catch (Throwable $e) {\n $pdo-\u003erollBack();\n throw $e;\n }\n }\n\n /**\n * Creates a Rental from an existing Rental Request WITHOUT reserving\n * equipment, starting billing, or creating a Check-Out/Reservation\n * (Section 9). Preserves the Request's own data untouched and links\n * the two records both ways (rentals.source_rental_request_id and\n * rental_requests.rental_id).\n */\n public static function createFromRentalRequest(int $rentalRequestId, ?int $customerId, ?array $newCustomerData, int $createdBy): array\n {\n $request = RentalRequest::find($rentalRequestId);\n if (!$request) {\n throw new InvalidArgumentException('Rental Request not found.');\n }\n if (!empty($request['rental_id'])) {\n throw new InvalidArgumentException('This Rental Request is already linked to a Rental.');\n }\n\n $pdo = db();\n $pdo-\u003ebeginTransaction();\n try {\n // No customer selected/created explicitly -- fall back to a\n // deliberate (not automatic-dedup) Customer built from the\n // Request's own flat fields, so a Rental can never exist\n // without SOME customer identity attached.\n if ($customerId === null \u0026\u0026 $newCustomerData === null) {\n $newCustomerData = [\n 'customer_type' =\u003e !empty($request['company_name']) ? 'company' : 'individual',\n 'full_name' =\u003e $request['customer_name'] ?? null,\n 'company_name' =\u003e $request['company_name'] ?? null,\n 'contact_person' =\u003e $request['customer_name'] ?? null,\n 'phone' =\u003e $request['phone'] ?: ($request['whatsapp'] ?? ''),\n 'email' =\u003e $request['email'] ?? null,\n ];\n }\n $customer = self::resolveCustomer($customerId, $newCustomerData, $createdBy);\n\n $businessNumber = RentalNumberService::nextRentalNumber();\n\n $rentalId = Rental::create([\n 'business_number' =\u003e $businessNumber,\n 'customer_id' =\u003e (int) $customer['id'],\n 'source_rental_request_id' =\u003e $rentalRequestId,\n 'operational_status' =\u003e 'draft',\n 'notification_email' =\u003e $request['email'] ?? null,\n 'email_notifications_enabled' =\u003e true,\n 'rental_currency' =\u003e Rental::DEFAULT_CURRENCY,\n 'snapshot_customer_type' =\u003e $customer['customer_type'],\n 'snapshot_full_name' =\u003e $customer['full_name'],\n 'snapshot_company_name' =\u003e $customer['company_name'],\n 'snapshot_contact_person' =\u003e $customer['contact_person'],\n 'snapshot_phone' =\u003e $customer['phone'],\n 'snapshot_email' =\u003e $customer['email'],\n 'snapshot_address' =\u003e $customer['address'],\n 'created_by' =\u003e $createdBy,\n ]);\n\n RentalRequest::update($rentalRequestId, ['rental_id' =\u003e $rentalId]);\n\n $pdo-\u003ecommit();\n\n inventory_log_change('rental', $rentalId, 'created_from_request', null, null, (string) $rentalRequestId, null, $createdBy);\n inventory_log_change('rental_request', $rentalRequestId, 'converted_to_rental', 'rental_id', null, (string) $rentalId, null, $createdBy);\n\n return Rental::find($rentalId);\n } catch (Throwable $e) {\n $pdo-\u003erollBack();\n throw $e;\n }\n }\n\n /**\n * Cancels a Rental -- status change only (No Hard Delete, Section 19).\n * Preserves business number, customer snapshot, source request link,\n * and created history untouched; records who/when/why. No financial\n * reversal logic exists yet because no financial ledger exists in\n * Phase 2 (Section 19/45).\n */\n public static function cancel(int $rentalId, int $cancelledBy, string $reason): array\n {\n $rental = Rental::find($rentalId);\n if (!$rental) {\n throw new InvalidArgumentException('Rental not found.');\n }\n if ($rental['operational_status'] === 'cancelled') {\n return $rental;\n }\n if (trim($reason) === '') {\n throw new InvalidArgumentException('A cancellation reason is required.');\n }\n\n $oldStatus = $rental['operational_status'];\n\n Rental::update($rentalId, [\n 'operational_status' =\u003e 'cancelled',\n 'cancelled_by' =\u003e $cancelledBy,\n 'cancelled_at' =\u003e date('Y-m-d H:i:s'),\n 'cancellation_reason' =\u003e $reason,\n ]);\n\n inventory_log_change('rental', $rentalId, 'cancelled', 'operational_status', $oldStatus, 'cancelled', $reason, $cancelledBy);\n\n return Rental::find($rentalId);\n } /** * Phase 3 (Direct Check-Out): minimal operational-status sync. * Moves a Rental from Draft to Active the first time any Check-Out * (Reservation-based or Direct) against it is finalized. Intentionally * NOT a full status engine -- only handles the single Draft->Active * transition and is a no-op for every other status. */ public static function markActiveIfDraft(int $rentalId, ?int $adminUserId = null): void { $rental = Rental::find($rentalId); if (!$rental) { return; } if ($rental['operational_status'] !== 'draft') { return; } Rental::update($rentalId, [ 'operational_status' => 'active', ]); inventory_log_change('rental', $rentalId, 'activated', 'operational_status', 'draft', 'active', 'First Check-Out finalized.', $adminUserId); }\n\n /**\n * Resolves a Customer for a new Rental: either an existing row by id,\n * or a brand-new row from newCustomerData (validated first). Exactly\n * one of customerId/newCustomerData is expected to be non-null --\n * never both, never neither, by the time this is called.\n */\n private static function resolveCustomer(?int $customerId, ?array $newCustomerData, int $createdBy): array\n {\n if ($customerId !== null) {\n $customer = Customer::find($customerId);\n if (!$customer) {\n throw new InvalidArgumentException('Selected customer was not found.');\n }\n return $customer;\n }\n\n if ($newCustomerData === null) {\n throw new InvalidArgumentException('A customer must be selected or created.');\n }\n\n $errors = Customer::validate($newCustomerData);\n if ($errors) {\n throw new InvalidArgumentException(implode(' ', $errors));\n }\n\n $newCustomerData['created_by'] = $createdBy;\n $newCustomerId = Customer::create($newCustomerData);\n inventory_log_change('customer', $newCustomerId, 'created', null, null, Customer::displayName($newCustomerData), null, $createdBy);\n\n return Customer::find($newCustomerId);\n }\n}\n","link":""} https://belqeesmedia.com/en https://belqeesmedia.com/tr https://belqeesmedia.com/ar https://belqeesmedia.com/en/about https://belqeesmedia.com/tr/about https://belqeesmedia.com/ar/about https://belqeesmedia.com/en/services https://belqeesmedia.com/tr/services https://belqeesmedia.com/ar/services https://belqeesmedia.com/en/portfolio https://belqeesmedia.com/tr/portfolio https://belqeesmedia.com/ar/portfolio https://belqeesmedia.com/en/equipment-rental https://belqeesmedia.com/tr/equipment-rental https://belqeesmedia.com/ar/equipment-rental https://belqeesmedia.com/en/news https://belqeesmedia.com/tr/news https://belqeesmedia.com/ar/news https://belqeesmedia.com/en/contact https://belqeesmedia.com/tr/contact https://belqeesmedia.com/ar/contact https://belqeesmedia.com/en/portfolio/yemen-through-a-swedish-lens https://belqeesmedia.com/tr/portfolio/yemen-through-a-swedish-lens https://belqeesmedia.com/ar/portfolio/yemen-through-a-swedish-lens