Rental Platform
Operator-first asset rental marketplace
What the Rental Platform Does
The Rental Platform is operator-first SaaS for equipment rental businesses. It's NOT a marketplace (we don't take a cut of rentals). It's a tool for rental operators to manage their fleet, bookings, payments, and customer relationships.
The core philosophy: Sovereignty, clarity, fairness. Your business, your customers, your pricing. We charge a flat subscription and get out of your way.
The Architecture
Three-Pillar Breakdown
๐๏ธ DATABASE: PostgreSQL โ โโ operators Business owners (fleet managers) โโ renters Customers who book equipment โโ equipment Trailers, tools, vehicles in the fleet โโ bookings Rental reservations with status tracking โโ stripe_accounts Connect account links for operators โโ payments Charges, refunds, platform fees ๐ฅ๏ธ INTERFACE: Next.js 14 (App Router) โ โโ /dashboard Operator control panel (fleet, bookings, earnings) โโ /equipment Public equipment listings โโ /booking Multi-step booking flow โโ /account Renter profile, booking history โโ /onboarding Stripe Connect setup for operators โก API LOGIC: Next.js API Routes + Service Layer โ โโ /api/v1/equipment CRUD for fleet management โโ /api/v1/bookings Booking creation, status updates โโ /api/v1/payments Stripe charge orchestration โโ /api/v1/identity Cross-app identity check (calls Platform API) โโ src/services/ Business logic (booking.service, stripe.service)
Why Next.js?
Server-side rendering + API routes in one framework. Rental Platform needs:
- SEO: Equipment listings need to be crawlable by Google
- Fast page loads: Server renders HTML, sends to browser instantly
- Type safety: TypeScript across frontend + backend
- API routes: Backend logic in /api directory, shares database pool with pages
We tried separate React frontend + Express backend first. Managing two codebases, two deploys, two environments... Next.js unified it all.
The Booking Flow
1. Renter browses equipment (public, no login required)
โโ /equipment โ SSR page with all available trailers/tools
2. Renter selects dates & equipment
โโ Multi-step form: dates โ equipment โ delivery address
3. Price calculation (server-side service layer)
โโ booking.service.ts calculates:
โโ Rental fee (daily rate ร days)
โโ Tax (8.6% Arizona TPT on rental only, not deposit)
โโ Deposit (configurable per equipment, max 5ร daily rate)
โโ Platform fee (10% of total, covers Stripe fees)
4. Payment via Stripe Connect
โโ Charge goes to OPERATOR's Stripe account
โโ Platform fee automatically deducted
โโ Deposit held separately (not released to operator yet)
5. Booking created with status: pending_pickup
โโ Operator sees booking in dashboard
โโ Renter gets confirmation email
6. Status transitions:
pending_pickup โ active โ returned โ completed
(or: pending_pickup โ cancelled โ deposit_refunded)
Stripe Connect Architecture
This was the hardest part to get right. Rental platforms usually take payments on behalf of operators (bad for operators โ they don't see money for days). We use Stripe Connect with direct charges:
OPERATOR ONBOARDING:
1. Operator clicks "Enable Payments" in dashboard
2. System creates Stripe Connect account
3. Operator redirected to Stripe-hosted onboarding
4. Stripe collects bank info, tax IDs, etc.
5. Operator redirected back to platform
6. We save Stripe account ID โ stripe_accounts table
BOOKING PAYMENT:
1. Renter enters card info (Stripe Checkout or Elements)
2. We create charge via Stripe API:
stripe.charges.create({
amount: totalCents,
currency: 'usd',
destination: operator.stripeAccountId, // โ Money goes HERE
application_fee_amount: platformFeeCents // โ We keep this
})
3. Money lands in operator's account (minus platform fee)
4. Deposit held as separate authorization (released later)
Why this matters: Operators get paid instantly. No waiting for platform to "process payouts". Their Stripe account, their money, their control.
Financial Calculations (CRITICAL)
See [PAYMENT_SYSTEM.md](https://stagingtrailers.bened.works) for authoritative formulas. Here's the summary:
| Component | Formula | Example (5 days @ $50/day) |
|---|---|---|
| Rental | Daily Rate ร Days | $50 ร 5 = $250 |
| Tax (AZ TPT) | Rental ร 0.086 | $250 ร 0.086 = $21.50 |
| Deposit | Per-equipment (max 5ร daily) | $200 (operator sets this) |
| Total Charged | Rental + Tax + Deposit | $250 + $21.50 + $200 = $471.50 |
| Platform Fee | Total ร 0.10 | $471.50 ร 0.10 = $47.15 |
| Operator Gets | Total - Platform Fee | $471.50 - $47.15 = $424.35 |
Key insight: Platform fee is 10% of TOTAL (including deposit). This covers Stripe fees (2.9% + 30ยข) plus platform costs. The deposit gets refunded to renter after return, but the platform fee on it stays (covers risk of holding deposit).
Cross-App Identity Integration
When a renter tries to book, we check identity verification status:
1. Check local database (renters.identity_verified)
โโ โ Not verified locally
2. Call Platform API (3-second timeout):
GET http://api.bened.works/api/identity/{keycloakId}
โโ โ
User verified on TradeCraft (for live trading)
3. Sync verification locally:
UPDATE renters SET
identity_verified = true,
legal_name = 'John Doe',
verification_source = 'tradecraft'
WHERE keycloak_id = '...'
4. Booking proceeds without re-verification
Why this matters: Identity verification costs money (Stripe Identity charges per verification). If you verified on TradeCraft, you don't re-upload ID for rentals. One verification, all apps.
The Service Layer Pattern
API routes NEVER touch the database directly. They call service functions:
FILE: src/app/api/v1/bookings/route.ts
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
export async function POST(request: Request) {
const { user } = await getAuthenticatedUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const bookingData = await request.json();
// โ NEVER DO THIS:
// await sql`INSERT INTO bookings ...`
// โ
DO THIS:
const booking = await bookingService.createBooking(user.sub, bookingData);
return NextResponse.json({ data: booking });
}
FILE: src/services/booking.service.ts
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
export async function createBooking(userId: string, data: BookingData) {
// Business logic here:
// - Validate dates
// - Check equipment availability
// - Calculate pricing
// - Create Stripe charge
// - Insert booking record
// - Send confirmation email
return booking;
}
Why? Service functions are testable, reusable, and enforce business rules. The API route is just a thin HTTP wrapper.
Staging vs Production
| Staging | Production | |
|---|---|---|
| URL | stagingtrailers.bened.works | trailers.bened.works |
| Code | /opt/bened-rental | /srv/bened-rental |
| Database | bened_rental_staging | bened_rental_production |
| Stripe | Test mode (fake cards) | Live mode (real money) |
| Purpose | Feature dev, testing payments | Real operators, real bookings |
The Tech Stack
| Layer | Technology | Why This Choice |
|---|---|---|
| Frontend | React + Next.js 14 | SSR for SEO, App Router for nested layouts, TypeScript. |
| Backend | Next.js API Routes | Unified codebase, shared types, easy deployment. |
| Database | PostgreSQL | ACID transactions for bookings, JSON columns for flexibility. |
| Payments | Stripe Connect | Direct charges to operators, automatic platform fees. |
| Auth | Keycloak (JWT) | Cross-app SSO, refresh token flow in middleware. |
| Styling | Tailwind CSS | Utility-first, responsive, fast iteration. |
| Deployment | Docker | Containerized Next.js, nginx reverse proxy. |
Database Schema Highlights
bookings Table
CREATE TABLE bookings (
id SERIAL PRIMARY KEY,
renter_id INT REFERENCES renters(id),
operator_id INT REFERENCES operators(id),
equipment_id INT REFERENCES equipment(id),
start_date DATE NOT NULL,
end_date DATE NOT NULL,
status VARCHAR(50) DEFAULT 'pending_pickup',
-- Financial breakdown (all in cents):
rental_amount INT NOT NULL,
tax_amount INT NOT NULL,
deposit_amount INT NOT NULL,
platform_fee INT NOT NULL,
total_charged INT NOT NULL,
stripe_charge_id VARCHAR(255),
stripe_deposit_auth_id VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW()
);
Key insight: We store the financial breakdown explicitly (not just total). This lets us generate detailed receipts and handle partial refunds correctly.
Deployment Process
STAGING DEPLOY (Hot Reload for Dev):
1. docker stop bened-trailers-staging
2. cd /opt/bened-rental && npm run dev
3. Nginx proxies to dev server
4. Changes reflect instantly (hot module replacement)
PRODUCTION DEPLOY (Docker Build):
1. cd /opt/bened-rental
2. docker build -t bened-trailers:latest \\
--build-arg NEXT_PUBLIC_APP_URL=https://trailers.bened.works \\
--no-cache .
3. docker stop bened-rental && docker rm bened-rental
4. docker run -d --name bened-rental \\
--network bened-platform_bened-network \\
--env-file .env \\
bened-trailers:latest
5. Nginx at trailers.bened.works proxies to new container
What's Proprietary (Not Shown Here)
- Specific tax calculation rules (varies by state, equipment type)
- Fraud detection logic (booking patterns, risk scoring)
- Operator payout timing rules
- Damage claim dispute resolution workflow
This is appropriate disclosure โ you see the architecture and payment flow, not the proprietary business logic.