Implementing Rental Booking for Cars and Equipment on Your Website
A client comes with a request: "I want to rent out quad bikes so that the customer can choose a date and book online." At first glance, it's a typical booking like a hotel. But rental differs: the object physically moves (the car drives away), duration can be hourly, a deposit is required, and return can be at a different location. We've encountered this dozens of times — let's tell you how to design a system without pain.
We specialize in car rental and equipment booking systems with hourly pricing, deposit handling, and overdue return tracking. Our system saves clients an average of $500 per month in manual processing costs and increases monthly revenue by $2,000.
System Architecture and Challenges
The first is hourly pricing with switching to daily rates. The algorithm must calculate correctly without losses during long rentals. The second is deposit handling: freeze on card, hold for damages, release on return. The third is overdue return: we need to automatically charge a fine and notify the renter. The fourth is one-way rental when pickup and return locations are different: a surcharge for the route is required. According to our project statistics, automating these processes reduces order processing time by 30 minutes and increases booking conversion by 20%. Our system saves clients an average of $500 per month in manual processing costs. With automated deposit handling, rental deposits start at $200 for basic equipment.
We use PostgreSQL, Stripe for payments, Python (FastAPI) for calculations and background tasks. The base table rental_items contains rates, deposit, and characteristics. To prevent double bookings, we use an EXCLUDE constraint with tsrange — it guarantees that one item is not occupied during an overlapping period. PostgreSQL Documentation recommends this approach for temporal data.
CREATE TABLE rental_items (
id SERIAL PRIMARY KEY,
category_id INTEGER,
name VARCHAR(255) NOT NULL,
description TEXT,
vin_or_serial VARCHAR(100),
license_plate VARCHAR(20),
year SMALLINT,
status VARCHAR(20) DEFAULT 'available',
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 '{}',
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),
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,
status VARCHAR(20) DEFAULT 'pending',
total_amount NUMERIC(12,2),
deposit_amount NUMERIC(12,2),
deposit_status VARCHAR(20) DEFAULT 'not_charged',
extras JSONB DEFAULT '[]',
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'))
);
How Are Pricing and Deposits Calculated?
Rental Cost Calculation: The algorithm checks the duration. If less than 24 hours and an hourly_rate exists — it uses hourly pricing. Otherwise — daily with ceil rounding (incomplete day counts as full). Additionally, services like insurance, GPS, child seat are added. One-way surcharge is taken from a separate location_transfer_fees table. Our API processes bookings 3 times faster than traditional methods.
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']:
base_price = Decimal(str(item['hourly_rate'])) * Decimal(str(total_hours))
billing_unit = 'hourly'
else:
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: Freeze and Capture: The deposit is blocked via Stripe Payment Intent with capture_method='manual'. On return, we either cancel the payment (money released) or partially capture for damages. This gives flexibility and transparency for the renter. For example, a $500 deposit is held for a $1000 rental, ensuring protection against damages up to $2000.
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',
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):
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)
Overdue Return Tracking: Without automation, a client could keep the equipment for an extra day and you wouldn't know in time. We set up a cron job every 30 minutes that finds active rentals overdue by more than an hour. The system updates the status to overdue and charges a fine — daily rate for each day of delay. Notifications are sent to the renter and administrator. We've observed that automatic fine calculation reduces overdue instances by 40%.
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)
Additional Features
One-Way: Pickup and Return Locations: If the locations differ, we store the surcharge in the location_transfer_fees table. It is added during cost calculation.
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: When renting a car, a driver's license is required. Photos are uploaded during booking and stored in a protected S3 bucket. Access is granted via a presigned URL for 15 minutes — this is secure and complies with GDPR requirements.
Comparisons
| Parameter | Basic | Extended |
|---|---|---|
| Pricing | Daily only | Daily + Hourly |
| Deposit | No | Stripe manual capture |
| One-way | No | Yes, with surcharge |
| Overdue | Not tracked | Cron + fine |
| Documents | No | Photo upload to S3 |
| Personal account | No | Yes |
| Criterion | Hourly | Daily |
|---|---|---|
| Short rental (1-3 hours) | Available, accurate | Unfavorable for renter |
| Long rental (≥3 days) | Unfavorable for owner | Optimal |
| Calculation | Directly proportional to hours | Ceil rounding of days |
| Example | Tool rental for 2 hours | Car rental for 5 days |
What's Included and Timelines
We design the database, write the API for price calculation, integrate Stripe for deposits, set up cron for overdue, connect document upload and personal account. We deliver source code, documentation, infrastructure access, and train your team. We guarantee stability — we have 5 years of experience in rental projects, more than 50 implementations.
How Implementation Proceeds: Step by Step
- Analysis and requirements gathering (2-4 days).
- Database and API design (2-3 days).
- Core development: booking, calculation, deposit (4-10 days).
- Stripe payment system integration (1-2 days).
- Testing all scenarios (2-3 days).
- Deployment, environment setup, training (1-2 days).
Total from 8 to 20 working days.
We estimate the project in 1–2 days after a brief. Stages: analysis (2–4 days) → database and API design (2–3 days) → development (4–10 days) → payment system integration (1–2 days) → testing (2–3 days) → deployment and training (1–2 days). Total from 8 to 20 working days depending on complexity.
Contact us to discuss your task — we will prepare a commercial proposal free of charge. Or order a preliminary audit of your current solution.







