3.7, 'AUD' => 1.5, 'BRL' => 5.5, 'CAD' => 1.4, 'CHF' => 0.9, 'CNY' => 7.2, 'CZK' => 23, 'DKK' => 6.9, 'EGP' => 50, 'EUR' => 0.9, 'GBP' => 0.8, 'HKD' => 7.8, 'HUF' => 360, 'IDR' => 16000, 'ILS' => 3.7, 'INR' => 85, 'JPY' => 150, 'KES' => 130, 'KHR' => 4100, 'KRW' => 1400, 'LAK' => 21000, 'LKR' => 300, 'MAD' => 10, 'MXN' => 18, 'MYR' => 4.5, 'NGN' => 1500, 'NOK' => 10.8, 'NZD' => 1.7, 'PHP' => 57, 'PKR' => 280, 'PLN' => 4, 'RON' => 4.6, 'SAR' => 3.75, 'SEK' => 10.5, 'SGD' => 1.35, 'THB' => 35, 'TRY' => 40, 'TWD' => 32, 'USD' => 1, 'VND' => 25000, 'ZAR' => 18, ); /** * Payment methods paid before the pick-up (the others are paid at the counter). */ private const PREPAID = array( 'bank_transfer', 'woocommerce', 'qris' ); /** * Media Library images made in this request: asset name => attachment ID. * * @var array */ private static $media = array(); /** * Saved record of the sample data. * * @return array */ public static function data(): array { $data = get_option( self::OPTION, array() ); $data = is_array( $data ) ? $data : array(); $defaults = array( 'vehicles' => array(), 'units' => array(), 'extras' => array(), 'bookings' => array(), 'customers' => array(), 'media' => array(), 'blocks' => array(), ); foreach ( $defaults as $key => $empty ) { $defaults[ $key ] = array_map( 'intval', (array) ( $data[ $key ] ?? $empty ) ); } return $defaults; } /** * Whether sample data is installed. * * @return bool */ public static function exists(): bool { $data = self::data(); return ! empty( $data['vehicles'] ) || ! empty( $data['bookings'] ); } /** * Whether a vehicle model is sample data. * * @param int $vehicle_id Vehicle post ID. * @return bool */ public static function is_sample_vehicle( int $vehicle_id ): bool { return '1' === (string) get_post_meta( $vehicle_id, self::META, true ); } /** * Create the sample data (does nothing when it already exists). * * @return array Number of created vehicles, units, extras and bookings. */ public static function install(): array { if ( self::exists() ) { $data = self::data(); return array_map( 'count', array_intersect_key( $data, array_flip( array( 'vehicles', 'units', 'extras', 'bookings' ) ) ) ); } $data = self::data(); $location = LocationRepository::primary(); $location = $location ? (int) $location['id'] : 0; $types = array_keys( Vehicle::enabled_types() ); $plate = 100; $models = array(); $extras = array(); Emails::mute( true ); try { foreach ( self::vehicle_definitions() as $definition ) { if ( ! in_array( $definition['type'], $types, true ) ) { continue; } $vehicle_id = self::create_vehicle( $definition ); if ( $vehicle_id <= 0 ) { continue; } $data['vehicles'][] = $vehicle_id; self::add_vehicle_images( $vehicle_id, $definition ); $models[ $definition['art'] ] = $vehicle_id; for ( $i = 0; $i < $definition['units']; $i++ ) { $unit_id = self::create_unit( $vehicle_id, $location, $plate, $definition['colors'][ $i % count( $definition['colors'] ) ] ); ++$plate; if ( $unit_id > 0 ) { $data['units'][] = $unit_id; // A photo of this unit in its own colour. $art_color = $definition['art_colors'][ $i % count( $definition['art_colors'] ) ]; $photo = self::media( $definition['art'] . '-' . $art_color, (string) $definition['title'] ); if ( $photo > 0 ) { UnitRepository::save( $unit_id, array( 'photo_id' => $photo ) ); } } } } foreach ( self::extra_definitions() as $key => $extra ) { if ( empty( array_intersect( $extra['vehicle_types'], $types ) ) ) { continue; } $extra_id = ExtraRepository::save( 0, array( 'name' => $extra['name'], 'description' => $extra['description'], 'price' => self::price( $extra['usd'] ), 'charge_type' => $extra['charge_type'], 'max_quantity' => $extra['max_quantity'], 'stock' => null, 'is_required' => 0, 'vehicle_types' => implode( ',', array_intersect( $extra['vehicle_types'], $types ) ), 'status' => 'active', 'sort_order' => 90, ) ); if ( $extra_id > 0 ) { $data['extras'][] = $extra_id; $extras[ $key ] = $extra_id; } } // Save what exists so far, so a failure below still leaves everything removable. $data['media'] = array_values( array_unique( array_merge( $data['media'], array_values( self::$media ) ) ) ); update_option( self::OPTION, $data, false ); if ( ! empty( $models ) ) { $data = self::create_bookings( $data, $models, $extras, $location ); update_option( self::OPTION, $data, false ); $data = self::add_service_block( $data, $models ); } } finally { Emails::mute( false ); } $data['media'] = array_values( array_unique( array_merge( $data['media'], array_values( self::$media ) ) ) ); update_option( self::OPTION, $data, false ); Repository::bump_data_version(); return array( 'vehicles' => count( $data['vehicles'] ), 'units' => count( $data['units'] ), 'extras' => count( $data['extras'] ), 'bookings' => count( $data['bookings'] ), ); } /** * Remove all sample data. Sample vehicles that real bookings use are moved to the trash instead. * * @return void */ public static function remove(): void { $data = self::data(); foreach ( $data['blocks'] as $block_id ) { BlockRepository::delete( $block_id ); } foreach ( $data['bookings'] as $booking_id ) { BlockRepository::delete_for_booking( $booking_id ); PaymentRepository::delete_for_booking( $booking_id ); BookingRepository::delete_items( $booking_id ); BookingRepository::delete( $booking_id ); \Rentbaik\Activity\ActivityLog::delete_for( 'booking', $booking_id ); } foreach ( $data['customers'] as $customer_id ) { if ( empty( BookingRepository::for_customer( $customer_id ) ) ) { // Sample photos and payment proofs in the private folder. Photos::remove_for_customer( $customer_id ); CustomerRepository::delete( $customer_id ); } } $kept = array(); foreach ( $data['vehicles'] as $vehicle_id ) { if ( ! empty( BookingRepository::ids_for_vehicle( $vehicle_id ) ) ) { // A real booking rents this vehicle: keep its name on that booking. $kept[] = $vehicle_id; wp_trash_post( $vehicle_id ); } } foreach ( $data['units'] as $unit_id ) { $unit = UnitRepository::find( $unit_id ); if ( ! $unit ) { continue; } if ( in_array( (int) $unit['vehicle_id'], $kept, true ) || ! empty( BlockRepository::upcoming_for_unit( $unit_id, 1 ) ) ) { UnitRepository::set_status( $unit_id, 'inactive' ); } else { UnitRepository::delete( $unit_id ); \Rentbaik\Activity\ActivityLog::delete_for( 'unit', $unit_id ); } } foreach ( $data['vehicles'] as $vehicle_id ) { if ( ! in_array( $vehicle_id, $kept, true ) && get_post( $vehicle_id ) ) { RateRepository::delete_for_vehicle( $vehicle_id ); wp_delete_post( $vehicle_id, true ); } } foreach ( $data['extras'] as $extra_id ) { ExtraRepository::delete( $extra_id ); } foreach ( $data['media'] as $attachment_id ) { if ( '1' === (string) get_post_meta( $attachment_id, self::META, true ) ) { wp_delete_attachment( $attachment_id, true ); } } delete_option( self::OPTION ); Repository::bump_data_version(); } /** * Path of a sample illustration shipped with the plugin. * * @param string $name Asset name without extension. * @return string '' when missing. */ private static function asset( string $name ): string { $path = RENTBAIK_PATH . 'assets/sample/' . sanitize_file_name( $name ) . '.png'; return file_exists( $path ) ? $path : ''; } /** * Media Library image from a sample illustration (made once per request and reused). * * @param string $name Asset name. * @param string $title Image title. * @return int Attachment ID (0 when it could not be made). */ private static function media( string $name, string $title ): int { if ( isset( self::$media[ $name ] ) ) { return self::$media[ $name ]; } $source = self::asset( $name ); if ( '' === $source ) { return 0; } $upload = wp_upload_bits( 'rentbaik-sample-' . $name . '.png', null, (string) Photos::filesystem()->get_contents( $source ) ); if ( ! empty( $upload['error'] ) ) { return 0; } $attachment_id = wp_insert_attachment( array( 'post_mime_type' => 'image/png', 'post_title' => $title, 'post_status' => 'inherit', ), $upload['file'] ); if ( ! $attachment_id || is_wp_error( $attachment_id ) ) { return 0; } require_once ABSPATH . 'wp-admin/includes/image.php'; wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $upload['file'] ) ); update_post_meta( $attachment_id, self::META, '1' ); update_post_meta( $attachment_id, '_wp_attachment_image_alt', $title ); self::$media[ $name ] = (int) $attachment_id; return (int) $attachment_id; } /** * Featured image and gallery of a sample vehicle: its colours and the gear that comes with it. * * @param int $vehicle_id Vehicle post ID. * @param array $definition Vehicle definition. * @return void */ private static function add_vehicle_images( int $vehicle_id, array $definition ): void { $title = (string) $definition['title']; $featured = self::media( $definition['art'] . '-' . $definition['art_colors'][0], $title ); if ( $featured > 0 ) { set_post_thumbnail( $vehicle_id, $featured ); } $gallery = array(); foreach ( array_slice( $definition['art_colors'], 1 ) as $color ) { $gallery[] = self::media( $definition['art'] . '-' . $color, $title ); } $gear = array( 'motorcycle' => 'gear-scooter', 'car' => 'gear-car', ); $gallery[] = self::media( $gear[ $definition['type'] ] ?? 'gear-bike', __( 'Included gear (sample)', 'rentbaik' ) ); $gallery = array_filter( $gallery ); if ( ! empty( $gallery ) ) { update_post_meta( $vehicle_id, '_rentbaik_gallery', implode( ',', $gallery ) ); } } /** * Copy a sample illustration into the private folder. * * @param string $name Asset name. * @param string $prefix File name prefix (photo, proof). * @return string File name in the private folder ('' when missing). */ private static function private_copy( string $name, string $prefix ): string { $source = self::asset( $name ); if ( '' === $source ) { return ''; } $file = $prefix . '-' . strtolower( wp_generate_password( 24, false ) ) . '.png'; $path = Photos::dir() . '/' . $file; if ( ! Photos::filesystem()->put_contents( $path, (string) Photos::filesystem()->get_contents( $source ), FS_CHMOD_FILE ) ) { return ''; } Photos::make_thumb( $path ); return $file; } /** * Customer photo taken at the hand-over and the transfer receipt a customer uploaded, as on a real booking. * * @param int $booking_id Booking ID. * @param array $plan Plan. * @return void */ private static function add_booking_files( int $booking_id, array $plan ): void { $booking = BookingRepository::find( $booking_id ); if ( ! $booking ) { return; } $add = static function ( string $asset, string $prefix, string $type, string $status, string $when ) use ( $booking_id, $booking ): void { $file = self::private_copy( $asset, $prefix ); if ( '' !== $file ) { Photos::insert( array( 'customer_id' => (int) $booking['customer_id'], 'booking_id' => $booking_id, 'doc_type' => $type, 'file_name' => $file, 'mime_type' => 'image/png', 'status' => $status, 'created_at' => $when, ), array( '%d', '%d', '%s', '%s', '%s', '%s', '%s' ) ); } }; if ( ! empty( $plan['proof'] ) ) { $add( 'receipt-sample', 'proof', 'payment_proof', (string) $plan['proof'], self::created_at( $booking ) ); } if ( ! empty( $plan['photo'] ) && in_array( $plan['status'], array( 'active', 'returned', 'completed' ), true ) ) { $add( 'customer-' . (int) $plan['photo'], 'photo', 'photo', 'valid', (string) $booking['pickup_at'] ); } } /** * Sample vehicle models. Prices are in US dollars and converted to the shop currency. * * @return array> */ private static function vehicle_definitions(): array { $note = __( 'This is sample data from Rentbaik. Change it, or remove all sample data from the Rentbaik dashboard.', 'rentbaik' ); return array( array( 'type' => 'motorcycle', 'art' => 'scooter-125', 'art_colors' => array( 'white', 'red', 'black' ), 'title' => __( 'Automatic scooter 125 cc (sample)', 'rentbaik' ), 'content' => __( 'Easy automatic scooter for the city. Two helmets included.', 'rentbaik' ) . "\n\n" . $note, 'specs' => array( 'engine_cc' => 125, 'transmission' => 'automatic', 'helmets' => 2, ), 'rates' => array( 'hour' => 4, 'day' => 15, 'week' => 90, ), 'deposit' => 50, 'units' => 3, 'colors' => array( __( 'White', 'rentbaik' ), __( 'Red', 'rentbaik' ), __( 'Black', 'rentbaik' ) ), ), array( 'type' => 'motorcycle', 'art' => 'scooter-160', 'art_colors' => array( 'grey', 'blue' ), 'title' => __( 'Comfort scooter 160 cc (sample)', 'rentbaik' ), 'content' => __( 'Bigger scooter with a large seat and storage, good for longer trips.', 'rentbaik' ) . "\n\n" . $note, 'specs' => array( 'engine_cc' => 160, 'transmission' => 'automatic', 'helmets' => 2, ), 'rates' => array( 'day' => 22, 'week' => 130, ), 'deposit' => 80, 'units' => 2, 'colors' => array( __( 'Grey', 'rentbaik' ), __( 'Blue', 'rentbaik' ) ), ), array( 'type' => 'car', 'art' => 'car-compact', 'art_colors' => array( 'silver', 'white' ), 'title' => __( 'Compact car, automatic (sample)', 'rentbaik' ), 'content' => __( 'Small and easy to park. Air conditioning, five seats.', 'rentbaik' ) . "\n\n" . $note, 'specs' => array( 'seats' => 5, 'doors' => 5, 'transmission' => 'automatic', 'fuel' => 'petrol', 'air_con' => true, 'luggage' => 2, ), 'rates' => array( 'day' => 45, 'week' => 270, ), 'deposit' => 200, 'units' => 2, 'colors' => array( __( 'Silver', 'rentbaik' ), __( 'White', 'rentbaik' ) ), ), array( 'type' => 'car', 'art' => 'car-family', 'art_colors' => array( 'black' ), 'title' => __( 'Family car, 7 seats (sample)', 'rentbaik' ), 'content' => __( 'Room for the whole family and their bags.', 'rentbaik' ) . "\n\n" . $note, 'specs' => array( 'seats' => 7, 'doors' => 5, 'transmission' => 'manual', 'fuel' => 'petrol', 'air_con' => true, 'luggage' => 3, ), 'rates' => array( 'day' => 65, 'week' => 390, ), 'deposit' => 300, 'units' => 1, 'colors' => array( __( 'Black', 'rentbaik' ) ), ), array( 'type' => 'bicycle', 'art' => 'bike-city', 'art_colors' => array( 'green' ), 'title' => __( 'City bike (sample)', 'rentbaik' ), 'content' => __( 'Comfortable bike with a basket and a lock.', 'rentbaik' ) . "\n\n" . $note, 'specs' => array( 'frame_size' => 'M', 'gears' => 7, ), 'rates' => array( 'hour' => 3, 'day' => 10, ), 'deposit' => 0, 'units' => 2, 'colors' => array( __( 'Green', 'rentbaik' ) ), ), array( 'type' => 'ebike', 'art' => 'ebike', 'art_colors' => array( 'teal' ), 'title' => __( 'Electric bike (sample)', 'rentbaik' ), 'content' => __( 'Pedal assist up to 60 km per charge.', 'rentbaik' ) . "\n\n" . $note, 'specs' => array( 'frame_size' => 'M', 'range_km' => 60, ), 'rates' => array( 'hour' => 6, 'day' => 25, ), 'deposit' => 100, 'units' => 2, 'colors' => array( __( 'White', 'rentbaik' ) ), ), ); } /** * Sample extras, by key. * * @return array> */ private static function extra_definitions(): array { return array( 'helmet' => array( 'name' => __( 'Extra helmet (sample)', 'rentbaik' ), 'description' => __( 'Clean helmet for a passenger.', 'rentbaik' ), 'usd' => 1, 'charge_type' => 'per_day', 'max_quantity' => 2, 'vehicle_types' => array( 'motorcycle', 'bicycle', 'ebike' ), ), 'poncho' => array( 'name' => __( 'Rain poncho (sample)', 'rentbaik' ), 'description' => __( 'Yours to keep.', 'rentbaik' ), 'usd' => 3, 'charge_type' => 'per_rental', 'max_quantity' => 2, 'vehicle_types' => array( 'motorcycle', 'bicycle', 'ebike' ), ), 'child_seat' => array( 'name' => __( 'Child seat (sample)', 'rentbaik' ), 'description' => __( 'For children from 9 to 18 kg.', 'rentbaik' ), 'usd' => 5, 'charge_type' => 'per_day', 'max_quantity' => 2, 'vehicle_types' => array( 'car' ), ), ); } /** * Create one sample vehicle model with its prices. * * @param array $definition Vehicle definition. * @return int Post ID (0 on failure). */ private static function create_vehicle( array $definition ): int { $vehicle_id = wp_insert_post( array( 'post_type' => Vehicle::POST_TYPE, 'post_status' => 'publish', 'post_title' => $definition['title'], 'post_content' => $definition['content'], 'meta_input' => array( self::META => '1', '_rentbaik_type' => $definition['type'], '_rentbaik_specs' => $definition['specs'], '_rentbaik_deposit' => self::price( (float) $definition['deposit'] ), ), ), true ); if ( is_wp_error( $vehicle_id ) ) { return 0; } $prices = array(); foreach ( $definition['rates'] as $period => $usd ) { $prices[ $period ] = self::price( (float) $usd ); } RateRepository::save_for_vehicle( (int) $vehicle_id, $prices ); return (int) $vehicle_id; } /** * Create one sample unit with a free "SAMPLE-123" plate. * * @param int $vehicle_id Vehicle post ID. * @param int $location_id Location ID. * @param int $number Preferred plate number. * @param string $color Colour. * @return int Unit ID. */ private static function create_unit( int $vehicle_id, int $location_id, int $number, string $color ): int { do { $plate = 'SAMPLE-' . $number; $number += 1000; } while ( UnitRepository::plate_exists( $plate ) ); return UnitRepository::save( 0, array( 'vehicle_id' => $vehicle_id, 'location_id' => $location_id, 'plate_number' => $plate, 'color' => $color, 'odometer' => 1000 + ( $number % 997 ) * 17, 'status' => 'available', 'notes' => __( 'Sample data', 'rentbaik' ), ) ); } /** * A month of sample bookings in every common situation, around today. * * Keys: model (vehicle art), offset (days from today), hour, days, status, source, method (payment), paid * (none, part, full, refunded), part (share paid), deposit (none, held, returned), proof (pending, valid), * photo (customer portrait 1-8), extra (extra key), no_unit (taken although no unit is free). * * @return array> */ private static function plans(): array { $keys = array( 'first_name', 'country', 'phone', 'model', 'offset', 'hour', 'days', 'status', 'source', 'method', 'paid', 'part', 'deposit', 'proof', 'photo', 'extra', 'no_unit' ); $rows = array( // Finished last week: paid in cash, deposit given back. array( 'Emma', 'GB', '15550106', 'scooter-125', -7, 10, 2, 'completed', 'whatsapp', 'cash', 'full', 0, 'returned', '', 1, 'helmet', false ), // Finished: paid by card at the counter. array( 'Olivia', 'US', '15550108', 'car-compact', -6, 9, 2, 'completed', 'web', 'card', 'full', 0, 'returned', '', 4, 'child_seat', false ), // Returned two days ago, the deposit is still to be given back. array( 'Lucas', 'FR', '15550103', 'scooter-160', -4, 9, 2, 'returned', 'admin', 'cash', 'full', 0, 'held', '', 3, '', false ), // Cancelled by the customer: the transfer was refunded. array( 'Liam', 'NZ', '15550107', 'bike-city', -3, 10, 1, 'cancelled', 'web', 'bank_transfer', 'refunded', 0.5, 'none', '', 0, '', false ), // Did not come. array( 'Wei', 'CN', '15550109', 'ebike', -2, 11, 1, 'no_show', 'web', 'pay_at_pickup', 'none', 0, 'none', '', 0, '', false ), // Should have come back yesterday: late, deposit held, rent not paid yet. array( 'Noah', 'US', '15550105', 'car-compact', -3, 9, 2, 'active', 'admin', 'pay_at_pickup', 'none', 0, 'held', '', 7, '', false ), // On rent, comes back today; half paid with a QR code. array( 'Maria', 'DE', '15550101', 'scooter-125', -2, 16, 2, 'active', 'whatsapp', 'qris', 'part', 0.5, 'held', '', 2, 'poncho', false ), // Long rental under way, paid in advance by bank transfer. array( 'Sofia', 'IT', '15550104', 'car-family', -5, 10, 12, 'active', 'web', 'bank_transfer', 'full', 0, 'held', 'valid', 5, '', false ), // Picks up today, pays at the counter. array( 'Alex', 'AU', '15550100', 'scooter-125', 0, 10, 3, 'confirmed', 'web', 'pay_at_pickup', 'none', 0, 'none', '', 0, '', false ), // Picks up this afternoon, already paid online. array( 'Aisha', 'AE', '15550110', 'scooter-160', 0, 15, 2, 'confirmed', 'web', 'woocommerce', 'full', 0, 'none', '', 0, '', false ), // Taken by phone although the only family car is out: needs a unit before pick-up. array( 'Omar', 'EG', '15550111', 'car-family', 2, 10, 3, 'confirmed', 'admin', 'pay_at_pickup', 'none', 0, 'none', '', 0, '', true ), // Waiting for confirmation, transfer receipt uploaded but not checked yet. array( 'Kenji', 'JP', '15550102', 'ebike', 3, 11, 4, 'pending', 'web', 'bank_transfer', 'none', 0, 'none', 'pending', 0, '', false ), // Confirmed with a part payment by transfer. array( 'Priya', 'IN', '15550112', 'car-compact', 6, 10, 5, 'confirmed', 'web', 'bank_transfer', 'part', 0.3, 'none', 'valid', 0, 'child_seat', false ), // A week by WhatsApp, pays at pick-up. array( 'Mateo', 'MX', '15550113', 'scooter-125', 9, 9, 7, 'confirmed', 'whatsapp', 'pay_at_pickup', 'none', 0, 'none', '', 0, 'helmet', false ), // Waiting for confirmation from WhatsApp. array( 'Putri', 'ID', '15550114', 'bike-city', 12, 8, 1, 'pending', 'whatsapp', 'pay_at_pickup', 'none', 0, 'none', '', 0, '', false ), // Paid online for later this month. array( 'Lena', 'SE', '15550115', 'car-compact', 16, 10, 4, 'confirmed', 'web', 'woocommerce', 'full', 0, 'none', '', 0, '', false ), // A returning customer books again. array( 'Emma', 'GB', '15550106', 'scooter-160', 19, 10, 3, 'pending', 'whatsapp', 'pay_at_pickup', 'none', 0, 'none', '', 0, '', false ), ); return array_map( static function ( array $row ) use ( $keys ): array { return array_combine( $keys, $row ); }, $rows ); } /** * The planned model, or a similar one when its vehicle type is switched off (a shop with only cars still gets * every situation). * * @param string $model Vehicle art. * @param array $models Created models: art => vehicle ID. * @return string '' when no model exists. */ private static function model_for( string $model, array $models ): string { $similar = array( 'scooter-125' => array( 'scooter-160', 'bike-city', 'ebike' ), 'scooter-160' => array( 'scooter-125', 'ebike', 'bike-city' ), 'car-compact' => array( 'car-family', 'scooter-160', 'scooter-125' ), 'car-family' => array( 'car-compact', 'scooter-160', 'scooter-125' ), 'bike-city' => array( 'ebike', 'scooter-125', 'scooter-160' ), 'ebike' => array( 'bike-city', 'scooter-160', 'scooter-125' ), ); foreach ( array_merge( array( $model ), $similar[ $model ] ?? array(), array_keys( $models ) ) as $candidate ) { if ( isset( $models[ $candidate ] ) ) { return $candidate; } } return ''; } /** * Days from today for a plan, kept inside the current month where its situation allows it. * * @param array $plan Plan. * @return int */ private static function offset_in_month( array $plan ): int { $today = new DateTimeImmutable( 'today', wp_timezone() ); $offset = (int) $plan['offset']; $days = (int) $plan['days']; if ( $offset > 0 ) { // Later this month: keep the whole rental before the end of the month when there is room. $left = (int) $today->format( 't' ) - (int) $today->format( 'j' ); return max( 1, min( $offset, $left - $days ) ); } if ( $offset + $days <= 0 ) { // Already over: as early as needed, but not before the first day of the month when it fits. $first = 1 - (int) $today->format( 'j' ); return max( $offset, $first ) + $days <= 0 ? max( $offset, $first ) : $offset; } return $offset; } /** * Create the sample bookings and bring each to the situation of its plan. * * @param array $data Sample data record. * @param array $models Vehicle art => vehicle ID. * @param array $extras Extra key => extra ID. * @param int $location_id Location ID. * @return array */ private static function create_bookings( array $data, array $models, array $extras, int $location_id ): array { foreach ( self::plans() as $plan ) { $plan['model'] = self::model_for( (string) $plan['model'], $models ); if ( '' === $plan['model'] ) { continue; } $offset = self::offset_in_month( $plan ); $booking_id = 0; // Move a day later when the location is closed on the planned day or no unit is free. for ( $shift = 0; $shift < 7 && $booking_id <= 0; $shift++ ) { $pickup = Time::local_midnight( $offset + $shift )->modify( '+' . $plan['hour'] . ' hours' ); $input = array( 'vehicle_id' => $models[ $plan['model'] ], 'pickup' => $pickup, 'return' => $pickup->modify( '+' . $plan['days'] . ' days' ), 'location_id' => $location_id, 'customer' => array( 'first_name' => $plan['first_name'], 'last_name' => __( 'Sample', 'rentbaik' ), 'phone' => $plan['phone'], 'country' => $plan['country'], 'notes' => __( 'Sample data', 'rentbaik' ), ), 'status' => 'pending' === $plan['status'] ? 'pending' : 'confirmed', 'source' => $plan['source'], 'payment_method' => $plan['method'], 'internal_note' => __( 'Sample booking. It is removed together with the sample data.', 'rentbaik' ), 'allow_past' => true, 'allow_unassigned' => ! empty( $plan['no_unit'] ), ); if ( ! empty( $plan['extra'] ) && isset( $extras[ $plan['extra'] ] ) ) { $input['extras'] = array( $extras[ $plan['extra'] ] => 1 ); } $result = BookingService::create( $input ); // An extra the vehicle type does not allow: try again without it. if ( ! is_int( $result ) && isset( $input['extras'] ) ) { unset( $input['extras'] ); $result = BookingService::create( $input ); } $booking_id = is_int( $result ) ? $result : 0; } if ( $booking_id <= 0 ) { continue; } $data['bookings'][] = $booking_id; $booking = BookingRepository::find( $booking_id ); if ( $booking && ! in_array( (int) $booking['customer_id'], $data['customers'], true ) ) { $data['customers'][] = (int) $booking['customer_id']; } self::play_out( $booking_id, $plan ); self::add_booking_files( $booking_id, $plan ); } return $data; } /** * When the sample booking was made: three days before the pick-up, never later than an hour ago. * * @param array $booking Booking row. * @return string UTC (Y-m-d H:i:s). */ private static function created_at( array $booking ): string { $pickup = Time::from_utc( (string) $booking['pickup_at'] ); $latest = Time::now()->modify( '-1 hour' ); $made = $pickup ? $pickup->modify( '-3 days' ) : $latest; return Time::to_mysql( $made < $latest ? $made : $latest ); } /** * Bring a sample booking to the situation of its plan: handed over, paid, returned, cancelled or not shown. * Dates are set to when it would have happened, so the reports and the calendar look like a real month. * * @param int $booking_id Booking ID. * @param array $plan Plan. * @return void */ private static function play_out( int $booking_id, array $plan ): void { $booking = BookingRepository::find( $booking_id ); if ( ! $booking ) { return; } $now = Time::to_mysql( Time::now() ); $made = self::created_at( $booking ); $pickup = min( (string) $booking['pickup_at'], $now ); $return = min( (string) $booking['return_at'], $now ); $method = (string) $plan['method']; $paid = (string) ( $plan['paid'] ?? 'none' ); $deposit = (string) ( $plan['deposit'] ?? 'none' ); // Money paid in advance arrives two hours after booking; the rest at the counter. $prepaid = Time::to_mysql( min( Time::now(), Time::from_utc( $made )->modify( '+2 hours' ) ) ); $pay = static function ( string $type, string $how, int $amount, string $when, string $note = '' ) use ( $booking_id ): void { if ( $amount > 0 ) { BookingService::add_payment( $booking_id, array( 'payment_type' => $type, 'method' => $how, 'amount' => $amount, 'paid_at' => $when, 'reference' => 'cash' === $how ? '' : 'SAMPLE-' . $booking_id, 'note' => '' === $note ? __( 'Sample payment', 'rentbaik' ) : $note, ) ); } }; BookingRepository::patch( $booking_id, array( 'created_at' => $made ) ); $total = (int) $booking['total']; $share = 'full' === $paid ? $total : (int) round( $total * (float) ( $plan['part'] ?? 0 ) ); if ( in_array( $paid, array( 'full', 'part', 'refunded' ), true ) && in_array( $method, self::PREPAID, true ) ) { $pay( 'payment', $method, $share, $prepaid ); } if ( in_array( $plan['status'], array( 'active', 'returned', 'completed' ), true ) ) { BookingService::set_status( $booking_id, 'active' ); BookingRepository::patch( $booking_id, array( 'actual_pickup_at' => $pickup ) ); if ( in_array( $paid, array( 'full', 'part' ), true ) && ! in_array( $method, self::PREPAID, true ) ) { $pay( 'payment', $method, $share, $pickup ); } if ( 'none' !== $deposit ) { $pay( 'deposit', 'cash', (int) $booking['deposit_amount'], $pickup, __( 'Deposit at pick-up', 'rentbaik' ) ); } } if ( in_array( $plan['status'], array( 'returned', 'completed' ), true ) ) { BookingService::set_status( $booking_id, 'returned' ); BookingRepository::patch( $booking_id, array( 'actual_return_at' => $return ) ); if ( 'returned' === $deposit ) { $pay( 'deposit_refund', 'cash', (int) $booking['deposit_amount'], $return, __( 'Deposit given back', 'rentbaik' ) ); } if ( 'completed' === $plan['status'] ) { BookingService::set_status( $booking_id, 'completed' ); } } if ( 'cancelled' === $plan['status'] ) { BookingService::set_status( $booking_id, 'cancelled' ); if ( 'refunded' === $paid ) { $pay( 'refund', $method, $share, max( $prepaid, Time::to_mysql( Time::now()->modify( '-1 day' ) ) ), __( 'Refund after cancelling', 'rentbaik' ) ); } } if ( 'no_show' === $plan['status'] ) { BookingService::set_status( $booking_id, 'no_show' ); } } /** * Service for a scooter over the next two days, so the calendar shows a unit that cannot be rented. * * @param array $data Sample data record. * @param array $models Vehicle art => vehicle ID. * @return array */ private static function add_service_block( array $data, array $models ): array { $vehicle_id = $models['scooter-160'] ?? ( $models['scooter-125'] ?? 0 ); if ( $vehicle_id <= 0 ) { return $data; } $start = Time::to_mysql( Time::local_midnight( 1 )->modify( '+8 hours' ) ); $end = Time::to_mysql( Time::local_midnight( 3 )->modify( '+8 hours' ) ); $units = array(); foreach ( $data['units'] as $unit_id ) { $unit = UnitRepository::find( $unit_id ); if ( $unit && (int) $unit['vehicle_id'] === $vehicle_id ) { $units[] = $unit_id; } } $busy = BlockRepository::busy_units( $units, $start, $end ); foreach ( $units as $unit_id ) { if ( ! in_array( $unit_id, $busy, true ) ) { $block_id = BlockRepository::add( $unit_id, $start, $end, 'maintenance', 0, null, __( 'Oil change (sample)', 'rentbaik' ) ); if ( $block_id > 0 ) { $data['blocks'][] = $block_id; } break; } } return $data; } /** * A sample price in the shop currency (minor units), rounded to a believable figure. * * @param float $usd Price in US dollars. * @return int */ public static function price( float $usd ): int { if ( $usd <= 0 ) { return 0; } $currency = Format::currency(); $amount = $usd * (float) ( self::USD_RATES[ $currency ] ?? 1 ); // Small amounts keep halves (5.50), larger ones are rounded to two significant digits (88,000). $step = $amount >= 100 ? 10 ** ( (int) floor( log10( $amount ) ) - 1 ) : ( $amount < 10 ? 0.5 : 1 ); $amount = max( $step, round( $amount / $step ) * $step ); return (int) round( $amount * ( 10 ** Format::decimals() ) ); } }