Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,31 @@ Common variants:
# 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:
Expand Down Expand Up @@ -86,8 +106,13 @@ Useful options:
- `-NoSeed` to skip the `POST /dev/seed` call
- `-ResetDb` to run `docker compose down -v` before starting Postgres
- `-SkipInstall` to skip `pip install`
- `-PaymentMode mock` or `-PaymentMode service`
- `-PaymentServiceBaseUrl "http://localhost:8001"` for service payment mode
- `-MobileRedirectBase "exp://<YOUR-LAN-IP>:8081/--"` for Expo Go returns, or `shoeinn://app` for a dev build
- `-Port 8000` to 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:

```powershell
Expand All @@ -98,11 +123,36 @@ Useful options:

- `-ApiBaseUrl "http://10.0.2.2:8000"` for Android emulator
- `-ApiBaseUrl "http://<YOUR-LAN-IP>:8000"` for a physical device
- `-ExpectedPaymentMode service` to fail fast if the API is not actually running in service payment mode
- `-Tunnel` to run `npm start -- --tunnel`
- `-SkipApiCheck` to skip the preflight `GET /health` check
- `-SkipInstall` to skip `npm install`

`scripts/start-payment.ps1` prepares and runs the optional Stripe payment service:

```powershell
.\scripts\start-payment.ps1
```

Useful options:

- `-Port 8001` to override the payment service port
- `-SkipInstall` to skip `pip 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 8002` to pass a non-default API port through to `start-api.ps1`
- `-ApiBaseUrl "http://<YOUR-LAN-IP>:8002"` to point Expo at that same API port on a physical device
- `-PaymentPort 8001` to override the local payment service port
- `-PaymentServiceBaseUrl "http://localhost:8001"` to point the API at a specific payment service URL
- `-SkipPaymentService` to use an already-running payment service without auto-starting one

## API Local Development

`apps/api/docker-compose.yml` runs Postgres only:
Expand Down Expand Up @@ -197,6 +247,8 @@ Mt. Juliet quick-demo logins use `Password123!`:

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.

## Mobile Local Development

Install dependencies:
Expand Down Expand Up @@ -301,6 +353,19 @@ If you want the payment service to push status back to the booking API:
$env:BOOKING_API_WEBHOOK_URL="http://localhost:8000/webhooks/payments"
```

### Service-mode booking validation

Use this path when validating real Stripe Checkout from mobile:

1. Start `apps/payment` with Stripe test keys.
2. Start `apps/api` with `PAYMENT_MODE=service`, `PAYMENT_SERVICE_BASE_URL`, and `PAYMENT_MOBILE_REDIRECT_BASE`.
3. Start mobile with the correct LAN API URL.
4. Select `Add new card in secure checkout`.
5. Tap `Place Booking` and verify Stripe Checkout opens immediately.
6. Cancel Checkout and verify the appointment remains visible as payment pending.
7. Reopen the appointment and use `Open secure checkout`, `Check payment status`, or `Cancel unpaid booking`.
8. Complete payment and verify return or `Check payment status` moves the appointment to paid/confirmed.

## Workers

The API process starts the payment sync worker only when:
Expand Down Expand Up @@ -382,6 +447,8 @@ Mobile cannot reach the API
- 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 `8000` when using a physical device.
- If using `-Port 8002`, update both values: `-Port 8002 -ApiBaseUrl "http://<YOUR-LAN-IP>:8002"`.
- If `http://localhost:<PORT>/health` works but `http://<YOUR-LAN-IP>:<PORT>/health` does 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

Expand Down
43 changes: 31 additions & 12 deletions apps/api/.env.staging.example
Original file line number Diff line number Diff line change
@@ -1,29 +1,48 @@
# Copy to `.env.staging` before starting `docker compose -f docker-compose.staging.yml up`.
# Copy to `.env.staging` before starting:
# docker compose -f docker-compose.staging.yml up --build -d
#
# Staging v1 assumptions:
# Staging v2 assumptions:
# - single API instance
# - Postgres runs in Docker
# - notification worker runs as a separate service
# - payment remains in explicit mock mode
# - payment service runs as a separate service
# - payment mode uses Stripe Checkout service mode
# - websocket fanout is single-instance only

DATABASE_URL=postgresql+psycopg://postgres:postgres@db:5432/shoeinn_staging
API_HOST=0.0.0.0
API_PORT=8000
JWT_SECRET=replace-me-for-staging

# Set this to the mobile preview URL and any web origins that should reach staging.
# Set this to mobile preview origins and any web origins that should reach staging.
ALLOWED_ORIGINS=*

NOTIFICATION_DISPATCH_INTERVAL_SECONDS=5
NOTIFICATION_MAX_ATTEMPTS=5
NOTIFICATION_BACKOFF_SECONDS=30
ENABLE_NOTIFICATION_DISPATCHER=true

# Staging keeps payment simulated unless this is intentionally configured later.
PAYMENT_MODE=mock
PAYMENT_SERVICE_BASE_URL=
# Only required if PAYMENT_MODE=service. Use a mobile/frontend redirect base, not the API host.
PAYMENT_MOBILE_REDIRECT_BASE=
ENABLE_PAYMENT_SYNC_WORKER=false
# In staging, notification-worker is a separate container.
# Keep this false in the API container to avoid duplicate dispatch loops.
ENABLE_NOTIFICATION_DISPATCHER=false

# Real payment demo mode.
PAYMENT_MODE=service
PAYMENT_SERVICE_BASE_URL=http://payment:8001
ENABLE_PAYMENT_SYNC_WORKER=true
PAYMENT_CURRENCY=usd

# Demo/test seed routes are still available in staging v1 and should remain access-controlled at the deployment layer.
# Stripe Checkout browser return URLs.
# For public staging, use your real HTTPS API domain.
PAYMENT_CHECKOUT_SUCCESS_URL=https://api.your-domain.com/payments/return/success
PAYMENT_CHECKOUT_CANCEL_URL=https://api.your-domain.com/payments/return/cancel

# App return URL after the browser success/cancel page.
# For installed preview/dev builds, use your app scheme.
# For Expo Go local testing, this may be exp://<LAN-IP>:8081/--/payment-return
PAYMENT_RETURN_APP_URL=shoeinn://payment-return

# Legacy/optional redirect base. Keep blank unless existing code still reads it.
PAYMENT_MOBILE_REDIRECT_BASE=

# Demo/test seed routes are still available in staging v2.
# Protect them at the deployment layer or add an explicit seed secret before external demos.
4 changes: 4 additions & 0 deletions apps/api/app/routers/appointments.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,10 @@ def list_my_appointments(
"postal_code": appt.postal_code,
"start_time": _ensure_utc(appt.start_time),
"status": appt.status,
"payment_status": appt.payment_status,
"payment_checkout_url": appt.payment_checkout_url,
"payment_mode": settings.payment_mode,
"payment_message": _build_payment_message(appt, payment_mode=settings.payment_mode),
}
)
)
Expand Down
31 changes: 21 additions & 10 deletions apps/api/app/routers/dev_seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,25 @@ class SeedMarket(TypedDict):
DEFAULT_DEMO_MARKET = "shelby"


def _all_demo_company_names() -> set[str]:
return {
company["name"]
for demo_market in DEMO_MARKETS.values()
for company in demo_market["companies"]
}


def _all_demo_emails() -> set[str]:
emails = {"admin@shoeinn.com"}
for demo_market in DEMO_MARKETS.values():
emails.add(demo_market["customer"]["email"])
emails.update(user["email"] for user in demo_market["quick_demo_users"].values())
for company in demo_market["companies"]:
emails.add(company["admin"]["email"])
emails.update(provider["email"] for provider in company["providers"])
return emails


def _select_cluster_address(market: SeedMarket, index: int) -> SeedAddress:
addresses = market["customer_job_addresses"]
return addresses[index % len(addresses)]
Expand All @@ -463,17 +482,9 @@ def seed(
"assignments": 0,
}

demo_company_names = {company["name"] for company in market["companies"]}
demo_emails = {
"admin@shoeinn.com",
}
demo_emails.add(market["customer"]["email"])
demo_emails.update(user["email"] for user in market["quick_demo_users"].values())
for company in market["companies"]:
demo_emails.add(company["admin"]["email"])
demo_emails.update(provider["email"] for provider in company["providers"])

if reset:
demo_company_names = _all_demo_company_names()
demo_emails = _all_demo_emails()
demo_users = db.query(User).filter(User.email.in_(demo_emails)).all()
demo_user_ids = [user.id for user in demo_users]
demo_companies = db.query(Company).filter(Company.name.in_(demo_company_names)).all()
Expand Down
4 changes: 4 additions & 0 deletions apps/api/app/schemas/appointment.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ class AppointmentListItem(BaseModel):
postal_code: str | None = None
start_time: datetime
status: AppointmentStatus
payment_status: PaymentStatus | None = None
payment_checkout_url: str | None = None
payment_mode: str | None = None
payment_message: str | None = None

model_config = ConfigDict(from_attributes=True)

Expand Down
2 changes: 1 addition & 1 deletion apps/api/app/services/payment_reconciliation.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def reconcile_payment_record(session: Session, appointment: Appointment, payment

def cancel_unpaid_appointment(session: Session, appointment: Appointment) -> None:
previous_status = appointment.status
appointment.status = AppointmentStatus.payment_failed
appointment.status = AppointmentStatus.cancelled
if appointment.payment_status != PaymentStatus.succeeded:
appointment.payment_status = PaymentStatus.failed
appointment.payment_checkout_url = None
Expand Down
22 changes: 21 additions & 1 deletion apps/api/docker-compose.staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,23 @@ services:
timeout: 5s
retries: 10

payment:
build:
context: ../..
dockerfile: apps/payment/Dockerfile
env_file:
- ../payment/.env.staging
ports:
- "8001:8001"
command: >
sh -c "python -m uvicorn app.main:app --host 0.0.0.0 --port 8001"
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8001/health', timeout=5)\""]
interval: 15s
timeout: 5s
retries: 10
restart: unless-stopped

api:
build:
context: ../..
Expand All @@ -26,6 +43,8 @@ services:
depends_on:
db:
condition: service_healthy
payment:
condition: service_started
command: >
sh -c "python -m alembic upgrade heads &&
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000"
Expand All @@ -34,6 +53,7 @@ services:
interval: 15s
timeout: 5s
retries: 10
restart: unless-stopped

notification-worker:
build:
Expand All @@ -50,4 +70,4 @@ services:
restart: unless-stopped

volumes:
pgdata_staging:
pgdata_staging:
8 changes: 2 additions & 6 deletions apps/api/tests/test_dev_seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ def test_dev_seed_populates_realistic_city_aligned_addresses(
assert customer.address_line1 in distinct_appointment_addresses


def test_dev_seed_mt_juliet_selector_preserves_default_market_and_rotates_selected_pool(
def test_dev_seed_mt_juliet_selector_resets_other_demo_markets_and_rotates_selected_pool(
db_session: Session,
client: TestClient,
) -> None:
Expand All @@ -146,11 +146,7 @@ def test_dev_seed_mt_juliet_selector_preserves_default_market_and_rotates_select
default_companies = db_session.query(Company).filter(
Company.name.in_(EXPECTED_COMPANY_ADDRESSES.keys())
).all()
assert len(default_companies) == 3
assert {
company.name: (company.address_line1, company.city, company.state, company.postal_code)
for company in default_companies
} == EXPECTED_COMPANY_ADDRESSES
assert default_companies == []

customer = db_session.query(User).filter(User.email == "mtjuliet.customer@shoeinn.com").one()
assert customer.address_line1 == "3005 Willow Bend Dr"
Expand Down
Loading
Loading