*/ public static function status_labels(): array { return array( 'pending' => __( 'Waiting', 'rentbaik' ), 'confirmed' => __( 'Confirmed', 'rentbaik' ), 'active' => __( 'On rent', 'rentbaik' ), 'returned' => __( 'Returned', 'rentbaik' ), 'completed' => __( 'Completed', 'rentbaik' ), 'cancelled' => __( 'Cancelled', 'rentbaik' ), 'no_show' => __( 'No-show', 'rentbaik' ), ); } /** * Labels for where a booking came from. * * @return array */ public static function source_labels(): array { return array( 'web' => __( 'Website', 'rentbaik' ), 'whatsapp' => __( 'WhatsApp', 'rentbaik' ), 'admin' => __( 'Staff (phone / walk-in)', 'rentbaik' ), ); } /** * Allowed next statuses. * * @param string $status Current status. * @return string[] */ public static function next_statuses( string $status ): array { $map = array( 'pending' => array( 'confirmed', 'cancelled' ), 'confirmed' => array( 'active', 'cancelled', 'no_show' ), 'active' => array( 'returned' ), 'returned' => array( 'completed' ), 'completed' => array(), 'cancelled' => array(), 'no_show' => array(), ); return $map[ $status ] ?? array(); } /** * Create a booking. * * @param array $input vehicle_id, unit_id, pickup, return (UTC DateTimeImmutable), location_id, * return_location_id, extras (id => qty), customer (sanitized array), status, * source, payment_method, customer_note, internal_note, allow_past, hold, * hold_minutes (optional, 0 = setting), allow_unassigned (staff only: keep * the booking without a unit when none is free). * @return int|WP_Error Booking ID. */ public static function create( array $input ) { $vehicle_id = (int) ( $input['vehicle_id'] ?? 0 ); $pickup = $input['pickup'] ?? null; $return = $input['return'] ?? null; if ( $vehicle_id <= 0 || ! $pickup instanceof DateTimeImmutable || ! $return instanceof DateTimeImmutable ) { return new WP_Error( 'rentbaik_input', __( 'Choose a vehicle, a pick-up time and a return time.', 'rentbaik' ) ); } $location_id = (int) ( $input['location_id'] ?? 0 ); $valid = Rules::validate( $vehicle_id, $pickup, $return, $location_id, ! empty( $input['allow_past'] ) ); if ( is_wp_error( $valid ) ) { return $valid; } $quote = Calculator::quote( $vehicle_id, $pickup, $return, (array) ( $input['extras'] ?? array() ), $location_id ); if ( is_wp_error( $quote ) ) { return $quote; } foreach ( $quote['lines'] as $line ) { if ( 'extra' !== $line['type'] ) { continue; } $extra = \Rentbaik\Pricing\ExtraRepository::find( (int) $line['extra_id'] ); if ( $extra && null !== $extra['stock'] ) { $used = BookingRepository::extra_quantity_between( (int) $extra['id'], Time::to_mysql( $pickup ), Time::to_mysql( $return ) ); if ( $used + (int) $line['quantity'] > (int) $extra['stock'] ) { /* translators: 1: extra name, 2: number still available. */ return new WP_Error( 'rentbaik_extra_stock', sprintf( __( 'Not enough “%1$s” left for these dates (%2$d available).', 'rentbaik' ), $extra['name'], max( 0, (int) $extra['stock'] - $used ) ) ); } } } $customer = (array) ( $input['customer'] ?? array() ); if ( '' === ( $customer['first_name'] ?? '' ) && '' === ( $customer['last_name'] ?? '' ) ) { return new WP_Error( 'rentbaik_customer', __( 'Enter the customer name.', 'rentbaik' ) ); } if ( '' === ( $customer['email'] ?? '' ) && '' === ( $customer['phone'] ?? '' ) ) { return new WP_Error( 'rentbaik_contact', __( 'Enter an email address or a phone number for the customer.', 'rentbaik' ) ); } $customer_id = CustomerRepository::find_or_create( $customer ); $status = in_array( $input['status'] ?? '', array( 'pending', 'confirmed' ), true ) ? $input['status'] : 'pending'; $hold = ! empty( $input['hold'] ); $minutes = (int) ( $input['hold_minutes'] ?? 0 ) > 0 ? (int) $input['hold_minutes'] : (int) Settings::get( 'advanced', 'hold_minutes' ); $start = Time::to_mysql( $pickup ); $end = Time::to_mysql( $return ); $now = gmdate( 'Y-m-d H:i:s' ); $booking_id = BookingRepository::insert( array( 'booking_number' => 'tmp-' . wp_generate_password( 12, false ), 'status' => $status, 'source' => in_array( $input['source'] ?? '', array( 'web', 'whatsapp', 'admin' ), true ) ? $input['source'] : 'admin', 'customer_id' => $customer_id, 'pickup_location_id' => $location_id, 'return_location_id' => (int) ( $input['return_location_id'] ?? $location_id ), 'pickup_at' => $start, 'return_at' => $end, 'currency' => $quote['currency'], 'subtotal' => $quote['rental_total'], 'extras_total' => $quote['extras_total'], 'fees_total' => $quote['fees_total'], 'tax_total' => $quote['tax_total'], 'total' => $quote['total'], 'deposit_amount' => $quote['deposit'], 'deposit_status' => $quote['deposit'] > 0 ? 'due' : 'none', 'payment_method' => sanitize_key( (string) ( $input['payment_method'] ?? '' ) ), 'customer_note' => (string) ( $input['customer_note'] ?? '' ), 'internal_note' => (string) ( $input['internal_note'] ?? '' ), 'created_by' => get_current_user_id(), 'created_at' => $now, 'updated_at' => $now, ), array( '%s', '%s', '%s', '%d', '%d', '%d', '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%d', '%d', '%s', '%s', '%s', '%s', '%d', '%s', '%s' ) ); if ( $booking_id <= 0 ) { return new WP_Error( 'rentbaik_db', __( 'The booking could not be saved. Please try again.', 'rentbaik' ) ); } $unit_id = Availability::reserve( $vehicle_id, $start, $end, $booking_id, $hold ? 'hold' : 'booking', (int) ( $input['unit_id'] ?? 0 ), $location_id, $minutes ); $patch = array( 'booking_number' => self::number_for( $booking_id ) ); if ( $unit_id <= 0 ) { if ( $hold || empty( $input['allow_unassigned'] ) ) { BookingRepository::delete( $booking_id ); return new WP_Error( 'rentbaik_unavailable', __( 'No unit of this vehicle is free for these dates.', 'rentbaik' ) ); } // Staff chose to take the booking anyway: it has no unit until one is assigned. $unit_id = 0; $patch['internal_note'] = trim( (string) ( $input['internal_note'] ?? '' ) . "\n" . __( 'Created without a unit because no unit was free. Assign a unit before pick-up.', 'rentbaik' ) ); } if ( $hold ) { $patch['hold_expires_at'] = Time::to_mysql( Time::now()->modify( '+' . $minutes . ' minutes' ) ); } BookingRepository::patch( $booking_id, $patch ); self::add_items( $booking_id, $quote, $vehicle_id, $unit_id ); if ( ! $hold ) { Emails::booking_created( $booking_id ); } /** * Fires after a booking is created. * * @param int $booking_id Booking ID. * @param array $input Input used to create it. */ do_action( 'rentbaik_booking_created', $booking_id, $input ); return $booking_id; } /** * Write the lines of a quote as booking items. * * @param int $booking_id Booking ID. * @param array $quote Quote from Calculator::quote(). * @param int $vehicle_id Vehicle post ID. * @param int $unit_id Unit ID. * @return void */ private static function add_items( int $booking_id, array $quote, int $vehicle_id, int $unit_id ): void { foreach ( $quote['lines'] as $line ) { BookingRepository::add_item( array( 'booking_id' => $booking_id, 'item_type' => $line['type'], 'vehicle_id' => 'vehicle' === $line['type'] ? $vehicle_id : 0, 'unit_id' => 'vehicle' === $line['type'] ? $unit_id : 0, 'extra_id' => (int) ( $line['extra_id'] ?? 0 ), 'label' => $line['label'], 'quantity' => $line['quantity'], 'duration' => $line['duration'], 'duration_unit' => $line['duration_unit'], 'unit_price' => $line['unit_price'], 'total' => $line['total'], 'meta' => array( 'detail' => $line['detail'] ), ) ); } } /** * Move the pick-up and/or return of a booking by whole days (the times of day stay the same). * Checks the rental rules and availability, and recalculates the price with the same extras. * * @param int $booking_id Booking ID. * @param int $start_days Days to move the pick-up (negative = earlier). * @param int $end_days Days to move the return. * @param bool $save False = only check and describe the change. * @param int $target_unit Move to this unit of the same vehicle (0 = keep, or any free unit when needed). * @return array|WP_Error Old and new pick-up, return, total and unit. */ public static function reschedule( int $booking_id, int $start_days, int $end_days, bool $save, int $target_unit = 0 ) { $booking = BookingRepository::find( $booking_id ); if ( ! $booking ) { return new WP_Error( 'rentbaik_missing', __( 'Booking not found.', 'rentbaik' ) ); } $status = (string) $booking['status']; if ( ! in_array( $status, array( 'pending', 'confirmed', 'active' ), true ) ) { return new WP_Error( 'rentbaik_move_status', __( 'Only bookings that are waiting, confirmed or on rent can be moved.', 'rentbaik' ) ); } if ( '' !== (string) $booking['hold_expires_at'] ) { return new WP_Error( 'rentbaik_move_hold', __( 'This booking is waiting for an online payment. Move it after the payment.', 'rentbaik' ) ); } if ( 'active' === $status && 0 !== $start_days ) { return new WP_Error( 'rentbaik_move_active', __( 'The vehicle is already picked up, so only the return date can change.', 'rentbaik' ) ); } $item = BookingRepository::vehicle_item( $booking_id ); if ( $item && $target_unit === (int) $item['unit_id'] ) { $target_unit = 0; } if ( 0 === $start_days && 0 === $end_days && 0 === $target_unit ) { return new WP_Error( 'rentbaik_move_none', __( 'The dates did not change.', 'rentbaik' ) ); } if ( $target_unit > 0 ) { if ( 'active' === $status ) { return new WP_Error( 'rentbaik_move_active_unit', __( 'The vehicle is already with the customer, so the booking cannot move to another unit.', 'rentbaik' ) ); } $target = UnitRepository::find( $target_unit ); if ( ! $item || ! $target || (int) $target['vehicle_id'] !== (int) $item['vehicle_id'] || ! in_array( $target['status'], array( 'available', 'rented' ), true ) ) { return new WP_Error( 'rentbaik_move_unit', __( 'A booking can only move to another unit of the same vehicle that is in use.', 'rentbaik' ) ); } } $old_pickup = Time::from_utc( (string) $booking['pickup_at'] ); $old_return = Time::from_utc( (string) $booking['return_at'] ); if ( ! $item || ! $old_pickup || ! $old_return ) { return new WP_Error( 'rentbaik_missing', __( 'Booking not found.', 'rentbaik' ) ); } // Move in local time so the time of day stays the same across daylight saving changes. $utc = new \DateTimeZone( 'UTC' ); $pickup = Time::local( $old_pickup )->modify( sprintf( '%+d days', $start_days ) )->setTimezone( $utc ); $return = Time::local( $old_return )->modify( sprintf( '%+d days', $end_days ) )->setTimezone( $utc ); if ( $return <= $pickup ) { return new WP_Error( 'rentbaik_period', __( 'The return time must be after the pick-up time.', 'rentbaik' ) ); } $vehicle_id = (int) $item['vehicle_id']; $location = (int) $booking['pickup_location_id']; if ( 0 !== $end_days && $return < Time::now() ) { return new WP_Error( 'rentbaik_move_past', __( 'The new return is in the past. To end a rental early, mark the booking as returned.', 'rentbaik' ) ); } // A moved pick-up must not be in the past; an unchanged one may be (bookings already under way). $valid = Rules::validate( $vehicle_id, $pickup, $return, $location, 0 === $start_days ); if ( is_wp_error( $valid ) ) { return new WP_Error( 'rentbaik_move_rules', implode( ' ', $valid->get_error_messages() ) ); } $start = Time::to_mysql( $pickup ); $end = Time::to_mysql( $return ); $old_uid = (int) $item['unit_id']; $unit_id = $target_unit > 0 ? Availability::unit_for_move( $booking_id, $vehicle_id, $target_unit, $start, $end, false, $location ) : Availability::unit_for_move( $booking_id, $vehicle_id, $old_uid, $start, $end, 'active' !== $status, $location ); // A booking without a unit may move its dates and still have no unit. $keep_unassigned = 0 === $old_uid && 0 === $target_unit && $unit_id <= 0; if ( $unit_id <= 0 && ! $keep_unassigned ) { return new WP_Error( 'rentbaik_unavailable', $target_unit > 0 ? __( 'That unit is not free for these dates.', 'rentbaik' ) : ( 'active' === $status ? __( 'This vehicle is booked by another customer in the new period.', 'rentbaik' ) : __( 'No unit of this vehicle is free for the new dates.', 'rentbaik' ) ) ); } $extras = array(); foreach ( BookingRepository::items( $booking_id ) as $row ) { if ( 'extra' === $row['item_type'] && (int) $row['extra_id'] > 0 ) { $extras[ (int) $row['extra_id'] ] = (int) $row['quantity']; } } $quote = Calculator::quote( $vehicle_id, $pickup, $return, $extras, $location ); if ( is_wp_error( $quote ) ) { return $quote; } $result = array( 'old_pickup' => (string) $booking['pickup_at'], 'old_return' => (string) $booking['return_at'], 'pickup' => $start, 'return' => $end, 'old_total' => (int) $booking['total'], 'total' => (int) $quote['total'], 'paid' => (int) $booking['paid_total'], 'currency' => (string) $booking['currency'], 'old_unit' => $old_uid, 'unit' => $unit_id, 'chosen' => $target_unit > 0, ); if ( ! $save ) { return $result; } // Check again under the lock while moving, in case someone booked the unit in the meantime. $unit_id = $target_unit > 0 ? Availability::unit_for_move( $booking_id, $vehicle_id, $target_unit, $start, $end, false, $location, true ) : Availability::unit_for_move( $booking_id, $vehicle_id, $old_uid, $start, $end, 'active' !== $status, $location, true ); if ( $unit_id <= 0 && $keep_unassigned ) { $unit_id = 0; } elseif ( $unit_id <= 0 ) { return new WP_Error( 'rentbaik_unavailable', __( 'No unit of this vehicle is free for the new dates.', 'rentbaik' ) ); } $result['unit'] = $unit_id; BookingRepository::delete_items( $booking_id ); self::add_items( $booking_id, $quote, $vehicle_id, $unit_id ); $user = wp_get_current_user(); $note = sprintf( /* translators: 1: date, 2: user name, 3: old pick-up, 4: old return, 5: new pick-up, 6: new return. */ __( '%1$s, %2$s moved the dates: %3$s – %4$s → %5$s – %6$s.', 'rentbaik' ), wp_date( Format::date_format() ), $user->exists() ? $user->display_name : __( 'Staff', 'rentbaik' ), Format::datetime( (string) $booking['pickup_at'] ), Format::datetime( (string) $booking['return_at'] ), Format::datetime( $start ), Format::datetime( $end ) ); if ( $unit_id !== $old_uid ) { $from_unit = UnitRepository::find( $old_uid ); $to_unit = UnitRepository::find( $unit_id ); /* translators: 1: old plate number, 2: new plate number. */ $note .= ' ' . sprintf( __( 'Unit: %1$s → %2$s.', 'rentbaik' ), $from_unit ? $from_unit['plate_number'] : '?', $to_unit ? $to_unit['plate_number'] : '?' ); } $patch = array( 'pickup_at' => $start, 'return_at' => $end, 'subtotal' => (int) $quote['rental_total'], 'extras_total' => (int) $quote['extras_total'], 'fees_total' => (int) $quote['fees_total'], 'tax_total' => (int) $quote['tax_total'], 'total' => (int) $quote['total'], 'internal_note' => trim( (string) $booking['internal_note'] . "\n" . $note ), ); // Reminders follow the new dates. if ( 0 !== $start_days ) { $patch['pickup_reminder_sent'] = 0; } if ( 0 !== $end_days ) { $patch['return_reminder_sent'] = 0; } BookingRepository::patch( $booking_id, $patch ); self::refresh_totals( $booking_id ); /** * Fires after staff moved the dates of a booking. * * @param int $booking_id Booking ID. * @param array $result Old and new pick-up, return, total and unit. */ do_action( 'rentbaik_booking_rescheduled', $booking_id, $result ); return $result; } /** * Public booking number for an ID. * * @param int $booking_id Booking ID. * @return string */ public static function number_for( int $booking_id ): string { return Settings::get( 'advanced', 'booking_prefix' ) . ( 1000 + $booking_id ); } /** * Change the status of a booking and apply its side effects. * * @param int $booking_id Booking ID. * @param string $status New status. * @return true|WP_Error */ public static function set_status( int $booking_id, string $status ) { $booking = BookingRepository::find( $booking_id ); if ( ! $booking ) { return new WP_Error( 'rentbaik_missing', __( 'Booking not found.', 'rentbaik' ) ); } if ( ! in_array( $status, self::next_statuses( (string) $booking['status'] ), true ) ) { return new WP_Error( 'rentbaik_status', __( 'This status change is not allowed.', 'rentbaik' ) ); } $item = BookingRepository::vehicle_item( $booking_id ); $unit_id = $item ? (int) $item['unit_id'] : 0; $now = gmdate( 'Y-m-d H:i:s' ); $patch = array( 'status' => $status ); switch ( $status ) { case 'confirmed': BlockRepository::confirm_holds( $booking_id ); $patch['hold_expires_at'] = null; break; case 'active': if ( $unit_id <= 0 ) { return new WP_Error( 'rentbaik_no_unit', __( 'Assign a unit to this booking before handing over the vehicle.', 'rentbaik' ) ); } $patch['actual_pickup_at'] = $now; UnitRepository::set_status( $unit_id, 'rented' ); break; case 'returned': $patch['actual_return_at'] = $now; UnitRepository::set_status( $unit_id, 'available' ); if ( $now < $booking['return_at'] ) { // Early return frees the rest of the period. BlockRepository::set_booking_end( $booking_id, $now ); } break; case 'cancelled': case 'no_show': BlockRepository::delete_for_booking( $booking_id ); break; } $patch = array_filter( $patch, static function ( $value ) { return null !== $value; } ); BookingRepository::patch( $booking_id, $patch ); if ( 'confirmed' === $status ) { self::clear_hold_expiry( $booking_id ); } Emails::status_changed( $booking_id, $status ); /** * Fires after a booking status changes. * * @param int $booking_id Booking ID. * @param string $status New status. * @param string $old_status Previous status. */ do_action( 'rentbaik_booking_status_changed', $booking_id, $status, (string) $booking['status'] ); return true; } /** * Remove the checkout hold expiry of a booking. * * @param int $booking_id Booking ID. * @return void */ public static function clear_hold_expiry( int $booking_id ): void { BookingRepository::clear_hold_expiry( $booking_id ); } /** * Record a payment, refund or deposit and update the booking totals. * * @param int $booking_id Booking ID. * @param array $payment payment_type, method, amount (minor), reference, note. * @return int|WP_Error Payment ID. */ public static function add_payment( int $booking_id, array $payment ) { $booking = BookingRepository::find( $booking_id ); if ( ! $booking ) { return new WP_Error( 'rentbaik_missing', __( 'Booking not found.', 'rentbaik' ) ); } if ( (int) ( $payment['amount'] ?? 0 ) <= 0 ) { return new WP_Error( 'rentbaik_amount', __( 'Enter an amount greater than zero.', 'rentbaik' ) ); } $payment_id = PaymentRepository::add( array_merge( $payment, array( 'booking_id' => $booking_id, 'currency' => $booking['currency'], 'status' => 'paid', ) ) ); self::refresh_totals( $booking_id ); /** * Fires after staff recorded a payment, refund or deposit on a booking. * * @param int $booking_id Booking ID. * @param array $payment Payment data (method, amount, payment_type). */ do_action( 'rentbaik_payment_added', $booking_id, $payment ); return $payment_id; } /** * Recalculate the paid total and deposit status of a booking from its payments. * * @param int $booking_id Booking ID. * @return void */ public static function refresh_totals( int $booking_id ): void { $booking = BookingRepository::find( $booking_id ); if ( ! $booking ) { return; } $totals = PaymentRepository::totals_for_booking( $booking_id ); $deposit = (int) $booking['deposit_amount']; $dstatus = 'none'; if ( $deposit > 0 ) { $dstatus = $totals['deposit'] >= $deposit ? 'held' : 'due'; foreach ( PaymentRepository::for_booking( $booking_id ) as $row ) { if ( 'deposit_refund' === $row['payment_type'] && $totals['deposit'] <= 0 ) { $dstatus = 'returned'; } } } BookingRepository::patch( $booking_id, array( 'paid_total' => $totals['paid'], 'deposit_status' => $dstatus, ) ); } /** * Payment state of a booking: unpaid, partial or paid. * * @param array $booking Booking row. * @return string */ public static function payment_state( array $booking ): string { $paid = (int) $booking['paid_total']; $total = (int) $booking['total']; if ( $paid <= 0 ) { return 'unpaid'; } return $paid >= $total ? 'paid' : 'partial'; } /** * Payment state labels. * * @return array */ public static function payment_state_labels(): array { return array( 'unpaid' => __( 'Unpaid', 'rentbaik' ), 'partial' => __( 'Part paid', 'rentbaik' ), 'paid' => __( 'Paid', 'rentbaik' ), ); } }