From 0e0bbb1c255c8fcc20a70f76baf8adc31d19e1ce Mon Sep 17 00:00:00 2001 From: Anthony Wright Date: Sun, 31 May 2026 07:36:24 -0500 Subject: [PATCH 1/5] fix(payments): open checkout and show pending appointments --- apps/api/app/routers/appointments.py | 4 ++ apps/api/app/schemas/appointment.py | 4 ++ .../app/services/payment_reconciliation.py | 2 +- apps/api/tests/test_hold_confirm.py | 16 +++++- .../mobile/src/components/AppointmentCard.tsx | 35 ++++++++++++ .../customer/AppointmentDetailScreen.tsx | 13 +++-- .../screens/customer/PaymentResultScreen.tsx | 8 ++- .../screens/home/BookingReviewPayScreen.tsx | 31 ++++++++-- apps/mobile/src/types/booking.ts | 4 ++ .../.openspec.yaml | 2 + .../design.md | 34 +++++++++++ .../proposal.md | 25 +++++++++ .../spec.md | 56 +++++++++++++++++++ .../tasks.md | 23 ++++++++ 14 files changed, 244 insertions(+), 13 deletions(-) create mode 100644 openspec/changes/fix-service-mode-booking-checkout-and-appointment-list-visibility/.openspec.yaml create mode 100644 openspec/changes/fix-service-mode-booking-checkout-and-appointment-list-visibility/design.md create mode 100644 openspec/changes/fix-service-mode-booking-checkout-and-appointment-list-visibility/proposal.md create mode 100644 openspec/changes/fix-service-mode-booking-checkout-and-appointment-list-visibility/specs/service-mode-booking-checkout-recovery/spec.md create mode 100644 openspec/changes/fix-service-mode-booking-checkout-and-appointment-list-visibility/tasks.md diff --git a/apps/api/app/routers/appointments.py b/apps/api/app/routers/appointments.py index ac3a03c..2719b9a 100644 --- a/apps/api/app/routers/appointments.py +++ b/apps/api/app/routers/appointments.py @@ -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), } ) ) diff --git a/apps/api/app/schemas/appointment.py b/apps/api/app/schemas/appointment.py index 2226c6b..eeedc18 100644 --- a/apps/api/app/schemas/appointment.py +++ b/apps/api/app/schemas/appointment.py @@ -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) diff --git a/apps/api/app/services/payment_reconciliation.py b/apps/api/app/services/payment_reconciliation.py index dd102a4..a103045 100644 --- a/apps/api/app/services/payment_reconciliation.py +++ b/apps/api/app/services/payment_reconciliation.py @@ -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 diff --git a/apps/api/tests/test_hold_confirm.py b/apps/api/tests/test_hold_confirm.py index ce32eaa..bb6c932 100644 --- a/apps/api/tests/test_hold_confirm.py +++ b/apps/api/tests/test_hold_confirm.py @@ -272,8 +272,18 @@ def test_service_mode_payment_refresh_and_unpaid_cancel_flow( assert appointment["payment_status"] == "pending" assert appointment["payment_mode"] == "service" assert appointment["payment_checkout_url"].startswith("https://checkout.stripe.test/") + assert appointment["payment_message"] headers = _auth_header(customer) + mine_res = client.get("/appointments/mine", headers=headers) + assert mine_res.status_code == 200, mine_res.text + mine_items = mine_res.json() + pending_item = next((item for item in mine_items if item["id"] == appointment["id"]), None) + assert pending_item is not None + assert pending_item["status"] == "pending_payment" + assert pending_item["payment_status"] == "pending" + assert pending_item["payment_mode"] == "service" + assert pending_item["payment_checkout_url"].startswith("https://checkout.stripe.test/") gateway.payment_status = "succeeded" refresh_res = client.post(f"/appointments/{appointment['id']}/payment/refresh", headers=headers) @@ -312,10 +322,14 @@ def test_service_mode_payment_refresh_and_unpaid_cancel_flow( cancel_res = client.post(f"/appointments/{second_confirm.json()['id']}/payment/cancel", headers=headers) assert cancel_res.status_code == 200, cancel_res.text cancelled = cancel_res.json() - assert cancelled["status"] == "payment_failed" + assert cancelled["status"] == "cancelled" assert cancelled["payment_status"] == "failed" assert cancelled["payment_checkout_url"] is None + mine_after_cancel = client.get("/appointments/mine", headers=headers) + assert mine_after_cancel.status_code == 200, mine_after_cancel.text + assert all(item["id"] != second_confirm.json()["id"] for item in mine_after_cancel.json()) + client.app.dependency_overrides.pop(get_payment_gateway, None) diff --git a/apps/mobile/src/components/AppointmentCard.tsx b/apps/mobile/src/components/AppointmentCard.tsx index e8c2439..b82367d 100644 --- a/apps/mobile/src/components/AppointmentCard.tsx +++ b/apps/mobile/src/components/AppointmentCard.tsx @@ -42,6 +42,34 @@ export function AppointmentCard({ }: Props) { const theme = useTheme(); const statusColor = statusColors[appointment.status] ?? theme.colors.mutedText; + const paymentStatus = appointment.payment_status ?? null; + const paymentLabel = (() => { + if (appointment.payment_mode !== "service") { + return null; + } + if (paymentStatus === "succeeded") { + return "Paid"; + } + if (paymentStatus === "failed" || appointment.status === "payment_failed") { + return "Payment failed"; + } + if (paymentStatus === "requires_action" || appointment.status === "pending_payment") { + return appointment.payment_checkout_url ? "Complete payment" : "Payment pending"; + } + if (paymentStatus === "pending") { + return "Payment pending"; + } + return null; + })(); + const paymentTone = (() => { + if (paymentLabel === "Paid") { + return { backgroundColor: "#ecfdf5", borderColor: "#86efac", color: "#166534" }; + } + if (paymentLabel === "Payment failed") { + return { backgroundColor: "#fef2f2", borderColor: "#fecaca", color: "#b91c1c" }; + } + return { backgroundColor: "#fffbeb", borderColor: "#fde68a", color: "#92400e" }; + })(); const actionTone = { actionable: { background: "#ecfdf5", border: "#86efac", text: "#166534", accent: "#0f766e" }, owned: { background: "#eff6ff", border: "#93c5fd", text: "#1d4ed8", accent: "#1d4ed8" }, @@ -79,6 +107,13 @@ export function AppointmentCard({ {appointment.status.replace(/_/g, " ")} + {paymentLabel ? ( + + + {paymentLabel} + + + ) : null} {appointment.service_name ?? "Appointment"} diff --git a/apps/mobile/src/screens/customer/AppointmentDetailScreen.tsx b/apps/mobile/src/screens/customer/AppointmentDetailScreen.tsx index b9f862c..8263521 100644 --- a/apps/mobile/src/screens/customer/AppointmentDetailScreen.tsx +++ b/apps/mobile/src/screens/customer/AppointmentDetailScreen.tsx @@ -12,7 +12,7 @@ import { View, } from "react-native"; import { NativeStackScreenProps } from "@react-navigation/native-stack"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigation } from "@react-navigation/native"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import { SafeAreaView } from "react-native-safe-area-context"; @@ -145,6 +145,7 @@ const resolvePhotoUrl = (url?: string | null) => { export default function AppointmentDetailScreen({ route }: Props) { const { appointmentId, summary, refreshPaymentOnOpen, paymentReturnStatus } = route.params; const navigation = useNavigation>(); + const queryClient = useQueryClient(); const [expandedPhotoUrl, setExpandedPhotoUrl] = useState(null); const handledAutoRefreshRef = useRef(false); @@ -242,6 +243,8 @@ export default function AppointmentDetailScreen({ route }: Props) { const handleRefreshPayment = async (reason: "manual" | "return" = "manual") => { try { const latest = await refreshAppointmentPayment(appointmentId); + queryClient.setQueryData(["appointment", appointmentId], latest); + await queryClient.invalidateQueries({ queryKey: ["appointments", "mine"] }); await appointmentQuery.refetch(); await eventsQuery.refetch(); @@ -274,7 +277,9 @@ export default function AppointmentDetailScreen({ route }: Props) { const handleCancelPendingPayment = async () => { try { - await cancelAppointmentPayment(appointmentId); + const latest = await cancelAppointmentPayment(appointmentId); + queryClient.setQueryData(["appointment", appointmentId], latest); + await queryClient.invalidateQueries({ queryKey: ["appointments", "mine"] }); await appointmentQuery.refetch(); await eventsQuery.refetch(); } catch (error: any) { @@ -359,11 +364,11 @@ export default function AppointmentDetailScreen({ route }: Props) { {paymentAwareAppointment.payment_checkout_url ? ( void handleOpenCheckout()}> - Open checkout + Open secure checkout ) : null} void handleRefreshPayment("manual")}> - Refresh payment + Check payment status {appointment.status === "pending_payment" ? ( void handleCancelPendingPayment()}> diff --git a/apps/mobile/src/screens/customer/PaymentResultScreen.tsx b/apps/mobile/src/screens/customer/PaymentResultScreen.tsx index 7f4856d..a73ddcc 100644 --- a/apps/mobile/src/screens/customer/PaymentResultScreen.tsx +++ b/apps/mobile/src/screens/customer/PaymentResultScreen.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { ActivityIndicator, Alert, Pressable, StyleSheet, View } from "react-native"; import { NativeStackScreenProps } from "@react-navigation/native-stack"; +import { useQueryClient } from "@tanstack/react-query"; import { getAppointment, refreshAppointmentPayment } from "../../api/http"; import { ScreenContainer } from "../../components/ScreenContainer"; @@ -27,6 +28,7 @@ function formatMoney(amount: number | null | undefined, currency: string | null export default function PaymentResultScreen({ navigation, route }: Props) { const { bookingId, sessionId, status } = route.params; const [state, setState] = useState({ kind: "loading" }); + const queryClient = useQueryClient(); useEffect(() => { let active = true; @@ -40,6 +42,8 @@ export default function PaymentResultScreen({ navigation, route }: Props) { if (!active) { return; } + queryClient.setQueryData(["appointment", bookingId], appointment); + await queryClient.invalidateQueries({ queryKey: ["appointments", "mine"] }); setState({ kind: "loaded", appointment }); } catch (error: any) { if (!active) { @@ -53,7 +57,7 @@ export default function PaymentResultScreen({ navigation, route }: Props) { return () => { active = false; }; - }, [bookingId, status]); + }, [bookingId, queryClient, status]); const appointment = state.kind === "loaded" ? state.appointment : null; const statusTitle = useMemo(() => { @@ -155,6 +159,8 @@ export default function PaymentResultScreen({ navigation, route }: Props) { void (async () => { try { const latest = await refreshAppointmentPayment(bookingId); + queryClient.setQueryData(["appointment", bookingId], latest); + await queryClient.invalidateQueries({ queryKey: ["appointments", "mine"] }); setState({ kind: "loaded", appointment: latest }); } catch (error: any) { Alert.alert("Unable to refresh", error?.message ?? "Please try again."); diff --git a/apps/mobile/src/screens/home/BookingReviewPayScreen.tsx b/apps/mobile/src/screens/home/BookingReviewPayScreen.tsx index 7cf08bc..e5f6bb8 100644 --- a/apps/mobile/src/screens/home/BookingReviewPayScreen.tsx +++ b/apps/mobile/src/screens/home/BookingReviewPayScreen.tsx @@ -2,7 +2,7 @@ import React, { useMemo, useState } from "react"; import { Alert, Linking, Pressable, ScrollView, StyleSheet, View } from "react-native"; import { RouteProp, useNavigation, useRoute } from "@react-navigation/native"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { confirmAppointment, createHold, getAppointmentQuote } from "../../api/http"; import { ScreenContainer } from "../../components/ScreenContainer"; @@ -23,6 +23,7 @@ export default function BookingReviewPayScreen() { const { service, date, time, customerDetails } = route.params; const { companyId } = useAuthStore(); const [paymentMethod, setPaymentMethod] = useState("saved_card"); + const queryClient = useQueryClient(); const quoteQuery = useQuery({ queryKey: ["appointment-quote", service.id, time, customerDetails.type], @@ -76,18 +77,36 @@ export default function BookingReviewPayScreen() { }); }, onSuccess: async (appointment) => { - if (appointment.payment_mode === "service" && appointment.payment_checkout_url) { + queryClient.setQueryData(["appointment", appointment.id], appointment); + await queryClient.invalidateQueries({ queryKey: ["appointments", "mine"] }); + + const navigateToAppointment = () => { + navigation.getParent()?.navigate("AppointmentsTab", { + screen: "AppointmentDetail", + params: { appointmentId: appointment.id }, + } as never); + }; + + if (appointment.payment_mode === "service" && paymentMethod === "stripe_checkout") { + if (!appointment.payment_checkout_url) { + Alert.alert( + "Checkout link missing", + appointment.payment_message ?? "The booking was created, but Stripe Checkout was not returned. Open the appointment to check payment status or cancel the unpaid booking.", + ); + navigateToAppointment(); + return; + } + + navigateToAppointment(); try { await Linking.openURL(appointment.payment_checkout_url); } catch (error) { Alert.alert("Checkout unavailable", "Unable to open Stripe checkout right now."); } + return; } - navigation.getParent()?.navigate("AppointmentsTab", { - screen: "AppointmentDetail", - params: { appointmentId: appointment.id }, - } as never); + navigateToAppointment(); }, onError: (error: Error) => Alert.alert("Booking failed", error.message), }); diff --git a/apps/mobile/src/types/booking.ts b/apps/mobile/src/types/booking.ts index ea22791..33d4a04 100644 --- a/apps/mobile/src/types/booking.ts +++ b/apps/mobile/src/types/booking.ts @@ -89,6 +89,10 @@ export interface AppointmentSummary { service_name?: string | null; start_time: string; status: AppointmentStatus; + payment_status?: "pending" | "requires_action" | "succeeded" | "failed" | "refunded" | "disputed" | null; + payment_checkout_url?: string | null; + payment_mode?: "mock" | "service" | null; + payment_message?: string | null; } export interface AppointmentEvent { diff --git a/openspec/changes/fix-service-mode-booking-checkout-and-appointment-list-visibility/.openspec.yaml b/openspec/changes/fix-service-mode-booking-checkout-and-appointment-list-visibility/.openspec.yaml new file mode 100644 index 0000000..927e3e8 --- /dev/null +++ b/openspec/changes/fix-service-mode-booking-checkout-and-appointment-list-visibility/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-31 diff --git a/openspec/changes/fix-service-mode-booking-checkout-and-appointment-list-visibility/design.md b/openspec/changes/fix-service-mode-booking-checkout-and-appointment-list-visibility/design.md new file mode 100644 index 0000000..a8a8201 --- /dev/null +++ b/openspec/changes/fix-service-mode-booking-checkout-and-appointment-list-visibility/design.md @@ -0,0 +1,34 @@ +## Context + +The service payment mode confirms appointments through the booking API and creates a Stripe Checkout session through `apps/payment`. The confirm response can contain `payment_checkout_url`, `payment_status`, `payment_mode`, and a payment message, but the mobile booking flow must explicitly open the URL and keep the appointment recoverable when the user cancels or abandons checkout. + +The customer appointments list and detail screens are the recovery surface. They must include pending-payment appointments and expose actions to open checkout, refresh payment status, or cancel an unpaid booking. + +## Goals / Non-Goals + +**Goals:** +- Open Stripe Checkout immediately after Place Booking succeeds for secure-checkout payment selection. +- Keep pending-payment service-mode appointments visible in the customer appointment list. +- Make payment status and recovery actions obvious from list and detail surfaces. +- Keep cache invalidation aligned after booking, refresh, cancel, and return. +- Preserve mock-mode booking behavior. + +**Non-Goals:** +- Full card management. +- Refunds, disputes, payouts, settlement, or backoffice reconciliation. +- New payment providers. +- Database schema changes unless an existing endpoint is filtering out required rows. + +## Decisions + +- Treat the confirm response as the source of truth for initial checkout launch. If service mode returns `payment_checkout_url`, mobile opens it immediately when secure checkout was selected. +- Keep manual checkout opening on appointment detail as fallback and recovery path. This covers cancelled browser sessions, failed deep links, or users leaving Checkout. +- List pending-payment appointments in the same appointment list with visible payment state rather than hiding them behind a separate surface. This keeps the demo path simple and prevents bookings from feeling lost. +- Invalidate/refetch customer appointment queries after booking creation and payment status mutations. This avoids relying on navigation remount behavior. +- Prefer endpoint/test fixes over mobile-side filtering if pending-payment appointments are missing from `/appointments/mine`. + +## Risks / Trade-offs + +- Browser checkout launch can fail on some platforms -> keep the detail-screen fallback and show a clear error when launch or URL generation fails. +- Pending-payment appointments may look like active work before payment is complete -> label payment state clearly and keep service-mode actions attached. +- Query invalidation requires consistent query keys -> centralize around existing mobile query keys rather than adding parallel cache names. diff --git a/openspec/changes/fix-service-mode-booking-checkout-and-appointment-list-visibility/proposal.md b/openspec/changes/fix-service-mode-booking-checkout-and-appointment-list-visibility/proposal.md new file mode 100644 index 0000000..66f0b88 --- /dev/null +++ b/openspec/changes/fix-service-mode-booking-checkout-and-appointment-list-visibility/proposal.md @@ -0,0 +1,25 @@ +## Why + +Service-mode booking currently lets customers place a booking without reliably launching Stripe Checkout, and pending-payment bookings can appear to vanish from the appointments list. This breaks the demo-critical payment path and leaves users without a clear way to continue or recover payment. + +## What Changes + +- Launch Stripe Checkout immediately after a successful Place Booking action when the customer selected secure checkout and the API returns a checkout URL. +- Surface a clear error when service mode expects checkout but the confirm response does not include a checkout URL. +- Keep newly created pending-payment appointments visible in the customer appointments list. +- Show clear payment status affordances on list/detail screens for pending, failed, paid, and cancelled/unpaid states. +- Ensure booking creation, payment refresh, payment cancellation, and payment return flows invalidate/refetch customer appointment queries. +- Verify backend confirm and customer appointment endpoints expose service-mode payment state needed by the mobile UI. + +## Capabilities + +### New Capabilities +- `service-mode-booking-checkout-recovery`: Covers service-mode checkout launch, pending-payment appointment visibility, and customer recovery actions. + +### Modified Capabilities + +## Impact + +- Mobile booking review/pay, confirmation, appointment list, appointment detail, navigation, appointment cache invalidation, and appointment payment types. +- API appointment confirmation, customer appointment listing, payment refresh/cancel behavior, and related tests. +- No full card management, refunds, payouts, disputes, or marketplace settlement changes. diff --git a/openspec/changes/fix-service-mode-booking-checkout-and-appointment-list-visibility/specs/service-mode-booking-checkout-recovery/spec.md b/openspec/changes/fix-service-mode-booking-checkout-and-appointment-list-visibility/specs/service-mode-booking-checkout-recovery/spec.md new file mode 100644 index 0000000..edc9ff0 --- /dev/null +++ b/openspec/changes/fix-service-mode-booking-checkout-and-appointment-list-visibility/specs/service-mode-booking-checkout-recovery/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: Service-mode booking opens checkout immediately +When a customer selects secure checkout in service payment mode and taps Place Booking, the mobile app SHALL open the returned Stripe Checkout URL immediately after appointment confirmation succeeds. + +#### Scenario: Checkout URL returned +- **WHEN** the customer places a booking with secure checkout selected and the confirm response includes `payment_checkout_url` +- **THEN** the mobile app opens that URL and preserves the created appointment id for recovery + +#### Scenario: Checkout URL missing +- **WHEN** the customer places a service-mode booking with secure checkout selected and the confirm response does not include `payment_checkout_url` +- **THEN** the mobile app shows a clear error and does not silently navigate away from the payment flow + +### Requirement: Pending-payment bookings remain visible +Customer appointment listing SHALL include service-mode appointments while payment is pending or unpaid. + +#### Scenario: Pending booking listed +- **WHEN** a customer creates an appointment that has `payment_status` pending +- **THEN** the customer appointments list includes the appointment with visible pending-payment state + +#### Scenario: Payment state visible +- **WHEN** the customer views the appointments list +- **THEN** each service-mode appointment displays a clear payment state such as pending, failed, paid, or cancelled + +### Requirement: Pending-payment detail provides recovery actions +The customer appointment detail screen SHALL provide recovery actions for unpaid service-mode appointments. + +#### Scenario: Continue checkout +- **WHEN** a customer opens a pending-payment appointment detail and a checkout URL exists +- **THEN** the detail screen shows an Open secure checkout action + +#### Scenario: Refresh or cancel unpaid booking +- **WHEN** a customer opens a pending-payment appointment detail +- **THEN** the detail screen shows Check payment status and Cancel unpaid booking actions + +### Requirement: Appointment payment changes refresh customer appointment data +Mobile appointment queries SHALL be invalidated or refetched after booking creation, payment refresh, payment cancellation, and payment return handling. + +#### Scenario: Booking creation refreshes list +- **WHEN** Place Booking creates an appointment +- **THEN** the customer appointments list query is invalidated or refetched before the user relies on that list + +#### Scenario: Payment mutation refreshes list +- **WHEN** payment status is refreshed, cancelled, or returned from checkout +- **THEN** customer appointment list and detail data are invalidated or refetched + +### Requirement: Backend exposes service-mode payment state +The booking API SHALL expose service-mode payment state needed by the mobile recovery flow. + +#### Scenario: Confirm response includes checkout data +- **WHEN** an appointment is confirmed in service payment mode +- **THEN** the response includes appointment id, payment status, payment mode, checkout URL when available, and a payment message when relevant + +#### Scenario: Customer list includes pending payment appointments +- **WHEN** a customer lists their appointments +- **THEN** pending-payment appointments are returned unless explicitly cancelled diff --git a/openspec/changes/fix-service-mode-booking-checkout-and-appointment-list-visibility/tasks.md b/openspec/changes/fix-service-mode-booking-checkout-and-appointment-list-visibility/tasks.md new file mode 100644 index 0000000..6940342 --- /dev/null +++ b/openspec/changes/fix-service-mode-booking-checkout-and-appointment-list-visibility/tasks.md @@ -0,0 +1,23 @@ +## 1. Investigation And Backend Contract + +- [x] 1.1 Trace mobile Place Booking behavior for secure checkout selection, checkout URL handling, appointment id preservation, and navigation. +- [x] 1.2 Verify API service-mode confirm response fields and customer appointment list behavior for pending-payment appointments. +- [x] 1.3 Add or adjust backend tests for service-mode checkout URL, pending appointment list visibility, and unpaid booking cancellation. + +## 2. Mobile Checkout Launch + +- [x] 2.1 Update Review & Pay booking success handling to open `payment_checkout_url` immediately for secure-checkout service-mode bookings. +- [x] 2.2 Show a clear service-mode error when secure checkout was selected but no checkout URL is returned. +- [x] 2.3 Preserve appointment id/payment state through confirmation and return/recovery navigation. + +## 3. Mobile Appointment Visibility And Recovery + +- [x] 3.1 Ensure customer appointment list includes and labels pending, failed, paid, and cancelled payment states. +- [x] 3.2 Ensure pending-payment appointment detail exposes Open secure checkout, Check payment status, and Cancel unpaid booking actions. +- [x] 3.3 Invalidate/refetch appointment list/detail queries after booking creation, payment refresh, payment cancellation, and payment return handling. + +## 4. Validation + +- [x] 4.1 Run focused backend payment/hold/customer appointment tests. +- [x] 4.2 Run mobile typecheck. +- [x] 4.3 Document manual service-mode demo validation steps and final behavior. From 4c35731d9fb853d72c244469cfc295d16e65a39b Mon Sep 17 00:00:00 2001 From: Anthony Wright Date: Sun, 31 May 2026 07:38:19 -0500 Subject: [PATCH 2/5] mods to scripts and seed data --- README.md | 16 ++++++++++++++++ apps/api/app/routers/dev_seed.py | 31 +++++++++++++++++++++---------- apps/api/tests/test_dev_seed.py | 8 ++------ scripts/start-local.ps1 | 4 ++++ scripts/start-mobile.ps1 | 14 ++++++++++++++ 5 files changed, 57 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e7958a4..4aee1f8 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ Useful options: - `-ApiBaseUrl "http://10.0.2.2:8000"` for Android emulator - `-ApiBaseUrl "http://:8000"` for a physical device - `-Tunnel` to run `npm start -- --tunnel` +- `-SkipApiCheck` to skip the preflight `GET /health` check - `-SkipInstall` to skip `npm install` `scripts/start-local.ps1` opens the API script in a separate PowerShell window, waits briefly, then starts Expo in the current window. @@ -197,6 +198,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: @@ -301,6 +304,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: diff --git a/apps/api/app/routers/dev_seed.py b/apps/api/app/routers/dev_seed.py index 78f3a18..2f649af 100644 --- a/apps/api/app/routers/dev_seed.py +++ b/apps/api/app/routers/dev_seed.py @@ -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)] @@ -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() diff --git a/apps/api/tests/test_dev_seed.py b/apps/api/tests/test_dev_seed.py index c95fdcf..1900f06 100644 --- a/apps/api/tests/test_dev_seed.py +++ b/apps/api/tests/test_dev_seed.py @@ -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: @@ -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" diff --git a/scripts/start-local.ps1 b/scripts/start-local.ps1 index 0322be7..9eaa14b 100644 --- a/scripts/start-local.ps1 +++ b/scripts/start-local.ps1 @@ -3,6 +3,7 @@ param( [string]$DemoMarket = "shelby", [string]$ApiBaseUrl = "http://localhost:8000", [switch]$Tunnel, + [switch]$SkipApiCheck, [switch]$SkipInstall ) @@ -38,6 +39,9 @@ $mobileArgs = @( if ($Tunnel) { $mobileArgs += "-Tunnel" } +if ($SkipApiCheck) { + $mobileArgs += "-SkipApiCheck" +} if ($SkipInstall) { $mobileArgs += "-SkipInstall" } diff --git a/scripts/start-mobile.ps1 b/scripts/start-mobile.ps1 index 97ad7a6..536ae65 100644 --- a/scripts/start-mobile.ps1 +++ b/scripts/start-mobile.ps1 @@ -2,6 +2,7 @@ param( [string]$ApiBaseUrl = "http://localhost:8000", [string]$MobileRedirectBase = "", [switch]$Tunnel, + [switch]$SkipApiCheck, [switch]$SkipInstall ) @@ -28,6 +29,19 @@ try { Write-Host "==> Starting Expo" Write-Host "API: $ApiBaseUrl" + if (-not $SkipApiCheck) { + $healthUrl = "$($ApiBaseUrl.TrimEnd('/'))/health" + Write-Host "==> Checking API at $healthUrl" + try { + $health = Invoke-RestMethod $healthUrl + if ($health.status -ne "ok") { + throw "Unexpected health response: $($health | ConvertTo-Json -Compress)" + } + } catch { + throw "Could not reach the ShoeInn API at $healthUrl. Check the LAN IP, make sure the API is running on port 8000, and confirm Windows Firewall allows inbound connections." + } + } + if ($Tunnel) { npm start -- --tunnel } else { From f5c7b1e833d26a037c6235b7f1d772bcf1ebbc41 Mon Sep 17 00:00:00 2001 From: Anthony Wright Date: Sun, 31 May 2026 07:55:20 -0500 Subject: [PATCH 3/5] stripe fix --- .../src/__tests__/bookingCheckout.test.ts | 18 ++++++++- apps/mobile/src/features/bookingCheckout.ts | 9 +++++ .../screens/home/BookingReviewPayScreen.tsx | 39 +++++++++++++------ apps/payment/tests/test_payments.py | 2 + 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/__tests__/bookingCheckout.test.ts b/apps/mobile/src/__tests__/bookingCheckout.test.ts index 48e073c..3629c19 100644 --- a/apps/mobile/src/__tests__/bookingCheckout.test.ts +++ b/apps/mobile/src/__tests__/bookingCheckout.test.ts @@ -1,4 +1,4 @@ -import { buildQuoteDisplayRows } from "../features/bookingCheckout"; +import { buildQuoteDisplayRows, getImmediateCheckoutUrl } from "../features/bookingCheckout"; describe("buildQuoteDisplayRows", () => { it("includes all payment summary fields from the backend quote", () => { @@ -26,3 +26,19 @@ describe("buildQuoteDisplayRows", () => { ]); }); }); + +describe("getImmediateCheckoutUrl", () => { + it("opens service-mode checkout when the confirm response includes a URL", () => { + expect( + getImmediateCheckoutUrl({ + payment_mode: "service", + payment_checkout_url: "https://checkout.stripe.com/c/pay/cs_test_123", + }), + ).toBe("https://checkout.stripe.com/c/pay/cs_test_123"); + }); + + it("does not open checkout for mock mode or missing service checkout URLs", () => { + expect(getImmediateCheckoutUrl({ payment_mode: "mock", payment_checkout_url: "https://checkout.stripe.test" })).toBeNull(); + expect(getImmediateCheckoutUrl({ payment_mode: "service", payment_checkout_url: null })).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/bookingCheckout.ts b/apps/mobile/src/features/bookingCheckout.ts index 9c261b5..1a47f16 100644 --- a/apps/mobile/src/features/bookingCheckout.ts +++ b/apps/mobile/src/features/bookingCheckout.ts @@ -1,4 +1,5 @@ import type { AppointmentQuote } from "../types/booking"; +import type { Appointment } from "../types/booking"; export function formatMoney(amountCents: number, currency: string): string { return new Intl.NumberFormat("en-US", { @@ -26,3 +27,11 @@ export function buildQuoteDisplayRows(quote: AppointmentQuote) { }, ]; } + +export function getImmediateCheckoutUrl(appointment: Pick): string | null { + if (appointment.payment_mode !== "service") { + return null; + } + const checkoutUrl = appointment.payment_checkout_url?.trim(); + return checkoutUrl || null; +} diff --git a/apps/mobile/src/screens/home/BookingReviewPayScreen.tsx b/apps/mobile/src/screens/home/BookingReviewPayScreen.tsx index e5f6bb8..f798bbd 100644 --- a/apps/mobile/src/screens/home/BookingReviewPayScreen.tsx +++ b/apps/mobile/src/screens/home/BookingReviewPayScreen.tsx @@ -9,7 +9,7 @@ import { ScreenContainer } from "../../components/ScreenContainer"; import { Button } from "../../components/ui/Button"; import { Card } from "../../components/ui/Card"; import { Text } from "../../components/ui/Text"; -import { buildQuoteDisplayRows, formatMoney } from "../../features/bookingCheckout"; +import { buildQuoteDisplayRows, formatMoney, getImmediateCheckoutUrl } from "../../features/bookingCheckout"; import type { HomeStackParamList } from "../../navigation/types"; import { useAuthStore } from "../../state/authStore"; import { useTheme } from "../../theme/theme"; @@ -77,8 +77,16 @@ export default function BookingReviewPayScreen() { }); }, onSuccess: async (appointment) => { + console.log("[Booking] Confirm response", { + id: appointment.id, + status: appointment.status, + payment_mode: appointment.payment_mode, + payment_status: appointment.payment_status, + has_checkout_url: Boolean(appointment.payment_checkout_url), + payment_checkout_url: appointment.payment_checkout_url, + selected_payment_method: paymentMethod, + }); queryClient.setQueryData(["appointment", appointment.id], appointment); - await queryClient.invalidateQueries({ queryKey: ["appointments", "mine"] }); const navigateToAppointment = () => { navigation.getParent()?.navigate("AppointmentsTab", { @@ -87,8 +95,24 @@ export default function BookingReviewPayScreen() { } as never); }; + const checkoutUrl = getImmediateCheckoutUrl(appointment); + if (checkoutUrl) { + try { + console.log("[Booking] Opening Stripe Checkout", checkoutUrl); + await Linking.openURL(checkoutUrl); + } catch (error) { + console.warn("[Booking] Unable to open Stripe Checkout", error); + Alert.alert("Checkout unavailable", "Unable to open Stripe checkout right now. Open this appointment to continue payment."); + } finally { + await queryClient.invalidateQueries({ queryKey: ["appointments", "mine"] }); + navigateToAppointment(); + } + return; + } + if (appointment.payment_mode === "service" && paymentMethod === "stripe_checkout") { - if (!appointment.payment_checkout_url) { + await queryClient.invalidateQueries({ queryKey: ["appointments", "mine"] }); + if (!checkoutUrl) { Alert.alert( "Checkout link missing", appointment.payment_message ?? "The booking was created, but Stripe Checkout was not returned. Open the appointment to check payment status or cancel the unpaid booking.", @@ -96,16 +120,9 @@ export default function BookingReviewPayScreen() { navigateToAppointment(); return; } - - navigateToAppointment(); - try { - await Linking.openURL(appointment.payment_checkout_url); - } catch (error) { - Alert.alert("Checkout unavailable", "Unable to open Stripe checkout right now."); - } - return; } + await queryClient.invalidateQueries({ queryKey: ["appointments", "mine"] }); navigateToAppointment(); }, onError: (error: Error) => Alert.alert("Booking failed", error.message), diff --git a/apps/payment/tests/test_payments.py b/apps/payment/tests/test_payments.py index 305d100..b667b1d 100644 --- a/apps/payment/tests/test_payments.py +++ b/apps/payment/tests/test_payments.py @@ -35,6 +35,8 @@ def test_create_checkout_session(client: TestClient, db_session: Session) -> Non fake_stripe = client.app.state.fake_stripe assert data["checkout_session_id"] in fake_stripe.checkout_sessions + assert data["checkout_url"].startswith("https://stripe.test/checkout/") + assert data["status"] == "pending" payment = db_session.scalar(select(Payment).where(Payment.booking_id == payload["booking_id"])) assert payment is not None From 3d048419f33bd2d8e3b4295e4a4b43644be5d648 Mon Sep 17 00:00:00 2001 From: Anthony Wright Date: Sun, 31 May 2026 15:09:15 -0500 Subject: [PATCH 4/5] fix(mobile): reset appointments tab to list and remove alert badge --- README.md | 51 +++++++ apps/api/tests/test_hold_confirm.py | 77 +++++++++++ .../src/hooks/useLiveAppointmentEvents.ts | 14 +- apps/mobile/src/navigation/RootTabs.tsx | 13 +- apps/mobile/src/query/keys.ts | 8 +- .../appointments/AppointmentDetailScreen.tsx | 7 +- .../appointments/AppointmentListScreen.tsx | 11 +- .../customer/AppointmentDetailScreen.tsx | 20 ++- .../screens/customer/MyAppointmentsScreen.tsx | 10 +- .../screens/customer/PaymentResultScreen.tsx | 9 +- .../screens/home/BookingReviewPayScreen.tsx | 9 +- apps/payment/app/main.py | 4 + apps/payment/payment.db | Bin 73728 -> 90112 bytes apps/payment/tests/test_payments.py | 6 + .../.openspec.yaml | 2 + .../design.md | 29 ++++ .../proposal.md | 21 +++ .../customer-appointment-list-refresh/spec.md | 34 +++++ .../tasks.md | 24 ++++ .../.openspec.yaml | 2 + .../design.md | 31 +++++ .../proposal.md | 21 +++ .../customer-appointments-tab-entry/spec.md | 29 ++++ .../tasks.md | 23 ++++ scripts/start-api.ps1 | 88 +++++++++++- scripts/start-local.ps1 | 125 +++++++++++++++++- scripts/start-mobile.ps1 | 22 ++- scripts/start-payment.ps1 | 114 ++++++++++++++++ 28 files changed, 763 insertions(+), 41 deletions(-) create mode 100644 openspec/changes/fix-appointment-list-refresh-after-consecutive-service-bookings/.openspec.yaml create mode 100644 openspec/changes/fix-appointment-list-refresh-after-consecutive-service-bookings/design.md create mode 100644 openspec/changes/fix-appointment-list-refresh-after-consecutive-service-bookings/proposal.md create mode 100644 openspec/changes/fix-appointment-list-refresh-after-consecutive-service-bookings/specs/customer-appointment-list-refresh/spec.md create mode 100644 openspec/changes/fix-appointment-list-refresh-after-consecutive-service-bookings/tasks.md create mode 100644 openspec/changes/fix-customer-appointments-tab-list-and-badge-after-bookings/.openspec.yaml create mode 100644 openspec/changes/fix-customer-appointments-tab-list-and-badge-after-bookings/design.md create mode 100644 openspec/changes/fix-customer-appointments-tab-list-and-badge-after-bookings/proposal.md create mode 100644 openspec/changes/fix-customer-appointments-tab-list-and-badge-after-bookings/specs/customer-appointments-tab-entry/spec.md create mode 100644 openspec/changes/fix-customer-appointments-tab-list-and-badge-after-bookings/tasks.md create mode 100644 scripts/start-payment.ps1 diff --git a/README.md b/README.md index 4aee1f8..f346dd7 100644 --- a/README.md +++ b/README.md @@ -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://:8002" + # Physical device on the same LAN .\scripts\start-local.ps1 -ApiBaseUrl "http://: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: @@ -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://: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 @@ -98,12 +123,36 @@ Useful options: - `-ApiBaseUrl "http://10.0.2.2:8000"` for Android emulator - `-ApiBaseUrl "http://: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://: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: @@ -398,6 +447,8 @@ Mobile cannot reach the API - Physical devices should use `http://: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://:8002"`. +- If `http://localhost:/health` works but `http://:/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 diff --git a/apps/api/tests/test_hold_confirm.py b/apps/api/tests/test_hold_confirm.py index bb6c932..5a93963 100644 --- a/apps/api/tests/test_hold_confirm.py +++ b/apps/api/tests/test_hold_confirm.py @@ -333,6 +333,83 @@ def test_service_mode_payment_refresh_and_unpaid_cancel_flow( client.app.dependency_overrides.pop(get_payment_gateway, None) +def test_consecutive_service_mode_bookings_remain_in_customer_appointments( + client: TestClient, + db_session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service_id, company_id = _pick_service(client) + start_time = datetime.now(timezone.utc).replace(hour=10, minute=0, second=0, microsecond=0) + timedelta(days=2) + + customer = User( + email="consecutive-service-customer@example.com", + full_name="Consecutive Service Customer", + role="customer", + password_hash=hash_password("Password1!"), + ) + db_session.add(customer) + db_session.commit() + + gateway = FakeServiceGateway() + client.app.dependency_overrides[get_payment_gateway] = lambda: gateway + monkeypatch.setattr(settings, "payment_mode", "service") + + headers = _auth_header(customer) + appointment_ids: list[str] = [] + + for offset in range(2): + hold_res = client.post( + "/appointments/holds", + json={ + "service_id": str(service_id), + "start_time": (start_time + timedelta(hours=offset)).isoformat(), + "customer_email": customer.email, + }, + ) + assert hold_res.status_code == 201, hold_res.text + + confirm_res = client.post( + "/appointments/confirm", + json={ + "hold_id": hold_res.json()["id"], + "company_id": str(company_id), + "customer_name": "Consecutive Service Customer", + "customer_phone": "1234567890", + "customer_email": customer.email, + }, + ) + assert confirm_res.status_code == 200, confirm_res.text + appointment = confirm_res.json() + assert appointment["status"] == "pending_payment" + assert appointment["payment_status"] == "pending" + assert appointment["payment_mode"] == "service" + assert appointment["payment_checkout_url"].startswith("https://checkout.stripe.test/") + appointment_ids.append(appointment["id"]) + + pending_mine_res = client.get("/appointments/mine", headers=headers) + assert pending_mine_res.status_code == 200, pending_mine_res.text + pending_items = {item["id"]: item for item in pending_mine_res.json()} + assert set(appointment_ids).issubset(pending_items) + assert all(pending_items[appointment_id]["payment_status"] == "pending" for appointment_id in appointment_ids) + assert all(pending_items[appointment_id]["payment_mode"] == "service" for appointment_id in appointment_ids) + + gateway.payment_status = "succeeded" + for appointment_id in appointment_ids: + refresh_res = client.post(f"/appointments/{appointment_id}/payment/refresh", headers=headers) + assert refresh_res.status_code == 200, refresh_res.text + assert refresh_res.json()["payment_status"] == "succeeded" + + paid_mine_res = client.get("/appointments/mine", headers=headers) + assert paid_mine_res.status_code == 200, paid_mine_res.text + paid_items = {item["id"]: item for item in paid_mine_res.json()} + assert set(appointment_ids).issubset(paid_items) + assert all(paid_items[appointment_id]["status"] == "confirmed" for appointment_id in appointment_ids) + assert all(paid_items[appointment_id]["payment_status"] == "succeeded" for appointment_id in appointment_ids) + assert all(paid_items[appointment_id]["payment_mode"] == "service" for appointment_id in appointment_ids) + + client.app.dependency_overrides.pop(get_payment_gateway, None) + + def test_service_mode_payment_refresh_keeps_unpaid_session_pending( client: TestClient, db_session: Session, diff --git a/apps/mobile/src/hooks/useLiveAppointmentEvents.ts b/apps/mobile/src/hooks/useLiveAppointmentEvents.ts index e5d9ff6..ccf7e46 100644 --- a/apps/mobile/src/hooks/useLiveAppointmentEvents.ts +++ b/apps/mobile/src/hooks/useLiveAppointmentEvents.ts @@ -2,6 +2,12 @@ import { useEffect, useRef } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { getLiveEventsWebSocketUrl } from "../api/http"; +import { + appointmentAssignmentQueryKey, + appointmentEventsQueryKey, + appointmentQueryKey, + customerAppointmentsQueryKey, +} from "../query/keys"; import { useAuthStore } from "../state/authStore"; type LiveEvent = @@ -65,16 +71,16 @@ export function useLiveAppointmentEvents() { } if (role === "customer") { - void queryClient.invalidateQueries({ queryKey: ["appointments", "mine"] }); + void queryClient.invalidateQueries({ queryKey: customerAppointmentsQueryKey }); void queryClient.invalidateQueries({ queryKey: ["me", "notifications"] }); } else { void queryClient.invalidateQueries({ queryKey: ["provider", "open"] }); void queryClient.invalidateQueries({ queryKey: ["provider", "my"] }); } - void queryClient.invalidateQueries({ queryKey: ["appointment", appointmentId] }); - void queryClient.invalidateQueries({ queryKey: ["appointment", appointmentId, "assignment"] }); - void queryClient.invalidateQueries({ queryKey: ["appointment", appointmentId, "events"] }); + void queryClient.invalidateQueries({ queryKey: appointmentQueryKey(appointmentId) }); + void queryClient.invalidateQueries({ queryKey: appointmentAssignmentQueryKey(appointmentId) }); + void queryClient.invalidateQueries({ queryKey: appointmentEventsQueryKey(appointmentId) }); }; const connect = () => { diff --git a/apps/mobile/src/navigation/RootTabs.tsx b/apps/mobile/src/navigation/RootTabs.tsx index be9bcf3..0568baf 100644 --- a/apps/mobile/src/navigation/RootTabs.tsx +++ b/apps/mobile/src/navigation/RootTabs.tsx @@ -28,10 +28,6 @@ import type { RootTabParamList, } from "./types"; import { useAuthStore } from "../state/authStore"; -import { - getUnreadCustomerNotificationCount, - useCustomerNotifications, -} from "../hooks/useCustomerNotifications"; import { useTheme } from "../theme/theme"; const HomeStack = createNativeStackNavigator(); @@ -112,8 +108,6 @@ export default function RootTabs() { const usesOperationalHome = role === "provider" || role === "company_admin"; const showProviderTab = role === "company"; const homeTabComponent = usesOperationalHome ? ProviderNavigator : HomeNavigator; - const notificationsQuery = useCustomerNotifications(role === "customer"); - const unreadNotifications = getUnreadCustomerNotificationCount(notificationsQuery.data); const insets = useSafeAreaInsets(); const bottomPadding = Math.max(insets.bottom, 8); @@ -144,8 +138,13 @@ export default function RootTabs() { component={AppointmentNavigator} options={{ title: "Appointments", - tabBarBadge: unreadNotifications > 0 ? unreadNotifications : undefined, }} + listeners={({ navigation }) => ({ + tabPress: (event) => { + event.preventDefault(); + navigation.navigate("AppointmentsTab", { screen: "AppointmentList" }); + }, + })} /> ) : null} {showProviderTab ? ( diff --git a/apps/mobile/src/query/keys.ts b/apps/mobile/src/query/keys.ts index 2829842..879ad62 100644 --- a/apps/mobile/src/query/keys.ts +++ b/apps/mobile/src/query/keys.ts @@ -1 +1,7 @@ -export const servicesQueryKey = ['services'] as const; +export const servicesQueryKey = ["services"] as const; +export const customerAppointmentsQueryKey = ["appointments", "mine"] as const; + +export const appointmentQueryKey = (appointmentId: string) => ["appointment", appointmentId] as const; +export const appointmentEventsQueryKey = (appointmentId: string) => ["appointment", appointmentId, "events"] as const; +export const appointmentAssignmentQueryKey = (appointmentId: string) => + ["appointment", appointmentId, "assignment"] as const; diff --git a/apps/mobile/src/screens/appointments/AppointmentDetailScreen.tsx b/apps/mobile/src/screens/appointments/AppointmentDetailScreen.tsx index daba5e3..c67d57b 100644 --- a/apps/mobile/src/screens/appointments/AppointmentDetailScreen.tsx +++ b/apps/mobile/src/screens/appointments/AppointmentDetailScreen.tsx @@ -10,6 +10,7 @@ import { Card } from "../../components/ui/Card"; import { Button } from "../../components/ui/Button"; import { Text } from "../../components/ui/Text"; import type { AppointmentStackParamList } from "../../navigation/types"; +import { appointmentAssignmentQueryKey, appointmentEventsQueryKey, appointmentQueryKey } from "../../query/keys"; import { useAuthStore } from "../../state/authStore"; import { useTheme } from "../../theme/theme"; @@ -33,20 +34,20 @@ export default function AppointmentDetailScreen() { const isCustomer = role === "customer"; const appointmentQuery = useQuery({ - queryKey: ["appointment", appointmentId], + queryKey: appointmentQueryKey(appointmentId), queryFn: () => getAppointment(appointmentId), initialData: summary as any, enabled: isCustomer, }); const eventsQuery = useQuery({ - queryKey: ["appointment", appointmentId, "events"], + queryKey: appointmentEventsQueryKey(appointmentId), queryFn: () => getAppointmentEvents(appointmentId), enabled: isCustomer, }); const assignmentQuery = useQuery({ - queryKey: ["appointment", appointmentId, "assignment"], + queryKey: appointmentAssignmentQueryKey(appointmentId), queryFn: () => getAppointmentAssignment(appointmentId), retry: false, enabled: isCustomer, diff --git a/apps/mobile/src/screens/appointments/AppointmentListScreen.tsx b/apps/mobile/src/screens/appointments/AppointmentListScreen.tsx index 9fcb88a..980ec0e 100644 --- a/apps/mobile/src/screens/appointments/AppointmentListScreen.tsx +++ b/apps/mobile/src/screens/appointments/AppointmentListScreen.tsx @@ -14,7 +14,9 @@ import { getUnreadCustomerNotificationCount, useCustomerNotifications, } from "../../hooks/useCustomerNotifications"; +import { useFocusedAutoRefresh } from "../../hooks/useFocusedAutoRefresh"; import type { AppointmentStackParamList } from "../../navigation/types"; +import { customerAppointmentsQueryKey } from "../../query/keys"; import { useAuthStore } from "../../state/authStore"; import { useTheme } from "../../theme/theme"; import type { AppointmentSummary } from "../../types/booking"; @@ -27,11 +29,18 @@ export default function AppointmentListScreen() { const notificationsQuery = useCustomerNotifications(isCustomer); const unreadCount = getUnreadCustomerNotificationCount(notificationsQuery.data); const { data, isLoading, isError, refetch, isRefetching } = useQuery({ - queryKey: ["appointments", "mine"], + queryKey: customerAppointmentsQueryKey, queryFn: getMyAppointments, enabled: isCustomer, }); + useFocusedAutoRefresh({ + enabled: isCustomer, + onRefresh: () => { + void refetch(); + }, + }); + const renderItem = ({ item }: { item: AppointmentSummary }) => ( getAppointment(appointmentId), }); const eventsQuery = useQuery({ - queryKey: ["appointment", appointmentId, "events"], + queryKey: appointmentEventsQueryKey(appointmentId), queryFn: () => getAppointmentEvents(appointmentId), enabled: !!appointmentId, }); const assignmentQuery = useQuery({ - queryKey: ["appointment", appointmentId, "assignment"], + queryKey: appointmentAssignmentQueryKey(appointmentId), queryFn: () => getAppointmentAssignment(appointmentId), retry: false, }); @@ -243,8 +249,8 @@ export default function AppointmentDetailScreen({ route }: Props) { const handleRefreshPayment = async (reason: "manual" | "return" = "manual") => { try { const latest = await refreshAppointmentPayment(appointmentId); - queryClient.setQueryData(["appointment", appointmentId], latest); - await queryClient.invalidateQueries({ queryKey: ["appointments", "mine"] }); + queryClient.setQueryData(appointmentQueryKey(appointmentId), latest); + await queryClient.invalidateQueries({ queryKey: customerAppointmentsQueryKey }); await appointmentQuery.refetch(); await eventsQuery.refetch(); @@ -278,8 +284,8 @@ export default function AppointmentDetailScreen({ route }: Props) { const handleCancelPendingPayment = async () => { try { const latest = await cancelAppointmentPayment(appointmentId); - queryClient.setQueryData(["appointment", appointmentId], latest); - await queryClient.invalidateQueries({ queryKey: ["appointments", "mine"] }); + queryClient.setQueryData(appointmentQueryKey(appointmentId), latest); + await queryClient.invalidateQueries({ queryKey: customerAppointmentsQueryKey }); await appointmentQuery.refetch(); await eventsQuery.refetch(); } catch (error: any) { diff --git a/apps/mobile/src/screens/customer/MyAppointmentsScreen.tsx b/apps/mobile/src/screens/customer/MyAppointmentsScreen.tsx index 7f28f87..176c573 100644 --- a/apps/mobile/src/screens/customer/MyAppointmentsScreen.tsx +++ b/apps/mobile/src/screens/customer/MyAppointmentsScreen.tsx @@ -18,7 +18,9 @@ import { getUnreadCustomerNotificationCount, useCustomerNotifications, } from "../../hooks/useCustomerNotifications"; +import { useFocusedAutoRefresh } from "../../hooks/useFocusedAutoRefresh"; import type { CustomerFlowStackParamList } from "../../navigation/types"; +import { customerAppointmentsQueryKey } from "../../query/keys"; import type { AppointmentSummary } from "../../types/booking"; import { useAuthStore } from "../../state/authStore"; @@ -61,10 +63,16 @@ export default function MyAppointmentsScreen({ navigation }: Props) { const notificationsQuery = useCustomerNotifications(true); const unreadNotifications = getUnreadCustomerNotificationCount(notificationsQuery.data); const { data, isLoading, error, refetch, isRefetching } = useQuery({ - queryKey: ["appointments", "mine"], + queryKey: customerAppointmentsQueryKey, queryFn: getMyAppointments, }); + useFocusedAutoRefresh({ + onRefresh: () => { + void refetch(); + }, + }); + const renderItem = ({ item }: { item: AppointmentSummary }) => { const bg = statusColors[item.status] ?? "#e5e7eb"; const fg = statusTextColors[item.status] ?? "#111827"; diff --git a/apps/mobile/src/screens/customer/PaymentResultScreen.tsx b/apps/mobile/src/screens/customer/PaymentResultScreen.tsx index a73ddcc..2cb5cb8 100644 --- a/apps/mobile/src/screens/customer/PaymentResultScreen.tsx +++ b/apps/mobile/src/screens/customer/PaymentResultScreen.tsx @@ -7,6 +7,7 @@ import { getAppointment, refreshAppointmentPayment } from "../../api/http"; import { ScreenContainer } from "../../components/ScreenContainer"; import { Text } from "../../components/ui/Text"; import type { AppointmentStackParamList } from "../../navigation/types"; +import { appointmentQueryKey, customerAppointmentsQueryKey } from "../../query/keys"; type Props = NativeStackScreenProps; @@ -42,8 +43,8 @@ export default function PaymentResultScreen({ navigation, route }: Props) { if (!active) { return; } - queryClient.setQueryData(["appointment", bookingId], appointment); - await queryClient.invalidateQueries({ queryKey: ["appointments", "mine"] }); + queryClient.setQueryData(appointmentQueryKey(bookingId), appointment); + await queryClient.invalidateQueries({ queryKey: customerAppointmentsQueryKey }); setState({ kind: "loaded", appointment }); } catch (error: any) { if (!active) { @@ -159,8 +160,8 @@ export default function PaymentResultScreen({ navigation, route }: Props) { void (async () => { try { const latest = await refreshAppointmentPayment(bookingId); - queryClient.setQueryData(["appointment", bookingId], latest); - await queryClient.invalidateQueries({ queryKey: ["appointments", "mine"] }); + queryClient.setQueryData(appointmentQueryKey(bookingId), latest); + await queryClient.invalidateQueries({ queryKey: customerAppointmentsQueryKey }); setState({ kind: "loaded", appointment: latest }); } catch (error: any) { Alert.alert("Unable to refresh", error?.message ?? "Please try again."); diff --git a/apps/mobile/src/screens/home/BookingReviewPayScreen.tsx b/apps/mobile/src/screens/home/BookingReviewPayScreen.tsx index f798bbd..b2c5fcd 100644 --- a/apps/mobile/src/screens/home/BookingReviewPayScreen.tsx +++ b/apps/mobile/src/screens/home/BookingReviewPayScreen.tsx @@ -11,6 +11,7 @@ import { Card } from "../../components/ui/Card"; import { Text } from "../../components/ui/Text"; import { buildQuoteDisplayRows, formatMoney, getImmediateCheckoutUrl } from "../../features/bookingCheckout"; import type { HomeStackParamList } from "../../navigation/types"; +import { appointmentQueryKey, customerAppointmentsQueryKey } from "../../query/keys"; import { useAuthStore } from "../../state/authStore"; import { useTheme } from "../../theme/theme"; @@ -86,7 +87,7 @@ export default function BookingReviewPayScreen() { payment_checkout_url: appointment.payment_checkout_url, selected_payment_method: paymentMethod, }); - queryClient.setQueryData(["appointment", appointment.id], appointment); + queryClient.setQueryData(appointmentQueryKey(appointment.id), appointment); const navigateToAppointment = () => { navigation.getParent()?.navigate("AppointmentsTab", { @@ -104,14 +105,14 @@ export default function BookingReviewPayScreen() { console.warn("[Booking] Unable to open Stripe Checkout", error); Alert.alert("Checkout unavailable", "Unable to open Stripe checkout right now. Open this appointment to continue payment."); } finally { - await queryClient.invalidateQueries({ queryKey: ["appointments", "mine"] }); + await queryClient.invalidateQueries({ queryKey: customerAppointmentsQueryKey }); navigateToAppointment(); } return; } if (appointment.payment_mode === "service" && paymentMethod === "stripe_checkout") { - await queryClient.invalidateQueries({ queryKey: ["appointments", "mine"] }); + await queryClient.invalidateQueries({ queryKey: customerAppointmentsQueryKey }); if (!checkoutUrl) { Alert.alert( "Checkout link missing", @@ -122,7 +123,7 @@ export default function BookingReviewPayScreen() { } } - await queryClient.invalidateQueries({ queryKey: ["appointments", "mine"] }); + await queryClient.invalidateQueries({ queryKey: customerAppointmentsQueryKey }); navigateToAppointment(); }, onError: (error: Error) => Alert.alert("Booking failed", error.message), diff --git a/apps/payment/app/main.py b/apps/payment/app/main.py index a9452f3..3a8e962 100644 --- a/apps/payment/app/main.py +++ b/apps/payment/app/main.py @@ -14,6 +14,10 @@ def create_app() -> FastAPI: Base.metadata.create_all(bind=get_engine()) ensure_schema_compatibility() + @app.get("/health") + def health() -> dict[str, str]: + return {"status": "ok", "service": settings.app_name} + app.include_router(payments.router) return app diff --git a/apps/payment/payment.db b/apps/payment/payment.db index cc7ec84d14b36db4cd8fc72dd003c3613f2fd63d..50394dc8bf24cd2c5436ebf9150c275e19085357 100644 GIT binary patch delta 6926 zcmeHLU2GJ`9lwiBW6rlbccvhrRJB2c(uzH1c4l^GE`rKm=fl|8*xdOeMX)=&YmD*d z`3qEZ;7C#W2D(UYpoxn5l0LK~a;p^eA&IIsX&%~#DpDWXs-!Q8N-NPyZ50x=vv;=d ze70eE$FgpA_5a`K{=b>|{eQo?`flyjcXwQWZpYzJD75EB)3?Hh-#YgLkl!I+!{DL( z41F0qjDDy7R^-;sTXmz)nqS&c`}>;P&rF7ohQ8^Cmtr86$?V2Eu2KBr=VLCR*)SP{B zRLtd^_zNO;cGk_!p0!Z_f;-kZU=5(6C$(%f=7J2u^6G~0a>18Hym zWJk-X@m#87WU;-)>1@MO?u0WsF``3V{t~KT)P&5Wj*}E67}gDLR7AjPNKa5EBXs#4 z5dLc6yL%h*OGngFxzAjSw>B2bg~YR%ZW$Uu!jEG3CC}2^JxAk|a*fD8&E~|@JcI}&j9NMp4SpgQ z967e!#I{M1M&u^bm~ez2?X`Hge5iEZZw+;w?9cWOFPLJ!%Nd?*P8+F3$_XFHb&K%~%kY-YiHlQCbME4$ z^z8A8blYHyXsDX!q}4e*2QOas+#Y*;;G7HQERL1qZFIC8N3Et1iz98#pdw+b zHl$X>w<2bxP$VN{HT^6m6a=#FE%w*XrDdcVdRfH3w52(hIjnDcCzMR z_)no%ZrqsdUrI&mG8yf~1I=|Nr`$ubtGn2c4Ub6OV{@HqoO=d_+;I&L1|jUid#^PO z2T9z2xM{yVIXNc9%-Kl!P!rFhs~$O4Y9wV?dLd zQjBP>kP3znVQaDjLCrQWmK^CJm)k*_O0k$AiJOZFLcX$?_QF4I9at>}^CUDAOgA7h zmr{|nVhEFb?IJ@%NMP9}ktbJ17UQ^-U`gZHwT&QsPZqOpD1fHjTuYGl;MY<^2rfLh zwI?sfD;u?jPLhx$5T=x%rPR)~+PFg@g@z-gtS&V+^Q6Ycx@_+{b}iFljwz&Oc)T#} zwJwdj6SMs#L0I_RYYoFezP7p4APsKA9xTeuqNvnmS6xcgt(E55Qo%5$Ye;Z;prDjE zTsV$qQ;uy18N%W;h`1>3!61p7iwV+%*Y;pT$w{nfLX5b%5?=nS?(0$m?vDCOP*LuO z00A#a>2j>7`OYZ!rJ%Vk#myb{uI$JkDntf*= z#nvB$a-zPp3UZw5{X}WF9)DIMerAx3x6@AyvW#_pA``?a#H5gk-1)3mcDOT@9qzB- z&)^r}dteUqfg>QQeyaXj{h@kQJtI3DQkRc_@cwW^X^5AGSZM&IVKqhZ-#(I)->aVd zeefoD6)b@4dx*K1n%Gw%5y8nt-~b5NyHK+)j5L|U9c3vETK!sZ9rq)aZ5Df z)o27!P(k$4IvN{qV>Vcv{3AS0wZ&OQV=JA#6&k)ogGr(%OpHv5E6^y5H@(&*RIfxM zR*gmw2^K_ft)sE=HfDoQ%RhlpTSa3lSFsHmm?a5I7zlD+xi=&lYwE97hbF8;;i*>uAJZ*!E^J|0GwFrJK5~Jk*wGYnq* zCZY$PnwqDk=BcTvzNFvm1D=|i(na^Gsrg@B#OIN)`nKc|sgZxI%8L+X&9?=yFF~|Q W(YFOj+`L{vWKN}Tt56mcBlLf{k81w_ delta 450 zcmZ9|F=!J}90u_BxLodX_wN6pkSrE+G#290cu;UCiK9|GBtpj{fey74S{*tB3?>fh zm~z8e0^)6oLo2Tkf`fIc6gLqZir92%30(wRkbu5RmeTRT@8kb{ueqx?cN6Vvy2}{5 zy5Zj8?uXhNxQ==XEn>)0$??S#yJK}y9dp^ZozxTGv`BhxOY25CUKOqtTS_xpqCT~$ z2OmH195YmcO=)aNul&bJQ4ijSIaw^l zTttjE#R;Y8kSe(8d`8cy9|yu?G?LmsB|YR43q z)uB=ihBFNC3*WJiPuRgbyhfmGLtc60BbZ&Yj?>KK0djHKF#E#*>AbY;FB5tFwiR<&N+ZAMpz7cz}XB3xq!L f$*=-0m0h`P_ppskJj05z_Yodm=b5mT Non assert session_kwargs["saved_payment_method_options"]["payment_method_save"] == "enabled" +def test_health(client: TestClient) -> None: + response = client.get("/health") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + def test_create_checkout_session_reuses_existing_stripe_customer(client: TestClient, db_session: Session) -> None: first_response = client.post( "/payments/checkout-session", diff --git a/openspec/changes/fix-appointment-list-refresh-after-consecutive-service-bookings/.openspec.yaml b/openspec/changes/fix-appointment-list-refresh-after-consecutive-service-bookings/.openspec.yaml new file mode 100644 index 0000000..927e3e8 --- /dev/null +++ b/openspec/changes/fix-appointment-list-refresh-after-consecutive-service-bookings/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-31 diff --git a/openspec/changes/fix-appointment-list-refresh-after-consecutive-service-bookings/design.md b/openspec/changes/fix-appointment-list-refresh-after-consecutive-service-bookings/design.md new file mode 100644 index 0000000..c38ee9d --- /dev/null +++ b/openspec/changes/fix-appointment-list-refresh-after-consecutive-service-bookings/design.md @@ -0,0 +1,29 @@ +## Context + +Service-mode bookings create appointments before Stripe Checkout completion. The Appointments tab is the recovery surface for pending payments and the confirmation surface after successful payment. Consecutive bookings can expose stale React Query cache, focus/refetch gaps, or backend filtering issues that are less visible in a single booking flow. + +## Goals / Non-Goals + +**Goals:** +- Make the customer Appointments tab reliably show all customer-created service-mode appointments after consecutive booking flows. +- Refetch appointment list data when the Appointments tab becomes active. +- Invalidate consistent customer appointment query keys after booking creation and payment state mutations. +- Prove the backend returns consecutive pending/paid service-mode appointments. +- Preserve mock mode and existing Stripe Checkout launch behavior. + +**Non-Goals:** +- Full card management. +- Refunds, payouts, disputes, or settlement. +- Reworking navigation architecture beyond the cache/refetch paths needed for appointment visibility. + +## Decisions + +- Use a centralized query-key helper for customer appointment list data to avoid invalidating a different key than the one used by the list. +- Treat focus refetch as a necessary safety net because payment flows leave and re-enter the app and users can create bookings back-to-back. +- Keep appointment visibility controlled by the backend endpoint and avoid mobile-side filtering that hides pending-payment service-mode appointments. +- Preserve detail-screen recovery actions as the fallback if checkout is cancelled or payment remains pending. + +## Risks / Trade-offs + +- Additional focus refetches can add network calls, but the Appointments tab is a correctness-critical recovery surface. +- Query invalidation alone may not cover all app-return paths, so focus refetch is intentionally redundant. diff --git a/openspec/changes/fix-appointment-list-refresh-after-consecutive-service-bookings/proposal.md b/openspec/changes/fix-appointment-list-refresh-after-consecutive-service-bookings/proposal.md new file mode 100644 index 0000000..b4bba14 --- /dev/null +++ b/openspec/changes/fix-appointment-list-refresh-after-consecutive-service-bookings/proposal.md @@ -0,0 +1,21 @@ +## Why + +Stripe Checkout now opens from service-mode Place Booking, but the customer Appointments tab can still show stale data after the customer completes consecutive booking/payment flows. This makes new appointments appear missing even when the backend created them. + +## What Changes + +- Ensure customer appointment queries use consistent keys and are invalidated/refetched after booking creation, payment return, payment refresh, and unpaid cancellation. +- Refetch the Appointments tab whenever it receives focus. +- Verify consecutive service-mode bookings are returned by the backend customer appointments endpoint. +- Keep pending-payment and paid service-mode appointments visible with clear payment labels. + +## Capabilities + +### New Capabilities +- `customer-appointment-list-refresh`: Covers reliable customer appointment list refresh/visibility after service-mode booking and payment state changes. + +## Impact + +- Mobile booking review/pay, payment result, appointment list, appointment detail, live appointment cache invalidation, and query keys. +- API customer appointment listing and focused backend tests around consecutive service-mode bookings. +- No changes to refunds, payouts, disputes, card management, or Stripe Checkout launch behavior. diff --git a/openspec/changes/fix-appointment-list-refresh-after-consecutive-service-bookings/specs/customer-appointment-list-refresh/spec.md b/openspec/changes/fix-appointment-list-refresh-after-consecutive-service-bookings/specs/customer-appointment-list-refresh/spec.md new file mode 100644 index 0000000..2481c17 --- /dev/null +++ b/openspec/changes/fix-appointment-list-refresh-after-consecutive-service-bookings/specs/customer-appointment-list-refresh/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Customer appointment list refreshes after service-mode booking changes +The mobile app SHALL refresh the customer appointment list after service-mode booking creation and payment state changes. + +#### Scenario: Booking creation invalidates list +- **WHEN** Place Booking creates a service-mode appointment +- **THEN** the customer appointment list query is invalidated or refetched using the same key used by the Appointments tab + +#### Scenario: Payment return invalidates list +- **WHEN** the customer returns from Stripe Checkout or views a payment result +- **THEN** customer appointment list data is invalidated or refetched + +#### Scenario: Payment actions invalidate list +- **WHEN** the customer checks payment status or cancels an unpaid booking +- **THEN** customer appointment list and detail data are invalidated or refetched + +### Requirement: Appointments tab refetches on focus +The Appointments tab SHALL refetch customer appointment data whenever the tab receives focus. + +#### Scenario: User opens Appointments after consecutive bookings +- **WHEN** the customer completes one booking flow, completes another booking flow, and then taps Appointments +- **THEN** the Appointments tab refetches and displays all appointments returned by the backend + +### Requirement: Customer appointment list includes service-mode payment states +The backend and mobile list SHALL include customer service-mode appointments in pending-payment and paid states. + +#### Scenario: Consecutive service-mode bookings are listed +- **WHEN** a customer creates two consecutive service-mode bookings +- **THEN** the customer appointments endpoint returns both appointments with appointment id, payment mode, payment status, checkout URL when available, and payment message when relevant + +#### Scenario: Payment state remains visible +- **WHEN** the Appointments tab renders pending, paid, or failed service-mode appointments +- **THEN** each item displays a clear payment state such as Payment pending, Complete payment, Paid, or Payment failed diff --git a/openspec/changes/fix-appointment-list-refresh-after-consecutive-service-bookings/tasks.md b/openspec/changes/fix-appointment-list-refresh-after-consecutive-service-bookings/tasks.md new file mode 100644 index 0000000..bf72759 --- /dev/null +++ b/openspec/changes/fix-appointment-list-refresh-after-consecutive-service-bookings/tasks.md @@ -0,0 +1,24 @@ +## 1. Investigation + +- [x] 1.1 Trace customer appointment query keys, invalidation paths, and focus behavior in mobile. +- [x] 1.2 Verify backend customer appointment listing includes consecutive service-mode bookings. +- [x] 1.3 Check payment return, refresh, cancel, and booking creation paths for stale or mismatched cache invalidation. + +## 2. Backend Coverage + +- [x] 2.1 Add or update backend tests proving two consecutive service-mode bookings appear in the customer appointments endpoint with payment fields. +- [x] 2.2 Fix the customer appointment list endpoint if pending or paid service-mode appointments are filtered out. + +## 3. Mobile Refresh And Visibility + +- [x] 3.1 Centralize or align customer appointment query keys. +- [x] 3.2 Ensure booking creation invalidates/refetches the customer appointments list. +- [x] 3.3 Ensure payment return, payment refresh, and unpaid cancellation invalidate/refetch customer appointments. +- [x] 3.4 Ensure the Appointments tab refetches on focus. +- [x] 3.5 Preserve pending/paid/failed payment labels and detail recovery actions. + +## 4. Validation + +- [x] 4.1 Run focused backend tests for consecutive service-mode appointment list visibility. +- [x] 4.2 Run mobile typecheck. +- [x] 4.3 Summarize root cause, changed files, final cache/refetch behavior, and validation results. diff --git a/openspec/changes/fix-customer-appointments-tab-list-and-badge-after-bookings/.openspec.yaml b/openspec/changes/fix-customer-appointments-tab-list-and-badge-after-bookings/.openspec.yaml new file mode 100644 index 0000000..927e3e8 --- /dev/null +++ b/openspec/changes/fix-customer-appointments-tab-list-and-badge-after-bookings/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-31 diff --git a/openspec/changes/fix-customer-appointments-tab-list-and-badge-after-bookings/design.md b/openspec/changes/fix-customer-appointments-tab-list-and-badge-after-bookings/design.md new file mode 100644 index 0000000..6fa2474 --- /dev/null +++ b/openspec/changes/fix-customer-appointments-tab-list-and-badge-after-bookings/design.md @@ -0,0 +1,31 @@ +## Context + +The Appointments tab uses a nested stack so payment return flows and notification/deep-link flows can open appointment detail directly. React Navigation preserves nested stack state, so after opening `AppointmentDetail`, tapping the same tab can keep showing `AppointmentDetail` instead of returning to `AppointmentList`. + +Appointment list data was recently made refresh-safe through centralized query keys and focus refetch. This change focuses on the remaining navigation entry-point bug and incorrect badge source. + +## Goals / Non-Goals + +**Goals:** +- Tapping the customer Appointments tab always returns the nested appointment stack to `AppointmentList`. +- Preserve explicit detail navigation from payment return, notifications, deep links, and appointment cards. +- Keep appointment list query invalidation/refetch behavior intact. +- Remove notification unread count from the Appointments tab badge. + +**Non-Goals:** +- Backend appointment list changes unless data is missing. +- Redesigning tab navigation or route names outside customer appointments. +- Changing owner/provider tabs. +- Reworking notification surfaces. + +## Decisions + +- Handle the tab press at the root tab level and reset/navigate the nested Appointments stack to `AppointmentList`. +- Use this only for direct tab presses, not programmatic navigation that intentionally targets `AppointmentDetail`. +- Prefer no Appointments tab badge over a misleading notification badge. Notification counts remain on the notification/bell surface. +- Keep focus refetch on `AppointmentListScreen` as the data freshness safety net once the tab returns to list. + +## Risks / Trade-offs + +- Resetting stack on every Appointments tab press means a customer currently viewing detail can quickly return to the list by tapping the tab. This matches the requested list-entry behavior. +- Removing the badge loses a generic attention cue, but avoids showing notification state as appointment count. diff --git a/openspec/changes/fix-customer-appointments-tab-list-and-badge-after-bookings/proposal.md b/openspec/changes/fix-customer-appointments-tab-list-and-badge-after-bookings/proposal.md new file mode 100644 index 0000000..06ed0ac --- /dev/null +++ b/openspec/changes/fix-customer-appointments-tab-list-and-badge-after-bookings/proposal.md @@ -0,0 +1,21 @@ +## Why + +After consecutive service-mode Stripe bookings, the customer Appointments tab can remain focused on the latest appointment detail screen inside its nested stack. This makes tapping the tab look like the list is missing until logout/login resets navigation state. The tab badge also appears to use notification unread count, which makes appointment navigation show notification state in the wrong place. + +## What Changes + +- Make the customer Appointments tab behave as a list entry point when tapped. +- Preserve intentional navigation to appointment detail from payment returns, notification/deep links, and list cards. +- Keep customer appointment query invalidation/refetch behavior aligned after booking/payment actions. +- Remove incorrect notification-driven badge state from the Appointments tab unless a trustworthy appointment-specific count exists. + +## Capabilities + +### New Capabilities +- `customer-appointments-tab-entry`: Covers customer Appointments tab stack reset/list entry behavior and badge correctness. + +## Impact + +- Mobile customer tab navigation, appointment stack behavior, payment-return navigation, and tab badge configuration. +- No backend changes expected unless investigation shows the endpoint is missing data. +- No owner/provider tab changes. diff --git a/openspec/changes/fix-customer-appointments-tab-list-and-badge-after-bookings/specs/customer-appointments-tab-entry/spec.md b/openspec/changes/fix-customer-appointments-tab-list-and-badge-after-bookings/specs/customer-appointments-tab-entry/spec.md new file mode 100644 index 0000000..4c5e9a1 --- /dev/null +++ b/openspec/changes/fix-customer-appointments-tab-list-and-badge-after-bookings/specs/customer-appointments-tab-entry/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Appointments tab is a list entry point +The customer Appointments tab SHALL show the appointment list when the customer taps the tab. + +#### Scenario: Tab tapped while detail is active +- **WHEN** the Appointments tab nested stack is currently showing `AppointmentDetail` +- **AND** the customer taps the Appointments tab +- **THEN** the nested stack returns to `AppointmentList` + +#### Scenario: Explicit detail navigation is preserved +- **WHEN** payment return, notification/deep-link handling, or an appointment list card explicitly navigates to `AppointmentDetail` +- **THEN** the app opens that appointment detail screen + +### Requirement: Appointment list remains fresh after booking/payment events +The mobile app SHALL keep customer appointment list data invalidated or refetched after booking and payment state changes. + +#### Scenario: User opens Appointments after consecutive bookings +- **WHEN** the customer completes two service-mode booking/payment flows +- **AND** taps Appointments +- **THEN** the appointment list is shown and refetched so both bookings are visible when returned by the API + +### Requirement: Appointments tab badge is appointment-specific or absent +The Appointments tab SHALL NOT display notification unread count as its badge. + +#### Scenario: Notifications exist +- **WHEN** the customer has unread notifications +- **THEN** notification count remains on notification surfaces +- **AND** the Appointments tab does not show that notification count diff --git a/openspec/changes/fix-customer-appointments-tab-list-and-badge-after-bookings/tasks.md b/openspec/changes/fix-customer-appointments-tab-list-and-badge-after-bookings/tasks.md new file mode 100644 index 0000000..4d8e6a1 --- /dev/null +++ b/openspec/changes/fix-customer-appointments-tab-list-and-badge-after-bookings/tasks.md @@ -0,0 +1,23 @@ +## 1. Investigation + +- [x] 1.1 Inspect RootTabs and nested Appointments stack behavior. +- [x] 1.2 Inspect payment return, booking success, notification, and deep-link navigation into appointment detail. +- [x] 1.3 Confirm customer appointment list query refresh/invalidation paths remain aligned. +- [x] 1.4 Identify the source of the Appointments tab badge/count. + +## 2. Navigation Fix + +- [x] 2.1 Add tab press handling so tapping Appointments returns to the appointment list route. +- [x] 2.2 Preserve explicit navigation to appointment detail from payment return, notifications/deep links, and list cards. +- [x] 2.3 Verify repeated Appointments tab taps return from detail to list without logout/login. + +## 3. Badge Fix + +- [x] 3.1 Remove notification unread count from the Appointments tab badge. +- [x] 3.2 Keep notification unread count on notification/bell surfaces only. + +## 4. Validation + +- [x] 4.1 Run mobile typecheck. +- [x] 4.2 Validate OpenSpec change. +- [x] 4.3 Summarize root cause, files changed, final tab behavior, badge behavior, and validation results. diff --git a/scripts/start-api.ps1 b/scripts/start-api.ps1 index 008edc5..4c87d43 100644 --- a/scripts/start-api.ps1 +++ b/scripts/start-api.ps1 @@ -4,6 +4,10 @@ param( [switch]$NoSeed, [switch]$ResetDb, [switch]$SkipInstall, + [ValidateSet("", "mock", "service")] + [string]$PaymentMode = "", + [string]$PaymentServiceBaseUrl = "", + [string]$MobileRedirectBase = "", [int]$Port = 8000 ) @@ -55,6 +59,24 @@ function Set-DotEnvValue { Set-Content -Path $Path -Value $content -NoNewline } +function Get-DotEnvValue { + param( + [string]$Path, + [string]$Name + ) + + if (-not (Test-Path $Path)) { + return "" + } + + $match = Select-String -Path $Path -Pattern "^$([regex]::Escape($Name))=(.*)$" | Select-Object -First 1 + if (-not $match) { + return "" + } + + return $match.Matches[0].Groups[1].Value.Trim() +} + Push-Location $ApiDir try { Invoke-Step "Starting Postgres" { @@ -71,16 +93,65 @@ try { } Invoke-Step "Preparing API .env" { + $createdEnv = $false if (-not (Test-Path $EnvPath)) { Copy-Item $EnvExamplePath $EnvPath + $createdEnv = $true + } + + $existingPaymentMode = Get-DotEnvValue $EnvPath "PAYMENT_MODE" + $effectivePaymentMode = if ($PaymentMode) { + $PaymentMode + } elseif (-not $createdEnv -and $existingPaymentMode) { + $existingPaymentMode + } else { + "mock" + } + + $existingPaymentServiceBaseUrl = Get-DotEnvValue $EnvPath "PAYMENT_SERVICE_BASE_URL" + $effectivePaymentServiceBaseUrl = if ($PaymentServiceBaseUrl) { + $PaymentServiceBaseUrl + } elseif ($existingPaymentServiceBaseUrl) { + $existingPaymentServiceBaseUrl + } else { + "" + } + + $existingMobileRedirectBase = Get-DotEnvValue $EnvPath "PAYMENT_MOBILE_REDIRECT_BASE" + $effectiveMobileRedirectBase = if ($MobileRedirectBase) { + $MobileRedirectBase + } elseif ($existingMobileRedirectBase) { + $existingMobileRedirectBase + } else { + "" + } + + if ($effectivePaymentMode -eq "service") { + if (-not $effectivePaymentServiceBaseUrl) { + $effectivePaymentServiceBaseUrl = "http://localhost:8001" + } + if (-not $effectiveMobileRedirectBase) { + throw "PAYMENT_MODE=service requires -MobileRedirectBase, for example exp://:8081/-- for Expo Go or shoeinn://app for a dev build." + } + try { + Invoke-RestMethod "$($effectivePaymentServiceBaseUrl.TrimEnd('/'))/health" | Out-Null + } catch { + throw "PAYMENT_MODE=service requires the payment service to be reachable at $effectivePaymentServiceBaseUrl. Start apps/payment first, then rerun this script." + } } Set-DotEnvValue $EnvPath "DATABASE_URL" "postgresql+psycopg://postgres:postgres@localhost:5432/shoeinn" Set-DotEnvValue $EnvPath "API_HOST" "0.0.0.0" Set-DotEnvValue $EnvPath "API_PORT" "$Port" - Set-DotEnvValue $EnvPath "PAYMENT_MODE" "mock" - Set-DotEnvValue $EnvPath "PAYMENT_SERVICE_BASE_URL" "" - Set-DotEnvValue $EnvPath "PAYMENT_MOBILE_REDIRECT_BASE" "" + Set-DotEnvValue $EnvPath "PAYMENT_MODE" $effectivePaymentMode + Set-DotEnvValue $EnvPath "PAYMENT_SERVICE_BASE_URL" $effectivePaymentServiceBaseUrl + Set-DotEnvValue $EnvPath "PAYMENT_MOBILE_REDIRECT_BASE" $effectiveMobileRedirectBase + + Write-Host "Payment mode: $effectivePaymentMode" + if ($effectivePaymentMode -eq "service") { + Write-Host "Payment service: $effectivePaymentServiceBaseUrl" + Write-Host "Mobile redirect base: $effectiveMobileRedirectBase" + } } if (-not $SkipInstall) { @@ -95,6 +166,17 @@ try { } Invoke-Step "Starting API on http://localhost:$Port" { + $existingReadyUrl = "http://localhost:$Port/ready" + try { + $existingReady = Invoke-RestMethod $existingReadyUrl + $existingMode = $existingReady.payment_mode + throw "An API is already running on port $Port with payment_mode=$existingMode. Stop that process before starting a new API instance, or use a different -Port." + } catch { + if ($_.Exception.Message -like "An API is already running on port*") { + throw + } + } + $apiArgs = @( "-m", "uvicorn", "app.main:app", diff --git a/scripts/start-local.ps1 b/scripts/start-local.ps1 index 9eaa14b..4b8274d 100644 --- a/scripts/start-local.ps1 +++ b/scripts/start-local.ps1 @@ -1,10 +1,17 @@ param( [ValidateSet("shelby", "mt_juliet")] [string]$DemoMarket = "shelby", - [string]$ApiBaseUrl = "http://localhost:8000", + [int]$Port = 8000, + [string]$ApiBaseUrl = "", [switch]$Tunnel, [switch]$SkipApiCheck, - [switch]$SkipInstall + [switch]$SkipInstall, + [ValidateSet("", "mock", "service")] + [string]$PaymentMode = "", + [string]$PaymentServiceBaseUrl = "", + [string]$MobileRedirectBase = "", + [int]$PaymentPort = 8001, + [switch]$SkipPaymentService ) $ErrorActionPreference = "Stop" @@ -12,14 +19,104 @@ Set-StrictMode -Version Latest $ApiScript = Join-Path $PSScriptRoot "start-api.ps1" $MobileScript = Join-Path $PSScriptRoot "start-mobile.ps1" +$PaymentScript = Join-Path $PSScriptRoot "start-payment.ps1" + +if (-not $ApiBaseUrl) { + $ApiBaseUrl = "http://localhost:$Port" +} + +$paymentServiceBaseUrlWasProvided = [bool]$PaymentServiceBaseUrl +if ($PaymentMode -eq "service" -and -not $PaymentServiceBaseUrl) { + $PaymentServiceBaseUrl = "http://localhost:$PaymentPort" +} + +function Wait-ForHttpOk { + param( + [string]$Url, + [string]$ServiceName, + [int]$Attempts = 40 + ) + + for ($i = 0; $i -lt $Attempts; $i++) { + try { + $response = Invoke-RestMethod $Url + if ($response.status -eq "ok") { + return + } + } catch { + } + Start-Sleep -Seconds 1 + } + + throw "$ServiceName did not become healthy at $Url" +} + +function Test-SameApiUrl { + param( + [string]$Left, + [string]$Right + ) + + try { + $leftUri = [uri]$Left + $rightUri = [uri]$Right + return $leftUri.Scheme -eq $rightUri.Scheme -and $leftUri.Host -eq $rightUri.Host -and $leftUri.Port -eq $rightUri.Port + } catch { + return $Left.TrimEnd("/") -eq $Right.TrimEnd("/") + } +} + +if ($PaymentMode -eq "service" -and -not $SkipPaymentService) { + $paymentHealthUrl = "$($PaymentServiceBaseUrl.TrimEnd('/'))/health" + $shouldStartLocalPaymentService = -not $paymentServiceBaseUrlWasProvided -or $PaymentServiceBaseUrl -match "^https?://(localhost|127\.0\.0\.1)(:|/|$)" + + $paymentAlreadyRunning = $false + try { + $paymentHealth = Invoke-RestMethod $paymentHealthUrl + $paymentAlreadyRunning = $paymentHealth.status -eq "ok" + } catch { + } + + if ($paymentAlreadyRunning) { + Write-Host "==> Payment service is already running at $PaymentServiceBaseUrl" + } elseif (-not $shouldStartLocalPaymentService) { + Write-Host "==> Payment service is not local; skipping auto-start for $PaymentServiceBaseUrl" + } else { + $paymentArgs = @( + "-NoExit", + "-ExecutionPolicy", "Bypass", + "-File", "`"$PaymentScript`"", + "-Port", "$PaymentPort" + ) + + if ($SkipInstall) { + $paymentArgs += "-SkipInstall" + } + + Write-Host "==> Starting payment service in a new PowerShell window" + Start-Process -FilePath "powershell.exe" -ArgumentList $paymentArgs -WorkingDirectory (Split-Path -Parent $PSScriptRoot) + Write-Host "==> Waiting for payment service at $paymentHealthUrl" + Wait-ForHttpOk -Url $paymentHealthUrl -ServiceName "Payment service" + } +} $apiArgs = @( "-NoExit", "-ExecutionPolicy", "Bypass", "-File", "`"$ApiScript`"", - "-DemoMarket", $DemoMarket + "-DemoMarket", $DemoMarket, + "-Port", "$Port" ) +if ($PaymentMode) { + $apiArgs += @("-PaymentMode", $PaymentMode) +} +if ($PaymentServiceBaseUrl) { + $apiArgs += @("-PaymentServiceBaseUrl", $PaymentServiceBaseUrl) +} +if ($MobileRedirectBase) { + $apiArgs += @("-MobileRedirectBase", $MobileRedirectBase) +} if ($SkipInstall) { $apiArgs += "-SkipInstall" } @@ -27,8 +124,20 @@ if ($SkipInstall) { Write-Host "==> Starting API in a new PowerShell window" Start-Process -FilePath "powershell.exe" -ArgumentList $apiArgs -WorkingDirectory (Split-Path -Parent $PSScriptRoot) -Write-Host "==> Waiting briefly before starting Expo" -Start-Sleep -Seconds 8 +$localApiBaseUrl = "http://localhost:$Port" +$localApiHealthUrl = "$localApiBaseUrl/health" +Write-Host "==> Waiting for API at $localApiHealthUrl" +Wait-ForHttpOk -Url $localApiHealthUrl -ServiceName "API" -Attempts 90 + +if (-not (Test-SameApiUrl -Left $ApiBaseUrl -Right $localApiBaseUrl)) { + $lanApiHealthUrl = "$($ApiBaseUrl.TrimEnd('/'))/health" + Write-Host "==> Verifying mobile API URL at $lanApiHealthUrl" + try { + Wait-ForHttpOk -Url $lanApiHealthUrl -ServiceName "Mobile API URL" -Attempts 10 + } catch { + throw "API is healthy at $localApiHealthUrl, but $lanApiHealthUrl is not reachable from this machine. Check that $ApiBaseUrl uses the current LAN IP and that Windows Firewall allows inbound traffic on port $Port." + } +} $mobileArgs = @( "-ExecutionPolicy", "Bypass", @@ -36,6 +145,12 @@ $mobileArgs = @( "-ApiBaseUrl", $ApiBaseUrl ) +if ($MobileRedirectBase) { + $mobileArgs += @("-MobileRedirectBase", $MobileRedirectBase) +} +if ($PaymentMode) { + $mobileArgs += @("-ExpectedPaymentMode", $PaymentMode) +} if ($Tunnel) { $mobileArgs += "-Tunnel" } diff --git a/scripts/start-mobile.ps1 b/scripts/start-mobile.ps1 index 536ae65..d5d37b3 100644 --- a/scripts/start-mobile.ps1 +++ b/scripts/start-mobile.ps1 @@ -1,6 +1,8 @@ param( [string]$ApiBaseUrl = "http://localhost:8000", [string]$MobileRedirectBase = "", + [ValidateSet("", "mock", "service")] + [string]$ExpectedPaymentMode = "", [switch]$Tunnel, [switch]$SkipApiCheck, [switch]$SkipInstall @@ -38,7 +40,25 @@ try { throw "Unexpected health response: $($health | ConvertTo-Json -Compress)" } } catch { - throw "Could not reach the ShoeInn API at $healthUrl. Check the LAN IP, make sure the API is running on port 8000, and confirm Windows Firewall allows inbound connections." + $apiPort = "the configured port" + try { + $apiPort = ([uri]$ApiBaseUrl).Port + } catch { + } + throw "Could not reach the ShoeInn API at $healthUrl. Check the API window for startup errors, confirm the API is running on port $apiPort, verify the LAN IP in -ApiBaseUrl, and confirm Windows Firewall allows inbound connections." + } + + if ($ExpectedPaymentMode) { + $readyUrl = "$($ApiBaseUrl.TrimEnd('/'))/ready" + Write-Host "==> Checking API payment mode at $readyUrl" + try { + $ready = Invoke-RestMethod $readyUrl + if ($ready.payment_mode -ne $ExpectedPaymentMode) { + throw "Expected payment_mode=$ExpectedPaymentMode but API reported payment_mode=$($ready.payment_mode)" + } + } catch { + throw "API payment mode check failed. $($_.Exception.Message)" + } } } diff --git a/scripts/start-payment.ps1 b/scripts/start-payment.ps1 new file mode 100644 index 0000000..543c16d --- /dev/null +++ b/scripts/start-payment.ps1 @@ -0,0 +1,114 @@ +param( + [int]$Port = 8001, + [switch]$SkipInstall +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$RepoRoot = Split-Path -Parent $PSScriptRoot +$PaymentDir = Join-Path $RepoRoot "apps\payment" +$VenvPython = Join-Path $PaymentDir ".venv\Scripts\python.exe" +$VenvPip = Join-Path $PaymentDir ".venv\Scripts\pip.exe" +$EnvPath = Join-Path $PaymentDir ".env" + +function Invoke-Step { + param( + [string]$Label, + [scriptblock]$Command + ) + + Write-Host "" + Write-Host "==> $Label" + & $Command +} + +function Test-DotEnvValue { + param( + [string]$Path, + [string]$Name + ) + + if (-not (Test-Path $Path)) { + return $false + } + + $match = Select-String -Path $Path -Pattern "^$([regex]::Escape($Name))=(.+)$" | Select-Object -First 1 + return [bool]$match +} + +Push-Location $PaymentDir +try { + Invoke-Step "Checking payment service config" { + if (-not (Test-Path $EnvPath)) { + throw "apps/payment/.env is required for service payment mode. Add STRIPE_API_KEY and STRIPE_WEBHOOK_SECRET, then rerun this script." + } + if (-not (Test-DotEnvValue $EnvPath "STRIPE_API_KEY")) { + throw "apps/payment/.env is missing STRIPE_API_KEY." + } + if (-not (Test-DotEnvValue $EnvPath "STRIPE_WEBHOOK_SECRET")) { + throw "apps/payment/.env is missing STRIPE_WEBHOOK_SECRET." + } + } + + Invoke-Step "Preparing payment virtual environment" { + if (-not (Test-Path $VenvPython)) { + py -3.11 -m venv .venv + } + } + + if (-not $SkipInstall) { + Invoke-Step "Installing payment dependencies" { + & $VenvPip install -e . + } + } + + Invoke-Step "Starting payment service on http://localhost:$Port" { + $existingHealthUrl = "http://localhost:$Port/health" + try { + $existingHealth = Invoke-RestMethod $existingHealthUrl + if ($existingHealth.status -eq "ok") { + Write-Host "Payment service is already running on port $Port." + return + } + } catch { + } + + $paymentArgs = @( + "-m", "uvicorn", + "app.main:app", + "--reload", + "--host", "0.0.0.0", + "--port", "$Port" + ) + + $paymentProcess = Start-Process -FilePath $VenvPython -ArgumentList $paymentArgs -WorkingDirectory $PaymentDir -NoNewWindow -PassThru + + try { + $ready = $false + for ($i = 0; $i -lt 40; $i++) { + try { + Invoke-RestMethod $existingHealthUrl | Out-Null + $ready = $true + break + } catch { + Start-Sleep -Seconds 1 + } + } + + if (-not $ready) { + throw "Payment service did not become healthy at $existingHealthUrl" + } + + Write-Host "" + Write-Host "Payment service is running. Press Ctrl+C to stop." + Wait-Process -Id $paymentProcess.Id + } finally { + if ($paymentProcess -and -not $paymentProcess.HasExited) { + Stop-Process -Id $paymentProcess.Id -Force + } + } + } +} finally { + Pop-Location +} From a4bdd1386dee82c42bfb21670014176468ba7953 Mon Sep 17 00:00:00 2001 From: Anthony Wright Date: Sun, 31 May 2026 16:02:02 -0500 Subject: [PATCH 5/5] update docker compose and environment files --- apps/api/.env.staging.example | 43 +++++++++++++++++++++-------- apps/api/docker-compose.staging.yml | 22 ++++++++++++++- apps/payment/.env.staging.example | 2 ++ 3 files changed, 54 insertions(+), 13 deletions(-) create mode 100644 apps/payment/.env.staging.example diff --git a/apps/api/.env.staging.example b/apps/api/.env.staging.example index e43cb7a..95f8424 100644 --- a/apps/api/.env.staging.example +++ b/apps/api/.env.staging.example @@ -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://: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. \ No newline at end of file diff --git a/apps/api/docker-compose.staging.yml b/apps/api/docker-compose.staging.yml index 7972984..efdc879 100644 --- a/apps/api/docker-compose.staging.yml +++ b/apps/api/docker-compose.staging.yml @@ -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: ../.. @@ -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" @@ -34,6 +53,7 @@ services: interval: 15s timeout: 5s retries: 10 + restart: unless-stopped notification-worker: build: @@ -50,4 +70,4 @@ services: restart: unless-stopped volumes: - pgdata_staging: + pgdata_staging: \ No newline at end of file diff --git a/apps/payment/.env.staging.example b/apps/payment/.env.staging.example new file mode 100644 index 0000000..4563ac0 --- /dev/null +++ b/apps/payment/.env.staging.example @@ -0,0 +1,2 @@ +STRIPE_API_KEY=sk_test_51MQGxRF3eKbuRVWZpEts6oT949m7gkFXG1E1KhRQnFTCkKkif2Lyf61D45OgtzR9qNJxpAsJjNHzSde0i7YeAfn100VyzHguLt +STRIPE_WEBHOOK_SECRET=sk_test_51MQGxRF3eKbuRVWZpEts6oT949m7gkFXG1E1KhRQnFTCkKkif2Lyf61D45OgtzR9qNJxpAsJjNHzSde0i7YeAfn100VyzHguLt \ No newline at end of file