Разработка платформы для аренды жилья
Платформа аренды жилья — это не просто сайт с объявлениями. Это транзакционная система, где ошибка в логике бронирования или баг в расчёте доступности стоит реальных денег и репутации. Airbnb потратил годы на отладку calendar sync и конфликтов двойного бронирования — и всё равно периодически с этим борется. Здесь разбираем, как строить подобные системы грамотно с первого раза.
Архитектура данных: самое сложное место
Ядро любой rental-платформы — модель доступности. Наивный подход через available: boolean на объекте не работает. Нужна отдельная таблица периодов:
CREATE TABLE availability_blocks (
id BIGSERIAL PRIMARY KEY,
listing_id BIGINT NOT NULL REFERENCES listings(id),
start_date DATE NOT NULL,
end_date DATE NOT NULL,
block_type VARCHAR(20) NOT NULL, -- 'booked', 'owner_blocked', 'maintenance'
booking_id BIGINT REFERENCES bookings(id),
CHECK (end_date > start_date)
);
CREATE INDEX idx_availability_listing_dates
ON availability_blocks (listing_id, start_date, end_date);
Для проверки доступности на запрашиваемый период:
SELECT COUNT(*) = 0 AS is_available
FROM availability_blocks
WHERE listing_id = $1
AND block_type IN ('booked', 'owner_blocked')
AND start_date < $3 -- requested end
AND end_date > $2; -- requested start
Запрос должен выполняться под блокировкой SELECT FOR UPDATE при создании бронирования — иначе race condition при одновременных запросах на один объект.
Поиск с геофильтрацией
PostGIS — стандарт для geo-поиска. Минимальная конфигурация:
CREATE EXTENSION IF NOT EXISTS postgis;
ALTER TABLE listings
ADD COLUMN location GEOGRAPHY(POINT, 4326);
CREATE INDEX idx_listings_location
ON listings USING GIST(location);
Поиск объектов в радиусе с фильтрами:
SELECT
l.id,
l.title,
l.price_per_night,
ST_Distance(l.location, ST_MakePoint($1, $2)::geography) AS distance_meters
FROM listings l
WHERE ST_DWithin(
l.location,
ST_MakePoint($1, $2)::geography,
$3 -- radius in meters
)
AND l.guests_max >= $4
AND l.bedrooms >= $5
AND NOT EXISTS (
SELECT 1 FROM availability_blocks ab
WHERE ab.listing_id = l.id
AND ab.block_type IN ('booked', 'owner_blocked')
AND ab.start_date < $7
AND ab.end_date > $6
)
ORDER BY distance_meters
LIMIT 50;
На фронте карту реализуем через Mapbox GL JS или Leaflet + OpenStreetMap. Mapbox дороже, но векторные тайлы и кастомные стили — значительно лучший UX для аренды жилья.
Система бронирования: состояния и переходы
Бронирование — это конечный автомат. Нарушение порядка переходов = баги с деньгами:
pending_payment → confirmed → active → completed
↘ cancelled
confirmed → cancelled_by_host / cancelled_by_guest
active → disputed
Реализация на Laravel с паттерном State:
class Booking extends Model
{
public function confirm(): void
{
if ($this->status !== BookingStatus::PendingPayment) {
throw new InvalidBookingTransitionException(
"Cannot confirm booking in status: {$this->status->value}"
);
}
DB::transaction(function () {
$this->update(['status' => BookingStatus::Confirmed]);
AvailabilityBlock::create([
'listing_id' => $this->listing_id,
'start_date' => $this->check_in,
'end_date' => $this->check_out,
'block_type' => 'booked',
'booking_id' => $this->id,
]);
event(new BookingConfirmed($this));
});
}
public function cancel(string $initiator, ?string $reason = null): void
{
$allowed = [BookingStatus::PendingPayment, BookingStatus::Confirmed];
if (!in_array($this->status, $allowed)) {
throw new InvalidBookingTransitionException();
}
DB::transaction(function () use ($initiator, $reason) {
$status = $initiator === 'host'
? BookingStatus::CancelledByHost
: BookingStatus::CancelledByGuest;
$this->update(['status' => $status, 'cancel_reason' => $reason]);
AvailabilityBlock::where('booking_id', $this->id)->delete();
$this->processRefund();
event(new BookingCancelled($this, $initiator));
});
}
}
Платежи и удержание средств
Ключевая особенность rental: деньги удерживаются при бронировании и передаются хозяину после заезда (или после checkout). Stripe Connect — стандарт для маркетплейсов:
// Создание платёжного намерения с захватом средств
$paymentIntent = $stripe->paymentIntents->create([
'amount' => $booking->total_amount_cents,
'currency' => 'usd',
'capture_method' => 'manual', // не списываем сразу
'application_fee_amount' => $booking->platform_fee_cents,
'transfer_data' => [
'destination' => $host->stripe_account_id,
],
'metadata' => [
'booking_id' => $booking->id,
],
]);
// После подтверждения заезда — захват
$stripe->paymentIntents->capture($paymentIntent->id);
Для расчёта дат выплаты хозяину — обычно T+24h после check-in или через 24h после checkout (зависит от политики платформы). Реализуется через scheduled jobs:
// app/Jobs/ProcessHostPayout.php
class ProcessHostPayout implements ShouldQueue
{
public function handle(): void
{
Booking::query()
->where('status', BookingStatus::Active)
->where('check_in', '<=', now()->subDay())
->whereNull('payout_processed_at')
->each(function (Booking $booking) {
$this->stripe->transfers->create([
'amount' => $booking->host_payout_cents,
'currency' => 'usd',
'destination' => $booking->listing->host->stripe_account_id,
]);
$booking->update(['payout_processed_at' => now()]);
});
}
}
Синхронизация с внешними каналами (iCal)
Хозяева часто размещают объекты на нескольких платформах. Нужна синхронизация через iCal (RFC 5545):
use ICal\ICal;
class ICalSyncService
{
public function import(Listing $listing, string $icalUrl): void
{
$ical = new ICal($icalUrl, ['defaultTimeZone' => 'UTC']);
DB::transaction(function () use ($listing, $ical) {
// Удаляем старые внешние блокировки
AvailabilityBlock::where('listing_id', $listing->id)
->where('block_type', 'external_ical')
->delete();
foreach ($ical->events() as $event) {
AvailabilityBlock::create([
'listing_id' => $listing->id,
'start_date' => Carbon::parse($event->dtstart)->toDateString(),
'end_date' => Carbon::parse($event->dtend)->toDateString(),
'block_type' => 'external_ical',
'external_uid' => $event->uid,
]);
}
});
}
public function export(Listing $listing): string
{
$calendar = new \Eluceo\iCal\Domain\Entity\Calendar();
AvailabilityBlock::where('listing_id', $listing->id)
->where('block_type', 'booked')
->get()
->each(function ($block) use ($calendar) {
$event = (new \Eluceo\iCal\Domain\Entity\Event())
->setOccurrence(
new \Eluceo\iCal\Domain\ValueObject\DateTimeSpan(
new \Eluceo\iCal\Domain\ValueObject\DateTime(
Carbon::parse($block->start_date), false
),
new \Eluceo\iCal\Domain\ValueObject\DateTime(
Carbon::parse($block->end_date), false
)
)
)
->setSummary('Booked');
$calendar->addEvent($event);
});
return (new \Eluceo\iCal\Presentation\Factory\CalendarFactory())
->createCalendar($calendar);
}
}
Синхронизация запускается через Laravel Scheduler каждые 15-30 минут для активных листингов.
Система отзывов с двусторонней анонимностью
На Airbnb обе стороны оставляют отзыв до определённой даты, и отзывы публикуются одновременно — чтобы исключить давление. Реализация:
Schema::create('reviews', function (Blueprint $table) {
$table->id();
$table->foreignId('booking_id')->unique()->constrained();
$table->foreignId('reviewer_id')->constrained('users');
$table->foreignId('reviewee_id')->constrained('users');
$table->enum('reviewer_type', ['guest', 'host']);
$table->tinyInteger('rating'); // 1-5
$table->text('comment');
$table->timestamp('submitted_at');
$table->timestamp('published_at')->nullable(); // null пока не опубликован
});
Публикация через Job, который проверяет: оба ли отзыва сданы, или прошёл ли дедлайн (14 дней после checkout):
class PublishReviewsJob implements ShouldQueue
{
public function handle(): void
{
$bookingIds = Review::query()
->select('booking_id')
->whereNull('published_at')
->groupBy('booking_id')
->havingRaw('COUNT(*) = 2 OR MIN(submitted_at) < ?', [
now()->subDays(14)
])
->pluck('booking_id');
Review::whereIn('booking_id', $bookingIds)
->whereNull('published_at')
->update(['published_at' => now()]);
}
}
Уведомления: реальное время и email
WebSocket для уведомлений о новых бронированиях реализуем через Laravel Echo + Pusher или Soketi (self-hosted):
class BookingCreated implements ShouldBroadcast
{
public function broadcastOn(): array
{
return [
new PrivateChannel("host.{$this->booking->listing->host_id}"),
];
}
public function broadcastWith(): array
{
return [
'booking_id' => $this->booking->id,
'guest_name' => $this->booking->guest->name,
'check_in' => $this->booking->check_in->format('d.m.Y'),
'check_out' => $this->booking->check_out->format('d.m.Y'),
'total' => $this->booking->total_amount,
];
}
}
Сроки разработки
Базовая версия (поиск, листинги, бронирование, Stripe, кабинеты хозяина/гостя): 10–12 недель.
Добавление iCal-синхронизации, двустороннего ревью, карты с кластеризацией, фильтров, мобильной адаптации: ещё 4–6 недель.
Полноценный запуск с модерацией объявлений, верификацией личности, dispute resolution, аналитикой для хозяев: 20–24 недели от старта.
Самый длинный этап — не разработка, а тестирование граничных случаев с бронированием и выплатами. Здесь не бывает «достаточно хорошо» — каждый баг либо потеря денег, либо юридический риск.







