โ† Back to Projects

Rental Platform

Operator-first asset rental marketplace

Staging

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:

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)

This is appropriate disclosure โ€” you see the architecture and payment flow, not the proprietary business logic.