CleanNami - Automated Cleaning Service Platform

Next.js 15Stripe ConnectPayment OrchestrationCron AutomationSaaS PlatformSupabase

CleanNami: Automated Cleaning Service Platform

Executive Summary

CleanNami is a full-stack Next.js 15 application that manages the complete lifecycle of a subscription-based cleaning service. The platform orchestrates complex payment flows, automated job assignments, cleaner payouts, and evidence-based service verification—all while maintaining strict security and financial controls.

Core Achievement: Automated payment pre-authorization, capture, and cleaner payouts with zero manual intervention, processing transactions through a sophisticated 4-stage payment pipeline.


Technical Architecture

Tech Stack

  • Framework: Next.js 15.5.4 (App Router with Turbopack)
  • Runtime: React 19
  • Database: PostgreSQL with Drizzle ORM 0.44.5
  • Payments: Stripe 19.0.0 (with Stripe Connect for payouts)
  • Auth: Supabase 2.58.0
  • Styling: TailwindCSS 4
  • Deployment: Netlify
  • Cron Jobs cronjob.org

Architecture Highlights

  • 19 API Routes: RESTful endpoints for jobs, cleaners, customers, properties
  • 3 Cron Jobs: Automated pre-authorization, payout processing, calendar sync
  • 26 Database Schemas: Comprehensive data model for all entities
  • Server Actions: Type-safe server-side mutations with Next.js Server Actions
  • Real-time Pricing: Dynamic calculation engine based on property features

The Customer Journey: Booking to Payment

Phase 1: Multi-Step Booking Flow

The customer booking experience consists of 8 progressive steps:

  1. Customer Information - Name, email, phone number capture
  2. Property Details - Address with geocoding, bedrooms, bathrooms, square footage
  3. Service Add-ons - Laundry service type (in-unit/off-site), hot tub maintenance levels
  4. Calendar Integration - iCal sync or manual schedule selection
  5. Subscription Terms - Monthly/annual duration selection
  6. Payment Method - Stripe Elements integration for card capture
  7. Order Confirmation - Price validation and payment processing
  8. Success Screen - Order summary and next steps

Key Components:

  • Located in components/customers/cta-booking-button/
  • Progressive disclosure design minimizes abandonment
  • Real-time price calculation with PriceSummary.tsx
  • Server-side price validation prevents client-side tampering

Phase 2: First Clean Prepaid (Immediate Capture)

File: lib/actions/payment.actions.ts

When a customer completes signup:

// Payment is IMMEDIATELY captured for first clean
const paymentIntent = await stripe.paymentIntents.create({
  amount: calculatedPrice,
  currency: 'usd',
  customer: stripeCustomerId,
  payment_method: paymentMethodId,
  confirm: true,  // Immediate capture
  metadata: {
    customerId,
    subscriptionId,
    isFirstClean: 'true'
  }
});

Why immediate capture?

  • Validates card is real and has funds
  • Reduces cancellations risk for first appointment
  • Customer expects to pay upfront for first service
  • Sets subscription.isFirstCleanPrepaid = true

Security Measures:

  1. Server-side pricing calculation (prevents tampering)
  2. Price validation against PricingService
  3. Customer record created/updated in Stripe
  4. Payment Intent stored with subscription record

The 4-Stage Payment Pipeline

Stage 1: Pre-Authorization (Night Before)

Cron Job: app/api/cron/pre-authorize/route.ts Schedule: Daily at 1:00 AM or 8:00 PM Trigger: Cron job

What Happens:

// Find all jobs scheduled for tomorrow without payment intent
const tomorrowJobs = await db
  .select()
  .from(jobs)
  .where(
    and(
      eq(jobs.scheduledDate, tomorrow),
      isNull(jobs.paymentIntentId)
    )
  );

// For each job:
// 1. Calculate price using PricingService
const pricing = PricingService.calculatePrice({
  bedrooms: property.bedrooms,
  bathrooms: property.bathrooms,
  sqft: property.sqft,
  // ... add-ons
});

// 2. Create Payment Intent with manual capture
const paymentIntent = await stripe.paymentIntents.create({
  amount: pricing.totalPerClean * 100,
  currency: 'usd',
  customer: customer.stripeCustomerId,
  capture_method: 'manual',  // Key: Don't charge yet!
  confirm: true,             // But authorize immediately
  metadata: { jobId, customerId }
});

// 3. Update job record
await db.update(jobs)
  .set({
    paymentIntentId: paymentIntent.id,
    paymentStatus: 'authorized'
  });

Key Benefits:

  • Pre-authorization holds funds on customer's card for 7 days
  • No charge yet - money not moved until job completion verified
  • Automatic cancellation protection - If cleaner doesn't show, we can release hold
  • Cash flow assurance - Guaranteed payment once job completes

Authentication: Bearer token with CRON_SECRET environment variable


Stage 2: Job Execution

Endpoints:

  • app/api/jobs/[id]/check-in/route.ts - Cleaner arrives
  • app/api/jobs/[id]/check-out/route.ts - Cleaner finishes

Cleaner Actions During Job:

  1. Check-in - GPS verification, timestamp logged
  2. Complete checklist - Item-by-item task verification
  3. Upload evidence - Before/after photos, GPS tracking logs
  4. Check-out - Final timestamp, evidence packet submission

Evidence Packet Schema (db/schemas/evidencePackets.schema.ts):

export const evidencePackets = pgTable('evidence_packets', {
  id: uuid,
  jobId: uuid,
  checkInTime: timestamp,
  checkOutTime: timestamp,
  checkInGPS: text,        // Lat/long at arrival
  checkOutGPS: text,       // Lat/long at departure
  photoUrls: text[],       // Before/after photos
  checklistComplete: boolean,
  submittedAt: timestamp
});

Critical Validation: Evidence packet must be complete before payment capture.


Stage 3: Payment Capture & Payout Creation

Endpoint: app/api/jobs/complete-and-capture/route.ts (called by cleaner app) Authentication: API Key (X-API-Key: CLEANER_APP_API_KEY)

What Happens:

// 1. Validate evidence packet is complete
const evidence = await db.select()
  .from(evidencePackets)
  .where(eq(evidencePackets.jobId, jobId));

if (!evidence || !evidence.checklistComplete || !evidence.photoUrls.length) {
  return { error: 'Evidence packet incomplete' };
}

// 2. Capture the pre-authorized payment
const paymentIntent = await stripe.paymentIntents.capture(
  job.paymentIntentId
);

// 3. Calculate and deduct 2% reserve
const reserveAmount = paymentIntent.amount * 0.02;
await db.insert(reserveTransactions).values({
  jobId,
  amount: reserveAmount,
  type: 'deduction'
});

// 4. Create payout records for all assigned cleaners
const assignedCleaners = await db.select()
  .from(jobsToCleaners)
  .where(eq(jobsToCleaners.jobId, jobId));

for (const assignment of assignedCleaners) {
  // Calculate cleaner-specific payout
  const basePay = job.expectedHours * 17.00;
  const urgentBonus = job.isUrgent ? 10.00 : 0;
  const laundryBonus = assignment.role === 'laundry_lead'
    ? (property.laundryLoads * 5.00)
    : 0;

  await db.insert(payouts).values({
    jobId,
    cleanerId: assignment.cleanerId,
    amount: basePay,
    urgentBonusAmount: urgentBonus,
    laundryBonusAmount: laundryBonus,
    status: 'pending'  // Ready for payout cron
  });
}

// 5. Update job status
await db.update(jobs)
  .set({
    status: 'completed',
    paymentStatus: 'captured',
    completedAt: new Date()
  });

Reserve Fund Purpose:

  • 2% of all captured payments held in platform account
  • Used for refunds, disputes, chargebacks
  • Protects platform from financial exposure

Payout Calculation Formula:

Total Payout = (Expected Hours × $17/hr)
             + (Urgent Bonus: $10 if urgent)
             + (Laundry Bonus: $5 × loads if laundry_lead)

Stage 4: Automated Cleaner Payouts

Cron Job: app/api/cron/process-payout/route.ts Schedule: Set up with cron job Technology: Stripe Connect Express accounts

Processing Logic:

// Find all pending payouts
const pendingPayouts = await db.select()
  .from(payouts)
  .where(eq(payouts.status, 'pending'));

for (const payout of pendingPayouts) {
  // 1. Validate cleaner has Stripe Connect account
  const cleaner = await db.select()
    .from(cleaners)
    .where(eq(cleaners.id, payout.cleanerId));

  if (!cleaner.stripeAccountId || !cleaner.stripeOnboardingComplete) {
    await db.update(payouts)
      .set({
        status: 'held',
        notes: 'Cleaner has no Stripe account'
      });
    continue;
  }

  // 2. Calculate total payout
  const totalAmount =
    parseFloat(payout.amount) +
    parseFloat(payout.urgentBonusAmount || '0') +
    parseFloat(payout.laundryBonusAmount || '0');

  // 3. Validate minimum Stripe transfer amount
  if (totalAmount < 1.00) {
    await db.update(payouts).set({ status: 'held' });
    continue;
  }

  // 4. Create Stripe Transfer
  try {
    const transfer = await stripe.transfers.create({
      amount: Math.round(totalAmount * 100),
      currency: 'usd',
      destination: cleaner.stripeAccountId,
      metadata: {
        payoutId: payout.id,
        cleanerId: cleaner.id,
        jobId: payout.jobId
      }
    });

    // 5. Update payout status
    await db.update(payouts)
      .set({
        status: 'released',
        stripePayoutId: transfer.id,
        updatedAt: new Date()
      });

  } catch (error) {
    // Permanent errors (invalid account) → held
    // Retriable errors (network) → stays pending
    if (error.type === 'StripeInvalidRequestError') {
      await db.update(payouts).set({ status: 'held' });
    }
    // Otherwise stays 'pending' for automatic retry
  }
}

Payout Status Flow:

  1. pending - Created after payment capture, awaiting processing
  2. released - Stripe Transfer successful, funds sent to cleaner
  3. held - Permanent failure requiring admin intervention

Common Hold Reasons:

  • Cleaner hasn't completed Stripe onboarding
  • Invalid Stripe account ID
  • Amount below $1.00 minimum
  • Stripe account disabled/restricted

Stripe Connect Requirements:

  • Cleaners must create Stripe Connect Express account
  • Provides: Bank details, tax information (W9), identity verification
  • Platform stores stripeAccountId in cleaners table
  • Only after stripeOnboardingComplete = true can payouts process

Cron Job Infrastructure

Job 1: Pre-Authorization (/api/cron/pre-authorize)

  • Frequency: Daily at 1:00 AM
  • Purpose: Authorize payments 24 hours before clean
  • Success Metric: All tomorrow's jobs have paymentStatus: 'authorized'
  • Error Handling: Logs failures, sends admin notifications

Job 2: Payout Processing (/api/cron/process-payout)

  • Frequency: Every 15-30 minutes during business hours
  • Purpose: Transfer funds to cleaners via Stripe Connect
  • Success Metric: All eligible payouts move from 'pending' → 'released'
  • Error Handling: Permanent failures → 'held', retriable errors → stays 'pending'

Job 3: Calendar Sync (/api/cron/sync-calendars)

  • Frequency: Daily or on-demand
  • Purpose: Sync customer iCal calendars with job scheduling
  • Integration: External calendar availability → job assignment engine

Deployment Configuration (vercel.json or project settings):

{
  "crons": [
    {
      "path": "/api/cron/pre-authorize",
      "schedule": "0 1 * * *"
    },
    {
      "path": "/api/cron/process-payout",
      "schedule": "*/30 9-17 * * 1-5"
    },
    {
      "path": "/api/cron/sync-calendars",
      "schedule": "0 2 * * *"
    }
  ]
}

Security: All cron routes require Authorization: Bearer {CRON_SECRET} header


Database Architecture

Core Entity Relationships

users (Auth)
  └─→ customers (stripeCustomerId, email, phone)
       ├─→ properties (address, beds, baths, sqft, amenities)
           └─→ jobs (scheduledDate, status, paymentStatus)
                ├─→ jobsToCleaners (role: primary/backup/laundry_lead)
                    └─→ cleaners (reliabilityScore, stripeAccountId)
                ├─→ evidencePackets (photos, GPS, checklist)
                └─→ payouts (amount, bonuses, status)
       └─→ subscriptions (isFirstCleanPrepaid, frequency)

Platform Level:
  ├─→ reserveTransactions (2% reserve tracking)
  ├─→ pricing (dynamic pricing rules)
  └─→ availabilities (cleaner schedules)

Key Schema Highlights

Jobs Table (db/schemas/jobs.schema.ts):

export const jobs = pgTable('jobs', {
  id: uuid,
  propertyId: uuid,
  scheduledDate: date,
  scheduledTime: time,
  expectedHours: numeric,
  status: jobStatusEnum,           // unassigned, assigned, in-progress, completed
  paymentStatus: paymentStatusEnum, // pending, authorized, captured
  paymentIntentId: text,            // Stripe PaymentIntent ID
  isUrgent: boolean,
  completedAt: timestamp
});

Payouts Table (db/schemas/payouts.schema.ts):

export const payouts = pgTable('payouts', {
  id: uuid,
  jobId: uuid,
  cleanerId: uuid,
  amount: numeric,                  // Base pay
  urgentBonusAmount: numeric,       // $10 if urgent
  laundryBonusAmount: numeric,      // $5/load
  stripePayoutId: text,             // Stripe Transfer ID
  status: enum('pending','released','held'),
  createdAt: timestamp,
  updatedAt: timestamp
});

Reserve Transactions (db/schemas/reserveTransactions.schema.ts):

export const reserveTransactions = pgTable('reserve_transactions', {
  id: uuid,
  jobId: uuid,
  amount: numeric,                  // 2% of captured payment
  type: enum('deduction','refund','withdrawal'),
  createdAt: timestamp
});

Dynamic Pricing Engine

Service: lib/services/pricing.service.ts

The PricingService calculates prices based on property characteristics:

Input Factors

  • Bedrooms: 1-6+ rooms
  • Bathrooms: 1-5+ bathrooms
  • Square Footage: Surcharge for large homes (>3000 sqft)
  • Laundry Service: In-unit ($X/load) vs Off-site ($Y/load) vs None
  • Hot Tub: Base service + drain cadence (monthly/quarterly)

Calculation Example

const pricing = PricingService.calculatePrice({
  bedrooms: 3,
  bathrooms: 2,
  sqft: 2400,
  laundryService: 'in_unit',
  laundryLoads: 2,
  hasHotTub: true,
  hotTubService: 'standard',
  hotTubDrain: true,
  hotTubDrainCadence: 'quarterly'
});

// Returns:
{
  basePrice: 120.00,        // 3BR/2BA base rate
  sqftSurcharge: 0,         // No surcharge under 3000 sqft
  laundryCost: 30.00,       // 2 loads × $15
  hotTubCost: 45.00,        // Standard service + quarterly drain
  totalPerClean: 195.00,
  isCustomQuote: false
}

Security Implementation

// CLIENT-SIDE: Display only
<PriceSummary pricing={estimatedPricing} />

// SERVER-SIDE: Validation before payment
export async function processPayment(data) {
  // Recalculate price server-side
  const serverPricing = PricingService.calculatePrice({
    bedrooms: data.bedrooms,
    // ... all property data
  });

  // Reject if client-submitted price doesn't match
  if (data.submittedPrice !== serverPricing.totalPerClean) {
    throw new Error('Price validation failed');
  }

  // Proceed with payment
  const paymentIntent = await stripe.paymentIntents.create({
    amount: serverPricing.totalPerClean * 100,
    // ...
  });
}

Prevents: Client-side price manipulation via browser DevTools


Security & Validation

1. Server-Side Price Validation

  • Client cannot tamper with pricing logic
  • All payments validated against PricingService.calculatePrice()
  • Price mismatches rejected before Stripe API call

2. Cron Job Authentication

// All cron endpoints require:
const authHeader = request.headers.get('authorization');
const token = authHeader?.replace('Bearer ', '');

if (token !== process.env.CRON_SECRET) {
  return new Response('Unauthorized', { status: 401 });
}

3. API Key Authentication

// Cleaner app endpoints require:
const apiKey = request.headers.get('x-api-key');

if (apiKey !== process.env.CLEANER_APP_API_KEY) {
  return new Response('Forbidden', { status: 403 });
}

4. Evidence Packet Validation

  • Payment capture blocked until evidence complete
  • Requires: Check-in/out times, GPS coordinates, photos, checklist
  • Prevents fraudulent payment capture

5. Idempotency Checks

// Prevent double-capture
if (job.paymentStatus === 'captured') {
  return { error: 'Payment already captured' };
}

// Prevent double-payout
if (payout.status === 'released') {
  return { error: 'Payout already processed' };
}

6. Stripe Connect Validation

  • Cleaners must complete Stripe onboarding
  • stripeAccountId and stripeOnboardingComplete verified
  • Invalid accounts → payout status 'held'

Technical Highlights

1. Next.js 15 App Router

  • Server Components for data fetching
  • Server Actions for type-safe mutations
  • Route Handlers for RESTful API endpoints
  • Turbopack for faster development builds

2. Type-Safe Database Layer

  • Drizzle ORM with full TypeScript inference
  • 26 schema files with relationships
  • Migrations tracked in version control
  • Enum types for status fields prevent invalid states

3. Stripe Integration Best Practices

  • PaymentIntents API (not deprecated Charges)
  • Manual capture for hold-then-charge workflow
  • Stripe Connect Express for cleaner payouts
  • Metadata on all transactions for reconciliation
  • Idempotency keys prevent duplicate charges

4. GPS & Evidence Tracking

  • Check-in/out GPS validation verifies cleaner presence
  • Before/after photos stored in evidence packets
  • Checklist completion required for payment
  • Timestamps provide audit trail

5. Cleaner Assignment Algorithm

  • Reliability score (calculated from past performance)
  • Geographic proximity using lat/long distance
  • Availability windows from cleaners' calendars
  • Skill matching for hot tub, laundry specializations

6. Error Handling & Observability

  • Structured logging for all cron jobs
  • Error states (pending/held) for manual review
  • Retry logic for transient failures
  • Admin notifications for critical failures

Business Logic Flows

Payment Capture Decision Tree

Job Completed
  
Evidence Packet Submitted?
  ├─ No  Block capture, notify cleaner
  └─ Yes
      
Evidence Complete? (photos, GPS, checklist)
  ├─ No  Block capture
  └─ Yes
      
Payment Status = 'authorized'?
  ├─ No  Error (investigate)
  └─ Yes
      
Capture Payment
      
Deduct 2% Reserve
      
Create Payout Records (status: 'pending')
      
Update Job Status = 'completed'

Payout Processing Decision Tree

Payout Status = 'pending'
  
Cleaner Has Stripe Account?
  ├─ No  Status: 'held', notify cleaner
  └─ Yes
      
Stripe Onboarding Complete?
  ├─ No  Status: 'held'
  └─ Yes
      
Amount >= $1.00?
  ├─ No  Status: 'held' (below Stripe minimum)
  └─ Yes
      
Create Stripe Transfer
      
Transfer Successful?
  ├─ No (permanent error)  Status: 'held'
  ├─ No (retriable error)  Stays 'pending', retry later
  └─ Yes  Status: 'released', save stripePayoutId

Key Learnings & Insights

1. Pre-Authorization Reduces Risk

  • 7-day hold window provides flexibility for cancellations
  • Guaranteed payment once job completes
  • No-show protection - can release hold if cleaner doesn't arrive
  • Better than subscriptions alone - subscriptions don't hold funds

2. Evidence-Based Capture Prevents Fraud

  • Photo evidence proves work was done
  • GPS verification confirms cleaner was on-site
  • Checklist completion ensures quality standards met
  • Customer disputes reduced by transparent evidence

3. Automated Payouts Improve Cleaner Retention

  • Fast payment (15-30 min after capture) beats industry standard
  • Transparent bonuses (urgent, laundry) incentivize flexibility
  • Stripe Express handles tax forms and compliance
  • Direct deposit eliminates check processing

4. Cron Jobs Enable Scalability

  • Zero manual intervention for thousands of jobs
  • Predictable cash flow - payments authorized night before
  • Automatic retry logic handles transient failures
  • Admin intervention only for permanent failures

5. Server-Side Validation Is Critical

  • Client-side pricing for UX only, never trusted
  • All payments validated server-side before Stripe call
  • Prevents tampering via browser DevTools
  • Type safety with TypeScript catches bugs early

Future Enhancements

Short-Term Roadmap

  1. Customer refund flow - Use reserve fund for disputes
  2. Cleaner tip system - Optional tips captured with payment
  3. Dynamic pricing adjustments - Machine learning for demand-based pricing
  4. Real-time GPS tracking - Live map during job execution
  5. SMS notifications - Twilio integration for job reminders

Long-Term Vision

  1. Multi-region support - Expand beyond single market
  2. Cleaner teams - Team-based assignments for larger properties
  3. Recurring job optimization - AI-powered scheduling
  4. Customer portal - Self-service job rescheduling, billing history
  5. Analytics dashboard - Revenue, completion rates, cleaner performance

Conclusion

CleanNami demonstrates sophisticated payment orchestration in a service marketplace. The 4-stage payment pipeline (prepaid → pre-auth → capture → payout) balances customer trust, business cash flow, and cleaner satisfaction.

Key technical achievements:

  • Zero-touch payment processing via automated cron jobs
  • Evidence-based capture prevents fraud and disputes
  • Real-time cleaner payouts via Stripe Connect
  • Server-side validation prevents client-side tampering
  • Comprehensive audit trail with GPS, photos, timestamps

The platform handles the complexity of multi-party transactions (customer → platform → cleaners) while maintaining strict financial controls and providing excellent UX for all stakeholders.

Tech Stack: Next.js 15, React 19, Supabase, Drizzle ORM, Stripe, Stripe Connect, Netlify, Cron Payment Volume: Designed to scale to thousands of transactions daily Automation Level: 95%+ of payments processed without manual intervention


Repository Structure

cleannami-nextjs-main/
├── app/
   ├── api/                    # 19 API routes
      ├── jobs/              # Job CRUD + actions
      ├── cleaners/          # Cleaner management
      ├── customers/         # Customer management
      └── cron/              # 3 automated cron jobs
   └── dashboard/             # Admin & cleaner dashboards
├── components/
   ├── customers/             # Booking flow components
   └── dashboard/             # Dashboard UI components
├── db/
   └── schemas/               # 26 database schemas
├── lib/
   ├── actions/               # Next.js Server Actions
   ├── services/              # Business logic (PricingService)
   └── utils/                 # Shared utilities
├── Docs/                      # API & setup documentation
└── pricing_data/              # Pricing configuration

Case Study Prepared: October 27, 2025 Platform Status: Production-ready with automated workflows Documentation: Comprehensive API docs in /Docs directory