Architecture
System Overview
Evergrn is a three-tier application:
ββββββββββββββββββββββββ βββββββββββββββββββββββββ βββββββββββββββββββββββ
β React Web (Vite) β β Azure Static Web App β β React Native (Expo)β
β port 5173 (dev) β β web.staging.evergrn.coβ β iOS only β
β Vite proxy β :3000 β β Entra MFA required β β staging.evergrn.co β
ββββββββββ¬ββββββββββββββ ββββββββββββ¬ββββββββββββββ ββββββββββββ¬βββββββββββ
β localhost β VITE_API_BASE β JWT auth
β β fetch interceptor β
βΌ βΌ βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Node.js / Express API (port 3000) β
β src/app.js β src/routes/* β src/middleware/* β
β β
β Dev: evergrn-api-dev-c7dxhkf3ctcgdqby.canadacentral-01.azurewebsites.netβ
β Staging: staging.evergrn.co (evergrn-api-stage App Service B1) β
β Prod: evergrn.co (not yet provisioned) β
βββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Prisma ORM
βΌ
βββββββββββββββββββββββ βββββββββββββββ
β PostgreSQL β β Stripe β
β dev: evergrn-db β β (payments)β
β stage: evergrn-db- β β β
β stage β β β
βββββββββββββββββββββββ βββββββββββββββ
Environments
|
Dev |
Staging |
Production |
| API URL |
evergrn-api-dev-*.azurewebsites.net |
staging.evergrn.co |
evergrn.co (TBD) |
| Web URL |
localhost:5173 (Vite) |
web.staging.evergrn.co (Azure SWA) |
evergrn.co (TBD) |
| Web hosting |
Vite dev server |
Azure Static Web App Standard |
TBD |
| Database |
evergrn-db (shared dev data) |
evergrn-db-stage (isolated) |
evergrn-db-prod (TBD) |
| GodMode |
β
Enabled |
β Disabled |
β Disabled |
| Access control |
Open |
Entra CA policy (MFA) on web; API open |
Public |
| Deploy trigger |
.\deploy.ps1 |
API: ADO pipeline ID 2 (manual); Web SWA: ADO pipeline ID 4 (manual) |
TBD |
| iOS API target |
Dev App Service (via .env) |
staging.evergrn.co (via eas.json env) |
TBD |
Tech Stack
| Layer |
Technology |
Version / Notes |
| API runtime |
Node.js + Express |
CommonJS modules |
| ORM |
Prisma |
PostgreSQL driver |
| Database |
PostgreSQL |
Local in dev |
| Auth |
jsonwebtoken + bcryptjs |
HS256, 12 salt rounds |
| Payments |
Stripe |
PaymentIntents with manual capture |
| File uploads |
Multer (memoryStorage) + @azure/storage-blob |
Azure Blob Storage (evergrnuploads) |
| Input validation |
zod |
Schema validation middleware for all body-receiving routes |
| Rate limiting |
express-rate-limit |
Auth login + forgot-password endpoints |
| Web framework |
React 19 + Vite |
TailwindCSS v4 |
| Mobile framework |
React Native 0.81.5 + Expo SDK 54 |
Hermes engine; iOS primary; Android support in progress (eas.json Android profiles added 2026-06-28, Google Play Developer account pending approval) |
| Mobile auth storage |
expo-secure-store |
Encrypted on device |
| Push notifications |
expo-notifications + expo-server-sdk |
Expo push token saved on User/Provider; sent from backend |
| Maps (web) |
Leaflet |
MapPicker component |
| Navigation (mobile) |
React Navigation v6 |
Stack + Bottom Tabs |
| Weather |
Open-Meteo + zippopotam.us |
Free, no API key; 1h in-memory cache per ZIP; zippopotam.us for ZIPβlat/lng geocoding |
| Observability |
Azure Application Insights |
SDK in server.js; auto-instruments Express, Prisma, Stripe |
| Document Intelligence |
@azure-rest/ai-document-intelligence |
prebuilt-idDocument (driver's license OCR) + prebuilt-read (insurance certificate OCR); src/config/docIntelligence.js |
Database Schema
Models
User (customer accounts)
| Field |
Type |
Notes |
| id |
String (cuid) |
PK |
| email |
String |
unique |
| password |
String |
bcrypt hash |
| name |
String |
|
| address |
String |
home address for job ZIP inference |
| zip |
String |
|
| lat / lng |
Float? |
optional map pin |
| stripeCustomerId |
String? |
set on first card save |
| stripePaymentMethodId |
String? |
saved card reference |
| stripeSubscriptionId |
String? |
set when customer starts premium subscription |
| isPremium |
Boolean |
default false; unlocks multi-address, favorites, pro notes, spend summary, seasonal alerts |
| premiumSince |
DateTime? |
set when subscription first activates |
| seasonalAlertsEnabled |
Boolean |
default false; premium-only opt-in for seasonal push alerts |
| pushToken |
String? |
Expo push token; registered on mobile login |
| createdAt / updatedAt |
DateTime |
|
Relations: jobs[], payments[], reviews[], addresses UserAddress[], reports JobReport[], favoriteProviders FavoriteProvider[], proNotes ProNote[]
Provider (professional accounts)
| Field |
Type |
Notes |
| id |
String (cuid) |
PK |
| email |
String |
unique |
| password |
String |
bcrypt hash |
| name |
String |
|
| phone |
String? |
|
| companyName |
String? |
|
| companyUrl |
String? |
|
| insuranceInfo |
String? |
Auto-populated by OCR from insCheck* fields; not editable from mobile (image-upload only) |
| insDocUrl |
String? |
Azure blob URL of uploaded insurance certificate image |
| insCheckStatus |
String? |
pending / active / expired / review / error |
| insCheckCarrier |
String? |
OCR-extracted carrier name |
| insCheckPolicy |
String? |
OCR-extracted policy number |
| insCheckEffective |
String? |
OCR-extracted effective date (ISO format) |
| insCheckExpiry |
String? |
OCR-extracted expiry date (ISO format) |
| insCheckCoverage |
String? |
OCR-extracted coverage amount |
| insCheckFlags |
String[] |
e.g. ['expired', 'no_policy', 'no_carrier', 'near_expiry'] |
| insCheckAt |
DateTime? |
Timestamp of last OCR run |
| logoUrl |
String? |
Azure blob URL (https://evergrnuploads.blob.core.windows.net/logos/...) |
| headshots |
String[] |
Staff headshot blob URLs; required before bidding |
| idDocFront |
String? |
Driver's license front blob URL; required before bidding |
| idDocBack |
String? |
Driver's license back blob URL; required before bidding |
| idCheckStatus |
String? |
pending / passed / review / failed / error |
| idCheckName |
String? |
OCR-extracted full name |
| idCheckDOB |
String? |
OCR-extracted date of birth (ISO format) |
| idCheckExpiry |
String? |
OCR-extracted expiry date (ISO format) |
| idCheckState |
String? |
OCR-extracted issuing state/region |
| idCheckFlags |
String[] |
e.g. ['expired', 'near_expiry', 'no_dob', 'not_a_license'] |
| idCheckAt |
DateTime? |
Timestamp of last OCR run |
| services |
String[] |
e.g. ['lawncare','snowplowing'] |
| serviceZips |
String[] |
ZIP codes they serve |
| availability |
Json? |
Weekly schedule: { mon: { start, end, on }, ... } |
| isPremium |
Boolean |
default false β Preferred Pro member |
| premiumSince |
DateTime? |
when Preferred Pro was activated |
| premiumBilledMonth |
String? |
last month billed ("YYYY-MM") β prevents double-charging |
| preferredProAppliedAt |
DateTime? |
set when provider applies to Preferred Pro |
| pushToken |
String? |
Expo push token; registered on mobile login |
| createdAt / updatedAt |
DateTime |
|
Relations: quotes[], jobs[], reviews[]
Job
| Field |
Type |
Notes |
| id |
String (cuid) |
PK |
| customerId |
String |
FK β User |
| providerId |
String? |
FK β Provider; set on quote accept |
| addressId |
String? |
FK β UserAddress; set if customer selects a saved address at booking |
| service |
String |
lawncare / snowplowing / handyman / home_cleaning |
| address |
String |
copied from customer address at creation |
| zip |
String |
used for provider matching |
| description |
String? |
customer's free-text description |
| preferredAt |
DateTime |
customer's requested time |
| isAsap |
Boolean |
default false; urgent jobs sort to top of provider feed + trigger URGENT push notification |
| recurringGroupId |
String? |
shared ID linking jobs in the same recurring series |
| lat / lng |
Float? |
optional |
| details |
Json? |
structured service-specific details |
| images |
String[] |
general job photos (blob URLs) |
| beforeImages |
String[] |
set when provider starts job (blob URLs) |
| afterImages |
String[] |
set when provider completes job (blob URLs) |
| status |
JobStatus |
see enum below |
| createdAt / updatedAt |
DateTime |
|
Relations: customer, provider?, userAddress?, quotes[], messages[], payment?, review?, report JobReport?
Quote
| Field |
Type |
Notes |
| id |
String (cuid) |
PK |
| jobId |
String |
FK β Job |
| providerId |
String |
FK β Provider |
| amount |
Float |
provider's net price |
| platformFeeRate |
Float |
0.18 (18%) |
| platformFee |
Float |
amount Γ 0.18 |
| totalAmount |
Float |
amount + platformFee |
| scheduledAt |
DateTime? |
proposed service date/time |
| note |
String? |
optional message to customer |
| status |
QuoteStatus |
PENDING / ACCEPTED / REJECTED |
| createdAt |
DateTime |
|
Message
| Field |
Type |
Notes |
| id |
String (cuid) |
PK |
| jobId |
String |
FK β Job |
| senderId |
String |
User.id or Provider.id |
| senderRole |
String |
"user" or "provider" |
| content |
String |
|
| createdAt |
DateTime |
|
Review
| Field |
Type |
Notes |
| id |
String (cuid) |
PK |
| jobId |
String |
unique β one review per job |
| customerId |
String |
FK β User |
| providerId |
String |
FK β Provider |
| rating |
Int |
1β5 |
| comment |
String? |
|
| createdAt |
DateTime |
|
Payment
| Field |
Type |
Notes |
| id |
String (cuid) |
PK |
| jobId |
String |
unique β one payment per job |
| customerId |
String |
FK β User |
| amount |
Float |
quote totalAmount (platform take + provider net) |
| stripePaymentIntentId |
String? |
set on authorization at quote acceptance |
| tipAmount |
Float? |
set if customer adds a tip after job completion |
| stripeTipPaymentIntentId |
String? |
separate PaymentIntent for the tip charge |
| status |
PaymentStatus |
PENDING / PAID / FAILED |
| createdAt |
DateTime |
|
Current behavior (pre-production): Stripe capture fires immediately on job completion. The Payment Release Flow (HELD / DISPUTED states) is designed but not yet implemented β see the "Payment Release Flow" section below.
UserAddress (saved addresses per customer)
| Field |
Type |
Notes |
| id |
String (cuid) |
PK |
| userId |
String |
FK β User |
| label |
String |
e.g. "Home", "Cabin" β default "Home" |
| address |
String |
street address |
| zip |
String |
|
| isPrimary |
Boolean |
only one per user; used as default for booking |
| lat / lng |
Float? |
optional map pin |
| streetViewPhoto |
String? |
blob URL β uploaded by customer for provider reference |
| serviceAreaPhotos |
String[] |
blob URLs β additional property photos (driveway, lawn, etc.) |
| drivewayLength |
Float? |
in feet β used by snowplow providers |
| lawnAcres |
Float? |
lawn size β used by lawncare providers |
| createdAt |
DateTime |
|
JobReport (customer dispute, within 72h of completion)
| Field |
Type |
Notes |
| id |
String (cuid) |
PK |
| jobId |
String |
unique β one report per job |
| customerId |
String |
FK β User |
| description |
String |
customer's description of the problem |
| createdAt |
DateTime |
|
72-hour window is enforced server-side using job.updatedAt at the time status was set to COMPLETED. One report per job (DB unique on jobId).
ProviderPayout [NOT YET IMPLEMENTED β required before go-live]
Internal ledger of what is owed to each provider per job after a successful capture. Created by the daily payout cron when a payment is released.
| Field |
Type |
Notes |
| id |
String (cuid) |
PK |
| providerId |
String |
FK β Provider |
| jobId |
String |
FK β Job |
| paymentId |
String |
FK β Payment |
| amount |
Float |
provider's net share = quote.amount (totalAmount minus 18% platform fee) |
| status |
ProviderPayoutStatus |
PENDING / PAID |
| batchDate |
DateTime |
the 8 AM batch run that released this payout |
| paidAt |
DateTime? |
set when manual ACH/Stripe transfer is confirmed |
| createdAt |
DateTime |
|
Admin [GODMODE β remove before production]
| Field |
Type |
Notes |
| id |
String (cuid) |
PK |
| email |
String |
unique |
| password |
String |
bcrypt hash |
| createdAt |
DateTime |
|
FavoriteProvider (customer's saved providers β premium only)
| Field |
Type |
Notes |
| id |
String (cuid) |
PK |
| userId |
String |
FK β User |
| providerId |
String |
FK β Provider |
| createdAt |
DateTime |
|
Unique constraint: (userId, providerId) |
|
|
ProNote (private customer note on a provider β premium only)
| Field |
Type |
Notes |
| id |
String (cuid) |
PK |
| userId |
String |
FK β User |
| providerId |
String |
FK β Provider (not a DB relation β just stores the ID) |
| note |
String |
customer's private note text |
| updatedAt |
DateTime |
@updatedAt |
Unique constraint: (userId, providerId) β one note per provider per customer |
|
|
Notification (persistent in-app notification history)
| Field |
Type |
Notes |
| id |
String (cuid) |
PK |
| recipientId |
String |
User.id or Provider.id |
| recipientType |
String |
"user" or "provider" |
| title |
String |
|
| body |
String |
|
| jobId |
String? |
optional link to a job |
| read |
Boolean |
default false |
| createdAt |
DateTime |
|
Index: (recipientId, recipientType, read) |
|
|
WaitlistEmail (coming-soon page signups)
| Field |
Type |
Notes |
| id |
String (cuid) |
PK |
| email |
String |
unique |
| name |
String? |
|
| zip |
String? |
|
| role |
String? |
"customer" or "provider" |
| createdAt |
DateTime |
|
Staff (internal support accounts)
| Field |
Type |
Notes |
| id |
String (cuid) |
PK |
| email |
String |
unique |
| password |
String |
bcrypt hash |
| name |
String |
|
| role |
StaffRole |
see enum below |
| createdAt |
DateTime |
|
Relations: elevationRequests[], assignedTickets[] |
|
|
ElevationRequest (staff request to impersonate a user/provider account)
| Field |
Type |
Notes |
| id |
String (cuid) |
PK |
| staffId |
String |
FK β Staff |
| targetId |
String |
User.id or Provider.id being impersonated |
| targetRole |
String |
"user" or "provider" |
| targetName / targetEmail |
String |
snapshot at request time |
| reason |
String |
required justification |
| status |
ElevationStatus |
PENDING β APPROVED β ACTIVE β DEACTIVATED; or REJECTED |
| requestedAt |
DateTime |
|
| approvedAt / approvedBy |
DateTime? / String? |
set when admin clicks email approve link |
| activatedAt |
DateTime? |
set when staff triggers impersonation token |
| deactivatedAt / deactivatedBy |
DateTime? / String? |
auto-set after 15 min of ACTIVE state |
| notes |
String? |
internal admin notes |
SupportTicket
| Field |
Type |
Notes |
| id |
String (cuid) |
PK |
| submitterId |
String |
User.id or Provider.id |
| submitterRole |
String |
"user" or "provider" |
| submitterName |
String |
snapshot at submission |
| subject |
String |
|
| body |
String |
|
| status |
TicketStatus |
OPEN β IN_PROGRESS β RESOLVED β CLOSED |
| assignedToId |
String? |
FK β Staff |
| createdAt / updatedAt |
DateTime |
|
| resolvedAt |
DateTime? |
|
| notes |
String? |
internal staff notes |
AuditEvent (actions taken during an active elevation/impersonation session)
| Field |
Type |
Notes |
| id |
String (cuid) |
PK |
| elevationId |
String |
FK β ElevationRequest |
| method |
String |
HTTP method of the action |
| path |
String |
API path accessed |
| body |
String? |
request body (sanitized) |
| createdAt |
DateTime |
|
Index: (elevationId) β every request made during an active elevation is logged by auditLog middleware |
|
|
Enums
JobStatus: PENDING β QUOTED β ACCEPTED β ON_THE_WAY β IN_PROGRESS β COMPLETED
β CANCELLED
QuoteStatus: PENDING β ACCEPTED | REJECTED
PaymentStatus: PENDING β PAID | FAILED
(HELD and DISPUTED are designed for the Payment Release Flow β not yet in schema)
StaffRole: customer_support | professional_support | technical_support | internal_audit
ElevationStatus: PENDING β APPROVED β ACTIVE β DEACTIVATED
β REJECTED
TicketStatus: OPEN β IN_PROGRESS β RESOLVED β CLOSED
ProviderPayoutStatus [NOT YET IMPLEMENTED]:
PENDING β PAID
File Structure
evergrn/
βββ server.js Entry point β loads .env, starts Express on :3000
βββ package.json Backend dependencies
βββ .env Secrets (DATABASE_URL, JWT_SECRET, STRIPE_*)
βββ .env.example Template for new environments
β
βββ prisma/
β βββ schema.prisma All Prisma models, enums, relations
β
βββ src/
β βββ app.js Express setup: middleware, routes, static files
β βββ config/
β β βββ db.js PrismaClient singleton
β β βββ stripe.js Stripe client singleton
β β βββ push.js Expo push notification sender (expo-server-sdk)
β β βββ blob.js Azure Blob Storage helpers (uploadBuffer, uploadFiles, makeUpload); createIfNotExists() called before every upload
β β βββ docIntelligence.js Azure AI Document Intelligence: analyzeIdDocument() (prebuilt-idDocument) + analyzeInsuranceDocument() (prebuilt-read)
β β βββ notificationQueue.js In-memory Map queue for admin-to-user banners (replace with Redis pre-launch)
β β βββ email.js ACS email templates + sendEmail() β quoteReceived, quoteAccepted, jobCompleted, waitlistConfirmation
β β βββ mailer.js ACS-backed sendPasswordReset() β used by /auth/forgot-password
β βββ middleware/
β β βββ auth.js authenticate() β JWT verification, sets req.user
β β βββ validate.js validate(schema) β Zod middleware; returns 400 on invalid body
β β βββ auditLog.js auditLog β globally mounted; logs all requests during active elevation sessions to AuditEvent
β β βββ error.js Global error handler
β βββ schemas/
β β βββ index.js 18 Zod schemas for all body-receiving endpoints
β βββ routes/
β βββ auth.js /auth/* β register + login for both roles + password reset
β βββ jobs.js /jobs/* β full job lifecycle, messages, images, report, tip, recurring
β βββ quotes.js /quotes/* β quoting, acceptance, market rate
β βββ providers.js /providers/me/* β profile, headshots, ID docs (OCR), insurance doc (OCR), logo, schedule, insights, smart bundles, availability, premium-insights, notifications, preferred-pro, support
β βββ payments.js /payments/* β Stripe card save, setup intent, premium subscription
β βββ reviews.js /reviews β post review after completion
β βββ users.js /users/me/* β profile, location, addresses, address photos, favorites, pro notes, spend summary, seasonal alerts, push token, notifications, support
β βββ services.js /services/available β service lookup by ZIP
β βββ weather.js /weather?zip= β 14-day forecast (Open-Meteo + zippopotam.us, 1h cache)
β βββ waitlist.js /waitlist β coming-soon page signups, sends ACS confirmation email
β βββ webhooks.js /webhooks β Stripe event handling (PaymentIntents + subscription lifecycle)
β βββ staff.js /staff/* β internal support portal (login, elevation, impersonation, tickets, audit)
β βββ driveTime.js /drive-time β ETA + distance via OSRM (lat/lng or address string inputs)
β βββ admin.js /admin/* β [GODMODE] dev tooling + notify endpoint
β
βββ uploads/ Legacy local disk β no longer used for new uploads
β (Azure Blob Storage: evergrnuploads.blob.core.windows.net)
β
βββ client/ React web frontend
β βββ vite.config.js Vite + proxy config
β βββ package.json
β βββ src/
β βββ main.jsx React 19 entry point
β βββ App.jsx Router + layout + ImpersonationBanner [GODMODE]
β βββ context/
β β βββ AuthContext.jsx Auth state, localStorage, JWT decode
β βββ components/
β β βββ Navbar.jsx Top nav (hidden on landing + dashboards)
β β βββ EvergrnLogo.jsx SVG logo
β β βββ MapPicker.jsx Leaflet map for location selection
β β βββ MessageThread.jsx Job messaging UI
β βββ pages/
β βββ Landing.jsx Marketing homepage
β βββ Login.jsx Customer login
β βββ Register.jsx Customer registration
β βββ Dashboard.jsx Customer main UI β jobs, quotes, details
β βββ ProviderPortal.jsx Provider landing page
β βββ ProviderLogin.jsx Provider login
β βββ ProviderRegister.jsx Provider registration
β βββ ProviderDashboard.jsx Provider UI β browse, quote, schedule, insights
β βββ GodMode.jsx [GODMODE] Admin impersonation dashboard
β
βββ mobile/ React Native mobile app
β βββ App.js SafeAreaProvider + AuthContext + RootNavigator
β βββ app.json Expo config (bundle ID, theme color, push notification plugin)
β βββ eas.json EAS Build profiles (development/simulator, device, preview, production)
β βββ package.json
β βββ src/
β βββ api.js apiFetch() wrapper + BASE_URL (hardcoded to Azure App Service URL)
β βββ context/
β β βββ AuthContext.js SecureStore token storage + JWT parse + push token registration on login
β βββ components/
β β βββ MessageThread.js Messaging UI (shared with web pattern)
β β βββ StarRating.js 5-star rating input/display
β β βββ NotificationBanner.js Animated slide-in banner; persistent until user taps X
β βββ hooks/
β β βββ useNotificationPoller.js 30s polling hook β checks server queue then job state changes
β βββ utils/
β β βββ pushNotifications.js Requests permission, gets Expo push token, saves to backend
β βββ navigation/
β β βββ RootNavigator.js Auth gate β role-based navigator; mounts NotificationBanner overlay
β β βββ AuthNavigator.js Stack: Landing β Login/Register flows
β β βββ CustomerNavigator.js Bottom tabs for customer role
β β βββ ProviderNavigator.js Bottom tabs for provider role
β βββ screens/
β βββ auth/
β β βββ LandingScreen.js
β β βββ LoginScreen.js
β β βββ RegisterScreen.js
β β βββ ProviderLoginScreen.js
β β βββ ProviderRegisterScreen.js
β βββ customer/
β β βββ HomeScreen.js Browse + search
β β βββ BookScreen.js Post a new job
β β βββ JobsScreen.js My jobs list
β β βββ JobDetailScreen.js Detail view + messaging
β β βββ ProfileScreen.js Account settings + Report a Problem (72h window)
β βββ provider/
β βββ AvailableScreen.js Job board + conflict filter
β βββ ActiveScreen.js Assigned/active jobs
β βββ ScheduleScreen.js Upcoming schedule view
β βββ ProviderJobDetailScreen.js Quote submission + messaging
β βββ ProviderProfileScreen.js Profile editing
β
βββ scripts/ Dev/seed utilities (not for production)
β βββ seedAdmin.js Creates godmode@evergrn.com [GODMODE]
β βββ seedAvailableJobs.js Populates test PENDING jobs
β βββ seedSchedule.js Adds ACCEPTED jobs to a provider
β βββ seedConflicts.js Seeds conflicting jobs for UI testing
β βββ seedMultiAddress.js Creates multiaddress@test.com with 3 saved addresses
β βββ seedAlexJobs.js Creates completed jobs for Alex (dispute workflow testing)
β βββ reassignSchedule.js Moves jobs between provider accounts
β
βββ current-documentation/ This folder
Naming Conventions
Database / Prisma
- Models: PascalCase (
User, Provider, Job, Quote)
- Fields: camelCase (
customerId, serviceZips, stripePaymentIntentId)
- Enums: SCREAMING_SNAKE_CASE (
PENDING, ON_THE_WAY, IN_PROGRESS)
- IDs:
cuid() β collision-resistant unique IDs, URL-safe, sortable
Backend (Node.js)
- Route files: lowercase kebab or single word matching the resource (
auth.js, jobs.js, webhooks.js)
- Route variables: camelCase (
req.user, req.body.jobId)
- Middleware functions: camelCase verb+noun (
authenticate, errorHandler)
- Config exports: single named export matching the tool (
prisma, stripe)
Web Frontend (React)
- Component files: PascalCase (
Dashboard.jsx, MapPicker.jsx, AuthContext.jsx)
- Page components: PascalCase, one page per file
- Context: named
*Context.jsx, exports use* hook and *Provider
- Inline styles: JavaScript object style β no class names except on Tailwind-powered elements
- State variables: camelCase, descriptive (
scheduleJobs, filterFree, deleteTarget)
Mobile (React Native)
- Screen files: PascalCase +
Screen suffix (AvailableScreen.js, ProviderJobDetailScreen.js)
- Navigator files: PascalCase +
Navigator suffix
- Component files: PascalCase (
MessageThread.js, StarRating.js)
- StyleSheet keys: camelCase (
filterBar, conflictBanner, cardConflict)
API Routes
- Pattern:
/resource or /resource/:id/sub-resource
- Plural nouns for collections:
/jobs, /quotes, /providers
/me shortcut for authenticated user's own resource: /users/me, /providers/me
- Action sub-routes for state transitions:
/jobs/:id/cancel, /jobs/:id/start, /jobs/:id/complete
- Versioning: not yet implemented β all routes are unversioned
File Uploads
All uploads go to Azure Blob Storage (evergrnuploads, Cool tier, Canada Central). Files are held in memory by multer and streamed directly to blob β nothing touches the container filesystem.
| Container |
Path pattern |
Used by |
jobs |
{jobId}-{timestamp}-{random}.{ext} |
Job photos, before/after workflow photos |
logos |
{providerId}.{ext} |
Provider company logos (overwrites on update) |
headshots |
{providerId}-{timestamp}.{ext} |
Provider staff headshots |
id-docs |
{providerId}-front/back-{timestamp}.{ext} |
Provider ID verification docs; OCR runs in background after upload |
insurance-docs |
{providerId}-{timestamp}.{ext} |
Provider insurance certificate images; OCR runs in background after upload |
messages |
{jobId}-{timestamp}-{random}.{ext} |
Photo attachments sent in job message threads |
addresses |
{addressId}-street/area-{timestamp}.{ext} |
Customer property photos |
All returned URLs are full blob URLs: https://evergrnuploads.blob.core.windows.net/{container}/{blobName}
[GODMODE] Marker
All dev-only admin tooling is tagged with // [GODMODE] comments. To find every marked line:
grep -r "\[GODMODE\]" .
Files to delete entirely before production: src/routes/admin.js, client/src/pages/GodMode.jsx, scripts/seedAdmin.js
Platform Fee
Platform fee is 18% calculated at quote time:
platformFee = quote.amount Γ 0.18
totalAmount = quote.amount + platformFee
The platformFeeRate is stored on each Quote record so historical rates are preserved if the rate ever changes.
Payment Release Flow [NOT YET IMPLEMENTED β required before go-live]
This section documents the intended production payment lifecycle. The current codebase captures Stripe payments immediately on job completion, which must be changed before launch.
Design goals
- Customer's card is authorized at quote acceptance but not charged until the job is complete and the 72-hour dispute window has closed.
- Evergrn holds the funds for 72 hours after the provider marks the job COMPLETED.
- If no dispute is filed, funds are released in the next morning's daily 8 AM batch payout.
- If a dispute is filed within 72 hours, funds are captured to Evergrn but the provider payout is blocked until admin resolves the dispute manually.
- Provider payouts are tracked in an internal ledger (
ProviderPayout table). Actual transfers are made manually (Stripe Dashboard or ACH) on a per-batch basis. Stripe Connect is not used for MVP.
Payment lifecycle (state machine)
Quote accepted
β
βΌ
Stripe PaymentIntent created (capture_method: 'manual')
Payment.status = PENDING
β
Job marked COMPLETED by provider
β
βΌ
Payment.status = HELD
Payment.releaseAt = completedAt + 72 hours
β
ββββ Customer files JobReport within 72h βββββββββββββββββββββββββββββββ
β β
β 8 AM daily cron runs β
β β β
β releaseAt <= today 8 AM? β
β NO β skip (will be picked up at tomorrow's 8 AM batch) β
β YES β JobReport exists? β
β YES βββββββββββββββββββββββββββββββββββββββββββββββΊβ
β NO β
β β βΌ
β stripe.paymentIntents.capture() Payment.status = DISPUTED
β Payment.status = PAID ProviderPayout NOT created
β ProviderPayout created (status=PENDING) Flagged in GodMode for admin
β batchDate = today 8 AM Admin resolves manually:
β - capture + create payout, OR
βΌ - refund customer
End
Batch payout timing rules
The cron runs once daily at 8:00 AM local server time.
A payment is included in a given day's batch if and only if:
Payment.releaseAt <= today at 08:00:00
If a job is completed at 10:00 AM on Monday, releaseAt = Thursday 10:00 AM. The Thursday 8 AM batch does not include it (10 AM > 8 AM). It is picked up by the Friday 8 AM batch. This means the effective hold is between 72 and ~96 hours depending on time of completion β always at least 72h, never more than 96h.
| Completed at |
releaseAt |
Paid out at |
| Monday 6:00 AM |
Thursday 6:00 AM |
Thursday 8 AM β (6 AM < 8 AM) |
| Monday 10:00 AM |
Thursday 10:00 AM |
Friday 8 AM (10 AM > 8 AM) |
| Monday 11:59 PM |
Thursday 11:59 PM |
Friday 8 AM |
Provider payout split
The Stripe PaymentIntent is for totalAmount (provider net + 18% platform fee). When the cron captures it:
totalAmount = quote.totalAmount β charged to customer's card β Evergrn bank account
providerShare = quote.amount β owed to provider (recorded in ProviderPayout)
platformShare = quote.platformFee β retained by Evergrn
The ProviderPayout record is created with status = PENDING. A human reviews the daily batch in GodMode and initiates the ACH transfer or Stripe Dashboard transfer to the provider. Once confirmed, ProviderPayout.status is set to PAID and paidAt is recorded.
What needs to be built (implementation checklist)
Files to create/modify
src/jobs/
βββ payoutCron.js node-cron job β runs at 8 AM daily
src/routes/
βββ jobs.js remove capture calls; set HELD + releaseAt on completion
βββ admin.js add payout dashboard endpoints [GODMODE]
prisma/
βββ schema.prisma Payment (add releaseAt, new statuses); ProviderPayout model