Operations software for cleaning and care businesses, including booking, provider dispatch, notifications, and payment workflows.
The approved concept 45 compact SK ligature is the product identity. Exact Figma source exports are preserved in brand/source; cleaned, editable production masters for the deep blue-green, white, black, symbol-only, and horizontal lockups live in brand/svg. Platform raster derivatives live with the applications that consume them.
This repository is a small monorepo. Run each app from its own folder:
apps/api- FastAPI backend, Postgres schema, demo seed, workersapps/mobile- Expo React Native appapps/payment- optional Stripe-backed payment service
- New developer onboarding
- Central environment variable reference
- Architecture overview
- Mobile architecture
- API architecture
- Payment architecture
- Deployment architecture
- Staging runbook
- Troubleshooting
- OpenSpec workflow
The local workflow below is Windows PowerShell first because that is the current development environment. macOS/Linux equivalents are mostly the same, except virtual environment activation paths and shell environment-variable syntax.
- Python 3.11+
- Node.js and npm
- Docker Desktop
- Expo Go for device testing, or an Android/iOS simulator
- Optional: Stripe CLI and Stripe test credentials for real payment-service testing
- Optional: Google Directions API key for route polylines and ETA cards
The simplest local startup is:
.\scripts\start-local.ps1That command:
- starts Postgres with Docker Compose
- creates
apps/api/.venvif needed - creates and normalizes
apps/api/.envfor Windows host-to-Docker development - installs API dependencies unless
-SkipInstallis passed - runs Alembic migrations
- starts the API in a separate PowerShell window
- seeds demo data
- starts Expo in the current window
Common variants:
# Use the Mt. Juliet demo seed
.\scripts\start-local.ps1 -DemoMarket mt_juliet
# Use Expo tunnel for Metro connectivity
.\scripts\start-local.ps1 -Tunnel
# Skip dependency installation on repeat startups
.\scripts\start-local.ps1 -SkipInstall
# Use another API port if 8000 is already occupied
.\scripts\start-local.ps1 -Port 8002 -ApiBaseUrl "http://<YOUR-LAN-IP>:8002"
# Physical device on the same LAN
.\scripts\start-local.ps1 -ApiBaseUrl "http://<YOUR-LAN-IP>:8000"
# Physical device with Expo tunnel, matching npm start -- --tunnel
.\scripts\start-local.ps1 -ApiBaseUrl "http://192.168.1.14:8000" -Tunnel
# Service payment mode with local payment service and Expo Go return URL
.\scripts\start-local.ps1 `
-ApiBaseUrl "http://192.168.1.14:8000" `
-DemoMarket mt_juliet `
-Tunnel `
-PaymentMode service `
-MobileRedirectBase "exp://192.168.1.14:8081/--"
# Same service-mode startup, but with API on port 8002
.\scripts\start-local.ps1 `
-Port 8002 `
-ApiBaseUrl "http://192.168.1.14:8002" `
-DemoMarket mt_juliet `
-Tunnel `
-PaymentMode service `
-MobileRedirectBase "exp://192.168.1.14:8081/--"You can also start each side independently:
.\scripts\start-api.ps1
.\scripts\start-mobile.ps1For an Android emulator, point mobile at the emulator host bridge:
.\scripts\start-mobile.ps1 -ApiBaseUrl "http://10.0.2.2:8000"Manual setup commands are below for troubleshooting and non-Windows shells.
scripts/start-api.ps1 prepares and runs the backend stack:
.\scripts\start-api.ps1Useful options:
-DemoMarket shelbyor-DemoMarket mt_juliet-NoSeedto skip thePOST /dev/seedcall-ResetDbto rundocker compose down -vbefore starting Postgres-SkipInstallto skippip install-PaymentMode mockor-PaymentMode service-PaymentServiceBaseUrl "http://localhost:8001"for service payment mode-MobileRedirectBase "exp://<YOUR-LAN-IP>:8081/--"for Expo Go returns, orskruhb://appfor a dev build-Port 8000to override the API port
If apps/api/.env already exists, the scripts preserve existing payment settings unless you explicitly pass -PaymentMode, -PaymentServiceBaseUrl, or -MobileRedirectBase.
scripts/start-mobile.ps1 prepares Expo environment variables and starts the mobile app:
.\scripts\start-mobile.ps1 -ApiBaseUrl "http://localhost:8000"Useful options:
-ApiBaseUrl "http://10.0.2.2:8000"for Android emulator-ApiBaseUrl "http://<YOUR-LAN-IP>:8000"for a physical device-DemoMarket shelbyor-DemoMarket mt_julietto choose which demo login buttons Expo displays-ExpectedPaymentMode serviceto fail fast if the API is not actually running in service payment mode-Tunnelto runnpm start -- --tunnel-SkipApiCheckto skip the preflightGET /healthcheck-SkipInstallto skipnpm install
scripts/start-payment.ps1 prepares and runs the optional Stripe payment service:
.\scripts\start-payment.ps1Useful options:
-Port 8001to override the payment service port-SkipInstallto skippip install -e .
The payment script expects apps/payment/.env to contain STRIPE_API_KEY and STRIPE_WEBHOOK_SECRET.
scripts/start-local.ps1 opens the API script in a separate PowerShell window, waits briefly, then starts Expo in the current window.
When -PaymentMode service is passed, start-local.ps1 also starts apps/payment in a separate PowerShell window before starting the API. The local payment service defaults to http://localhost:8001.
Useful options:
-Port 8002to pass a non-default API port through tostart-api.ps1-ApiBaseUrl "http://<YOUR-LAN-IP>:8002"to point Expo at that same API port on a physical device-PaymentPort 8001to override the local payment service port-PaymentServiceBaseUrl "http://localhost:8001"to point the API at a specific payment service URL-SkipPaymentServiceto use an already-running payment service without auto-starting one
apps/api/docker-compose.yml runs Postgres only:
cd .\apps\api
docker compose up -dIf your Docker install only supports the old command, use docker-compose up -d.
Create and install the backend environment:
cd .\apps\api
py -3.11 -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r ..\..\requirements.txt
pip install -e .Create .env:
Copy-Item .env.example .envImportant local database setting:
- Use
localhostinDATABASE_URLwhen the API runs directly on Windows and Postgres runs in Docker. - Use
dbonly from another container on the same Docker network.
Known-good Windows host value:
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/shoeinnIf you previously started Postgres with different credentials, the Docker volume keeps them. To reset the local DB and delete all local data:
docker compose down -v
docker compose up -d
python -m alembic upgrade headStart the API:
python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000Health checks:
Invoke-RestMethod http://localhost:8000/health
Invoke-RestMethod http://localhost:8000/ready/health only confirms the process is alive. /ready also checks the database, migration heads, and required notification table.
Seed the default Shelby County, Alabama demo:
Invoke-RestMethod -Method Post "http://localhost:8000/dev/seed?reset=true"Seed the Mt. Juliet, Tennessee demo:
Invoke-RestMethod -Method Post "http://localhost:8000/dev/seed?reset=true&demo_market=mt_juliet"Default demo logins all use Password1!:
- Global admin:
admin@skruhb.com - Customer:
customer@skruhb.com - Pelham owner:
pelham.admin@skruhb.com - Pelham providers:
pelham.driver1@skruhb.com,pelham.driver2@skruhb.com - Helena owner:
helena.admin@skruhb.com - Helena provider:
helena.driver@skruhb.com - Alabaster owner:
alabaster.admin@skruhb.com - Alabaster provider:
alabaster.driver@skruhb.com
Mt. Juliet quick-demo logins use Password123!:
- Customer:
customer.mtjuliet@skruhb.demo - Provider:
provider.mtjuliet@skruhb.demo - Company admin:
admin.mtjuliet@skruhb.demo
The seed response also returns the current login list and generated company IDs.
When reset=true is used, the seed endpoint clears all known demo markets before creating the requested market. That keeps Shelby/Helena records from showing up after reseeding Mt. Juliet.
Install dependencies:
cd .\apps\mobile
npm installSet the API base URL before starting Expo. Set both names for now: most code reads EXPO_PUBLIC_API_BASE_URL, while a service-admin helper still reads EXPO_PUBLIC_API_URL.
Windows host or iOS simulator:
$env:EXPO_PUBLIC_API_BASE_URL="http://localhost:8000"
$env:EXPO_PUBLIC_API_URL=$env:EXPO_PUBLIC_API_BASE_URL
npm startAndroid emulator:
$env:EXPO_PUBLIC_API_BASE_URL="http://10.0.2.2:8000"
$env:EXPO_PUBLIC_API_URL=$env:EXPO_PUBLIC_API_BASE_URL
npm startPhysical device on the same LAN:
$env:EXPO_PUBLIC_API_BASE_URL="http://<YOUR-LAN-IP>:8000"
$env:EXPO_PUBLIC_API_URL=$env:EXPO_PUBLIC_API_BASE_URL
npm startUse Expo tunnel only for Metro connectivity if needed:
npx expo start --tunnelTunnel mode does not automatically expose the backend API. A phone still needs an API URL it can reach.
Optional mobile environment values:
$env:EXPO_PUBLIC_GOOGLE_MAPS_API_KEY="your-google-directions-api-key"
$env:EXPO_PUBLIC_MOBILE_REDIRECT_BASE="exp://<YOUR-LAN-IP>:8081/--"Use EXPO_PUBLIC_MOBILE_REDIRECT_BASE="skruhb://app" for a dev build or standalone app that supports the custom scheme.
Most local development should keep the API in mock payment mode:
PAYMENT_MODE=mock
PAYMENT_SERVICE_BASE_URL=
PAYMENT_MOBILE_REDIRECT_BASE=Only start apps/payment when validating the Stripe-hosted checkout path.
Start the payment service:
cd .\apps\payment
py -3.11 -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -e .
$env:STRIPE_API_KEY="sk_test_..."
$env:STRIPE_WEBHOOK_SECRET="whsec_..."
$env:DATABASE_URL="sqlite:///./payment.db"
$env:TENANT_ID="public"
python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8001Then set these API values in apps/api/.env:
PAYMENT_MODE=service
PAYMENT_SERVICE_BASE_URL=http://localhost:8001
PAYMENT_MOBILE_REDIRECT_BASE=skruhb://appFor Expo Go return-flow testing, use an explicit Expo URL instead:
PAYMENT_MOBILE_REDIRECT_BASE=exp://<YOUR-LAN-IP>:8081/--Forward Stripe webhooks in a separate shell when testing automatic reconciliation:
stripe listen --forward-to http://localhost:8001/payments/webhooks/stripeIf you want the payment service to push status back to the booking API:
$env:BOOKING_API_WEBHOOK_URL="http://localhost:8000/webhooks/payments"Use this path when validating real Stripe Checkout from mobile:
- Start
apps/paymentwith Stripe test keys. - Start
apps/apiwithPAYMENT_MODE=service,PAYMENT_SERVICE_BASE_URL, andPAYMENT_MOBILE_REDIRECT_BASE. - Start mobile with the correct LAN API URL.
- Select
Add new card in secure checkout. - Tap
Place Bookingand verify Stripe Checkout opens immediately. - Cancel Checkout and verify the appointment remains visible as payment pending.
- Reopen the appointment and use
Open secure checkout,Check payment status, orCancel unpaid booking. - Complete payment and verify return or
Check payment statusmoves the appointment to paid/confirmed.
The API process starts the payment sync worker only when:
PAYMENT_MODE=servicePAYMENT_SERVICE_BASE_URLis configuredENABLE_PAYMENT_SYNC_WORKERis not disabled
The notification worker is manual:
cd .\apps\api
.\.venv\Scripts\Activate.ps1
python -m app.workers.notification_workerStart it only when you need queued email, push, or in-app delivery validation.
Run backend tests:
cd .\apps\api
.\.venv\Scripts\Activate.ps1
python -m pytest tests -qRun a focused backend test file:
python -m pytest tests\test_assignment_claiming.py -qThe backend tests use in-memory SQLite through apps/api/tests/conftest.py, so they do not require local Postgres, migrations, or seeded data.
Run mobile tests and type checks:
cd .\apps\mobile
npm test -- --runInBand
npm run typecheckRun payment service tests:
cd .\apps\payment
.\.venv\Scripts\Activate.ps1
python -m pytest tests -qRecommended pre-demo validation:
cd .\apps\api
python -m pytest tests\test_assignment_claiming.py tests\test_dev_seed.py tests\test_payment_gateway.py -q
cd ..\mobile
npm test -- --runInBand
npm run typecheckpassword authentication failed for user
The Postgres Docker volume likely has credentials from an older run. Reset it from apps/api:
docker compose down -v
docker compose up -d
python -m alembic upgrade headMobile cannot reach the API
- Android emulator should use
http://10.0.2.2:8000. - Physical devices should use
http://<YOUR-LAN-IP>:8000. - Confirm the API is bound to
0.0.0.0. - Confirm Windows Firewall allows inbound traffic to port
8000when using a physical device. - If using
-Port 8002, update both values:-Port 8002 -ApiBaseUrl "http://<YOUR-LAN-IP>:8002". - If
http://localhost:<PORT>/healthworks buthttp://<YOUR-LAN-IP>:<PORT>/healthdoes not work from the same machine, the issue is the LAN IP or Windows Firewall rule for that port.
Payment confirmation fails in service mode
- Confirm
PAYMENT_SERVICE_BASE_URLis set. - Confirm
PAYMENT_MOBILE_REDIRECT_BASEis not blank or placeholder. - Keep
PAYMENT_MODE=mockif the payment service is not intentionally running.
Push warning: No projectId found
- Run
eas initinapps/mobile. - Ensure
extra.eas.projectIdis configured in the Expo app config.
Notifications are not sending
- Confirm the notification worker is running.
- Check
notification_outbox.status. - Verify push tokens exist for the target users.
GET /health- process healthGET /ready- database and migration readinessPOST /dev/seed?reset=true- reset and seed default demo dataPOST /dev/seed?reset=true&demo_market=mt_juliet- reset and seed Mt. Juliet demo dataGET /companies- list companiesGET /services- list servicesPOST /auth/login- login
Example login:
Invoke-RestMethod -Method Post "http://localhost:8000/auth/login" `
-ContentType "application/json" `
-Body '{"email":"customer@skruhb.com","password":"Password1!"}'