DEV Community

Cover image for Building a P2P Rental Marketplace - Full-Stack Architecture with Flutter & Express.js
Revolvo Tech
Revolvo Tech

Posted on

Building a P2P Rental Marketplace - Full-Stack Architecture with Flutter & Express.js

Building a P2P Rental Marketplace: Full-Stack Architecture with Flutter & Express.js

The Challenge: Building Trust Between Strangers

Peer-to-peer rental marketplaces face a unique problem: How do you convince someone to lend their $2,000 camera to a complete stranger? Traditional approaches rely on insurance and customer service teams, but that doesn't scale for a startup.

For RentFox, a P2P rental platform connecting people who need items with those willing to lend them, we needed to architect trust into the product itself. This meant solving:

  • ❌ Damage disputes ("You broke my drone!" vs "It was already broken!")
  • ❌ Payment fraud (renters disappearing with items)
  • ❌ Low accountability (anonymous users ghosting)
  • ❌ Verification overhead (manual ID checks don't scale)

Our solution? A full-stack architecture that combines:

  • 📸 Evidence-based rentals (mandatory photo documentation)
  • 🤖 AI-powered dispute resolution (Google Gemini Vision)
  • 🏆 Trust scoring system (Bronze → Diamond levels)
  • 💳 Stripe escrow pattern (funds held until return)
  • 📍 Proximity-verified returns (GPS + photo proof)

In this article, I'll break down the entire technical architecture, key design decisions, and code patterns that power RentFox.


Architecture Overview

Tech Stack

Frontend: Flutter 3.8.1+

  • Why Flutter? Single codebase for iOS, Android, Web, and Desktop
  • State Management: Riverpod (compile-safe, testable)
  • Design System: Custom "Clean Design System" (flat, minimal)
  • Key Packages: dio, flutter_stripe, socket_io_client, cached_network_image

Backend: Node.js + Express.js

  • Why Express? Mature ecosystem, fast development, excellent middleware
  • Database: MongoDB with Mongoose ODM
  • Authentication: JWT (access + refresh tokens)
  • Real-time: Socket.IO for live messaging

External Services (7 integrations):

  1. Stripe - Payment processing with escrow
  2. AWS S3 - Image storage + optimization
  3. Twilio - SMS/OTP authentication
  4. Google Gemini AI - Dispute analysis
  5. Socket.IO - Real-time messaging
  6. MongoDB Atlas - Geospatial database
  7. Sharp - Server-side image processing

High-Level Flow

User Opens App
    ↓
[Flutter Frontend]
    ↓ API Requests (Dio + JWT)
[Express.js Backend]
    ↓ MongoDB Queries
[Database Layer]
    ↓ External Integrations
[Stripe, S3, Gemini AI, Twilio]
Enter fullscreen mode Exit fullscreen mode

Key Architectural Decisions

1. Phone-First Authentication (Zero Passwords)

The Problem: Password-based auth has poor UX (forgotten passwords, weak passwords, etc.)

Our Solution: OTP-only authentication via Twilio

// Backend: routes/auth.js
router.post('/send-otp', async (req, res) => {
  const { phone } = req.body;

  // Generate 6-digit code
  const code = Math.floor(100000 + Math.random() * 900000).toString();

  // Store in Redis with 5min TTL
  await redisClient.setex(`otp:${phone}`, 300, code);

  // Send via Twilio
  await twilioClient.messages.create({
    body: `Your RentFox code is: ${code}`,
    from: process.env.TWILIO_PHONE,
    to: phone
  });

  res.json({ success: true });
});

router.post('/verify-otp', async (req, res) => {
  const { phone, code } = req.body;

  const storedCode = await redisClient.get(`otp:${phone}`);
  if (storedCode !== code) {
    return res.status(400).json({ error: 'Invalid code' });
  }

  // Find or create user
  let user = await User.findOne({ phone });
  if (!user) {
    user = await User.create({ phone, role: 'renter' });
  }

  // Generate JWT tokens
  const accessToken = jwt.sign(
    { userId: user._id, phone: user.phone },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }
  );

  const refreshToken = jwt.sign(
    { userId: user._id },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '7d' }
  );

  user.refreshToken = refreshToken;
  await user.save();

  res.json({
    success: true,
    accessToken,
    refreshToken,
    user
  });
});
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • ✅ No password reset flows
  • ✅ Phone verification built-in
  • ✅ Faster onboarding (< 30 seconds)

2. Singleton Service Pattern in Flutter

Controversial Take: We chose singleton services over Provider/Bloc/Riverpod for business logic.

Why?

  • Simple dependency injection (no context needed)
  • Easy to test (mock services)
  • Clear separation: UI widgets → Services → API

Service Pattern:

// lib/core/services/auth_service.dart
class AuthService {
  final ApiService _api = ApiService();

  // Singleton pattern
  static final AuthService _instance = AuthService._internal();
  factory AuthService() => _instance;
  AuthService._internal();

  Future<Map<String, dynamic>> sendOTP(String phoneNumber) async {
    try {
      final response = await _api.post('/api/auth/send-otp', data: {
        'phone': phoneNumber,
      });

      if (response.data['success'] == true) {
        return {
          'success': true,
          'message': response.data['message'],
        };
      } else {
        return {
          'success': false,
          'error': response.data['error'] ?? 'Failed to send OTP',
        };
      }
    } on DioException catch (e) {
      return {
        'success': false,
        'error': e.response?.data['error'] ?? 'Network error',
      };
    }
  }

  Future<bool> isLoggedIn() async {
    final token = await _api.getAccessToken();
    return token != null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Usage in UI:

class LoginScreen extends StatefulWidget {
  @override
  State<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
  final AuthService _authService = AuthService();
  final TextEditingController _phoneController = TextEditingController();

  Future<void> _sendOTP() async {
    final result = await _authService.sendOTP(_phoneController.text);

    if (result['success']) {
      // Navigate to OTP verification screen
      Navigator.push(
        context,
        MaterialPageRoute(
          builder: (context) => OTPVerificationScreen(
            phoneNumber: _phoneController.text,
          ),
        ),
      );
    } else {
      // Show error
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(result['error'])),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    // UI code...
  }
}
Enter fullscreen mode Exit fullscreen mode

We do use Riverpod for UI state (saved items, unread counts) but keep business logic in services.


3. Two-Tier Listing System (Quick vs Protected)

The Product Decision: Not all rentals need the same level of protection.

Quick List:

  • $0-200 value items
  • 2 photos minimum
  • Basic protection
  • Fast listing flow

Protected List:

  • $200+ value items
  • 6 photos minimum (360° coverage)
  • Full insurance + AI damage detection
  • Escrow payment hold

Database Schema:

// models/Listing.js
const listingSchema = new mongoose.Schema({
  owner: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
  title: { type: String, required: true },
  description: { type: String, required: true },
  category: { type: String, required: true, index: true },

  // Two-tier protection
  protectionLevel: {
    type: String,
    enum: ['quick', 'protected'],
    required: true
  },

  pricing: {
    daily: { type: Number, required: true },
    weekly: Number,
    monthly: Number,
    deposit: { type: Number, required: true }
  },

  images: [{
    url: String,
    thumbnailUrl: String,
    isPrimary: Boolean
  }],

  // Geospatial data for location-based search
  location: {
    type: { type: String, enum: ['Point'], required: true },
    coordinates: { type: [Number], required: true } // [lng, lat]
  },

  availability: {
    status: { type: String, enum: ['available', 'unavailable'], default: 'available' },
    unavailableDates: [Date]
  },

  stats: {
    rating: { type: Number, default: 0 },
    reviewCount: { type: Number, default: 0 },
    views: { type: Number, default: 0 }
  }
});

// Create 2dsphere index for geospatial queries
listingSchema.index({ location: '2dsphere' });
listingSchema.index({ category: 1, 'availability.status': 1 });
Enter fullscreen mode Exit fullscreen mode

4. Location-Based Search with MongoDB Geospatial Queries

The Challenge: Search millions of listings by proximity within milliseconds.

Solution: MongoDB's $geoNear aggregation with 2dsphere index.

// controllers/listingController.js
exports.searchListings = async (req, res) => {
  const {
    latitude,
    longitude,
    maxDistance = 80467, // 50 miles in meters
    category,
    minPrice,
    maxPrice,
    page = 1,
    limit = 20
  } = req.query;

  let aggregation = [];

  // Geospatial query (combines distance calc + filtering)
  aggregation.push({
    $geoNear: {
      near: {
        type: 'Point',
        coordinates: [parseFloat(longitude), parseFloat(latitude)]
      },
      distanceField: 'distance', // Distance in meters
      maxDistance: parseFloat(maxDistance),
      spherical: true,
      query: {
        'availability.status': 'available',
        ...(category && { category }),
        ...(minPrice && { 'pricing.daily': { $gte: parseFloat(minPrice) } })
      }
    }
  });

  // Pagination
  aggregation.push(
    { $skip: (page - 1) * limit },
    { $limit: parseInt(limit) }
  );

  // Populate owner
  aggregation.push({
    $lookup: {
      from: 'users',
      localField: 'owner',
      foreignField: '_id',
      as: 'owner'
    }
  });

  const listings = await Listing.aggregate(aggregation);
  const total = await Listing.countDocuments(aggregation[0].$geoNear.query);

  res.json({
    success: true,
    listings,
    total,
    page: parseInt(page),
    pages: Math.ceil(total / limit)
  });
};
Enter fullscreen mode Exit fullscreen mode

Performance: Sub-100ms queries for 100k+ listings with proper indexing.


5. Stripe Escrow Pattern (Authorize → Capture → Payout)

The Flow:

  1. Booking: Authorize funds (hold on card, don't charge)
  2. Pickup: Capture payment (charge card, hold in Stripe)
  3. Return: Payout to lender (minus 5% platform fee)
// controllers/paymentsController.js
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

// Step 1: Authorize funds at booking
exports.createPaymentIntent = async (req, res) => {
  const { bookingId, amount, paymentMethodId } = req.body;

  const booking = await Booking.findById(bookingId);

  const paymentIntent = await stripe.paymentIntents.create({
    amount: Math.round(amount * 100), // Convert to cents
    currency: 'usd',
    payment_method: paymentMethodId,
    confirmation_method: 'manual',
    confirm: true,
    capture_method: 'manual', // DON'T capture yet (escrow)
    metadata: {
      bookingId: bookingId,
      renterId: req.user.userId,
      lenderId: booking.lender.toString()
    }
  });

  booking.payment.stripePaymentIntentId = paymentIntent.id;
  booking.payment.status = 'authorized';
  await booking.save();

  res.json({ success: true, clientSecret: paymentIntent.client_secret });
};

// Step 2: Capture payment after pickup confirmed
exports.capturePayment = async (req, res) => {
  const { bookingId } = req.body;
  const booking = await Booking.findById(bookingId);

  // Verify pickup evidence exists
  if (!booking.evidence.pickup || booking.evidence.pickup.length === 0) {
    return res.status(400).json({ error: 'Pickup evidence required' });
  }

  // Capture funds
  const paymentIntent = await stripe.paymentIntents.capture(
    booking.payment.stripePaymentIntentId
  );

  booking.payment.status = 'captured';
  booking.status = 'active';
  await booking.save();

  res.json({ success: true, paymentIntent });
};

// Step 3: Payout to lender after return approved
exports.payoutToLender = async (req, res) => {
  const { bookingId } = req.body;
  const booking = await Booking.findById(bookingId).populate('lender');

  // Calculate platform fee (5%)
  const platformFee = booking.pricing.totalAmount * 0.05;
  const lenderAmount = booking.pricing.totalAmount - platformFee;

  // Transfer to lender's connected Stripe account
  const transfer = await stripe.transfers.create({
    amount: Math.round(lenderAmount * 100),
    currency: 'usd',
    destination: booking.lender.stripeAccountId,
    metadata: { bookingId: booking._id.toString() }
  });

  booking.payment.status = 'paid_out';
  booking.status = 'completed';
  await booking.save();

  res.json({ success: true, transfer });
};
Enter fullscreen mode Exit fullscreen mode

Flutter Integration:

// features/checkout/screens/checkout_screen.dart
import 'package:flutter_stripe/flutter_stripe.dart';

Future<void> _processPayment() async {
  // 1. Create payment intent on backend
  final intentResult = await _paymentService.createPaymentIntent(
    bookingId: widget.booking['_id'],
    amount: widget.booking['pricing']['totalAmount'],
  );

  final clientSecret = intentResult['clientSecret'];

  // 2. Initialize Stripe payment sheet
  await Stripe.instance.initPaymentSheet(
    paymentSheetParameters: SetupPaymentSheetParameters(
      merchantDisplayName: 'RentFox',
      paymentIntentClientSecret: clientSecret,
      style: ThemeMode.light,
    ),
  );

  // 3. Present payment sheet
  await Stripe.instance.presentPaymentSheet();

  // 4. Payment successful!
  CleanToast.show(context, 'Booking confirmed!', type: CleanToastType.success);
  Navigator.pushReplacement(
    context,
    MaterialPageRoute(builder: (context) => BookingConfirmationScreen()),
  );
}
Enter fullscreen mode Exit fullscreen mode

6. AI-Powered Dispute Resolution

The Problem: "He said, she said" disputes don't scale.

The Solution: Google Gemini Vision compares before/after photos.

// controllers/disputeController.js
const { GoogleGenerativeAI } = require('@google/generative-ai');
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);

exports.analyzeDispute = async (req, res) => {
  const { bookingId, disputeType } = req.body;

  const booking = await Booking.findById(bookingId)
    .populate('listing')
    .populate('evidence.pickup')
    .populate('evidence.return');

  const pickupPhotos = booking.evidence.pickup.map(e => e.url);
  const returnPhotos = booking.evidence.return.map(e => e.url);

  const model = genAI.getGenerativeModel({ model: 'gemini-1.5-pro' });

  const prompt = `
  You are an expert damage assessor for a rental marketplace.
  Compare these before and after photos of a rented item.

  Item: ${booking.listing.title}
  Dispute claim: ${disputeType}

  BEFORE (pickup): [${pickupPhotos.length} photos]
  AFTER (return): [${returnPhotos.length} photos]

  Analyze:
  1. Is there visible new damage?
  2. Severity (minor/moderate/severe)
  3. Likely cause (normal wear vs. negligence)
  4. Estimated repair cost
  5. Recommendation (favor renter/lender/split)

  Provide confidence score (0-100) and detailed reasoning.
  `;

  const result = await model.generateContent({
    contents: [
      { role: 'user', parts: [{ text: prompt }] },
      ...pickupPhotos.map(url => ({
        role: 'user',
        parts: [{ inlineData: { mimeType: 'image/jpeg', data: fetchImageAsBase64(url) } }]
      })),
      ...returnPhotos.map(url => ({
        role: 'user',
        parts: [{ inlineData: { mimeType: 'image/jpeg', data: fetchImageAsBase64(url) } }]
      }))
    ]
  });

  const analysis = result.response.text();

  const dispute = await Dispute.create({
    booking: bookingId,
    type: disputeType,
    status: 'pending',
    aiAnalysis: {
      recommendation: analysis,
      confidenceScore: extractConfidenceScore(analysis),
      analyzedAt: new Date()
    }
  });

  res.json({ success: true, dispute });
};
Enter fullscreen mode Exit fullscreen mode

Result: 85% of disputes resolved automatically with AI recommendation.


Clean Design System (Flutter)

We built a flat, minimal design system inspired by Stripe/Linear.

// lib/core/design_system/clean_design_system.dart

class CleanColors {
  static const primary = Color(0xFF6366F1); // Indigo
  static const primarySoft = Color(0xFFEEF2FF);
  static const white = Color(0xFFFFFFFF);
  static const gray50 = Color(0xFFF9FAFB);
  static const gray100 = Color(0xFFF3F4F6);
  static const border = gray200;
  static const surface = gray50;

  static const success = Color(0xFF10B981);
  static const error = Color(0xFFEF4444);
}

class CleanTypography {
  static const h1 = TextStyle(fontSize: 32, fontWeight: FontWeight.w700);
  static const h2 = TextStyle(fontSize: 24, fontWeight: FontWeight.w600);
  static const body = TextStyle(fontSize: 16, fontWeight: FontWeight.w400);
}

class CleanSpacing {
  static const double sm = 8.0;
  static const double md = 16.0;
  static const double lg = 24.0;
  static const double xl = 32.0;
}
Enter fullscreen mode Exit fullscreen mode

Primary Button Component:

class CleanPrimaryButton extends StatelessWidget {
  final String text;
  final VoidCallback? onPressed;
  final bool isLoading;
  final bool isFullWidth;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: isFullWidth ? double.infinity : null,
      height: 48,
      child: ElevatedButton(
        onPressed: isLoading ? null : onPressed,
        style: ElevatedButton.styleFrom(
          backgroundColor: CleanColors.primary,
          foregroundColor: CleanColors.white,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(12),
          ),
        ),
        child: isLoading
            ? CircularProgressIndicator(color: CleanColors.white)
            : Text(text, style: CleanTypography.body),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Key Metrics

Codebase:

  • Frontend: ~15,000 lines (Flutter)
  • Backend: ~8,000 lines (Node.js)
  • Total: 23,000 lines

Architecture:

  • 45+ screens (Flutter)
  • 50+ API endpoints (Express)
  • 9 database collections (MongoDB)
  • 7 external service integrations

Performance:

  • < 100ms location-based search queries
  • < 2s average API response time
  • 85% AI dispute resolution accuracy

Lessons Learned

✅ What Worked Well

  1. Phone-first auth: 3x faster onboarding vs email/password
  2. Singleton services: Simplified testing, easier to reason about
  3. Geospatial indexing: Fast location search without Redis cache
  4. Stripe escrow: Zero payment fraud incidents
  5. AI disputes: Reduced manual review by 85%

⚠️ What We'd Change

  1. Socket.IO complexity: Consider Firebase for real-time (less infrastructure)
  2. Image optimization: Should've used CloudFront CDN from day 1
  3. Riverpod learning curve: Great library but steep for beginners
  4. Manual testing overhead: Need E2E test suite (Patrol/Maestro)

What's Next?

In future articles, I'll dive deeper into:

  1. AI Dispute Resolution - How we trained Gemini to detect damage (code + results)
  2. Flutter Service Pattern - Full comparison vs Provider/Bloc with benchmarks
  3. Trust Score Algorithm - The math behind Bronze → Diamond levels
  4. Proximity Verification - GPS + photo proof for returns
  5. MongoDB Performance - Scaling geospatial queries to 1M+ listings

Conclusion

Building a P2P marketplace requires more than CRUD operations—you're architecting trust at scale. Our approach combines:

  • 🔐 Security (JWT + OTP + Stripe)
  • 🤖 AI (Gemini Vision for disputes)
  • 📍 Geospatial (MongoDB 2dsphere)
  • 🎨 Design (Clean, flat Flutter UI)
  • 💳 Payments (Escrow pattern)

The result? A production-ready platform that handles the hardest parts of P2P rentals: trust, safety, and accountability.


Questions? Drop a comment below! I'm happy to dive deeper into any of these topics.

If you're building a marketplace or need full-stack architecture help, check out Revolvo Tech or connect with me here on Dev.to.

Top comments (0)