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:
- Customer Information - Name, email, phone number capture
- Property Details - Address with geocoding, bedrooms, bathrooms, square footage
- Service Add-ons - Laundry service type (in-unit/off-site), hot tub maintenance levels
- Calendar Integration - iCal sync or manual schedule selection
- Subscription Terms - Monthly/annual duration selection
- Payment Method - Stripe Elements integration for card capture
- Order Confirmation - Price validation and payment processing
- 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:
- Server-side pricing calculation (prevents tampering)
- Price validation against
PricingService - Customer record created/updated in Stripe
- 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 arrivesapp/api/jobs/[id]/check-out/route.ts- Cleaner finishes
Cleaner Actions During Job:
- Check-in - GPS verification, timestamp logged
- Complete checklist - Item-by-item task verification
- Upload evidence - Before/after photos, GPS tracking logs
- 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:
pending- Created after payment capture, awaiting processingreleased- Stripe Transfer successful, funds sent to cleanerheld- 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
stripeAccountIdin cleaners table - Only after
stripeOnboardingComplete = truecan 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
stripeAccountIdandstripeOnboardingCompleteverified- 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
- Customer refund flow - Use reserve fund for disputes
- Cleaner tip system - Optional tips captured with payment
- Dynamic pricing adjustments - Machine learning for demand-based pricing
- Real-time GPS tracking - Live map during job execution
- SMS notifications - Twilio integration for job reminders
Long-Term Vision
- Multi-region support - Expand beyond single market
- Cleaner teams - Team-based assignments for larger properties
- Recurring job optimization - AI-powered scheduling
- Customer portal - Self-service job rescheduling, billing history
- 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