diff --git a/DEPLOY.md b/DEPLOY.md index e7cfb3c..8047a78 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -36,6 +36,7 @@ The backend image runs in three roles, all from `deploy/docker-compose.yml`: - `WORKER_ROLE` — set per-container in `docker-compose.yml` (`web`/`worker`/`scheduler`); no need to set in Doppler. - `STRIPE_PORTAL_CONFIG_ID` — optional `bpc_...`; empty uses the Stripe dashboard default portal config. Used by the Phase 1 billing portal endpoint. Create the portal configuration in Stripe (enable subscription update to the localmate price, payment-method update, cancellation) — one-time dashboard/API step. - `DASHBOARD_URL` — `https://localmate.crewcircle.co`; used as the Stripe portal `return_url` base. +- `SUPABASE_JWT_SECRET` — **Phase 1 (required for billing).** The Supabase project's JWT secret (Settings → API → JWT Settings in the Supabase dashboard). Used to verify dashboard bearer tokens and derive `client_id` from the authenticated user (tenant-auth binding, C8/D20). When unset, the legacy `require_auth` falls back to anonymous for old callers, but the strict `/billing/*` endpoints reject with 401 — so it must be set in prd. ### Migrations (Phase 0) @@ -64,6 +65,21 @@ It asserts, and FAILS the build otherwise: Record the gate's `ALL PHASE 0 STAGING GATE CHECKS PASSED` output on the PR before merge. +### Phase 1 — billing visibility + tenant auth + +**Migration:** apply `supabase/migrations/013_user_client_map.sql` on top of `012`. It creates `user_client_map (user_id text primary key, client_id uuid references clients(id))` with RLS + a `service_role_all` policy (matches `001_clients.sql`). One row per Supabase auth user → owned client; populate it at signup (or backfill existing users to their `clients` row by email). Until a user has a row, `/billing/*` returns 403. + +**Tenant-auth binding (C8/D20):** `GET /billing/usage` and `POST /billing/portal` derive `client_id` from the authenticated identity via `user_client_map` (never from the request body/query), so a user can only reach their own tenant. `SUPABASE_JWT_SECRET` must be set in Doppler — with it empty these endpoints reject 401 (the legacy `require_auth` still falls back to anonymous for old non-client-scoped callers). + +**One-time Stripe Billing Portal configuration:** +1. Stripe Dashboard → Settings → Billing → Customer portal → Add configuration (or via API: `stripe.billing_portal.Configuration.create(...)`). +2. Enable: subscription update (to the localmate price), payment-method update, subscription cancellation. +3. Copy the configuration id (`bpc_...`). +4. Set `STRIPE_PORTAL_CONFIG_ID` in Doppler (`localmate/prd`). Leave empty to use the Stripe dashboard default portal config. +5. `DASHBOARD_URL` controls where the user lands after leaving the portal (the `return_url`). + +Portal-driven subscription changes (plan/card/cancel) flow back through the existing Stripe webhook handlers (`customer.subscription.updated` / `deleted`) which already update `clients.subscription_status` — no new reconciliation path. + --- diff --git a/backend/.env.example b/backend/.env.example index ba9f41d..752f5fa 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -4,6 +4,8 @@ SUPABASE_URL= SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= +# Phase 1 — tenant auth (Supabase JWT secret; required for /billing/* client-scoped endpoints) +SUPABASE_JWT_SECRET= STRIPE_SECRET_KEY= STRIPE_PRICE_ID= STRIPE_WEBHOOK_SECRET= diff --git a/backend/config.py b/backend/config.py index c31022d..7dc79bb 100644 --- a/backend/config.py +++ b/backend/config.py @@ -25,8 +25,15 @@ class Settings(BaseSettings): sentry_dsn: str = "" project_id: str = "localmate" environment: str = "prod" + # Square — global token deprecated for production (per-client OAuth is + # authoritative via square_oauth.get_valid_token). Kept for sandbox/dev fallback. square_access_token: str = "" square_environment: str = "sandbox" + square_app_id: str = "" + square_app_secret: str = "" + square_oauth_redirect_path: str = "/auth/square-callback" + square_webhook_signature_key: str = "" + menu_images_bucket: str = "menu-images" supabase_jwt_secret: str = "" # --- Phase 0: queue / worker / billing-portal infra --- @@ -35,6 +42,11 @@ class Settings(BaseSettings): dashboard_url: str = "" # Stripe portal return_url base stripe_portal_config_id: str = "" # Stripe portal configuration id (bpc_...) + # --- Phase 4: GBP Pub/Sub provisioning (D15-B full automation) --- + gcp_project_id: str = "" # GCP project hosting the Pub/Sub topic + gcp_sa_json: str = "" # service-account JSON key (topic-admin) for Pub/Sub REST + gbp_pubsub_topic_name: str = "gbp-reviews" # shared topic short name + class Config: env_file = ".env.local" env_file_encoding = "utf-8" diff --git a/backend/jobs/competitor_watch.py b/backend/jobs/competitor_watch.py index 1c16c6a..a749d0b 100644 --- a/backend/jobs/competitor_watch.py +++ b/backend/jobs/competitor_watch.py @@ -7,16 +7,24 @@ from db import get_db from services.claude import generate_competitor_brief +from services.structured_extract import ( + extract_structured, + detect_prices_from_text, + diff_structured, +) from utils.retry import retry_on_failure logger = logging.getLogger(__name__) @retry_on_failure() -async def snapshot_website(url: str) -> tuple[str, str]: - """Fetch a competitor URL, strip non-content elements, and return (md5_hash, clean_text). +async def snapshot_website(url: str) -> tuple[str, str, str]: + """Fetch a competitor URL, strip non-content elements, and return (md5_hash, clean_text, raw_html). - Returns ("", "") when the key is missing or the response is empty. + The raw HTML is returned so the caller can run ``extract_structured`` on + the original (with JSON-LD ``' + '
Lots of other text that changes the hash
' + '