Implementing Rental Booking (Car, Equipment) on a Website
Rental differs from hotels in two ways: the rental unit moves physically (car leaves), and duration is measured in hours, not just days. This creates specific tasks: tracking pickup and return locations, hourly pricing, deposits.
Data Model
CREATE TABLE rental_items (
id SERIAL PRIMARY KEY,
category_id INTEGER,
name VARCHAR(255) NOT NULL,
description TEXT,
vin_or_serial VARCHAR(100), -- VIN for cars, serial for equipment
license_plate VARCHAR(20), -- cars only
year SMALLINT,
status VARCHAR(20) DEFAULT 'available',
-- available | rented | maintenance | out_of_service
daily_rate NUMERIC(10,2),
hourly_rate NUMERIC(10,2),
deposit_amount NUMERIC(10,2),
min_rental_hours SMALLINT DEFAULT 24,
max_rental_days SMALLINT,
images JSONB DEFAULT '[]',
specs JSONB DEFAULT '{}', -- specs (power, volume, etc.)
is_active BOOLEAN DEFAULT TRUE
);
CREATE TABLE rental_locations (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
address TEXT,
lat NUMERIC(9,6),
lng NUMERIC(9,6)
);
CREATE TABLE rentals (
id BIGSERIAL PRIMARY KEY,
item_id INTEGER REFERENCES rental_items(id),
customer_name VARCHAR(255) NOT NULL,
customer_email VARCHAR(255) NOT NULL,
customer_phone VARCHAR(50),
driver_license VARCHAR(50), -- for cars
pickup_location_id INTEGER REFERENCES rental_locations(id),
return_location_id INTEGER REFERENCES rental_locations(id),
pickup_at TIMESTAMP NOT NULL,
return_at TIMESTAMP NOT NULL,
actual_return_at TIMESTAMP, -- actual return
status VARCHAR(20) DEFAULT 'pending',
-- pending | confirmed | active | completed | cancelled | overdue
total_amount NUMERIC(12,2),
deposit_amount NUMERIC(12,2),
deposit_status VARCHAR(20) DEFAULT 'not_charged',
-- not_charged | held | released | partially_withheld | withheld
extras JSONB DEFAULT '[]', -- additional services
notes TEXT,
created_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT no_item_overlap EXCLUDE USING gist (
item_id WITH =,
tsrange(pickup_at, return_at, '[)') WITH &&
) WHERE (status NOT IN ('cancelled'))
);
Price Calculation
Rental can be priced differently: first hours at one rate, then daily:
from decimal import Decimal
from datetime import datetime, timedelta
def calculate_rental_price(item: dict, pickup_at: datetime, return_at: datetime, extras: list = None) -> dict:
duration = return_at - pickup_at
total_hours = duration.total_seconds() / 3600
if total_hours <= 24 and item['hourly_rate']:
# Hourly rental
base_price = Decimal(str(item['hourly_rate'])) * Decimal(str(total_hours))
billing_unit = 'hourly'
else:
# Daily (ceil — full day for partial day)
import math
days = math.ceil(total_hours / 24)
base_price = Decimal(str(item['daily_rate'])) * days
billing_unit = 'daily'
extras_total = sum(
Decimal(str(e['price'])) * (e.get('quantity', 1))
for e in (extras or [])
)
return {
'base_price': base_price,
'extras_total': extras_total,
'total': base_price + extras_total,
'billing_unit': billing_unit,
'deposit': Decimal(str(item['deposit_amount'])),
}
Deposit (Security Deposit)
Deposit is frozen on card on booking confirmation via Payment Intent with capture_method='manual':
def charge_deposit(rental: Rental, payment_method_id: str) -> str:
intent = stripe.PaymentIntent.create(
amount=int(rental.deposit_amount * 100),
currency='usd',
payment_method=payment_method_id,
capture_method='manual', # freeze only, don't charge
confirm=True,
metadata={'rental_id': str(rental.id), 'type': 'deposit'},
)
update_rental_deposit_status(rental.id, 'held', intent.id)
return intent.id
def release_deposit(rental: Rental):
stripe.PaymentIntent.cancel(rental.deposit_payment_intent_id)
update_rental_deposit_status(rental.id, 'released')
def withhold_deposit(rental: Rental, amount: Decimal, reason: str):
# Partial or full deposit charge for damages
stripe.PaymentIntent.capture(
rental.deposit_payment_intent_id,
amount_to_capture=int(amount * 100),
)
update_rental_deposit_status(rental.id, 'withheld' if amount == rental.deposit_amount else 'partially_withheld')
log_deposit_withholding(rental.id, amount, reason)
Late Return Tracking
Cron job every 30 minutes finds active rentals with expired return_at:
def check_overdue_rentals():
overdue = db.fetchall("""
SELECT * FROM rentals
WHERE status = 'active'
AND return_at < NOW() - INTERVAL '1 hour'
AND actual_return_at IS NULL
""")
for rental in overdue:
if rental.status != 'overdue':
update_status(rental.id, 'overdue')
send_overdue_notification(rental)
charge_overdue_fee(rental)
Late fee = daily rate × number of late days (minimum 1).
Pickup and Return Locations
Rental at different locations (one-way) may cost more. One-way fee stored in routes table:
CREATE TABLE location_transfer_fees (
from_location_id INTEGER,
to_location_id INTEGER,
fee NUMERIC(10,2),
PRIMARY KEY (from_location_id, to_location_id)
);
Customer Documents
For car rentals, driver's license required. Document photos uploaded on booking and stored in protected S3 bucket with restricted access. Links valid 15 minutes (presigned URL).
Implementation Timeline
Basic rental of one item type without deposit and one-way — 8–11 business days. Daily + hourly pricing, Stripe deposit, late fees, multiple pickup points, document upload, customer portal — 14–20 business days.







