🗄️ Car Rental Platform — Database Schema & Relationships¶
Database: PostgreSQL 16 with btree_gist extension
Purpose: Fleet management, hourly/daily bookings, availability calendar, payments
Tables: 8 tables. The double-booking guarantee lives in ONE table,vehicle_blocks
📊 Entity Relationship Diagram (Textual)¶
┌──────────────┐ ┌──────────────┐1───N┌────────────┐
│ vehicles │1───N│ reservations │ │ payments │
└──────┬───────┘ └──────┬───────┘ └────────────┘
│1 │1 (0..1 active)
│ │
│N ┌──────▼──────────┐ EXCLUDE (vehicle_id =, range &&) WHERE active
├────────────▶│ vehicle_blocks │◀──────────────┐
│ └──────┬──────────┘ │1
│1 │ derived (async) ┌───────┴─────────┐
│ ▼ │ maintenance_ │
│ ┌──────────────────┐ │ schedule │
└────────────▶│ availability_ │ └─────────────────┘
│ slots (read model)│
└──────────────────┘
┌──────────────┐ ┌──────────────┐
│ customers │1───N│ reservations │
└──────────────┘ └──────────────┘
┌──────────────┐
│ branches │─── locations
└──────────────┘
Figure: tables and foreign keys, generated from the DDL below (one-to-many from parent to child).
erDiagram
branches ||--o{ reservations : "branch_id"
branches ||--o{ vehicles : "branch_id"
customers ||--o{ reservations : "customer_id"
maintenance_schedule ||--o{ vehicle_blocks : "maintenance_id"
reservations ||--o{ availability_slots : "reservation_id"
reservations ||--o{ payments : "reservation_id"
reservations ||--o{ vehicle_blocks : "reservation_id"
vehicles ||--o{ availability_slots : "vehicle_id"
vehicles ||--o{ maintenance_schedule : "vehicle_id"
vehicles ||--o{ reservations : "vehicle_id"
vehicles ||--o{ vehicle_blocks : "vehicle_id"
🏛️ Complete DDL¶
-- ============================================================
-- Car Rental Platform - Production Database Schema
-- Database: PostgreSQL 16
-- ============================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "btree_gist"; -- For exclusion constraint
-- -----------------------------------------------------------
-- 1. BRANCHES / LOCATIONS (created first: vehicles references it)
-- -----------------------------------------------------------
CREATE TABLE branches (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
address TEXT NOT NULL,
city VARCHAR(100) NOT NULL,
latitude DECIMAL(10,7),
longitude DECIMAL(10,7),
phone VARCHAR(20),
opening_time TIME DEFAULT '08:00',
closing_time TIME DEFAULT '20:00',
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- -----------------------------------------------------------
-- 2. VEHICLES
-- -----------------------------------------------------------
CREATE TABLE vehicles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
vehicle_type VARCHAR(20) NOT NULL
CHECK (vehicle_type IN ('HATCHBACK','SEDAN','SUV','LUXURY','VAN','TRUCK')),
make VARCHAR(100) NOT NULL,
model VARCHAR(100) NOT NULL,
year INT NOT NULL,
license_plate VARCHAR(20) UNIQUE NOT NULL,
fuel_type VARCHAR(20) NOT NULL
CHECK (fuel_type IN ('PETROL','DIESEL','ELECTRIC','HYBRID')),
hourly_rate DECIMAL(8,2) NOT NULL,
daily_rate DECIMAL(8,2) NOT NULL,
weekly_rate DECIMAL(8,2), -- Discounted weekly rate
monthly_rate DECIMAL(8,2), -- Discounted monthly rate
mileage INT DEFAULT 0,
status VARCHAR(20) DEFAULT 'AVAILABLE'
CHECK (status IN ('AVAILABLE','RENTED','MAINTENANCE','RETIRED')), -- physical state; future bookings live in vehicle_blocks
location VARCHAR(255), -- Current branch/location
branch_id UUID REFERENCES branches(id),
seating_capacity INT NOT NULL,
transmission VARCHAR(20) DEFAULT 'MANUAL' CHECK (transmission IN ('MANUAL','AUTOMATIC')),
features JSONB DEFAULT '[]', -- ["GPS", "AC", "Bluetooth", ...]
image_urls TEXT[],
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_vehicles_type ON vehicles(vehicle_type);
CREATE INDEX idx_vehicles_location ON vehicles(location);
CREATE INDEX idx_vehicles_status ON vehicles(status) WHERE status = 'AVAILABLE';
-- -----------------------------------------------------------
-- 3. CUSTOMERS
-- -----------------------------------------------------------
CREATE TABLE customers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
phone VARCHAR(20) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
driver_license_number VARCHAR(50) UNIQUE NOT NULL,
driver_license_expiry DATE NOT NULL,
date_of_birth DATE,
address TEXT,
is_verified BOOLEAN DEFAULT false,
loyalty_points INT DEFAULT 0,
membership_tier VARCHAR(20) DEFAULT 'BASIC'
CHECK (membership_tier IN ('BASIC','SILVER','GOLD','PLATINUM')),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- -----------------------------------------------------------
-- 4. RESERVATIONS (lifecycle + money; the overlap guarantee is on vehicle_blocks)
-- -----------------------------------------------------------
CREATE TABLE reservations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id),
vehicle_id UUID NOT NULL REFERENCES vehicles(id),
branch_id UUID REFERENCES branches(id),
pickup_datetime TIMESTAMPTZ NOT NULL,
return_datetime TIMESTAMPTZ NOT NULL,
pickup_location VARCHAR(255) NOT NULL,
dropoff_location VARCHAR(255) NOT NULL,
status VARCHAR(20) DEFAULT 'PENDING'
CHECK (status IN ('PENDING','CONFIRMED','IN_PROGRESS','COMPLETED','CANCELLED','EXPIRED','NO_SHOW')),
hold_expires_at TIMESTAMPTZ, -- set while PENDING (payment hold)
-- Pricing breakdown
pricing_strategy VARCHAR(50), -- 'HOURLY', 'DAILY', 'WEEKLY_DISCOUNT'
hourly_rate DECIMAL(8,2),
daily_rate DECIMAL(8,2),
base_amount DECIMAL(10,2),
discount_amount DECIMAL(10,2) DEFAULT 0,
tax_amount DECIMAL(10,2) DEFAULT 0,
total_amount DECIMAL(10,2) NOT NULL,
additional_services JSONB DEFAULT '[]', -- [{"name": "GPS", "cost": 5.0}, ...]
-- Business rules
idempotency_key VARCHAR(64) UNIQUE, -- Prevent duplicate bookings
cancellation_reason TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT valid_range CHECK (pickup_datetime < return_datetime)
);
CREATE INDEX idx_reservations_customer ON reservations(customer_id);
CREATE INDEX idx_reservations_vehicle ON reservations(vehicle_id);
CREATE INDEX idx_reservations_status ON reservations(status);
CREATE INDEX idx_reservations_pickup ON reservations(pickup_datetime);
-- -----------------------------------------------------------
-- 5. VEHICLE BLOCKS: THE hard guarantee against double-booking
-- -----------------------------------------------------------
-- Every reservation AND every maintenance window inserts one row here, in the
-- same transaction as its parent row. One exclusion constraint therefore stops
-- reservation-vs-reservation AND reservation-vs-maintenance overlaps.
-- (Two tables with two constraints would not stop the cross-table case.)
CREATE TABLE vehicle_blocks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
vehicle_id UUID NOT NULL REFERENCES vehicles(id),
kind VARCHAR(20) NOT NULL CHECK (kind IN ('RESERVATION','MAINTENANCE')),
reservation_id UUID REFERENCES reservations(id),
maintenance_id UUID, -- FK added after maintenance_schedule exists
block_start TIMESTAMPTZ NOT NULL, -- = pickup_datetime
block_end TIMESTAMPTZ NOT NULL, -- = return_datetime + turnaround, computed by the app
-- (timestamptz + interval is STABLE, not allowed in a constraint)
active BOOLEAN NOT NULL DEFAULT true, -- false once cancelled / expired / completed
CHECK (block_start < block_end),
CHECK ((kind = 'RESERVATION') = (reservation_id IS NOT NULL))
);
-- Partial: only ACTIVE blocks conflict. Without WHERE (active), a cancelled
-- booking would block its slot forever. Default tstzrange bounds are [) so
-- back-to-back blocks touch without overlapping. Needs btree_gist for "=" on UUID.
-- An overlapping INSERT fails with SQLSTATE 23P01 (exclusion_violation) at any
-- isolation level, including READ COMMITTED.
ALTER TABLE vehicle_blocks ADD CONSTRAINT no_overlapping_blocks
EXCLUDE USING gist (
vehicle_id WITH =,
tstzrange(block_start, block_end) WITH &&
) WHERE (active);
-- Hold expiry can't be in the constraint (now() is not immutable). A sweeper
-- runs every few seconds:
-- UPDATE vehicle_blocks b SET active = false FROM reservations r
-- WHERE b.reservation_id = r.id AND r.status = 'PENDING' AND r.hold_expires_at < now();
-- and on 23P01 the booking path expires a lapsed blocking hold the same way, then retries once.
-- -----------------------------------------------------------
-- 6. AVAILABILITY SLOTS (derived read model, NOT the guarantee)
-- -----------------------------------------------------------
-- Rebuilt from vehicle_blocks by a worker (outbox/CDC) for the 7x24 grid.
-- May lag by seconds; booking never reads it.
-- -----------------------------------------------------------
CREATE TABLE availability_slots (
id BIGSERIAL,
vehicle_id UUID NOT NULL REFERENCES vehicles(id),
slot_date DATE NOT NULL,
slot_hour INT NOT NULL CHECK (slot_hour >= 0 AND slot_hour < 24),
is_booked BOOLEAN DEFAULT false,
reservation_id UUID REFERENCES reservations(id),
PRIMARY KEY (vehicle_id, slot_date, slot_hour)
);
CREATE INDEX idx_avail_vehicle_date ON availability_slots(vehicle_id, slot_date);
-- -----------------------------------------------------------
-- 7. MAINTENANCE SCHEDULE (its window is also a vehicle_blocks row)
-- -----------------------------------------------------------
CREATE TABLE maintenance_schedule (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
vehicle_id UUID NOT NULL REFERENCES vehicles(id),
maintenance_type VARCHAR(50) NOT NULL
CHECK (maintenance_type IN ('OIL_CHANGE','TIRE','SERVICE','DETAILING','REPAIR')),
scheduled_start TIMESTAMPTZ NOT NULL,
scheduled_end TIMESTAMPTZ NOT NULL,
status VARCHAR(20) DEFAULT 'SCHEDULED'
CHECK (status IN ('SCHEDULED','IN_PROGRESS','COMPLETED','CANCELLED')),
notes TEXT,
cost DECIMAL(10,2),
created_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE vehicle_blocks ADD CONSTRAINT fk_blocks_maintenance
FOREIGN KEY (maintenance_id) REFERENCES maintenance_schedule(id);
-- -----------------------------------------------------------
-- 8. PAYMENTS
-- -----------------------------------------------------------
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
reservation_id UUID NOT NULL REFERENCES reservations(id),
amount DECIMAL(10,2) NOT NULL,
currency VARCHAR(3) DEFAULT 'USD',
method VARCHAR(20) NOT NULL CHECK (method IN ('CARD','UPI','WALLET','BANK_TRANSFER')),
status VARCHAR(20) DEFAULT 'PENDING'
CHECK (status IN ('PENDING','AUTHORIZED','CAPTURED','REFUNDED','FAILED')),
is_pre_authorization BOOLEAN DEFAULT true,
gateway_transaction_id VARCHAR(255),
gateway_response JSONB,
idempotency_key VARCHAR(64) UNIQUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- -----------------------------------------------------------
-- KEY QUERY EXAMPLES
-- -----------------------------------------------------------
-- 1. Check if vehicle is available for a time range (a hint for search; the INSERT is the authority)
SELECT NOT EXISTS (
SELECT 1 FROM vehicle_blocks
WHERE vehicle_id = 'uuid-here' AND active
AND tstzrange(block_start, block_end) &&
tstzrange('2024-01-15 10:00:00+05:30', '2024-01-15 16:30:00+05:30') -- end includes turnaround
) AS is_available;
-- 1b. Book: one transaction, no pre-check needed
BEGIN;
INSERT INTO reservations (id, customer_id, vehicle_id, pickup_datetime, return_datetime,
pickup_location, dropoff_location, status, hold_expires_at,
total_amount, idempotency_key)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'PENDING', now() + interval '10 minutes', $8, $9);
INSERT INTO vehicle_blocks (vehicle_id, kind, reservation_id, block_start, block_end)
VALUES ($3, 'RESERVATION', $1, $4, $10); -- $10 = return + turnaround
COMMIT; -- 23P01 => "no longer available"
-- 2. Find ALL available vehicles for a time range + location
SELECT v.* FROM vehicles v
WHERE v.is_active
AND NOT EXISTS (
SELECT 1 FROM vehicle_blocks b
WHERE b.vehicle_id = v.id AND b.active
AND tstzrange(b.block_start, b.block_end) &&
tstzrange('2024-01-15 10:00:00+05:30', '2024-01-15 16:30:00+05:30')
)
AND v.location = 'Bangalore Airport';
-- 3. Get hourly availability for a vehicle on a specific date
SELECT slot_hour, is_booked
FROM availability_slots
WHERE vehicle_id = 'uuid-here'
AND slot_date = '2024-01-15'
ORDER BY slot_hour;
-- 4. Get weekly availability summary for a vehicle
SELECT slot_date,
COUNT(*) FILTER (WHERE NOT is_booked) AS available_hours,
COUNT(*) FILTER (WHERE is_booked) AS booked_hours
FROM availability_slots
WHERE vehicle_id = 'uuid-here'
AND slot_date >= CURRENT_DATE
AND slot_date < CURRENT_DATE + 7
GROUP BY slot_date
ORDER BY slot_date;
-- 5. Fleet utilization for the last 7 days: clip each rental to the window
-- (range intersection *), so multi-day rentals that started earlier count correctly.
WITH w AS (SELECT tstzrange(date_trunc('day', now()) - interval '7 days', date_trunc('day', now())) AS win)
SELECT v.id, v.make || ' ' || v.model AS vehicle,
COUNT(r.id) AS bookings_touching_window,
COALESCE(SUM(EXTRACT(EPOCH FROM upper(rr) - lower(rr)) / 3600), 0) AS booked_hours,
ROUND(COALESCE(SUM(EXTRACT(EPOCH FROM upper(rr) - lower(rr)) / 3600), 0) / 168.0 * 100, 1) AS utilization_pct
FROM vehicles v
CROSS JOIN w
LEFT JOIN reservations r ON r.vehicle_id = v.id
AND r.status IN ('IN_PROGRESS', 'COMPLETED')
AND tstzrange(r.pickup_datetime, r.return_datetime) && w.win
LEFT JOIN LATERAL (SELECT tstzrange(r.pickup_datetime, r.return_datetime) * w.win AS rr) x ON true
GROUP BY v.id, v.make, v.model
ORDER BY utilization_pct DESC;
🔑 Redis Schema (Caching Layer)¶
available:{vehicle_id}:{YYYY-MM-DD}:{HH} → BOOL (1=available, 0=booked)
fleet:available:{YYYY-MM-DD}:{HH} → INT (total available in city)
fleet:weekly_bitmap:{vehicle_id} → BYTES (21 bytes = 7×24 bits)
reservation:{id}:state → HASH (current reservation state)
📐 Table Relationships Summary¶
| # | Table | Parent FK | Child References | Key Indexes |
|---|---|---|---|---|
| 1 | branches |
— | vehicles(branch_id), reservations(branch_id) |
— |
| 2 | vehicles |
branch_id → branches |
reservations, vehicle_blocks, availability_slots, maintenance_schedule |
type, location, available(partial) |
| 3 | customers |
— | reservations(customer_id) |
email, phone (UNIQUE) |
| 4 | reservations |
customer_id → customers, vehicle_id → vehicles, branch_id → branches |
payments, vehicle_blocks, availability_slots |
customer, vehicle, status, pickup, idempotency_key (UNIQUE) |
| 5 | vehicle_blocks |
vehicle_id → vehicles, reservation_id → reservations, maintenance_id → maintenance_schedule |
— | EXCLUDE gist (vehicle_id =, tstzrange &&) WHERE active |
| 6 | availability_slots |
vehicle_id → vehicles, reservation_id → reservations |
— | (vehicle_id, slot_date) — derived read model |
| 7 | maintenance_schedule |
vehicle_id → vehicles |
vehicle_blocks(maintenance_id) |
— |
| 8 | payments |
reservation_id → reservations |
— | idempotency_key (UNIQUE) |