From 2269aaefd7b700aeff2a4442d6f684294ca2a538 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 29 May 2026 17:22:33 +0800 Subject: [PATCH] Reduce Supabase disk IO --- docs/SUPABASE_SETUP_ZH.md | 10 + frontend/app/api/auth/me/route.ts | 20 +- .../app/api/ops/analytics/funnel/route.ts | 4 + frontend/app/api/ops/config/route.ts | 7 + frontend/app/api/ops/health-check/route.ts | 4 + .../app/api/ops/leaderboard/weekly/route.ts | 4 + .../app/api/ops/memberships/growth/route.ts | 3 + .../app/api/ops/memberships/overview/route.ts | 47 + frontend/app/api/ops/memberships/route.ts | 3 + frontend/app/api/ops/online-users/route.ts | 4 + .../incidents/[eventId]/resolve/route.ts | 4 + .../app/api/ops/payments/incidents/route.ts | 4 + .../app/api/ops/subscriptions/extend/route.ts | 4 + .../app/api/ops/subscriptions/grant/route.ts | 23 +- .../api/ops/telegram/members-audit/route.ts | 4 + .../app/api/ops/training/accuracy/route.ts | 4 + frontend/app/api/ops/truth-history/route.ts | 4 + .../app/api/ops/users/grant-points/route.ts | 4 + frontend/app/api/ops/users/route.ts | 4 + frontend/app/api/ops/view-logs/route.ts | 4 + frontend/app/api/payments/config/route.ts | 2 +- frontend/app/api/payments/runtime/route.ts | 2 +- .../app/api/scan/terminal/overview/route.ts | 4 +- .../account/__tests__/paymentSecurity.test.ts | 69 ++ .../account/__tests__/paymentShell.test.ts | 138 ++- .../components/account/useAccountPayment.ts | 13 +- .../__tests__/proxyCachePolicy.test.ts | 30 + .../ops/memberships/MembershipsPageClient.tsx | 9 +- .../ops/overview/OverviewPageClient.tsx | 7 +- frontend/lib/backend-auth.ts | 60 +- frontend/lib/ops-admin.ts | 26 +- frontend/lib/ops-api.ts | 7 + frontend/lib/ops-proxy-auth.ts | 27 + frontend/lib/supabase/server.ts | 40 +- frontend/lib/system-priority-proxy.ts | 4 +- frontend/middleware.ts | 64 +- scripts/grant_subscription_by_email.py | 63 +- scripts/reconcile_payment_tx.py | 51 +- scripts/reconcile_subscription_by_email.py | 15 + scripts/supabase/disk_io_diagnostics.sql | 98 ++ scripts/supabase/io_budget_indexes.sql | 89 ++ scripts/supabase/schema.sql | 61 +- src/auth/supabase_entitlement.py | 389 ++++++- src/bot/handlers/basic.py | 1 + src/bot/weekly_reward_loop.py | 2 +- src/database/db_manager.py | 175 +++- src/payments/contract_checkout.py | 246 ++--- tests/test_bot_basic_handler.py | 40 + tests/test_direct_payment.py | 32 +- tests/test_payments_runtime.py | 742 +++++++++++++- tests/test_reconcile_payment_tx_io.py | 119 +++ tests/test_supabase_email_lookup_scripts.py | 104 ++ tests/test_supabase_entitlement.py | 470 ++++++++- tests/test_supabase_points_sync.py | 93 ++ tests/test_supabase_profile_sync.py | 83 ++ tests/test_supabase_schema_io.py | 137 +++ tests/test_web_observability.py | 948 +++++++++++++++++- tests/test_weekly_reward_supabase_io.py | 42 + web/core.py | 16 +- web/routers/ops.py | 10 + web/services/auth_api.py | 70 +- web/services/ops_api.py | 282 ++++-- web/services/payment_api.py | 2 +- 63 files changed, 4637 insertions(+), 410 deletions(-) create mode 100644 frontend/app/api/ops/memberships/overview/route.ts create mode 100644 frontend/lib/ops-proxy-auth.ts create mode 100644 scripts/supabase/disk_io_diagnostics.sql create mode 100644 scripts/supabase/io_budget_indexes.sql create mode 100644 tests/test_reconcile_payment_tx_io.py create mode 100644 tests/test_supabase_email_lookup_scripts.py create mode 100644 tests/test_supabase_points_sync.py create mode 100644 tests/test_supabase_profile_sync.py create mode 100644 tests/test_supabase_schema_io.py create mode 100644 tests/test_weekly_reward_supabase_io.py diff --git a/docs/SUPABASE_SETUP_ZH.md b/docs/SUPABASE_SETUP_ZH.md index 16c88e4c..8252df1f 100644 --- a/docs/SUPABASE_SETUP_ZH.md +++ b/docs/SUPABASE_SETUP_ZH.md @@ -22,6 +22,7 @@ 在 Supabase SQL Editor 执行: - `scripts/supabase/schema.sql` +- 既有生产项目遇到 Disk IO Budget 告警时,再执行 `scripts/supabase/io_budget_indexes.sql` 会创建支付与订阅相关表: @@ -33,6 +34,15 @@ - `payment_intents` - `payment_transactions` +### 3.1 Disk IO 告警处理 + +如果 Supabase 提示项目正在耗尽 Disk IO Budget,先在 SQL Editor 执行: + +1. `scripts/supabase/io_budget_indexes.sql` +2. `scripts/supabase/disk_io_diagnostics.sql` + +第一个脚本会把热查询索引收敛为更低写放大的部分索引,移除已被唯一约束覆盖或无热路径使用的冗余索引,并对相关表执行 `ANALYZE`。第二个脚本用于查看表扫描、dead tuples、索引命中和 `pg_stat_statements` 中的高读块 SQL。生产执行后,继续观察 Supabase daily/hourly Disk IO 图表,确认请求延迟和 IO wait 是否下降。 + ## 4. 环境变量 ### 4.1 前端(Vercel / frontend/.env.local) diff --git a/frontend/app/api/auth/me/route.ts b/frontend/app/api/auth/me/route.ts index 9639314a..8abcf3ca 100644 --- a/frontend/app/api/auth/me/route.ts +++ b/frontend/app/api/auth/me/route.ts @@ -11,6 +11,7 @@ import { getLocalDevAuthPayload, isLocalFullAccessHost, } from "@/lib/local-dev-access"; +import { hasSupabaseServerEnv } from "@/lib/supabase/server"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -33,8 +34,22 @@ export async function GET(req: NextRequest) { ); } + let auth: Awaited> | null = null; try { - const auth = await buildBackendRequestHeaders(req); + auth = await buildBackendRequestHeaders(req); + if ( + hasSupabaseServerEnv() && + !auth.authUserId && + !req.headers.get("authorization") + ) { + const response = NextResponse.json({ + authenticated: false, + subscription_active: false, + points: 0, + }); + return applyAuthResponseCookies(response, auth.response); + } + const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 6000); let res: Response; @@ -98,8 +113,7 @@ export async function GET(req: NextRequest) { const response = NextResponse.json(data); return applyAuthResponseCookies(response, auth.response); } catch (error) { - const auth = await buildBackendRequestHeaders(req); - if (auth.authUserId) { + if (auth?.authUserId) { const response = NextResponse.json({ authenticated: true, user_id: auth.authUserId, diff --git a/frontend/app/api/ops/analytics/funnel/route.ts b/frontend/app/api/ops/analytics/funnel/route.ts index 72b11a9c..4e144563 100644 --- a/frontend/app/api/ops/analytics/funnel/route.ts +++ b/frontend/app/api/ops/analytics/funnel/route.ts @@ -4,6 +4,7 @@ import { buildBackendRequestHeaders, } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -17,6 +18,9 @@ export async function GET(req: NextRequest) { try { const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + const url = new URL(`${API_BASE}/api/ops/analytics/funnel`); const days = req.nextUrl.searchParams.get("days"); if (days) { diff --git a/frontend/app/api/ops/config/route.ts b/frontend/app/api/ops/config/route.ts index f0113f58..74063efa 100644 --- a/frontend/app/api/ops/config/route.ts +++ b/frontend/app/api/ops/config/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const BACKEND = API_BASE ? `${API_BASE}/api/ops/config` : ""; @@ -9,6 +10,9 @@ export async function GET(req: NextRequest) { if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); try { const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" }); const raw = await res.text(); const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); @@ -20,6 +24,9 @@ export async function PUT(req: NextRequest) { if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); try { const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + const body = await req.text(); const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" }); const raw = await res.text(); diff --git a/frontend/app/api/ops/health-check/route.ts b/frontend/app/api/ops/health-check/route.ts index 25642c28..8103bc83 100644 --- a/frontend/app/api/ops/health-check/route.ts +++ b/frontend/app/api/ops/health-check/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -8,6 +9,9 @@ export async function GET(req: NextRequest) { if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); try { const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + const res = await fetch(`${API_BASE}/api/ops/health-check`, { headers: auth.headers, cache: "no-store" }); const raw = await res.text(); const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); diff --git a/frontend/app/api/ops/leaderboard/weekly/route.ts b/frontend/app/api/ops/leaderboard/weekly/route.ts index b2559e93..a919e897 100644 --- a/frontend/app/api/ops/leaderboard/weekly/route.ts +++ b/frontend/app/api/ops/leaderboard/weekly/route.ts @@ -4,6 +4,7 @@ import { buildBackendRequestHeaders, } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -17,6 +18,9 @@ export async function GET(req: NextRequest) { try { const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + const url = new URL(`${API_BASE}/api/ops/leaderboard/weekly`); const limit = req.nextUrl.searchParams.get("limit"); if (limit) url.searchParams.set("limit", limit); diff --git a/frontend/app/api/ops/memberships/growth/route.ts b/frontend/app/api/ops/memberships/growth/route.ts index 14d09028..5ece7efb 100644 --- a/frontend/app/api/ops/memberships/growth/route.ts +++ b/frontend/app/api/ops/memberships/growth/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -8,6 +9,8 @@ export async function GET(req: NextRequest) { if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); try { const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; const url = new URL(`${API_BASE}/api/ops/memberships/growth`); const days = req.nextUrl.searchParams.get("days"); if (days) url.searchParams.set("days", days); diff --git a/frontend/app/api/ops/memberships/overview/route.ts b/frontend/app/api/ops/memberships/overview/route.ts new file mode 100644 index 00000000..f7cf42cb --- /dev/null +++ b/frontend/app/api/ops/memberships/overview/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function GET(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + try { + const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + const url = new URL(`${API_BASE}/api/ops/memberships/overview`); + const limit = req.nextUrl.searchParams.get("limit"); + const days = req.nextUrl.searchParams.get("days"); + if (limit) url.searchParams.set("limit", limit); + if (days) url.searchParams.set("days", days); + + const res = await fetch(url.toString(), { + headers: auth.headers, + cache: "no-store", + }); + const raw = await res.text(); + const response = new NextResponse(raw, { + status: res.status, + headers: { + "Content-Type": res.headers.get("content-type") || "application/json", + "Cache-Control": "no-store", + }, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch memberships overview", + }); + } +} diff --git a/frontend/app/api/ops/memberships/route.ts b/frontend/app/api/ops/memberships/route.ts index ad38e350..4d6951a5 100644 --- a/frontend/app/api/ops/memberships/route.ts +++ b/frontend/app/api/ops/memberships/route.ts @@ -4,6 +4,7 @@ import { buildBackendRequestHeaders, } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -17,6 +18,8 @@ export async function GET(req: NextRequest) { try { const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; const url = new URL(`${API_BASE}/api/ops/memberships`); const limit = req.nextUrl.searchParams.get("limit"); if (limit) url.searchParams.set("limit", limit); diff --git a/frontend/app/api/ops/online-users/route.ts b/frontend/app/api/ops/online-users/route.ts index e285c7d0..8902bdc8 100644 --- a/frontend/app/api/ops/online-users/route.ts +++ b/frontend/app/api/ops/online-users/route.ts @@ -4,6 +4,7 @@ import { buildBackendRequestHeaders, } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -17,6 +18,9 @@ export async function GET(req: NextRequest) { try { const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + const res = await fetch(`${API_BASE}/api/ops/online-users`, { headers: auth.headers, cache: "no-store", diff --git a/frontend/app/api/ops/payments/incidents/[eventId]/resolve/route.ts b/frontend/app/api/ops/payments/incidents/[eventId]/resolve/route.ts index 93ba3ef9..f79e51be 100644 --- a/frontend/app/api/ops/payments/incidents/[eventId]/resolve/route.ts +++ b/frontend/app/api/ops/payments/incidents/[eventId]/resolve/route.ts @@ -4,6 +4,7 @@ import { buildBackendRequestHeaders, } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -21,6 +22,9 @@ export async function POST(req: NextRequest, context: RouteContext) { try { const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + const { eventId } = await context.params; const res = await fetch(`${API_BASE}/api/ops/payments/incidents/${eventId}/resolve`, { method: "POST", diff --git a/frontend/app/api/ops/payments/incidents/route.ts b/frontend/app/api/ops/payments/incidents/route.ts index 21a114bf..03dec195 100644 --- a/frontend/app/api/ops/payments/incidents/route.ts +++ b/frontend/app/api/ops/payments/incidents/route.ts @@ -4,6 +4,7 @@ import { buildBackendRequestHeaders, } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -17,6 +18,9 @@ export async function GET(req: NextRequest) { try { const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + const url = new URL(`${API_BASE}/api/ops/payments/incidents`); const limit = req.nextUrl.searchParams.get("limit"); if (limit) url.searchParams.set("limit", limit); diff --git a/frontend/app/api/ops/subscriptions/extend/route.ts b/frontend/app/api/ops/subscriptions/extend/route.ts index 41134b7e..eb3b45cc 100644 --- a/frontend/app/api/ops/subscriptions/extend/route.ts +++ b/frontend/app/api/ops/subscriptions/extend/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const ENTITLEMENT_TOKEN = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim() || ""; @@ -9,6 +10,9 @@ export async function POST(req: NextRequest) { if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); try { const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + const body = await req.text(); const headers: Record = { ...auth.headers as Record, "Content-Type": "application/json" }; if (ENTITLEMENT_TOKEN) { diff --git a/frontend/app/api/ops/subscriptions/grant/route.ts b/frontend/app/api/ops/subscriptions/grant/route.ts index d1b6e130..97e205d4 100644 --- a/frontend/app/api/ops/subscriptions/grant/route.ts +++ b/frontend/app/api/ops/subscriptions/grant/route.ts @@ -4,6 +4,7 @@ import { buildBackendRequestHeaders, } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -41,6 +42,23 @@ async function findSupabaseUserIdByEmail(email: string) { if (!supabaseUrl || !serviceRoleKey) { throw new Error("Supabase service role is not configured on Vercel"); } + const profileRes = await fetch( + `${supabaseUrl}/rest/v1/profiles?select=id&email=eq.${encodeURIComponent(email)}&limit=1`, + { + headers: { + apikey: serviceRoleKey, + Authorization: `Bearer ${serviceRoleKey}`, + Accept: "application/json", + }, + cache: "no-store", + }, + ); + const profileData = (await profileRes.json().catch(() => [])) as Array<{ id?: string }>; + if (profileRes.ok) { + const profileUserId = String(profileData?.[0]?.id || "").trim(); + if (profileUserId) return { supabaseUrl, serviceRoleKey, userId: profileUserId }; + } + const res = await fetch( `${supabaseUrl}/auth/v1/admin/users?filter=${encodeURIComponent(`email.eq.${email}`)}`, { @@ -109,7 +127,7 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut apikey: serviceRoleKey, Authorization: `Bearer ${serviceRoleKey}`, "Content-Type": "application/json", - Prefer: "return=representation", + Prefer: "return=minimal", }, body: JSON.stringify(payload), cache: "no-store", @@ -138,6 +156,9 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut export async function POST(req: NextRequest) { try { const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + const body = await req.text(); if (!API_BASE) { return grantSubscriptionDirectly(req, body, auth.authEmail); diff --git a/frontend/app/api/ops/telegram/members-audit/route.ts b/frontend/app/api/ops/telegram/members-audit/route.ts index 12c29056..6254dda1 100644 --- a/frontend/app/api/ops/telegram/members-audit/route.ts +++ b/frontend/app/api/ops/telegram/members-audit/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -8,6 +9,9 @@ export async function GET(req: NextRequest) { if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); try { const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + const res = await fetch(`${API_BASE}/api/ops/telegram/members-audit`, { headers: auth.headers, cache: "no-store" }); const raw = await res.text(); const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); diff --git a/frontend/app/api/ops/training/accuracy/route.ts b/frontend/app/api/ops/training/accuracy/route.ts index ca23fc44..292a18d9 100644 --- a/frontend/app/api/ops/training/accuracy/route.ts +++ b/frontend/app/api/ops/training/accuracy/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -8,6 +9,9 @@ export async function GET(req: NextRequest) { if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); try { const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + const res = await fetch(`${API_BASE}/api/ops/training/accuracy`, { headers: auth.headers, cache: "no-store" }); const raw = await res.text(); const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); diff --git a/frontend/app/api/ops/truth-history/route.ts b/frontend/app/api/ops/truth-history/route.ts index 8a4d7596..c62e2f60 100644 --- a/frontend/app/api/ops/truth-history/route.ts +++ b/frontend/app/api/ops/truth-history/route.ts @@ -4,6 +4,7 @@ import { buildBackendRequestHeaders, } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -17,6 +18,9 @@ export async function GET(req: NextRequest) { try { const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + const url = new URL(`${API_BASE}/api/ops/truth-history`); for (const key of ["city", "date_from", "date_to", "limit"]) { const value = req.nextUrl.searchParams.get(key); diff --git a/frontend/app/api/ops/users/grant-points/route.ts b/frontend/app/api/ops/users/grant-points/route.ts index 2e11e476..37880d55 100644 --- a/frontend/app/api/ops/users/grant-points/route.ts +++ b/frontend/app/api/ops/users/grant-points/route.ts @@ -4,6 +4,7 @@ import { buildBackendRequestHeaders, } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -17,6 +18,9 @@ export async function POST(req: NextRequest) { try { const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + const body = await req.text(); const res = await fetch(`${API_BASE}/api/ops/users/grant-points`, { method: "POST", diff --git a/frontend/app/api/ops/users/route.ts b/frontend/app/api/ops/users/route.ts index 5b8fab3b..036acbd3 100644 --- a/frontend/app/api/ops/users/route.ts +++ b/frontend/app/api/ops/users/route.ts @@ -4,6 +4,7 @@ import { buildBackendRequestHeaders, } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -17,6 +18,9 @@ export async function GET(req: NextRequest) { try { const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + const url = new URL(`${API_BASE}/api/ops/users`); const q = req.nextUrl.searchParams.get("q"); const limit = req.nextUrl.searchParams.get("limit"); diff --git a/frontend/app/api/ops/view-logs/route.ts b/frontend/app/api/ops/view-logs/route.ts index 41528525..8e9652f1 100644 --- a/frontend/app/api/ops/view-logs/route.ts +++ b/frontend/app/api/ops/view-logs/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -8,6 +9,9 @@ export async function GET(req: NextRequest) { if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); try { const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + const url = new URL(`${API_BASE}/api/ops/logs`); const level = req.nextUrl.searchParams.get("level"); const lines = req.nextUrl.searchParams.get("lines"); diff --git a/frontend/app/api/payments/config/route.ts b/frontend/app/api/payments/config/route.ts index 68199eb4..372f0f73 100644 --- a/frontend/app/api/payments/config/route.ts +++ b/frontend/app/api/payments/config/route.ts @@ -13,7 +13,7 @@ export async function GET(req: NextRequest) { return proxyBackendJsonGet(req, { cacheControl: "public, max-age=0, s-maxage=300, stale-while-revalidate=900", detailLimit: 350, - includeSupabaseIdentity: true, + includeSupabaseIdentity: false, publicMessage: "Failed to fetch payment config", revalidateSeconds: 300, url: `${API_BASE}/api/payments/config`, diff --git a/frontend/app/api/payments/runtime/route.ts b/frontend/app/api/payments/runtime/route.ts index 1189b0bd..cb92ea96 100644 --- a/frontend/app/api/payments/runtime/route.ts +++ b/frontend/app/api/payments/runtime/route.ts @@ -16,7 +16,7 @@ export async function GET(req: NextRequest) { conditionalResponse: false, detailLimit: 500, fetchCache: "no-store", - includeSupabaseIdentity: true, + includeSupabaseIdentity: false, publicMessage: "Failed to fetch payment runtime", url: `${API_BASE}/api/payments/runtime`, }); diff --git a/frontend/app/api/scan/terminal/overview/route.ts b/frontend/app/api/scan/terminal/overview/route.ts index 7cec550f..2b834e72 100644 --- a/frontend/app/api/scan/terminal/overview/route.ts +++ b/frontend/app/api/scan/terminal/overview/route.ts @@ -37,7 +37,9 @@ export async function POST(req: NextRequest) { const timeoutId = setTimeout(() => controller.abort(), OVERVIEW_PROXY_TIMEOUT_MS); try { - auth = await buildBackendRequestHeaders(req); + auth = await buildBackendRequestHeaders(req, { + includeSupabaseIdentity: false, + }); const headers = new Headers(auth.headers); headers.set("Content-Type", "application/json"); headers.set("Accept", "application/json"); diff --git a/frontend/components/account/__tests__/paymentSecurity.test.ts b/frontend/components/account/__tests__/paymentSecurity.test.ts index b1a2234a..982b82da 100644 --- a/frontend/components/account/__tests__/paymentSecurity.test.ts +++ b/frontend/components/account/__tests__/paymentSecurity.test.ts @@ -107,6 +107,75 @@ export function runTests() { !middlewareSource.includes("return NextResponse.next();\n }\n }\n\n const response = NextResponse.next"), "middleware must not treat the mere presence of a bearer token as authenticated", ); + assert( + middlewareSource.includes("function hasSupabaseSessionCookie") && + middlewareSource.includes("request.cookies.getAll()") && + middlewareSource.includes("hasSupabaseSessionCookieValues"), + "middleware must detect non-empty Supabase session cookies locally via the shared helper before refreshing auth", + ); + assert( + middlewareSource.includes("redirectToLogin(request, pathname)") && + middlewareSource.indexOf("if (!hasSupabaseSessionCookie(request))") < + middlewareSource.indexOf("await refreshMiddlewareSession(request)"), + "middleware must redirect no-cookie page requests without calling Supabase auth", + ); + assert( + middlewareSource.includes("unauthorizedSupabaseSessionResponse()"), + "middleware must reject no-cookie protected API requests without calling Supabase auth", + ); + for (const route of [ + "app/api/ops/analytics/funnel/route.ts", + "app/api/ops/config/route.ts", + "app/api/ops/health-check/route.ts", + "app/api/ops/leaderboard/weekly/route.ts", + "app/api/ops/memberships/route.ts", + "app/api/ops/memberships/growth/route.ts", + "app/api/ops/memberships/overview/route.ts", + "app/api/ops/online-users/route.ts", + "app/api/ops/payments/incidents/route.ts", + "app/api/ops/payments/incidents/[eventId]/resolve/route.ts", + "app/api/ops/subscriptions/extend/route.ts", + "app/api/ops/subscriptions/grant/route.ts", + "app/api/ops/telegram/members-audit/route.ts", + "app/api/ops/training/accuracy/route.ts", + "app/api/ops/truth-history/route.ts", + "app/api/ops/users/route.ts", + "app/api/ops/users/grant-points/route.ts", + "app/api/ops/view-logs/route.ts", + ]) { + const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8"); + assert( + routeSource.includes("requireOpsProxyAuth(req, auth)") && + routeSource.indexOf("requireOpsProxyAuth(req, auth)") > + routeSource.indexOf("buildBackendRequestHeaders(req"), + `${route} must reject requests without Supabase identity before forwarding the backend entitlement token`, + ); + } + assert( + middlewareSource.includes('pathname === "/api/payments/config"'), + "middleware must treat public payment config as public API so cached config requests do not refresh Supabase sessions", + ); + const optionalRefreshFunction = middlewareSource.slice( + middlewareSource.indexOf("function shouldRefreshOptionalSupabaseSession"), + middlewareSource.indexOf("function hasSupabaseSessionCookie"), + ); + assert( + !optionalRefreshFunction.includes('pathname.startsWith("/api/ops/")') && + !optionalRefreshFunction.includes('pathname.startsWith("/api/payments/")'), + "optional Supabase middleware refresh must not pre-read sessions for API routes that already build backend auth headers", + ); + const optionalRefreshIndex = middlewareSource.indexOf( + "function shouldRefreshOptionalSupabaseSession", + ); + const systemStatusPublicIndex = middlewareSource.indexOf( + 'pathname === "/api/system/status"', + ); + assert( + systemStatusPublicIndex >= 0 && + optionalRefreshIndex >= 0 && + systemStatusPublicIndex < optionalRefreshIndex, + "middleware must treat public system status as public API instead of optional Supabase session refresh", + ); for (const route of paymentRoutes) { const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8"); diff --git a/frontend/components/account/__tests__/paymentShell.test.ts b/frontend/components/account/__tests__/paymentShell.test.ts index 2db8408d..e48df0ed 100644 --- a/frontend/components/account/__tests__/paymentShell.test.ts +++ b/frontend/components/account/__tests__/paymentShell.test.ts @@ -92,6 +92,26 @@ export function runTests() { path.join(projectRoot, "app", "api", "auth", "me", "route.ts"), "utf8", ); + const paymentConfigRouteSource = fs.readFileSync( + path.join(projectRoot, "app", "api", "payments", "config", "route.ts"), + "utf8", + ); + const paymentRuntimeRouteSource = fs.readFileSync( + path.join(projectRoot, "app", "api", "payments", "runtime", "route.ts"), + "utf8", + ); + const backendAuthSource = fs.readFileSync( + path.join(projectRoot, "lib", "backend-auth.ts"), + "utf8", + ); + const opsAdminSource = fs.readFileSync( + path.join(projectRoot, "lib", "ops-admin.ts"), + "utf8", + ); + const supabaseServerSource = fs.readFileSync( + path.join(projectRoot, "lib", "supabase", "server.ts"), + "utf8", + ); const subscriptionsPageSource = fs.readFileSync( path.join( projectRoot, @@ -102,6 +122,26 @@ export function runTests() { ), "utf8", ); + const membershipsPageSource = fs.readFileSync( + path.join( + projectRoot, + "components", + "ops", + "memberships", + "MembershipsPageClient.tsx", + ), + "utf8", + ); + const opsOverviewSource = fs.readFileSync( + path.join( + projectRoot, + "components", + "ops", + "overview", + "OverviewPageClient.tsx", + ), + "utf8", + ); assert( accountCenterSource.includes( @@ -157,6 +197,11 @@ export function runTests() { hookSource.includes("retriedBackendJson"), "account snapshot loader must retry with a refreshed Supabase token when local user exists but /api/auth/me reports unauthenticated", ); + assert( + !hookSource.includes(".auth.getUser()") && + hookSource.includes(".auth.getSession()"), + "account snapshot loader must use the local Supabase session instead of calling getUser before /api/auth/me validates the bearer", + ); assert( subscriptionsPageSource.includes("getSupabaseBrowserClient") && subscriptionsPageSource.includes("refreshSession") && @@ -165,11 +210,28 @@ export function runTests() { subscriptionsPageSource.includes("/api/ops/subscriptions/extend"), "ops manual subscription grant/extend must send the Supabase bearer token to avoid 401 when route cookies are stale", ); + assert( + membershipsPageSource.includes("opsApi.membershipsOverview") && + !membershipsPageSource.includes('fetch("/api/ops/memberships/growth?days=90"') && + !membershipsPageSource.includes("Promise.all"), + "ops memberships page must load memberships and growth through one overview proxy request to avoid duplicate Supabase session/subscription reads", + ); + assert( + opsOverviewSource.includes("opsApi.membershipsOverview(200, 30)") && + !opsOverviewSource.includes("opsApi.memberships()") && + !opsOverviewSource.includes("opsApi.membershipsGrowth(30)"), + "ops overview page must reuse membershipsOverview for table and growth data instead of issuing separate membership/growth proxy requests", + ); assert( grantRouteSource.includes("grantSubscriptionDirectly") && grantRouteSource.includes("res.status === 404") && - grantRouteSource.includes('"status": "active"'), - "ops subscription grant route must fall back to direct Supabase grant when the VPS backend route is missing", + grantRouteSource.includes('"status": "active"') && + grantRouteSource.includes("/rest/v1/profiles") && + grantRouteSource.includes("select=id&email=eq.") && + grantRouteSource.indexOf("/rest/v1/profiles") < + grantRouteSource.indexOf("/auth/v1/admin/users") && + grantRouteSource.includes('Prefer: "return=minimal"'), + "ops subscription grant route must fall back to direct Supabase grant and resolve users via indexed profiles before Auth Admin", ); assert( authMeRouteSource.includes("if ((res.status === 401 || res.status === 403) && auth.authUserId)") && @@ -177,4 +239,76 @@ export function runTests() { authMeRouteSource.includes("subscription_active: null"), "auth profile proxy must preserve authenticated identity with unknown subscription on backend 401/403 instead of forcing a false paywall", ); + assert( + (authMeRouteSource.match(/buildBackendRequestHeaders\(req\)/g) || []).length === 1 && + authMeRouteSource.includes("let auth: Awaited> | null = null"), + "auth profile proxy must build backend auth headers once and reuse them on timeout/error fallback", + ); + assert( + authMeRouteSource.includes("hasSupabaseServerEnv()") && + authMeRouteSource.includes("!auth.authUserId") && + authMeRouteSource.includes('req.headers.get("authorization")') && + authMeRouteSource.indexOf("authenticated: false") < + authMeRouteSource.indexOf("await fetch(`${API_BASE}/api/auth/me`"), + "auth profile proxy must return unauthenticated locally for no-session Supabase requests instead of forwarding the backend entitlement token", + ); + assert( + backendAuthSource.includes("if (incomingAuth) {") && + backendAuthSource.indexOf("if (incomingAuth) {") < + backendAuthSource.indexOf("const supabase = createSupabaseRouteClient"), + "backend proxy must forward caller bearer tokens before creating a Supabase route client to avoid duplicate getUser calls", + ); + assert( + backendAuthSource.includes("headers.set(FORWARDED_SUPABASE_USER_ID_HEADER") && + backendAuthSource.includes("headers.set(FORWARDED_SUPABASE_EMAIL_HEADER") && + backendAuthSource.indexOf("headers.set(FORWARDED_SUPABASE_USER_ID_HEADER") > + backendAuthSource.indexOf("const sessionUser = session?.user"), + "backend proxy must forward Supabase session user id/email with the backend token so Python can skip duplicate /auth/v1/user validation", + ); + assert( + backendAuthSource.includes("function hasSupabaseSessionCookie") && + backendAuthSource.includes("String(cookie.value || \"\").trim()") && + backendAuthSource.includes("if (!hasSupabaseSessionCookie(request))") && + backendAuthSource.indexOf("if (!hasSupabaseSessionCookie(request))") < + backendAuthSource.indexOf("const supabase = createSupabaseRouteClient"), + "backend proxy must skip Supabase route client/session reads when no auth cookie is present", + ); + assert( + !backendAuthSource.includes(".auth.getUser()") && + backendAuthSource.includes(".auth.getSession()"), + "backend proxy must not call Supabase getUser before forwarding a bearer token that the backend validates again", + ); + assert( + supabaseServerSource.includes("export function hasSupabaseSessionCookieValues") && + supabaseServerSource.includes('name === "supabase-auth-token"') && + supabaseServerSource.includes('name.startsWith("sb-")') && + supabaseServerSource.indexOf("if (!hasSupabaseSessionCookieValues") < + supabaseServerSource.indexOf("const supabase = createSupabaseServerClient"), + "Supabase server helpers must expose one cookie detector and skip refresh getUser calls when no session cookie exists", + ); + assert( + supabaseServerSource.includes(".auth.getClaims()") && + !supabaseServerSource.includes(".auth.getUser()"), + "Supabase middleware refresh must validate JWTs with getClaims so asymmetric JWT projects avoid per-request /auth/v1/user reads", + ); + assert( + opsAdminSource.includes("hasSupabaseSessionCookieValues") && + opsAdminSource.indexOf("if (!hasSupabaseSessionCookieValues") < + opsAdminSource.indexOf("const supabase = createSupabaseServerClient"), + "ops admin page gate must redirect before creating a Supabase client/getUser call when no session cookie exists", + ); + assert( + opsAdminSource.includes(".auth.getClaims()") && + !opsAdminSource.includes(".auth.getUser()") && + opsAdminSource.includes("claims?.email"), + "ops admin page gate must use verified JWT claims instead of a per-page getUser auth lookup", + ); + assert( + paymentConfigRouteSource.includes("includeSupabaseIdentity: false"), + "payment config proxy must not read Supabase session cookies for public cached config", + ); + assert( + paymentRuntimeRouteSource.includes("includeSupabaseIdentity: false"), + "payment runtime proxy must not read Supabase session cookies because backend entitlement token already protects the runtime status payload", + ); } diff --git a/frontend/components/account/useAccountPayment.ts b/frontend/components/account/useAccountPayment.ts index c233a6a7..19dc76cb 100644 --- a/frontend/components/account/useAccountPayment.ts +++ b/frontend/components/account/useAccountPayment.ts @@ -151,14 +151,17 @@ export function useAccountPayment(params: UseAccountPaymentParams) { setErrorText(""); const attempt = async (retry: boolean): Promise => { const userPromise = supabaseReady - ? getSupabaseBrowserClient().auth.getUser() - : Promise.resolve({ data: { user: null as User | null } }); + ? getSupabaseBrowserClient() + .auth.getSession() + .then(({ data: { session } }) => session?.user ?? null) + .catch(() => null as User | null) + : Promise.resolve(null as User | null); const authHeadersPromise = buildAuthedHeaders(false); const backendPromise = authHeadersPromise.then((headers) => fetch("/api/auth/me", { cache: "no-store", headers }), ); - const [userResult, backendResult] = await Promise.all([userPromise, backendPromise]); - setUser(userResult.data?.user ?? null); + const [localUser, backendResult] = await Promise.all([userPromise, backendPromise]); + setUser(localUser); if (!backendResult.ok) { if (retry && backendResult.status === 401) { await new Promise((r) => setTimeout(r, 1200)); @@ -171,7 +174,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) { if ( retry && supabaseReady && - userResult.data?.user && + localUser && backendJson.authenticated === false ) { try { diff --git a/frontend/components/dashboard/scan-terminal/__tests__/proxyCachePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/proxyCachePolicy.test.ts index 9761d1c2..44306c24 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/proxyCachePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/proxyCachePolicy.test.ts @@ -1,4 +1,6 @@ import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; import { buildCityDetailProxyCachePolicy, buildForceRefreshProxyCachePolicy, @@ -22,4 +24,32 @@ export function runTests() { const scanForced = buildForceRefreshProxyCachePolicy("true", 10); assert.equal(scanForced.fetchMode, "no-store"); + + const overviewProxySource = fs.readFileSync( + path.join( + process.cwd(), + "app", + "api", + "scan", + "terminal", + "overview", + "route.ts", + ), + "utf8", + ); + assert.match( + overviewProxySource, + /buildBackendRequestHeaders\(req,\s*\{\s*includeSupabaseIdentity:\s*false,\s*\}\)/s, + "scan terminal overview proxy must not read Supabase sessions for public overview payloads", + ); + + const priorityWarmProxySource = fs.readFileSync( + path.join(process.cwd(), "lib", "system-priority-proxy.ts"), + "utf8", + ); + assert.match( + priorityWarmProxySource, + /buildBackendRequestHeaders\(req,\s*\{\s*includeSupabaseIdentity:\s*false,\s*\}\)/s, + "priority warm proxy must not read Supabase sessions for backend-token warm hints", + ); } diff --git a/frontend/components/ops/memberships/MembershipsPageClient.tsx b/frontend/components/ops/memberships/MembershipsPageClient.tsx index bc435993..907a0643 100644 --- a/frontend/components/ops/memberships/MembershipsPageClient.tsx +++ b/frontend/components/ops/memberships/MembershipsPageClient.tsx @@ -41,12 +41,9 @@ export function MembershipsPageClient() { const load = async () => { setLoading(true); try { - const [mData, gData] = await Promise.all([ - opsApi.memberships(), - fetch("/api/ops/memberships/growth?days=90", { cache: "no-store" }).then(r => r.ok ? r.json() : null), - ]); - setMemberships((mData as unknown as { memberships?: MembershipEntry[] }).memberships ?? []); - setGrowth((gData as { daily?: GrowthPoint[] })?.daily ?? []); + const data = await opsApi.membershipsOverview(200, 90); + setMemberships((data as unknown as { memberships?: MembershipEntry[] }).memberships ?? []); + setGrowth((data as { daily?: GrowthPoint[] })?.daily ?? []); } catch { /* */ } setLoading(false); }; diff --git a/frontend/components/ops/overview/OverviewPageClient.tsx b/frontend/components/ops/overview/OverviewPageClient.tsx index ea160679..4196a2d5 100644 --- a/frontend/components/ops/overview/OverviewPageClient.tsx +++ b/frontend/components/ops/overview/OverviewPageClient.tsx @@ -44,16 +44,15 @@ export function OverviewPageClient() { const load = async () => { setLoading(true); try { - const [s, m, f, g] = await Promise.all([ + const [s, m, f] = await Promise.all([ opsApi.systemStatus() as Promise, - opsApi.memberships() as Promise, + opsApi.membershipsOverview(200, 30) as Promise, opsApi.funnel(30), - opsApi.membershipsGrowth(30), ]); setStatus(s); setMemberships((m as MembershipsPayload).memberships ?? []); setFunnel(f); - setGrowth(g?.daily ?? []); + setGrowth(m?.daily ?? []); } catch { /* */ } setLoading(false); }; diff --git a/frontend/lib/backend-auth.ts b/frontend/lib/backend-auth.ts index da9278aa..bb4d7294 100644 --- a/frontend/lib/backend-auth.ts +++ b/frontend/lib/backend-auth.ts @@ -6,7 +6,7 @@ export const BACKEND_ENTITLEMENT_HEADER = "x-polyweather-entitlement"; export const FORWARDED_SUPABASE_USER_ID_HEADER = "x-polyweather-auth-user-id"; export const FORWARDED_SUPABASE_EMAIL_HEADER = "x-polyweather-auth-email"; -type HeaderBuildResult = { +export type BackendHeaderBuildResult = { headers: HeadersInit; response: NextResponse | null; authUserId?: string | null; @@ -26,10 +26,22 @@ function extractBearerToken(headerValue: string | null) { return ""; } +function hasSupabaseSessionCookie(request: NextRequest) { + return request.cookies.getAll().some((cookie) => { + const name = cookie.name.toLowerCase(); + const value = String(cookie.value || "").trim(); + if (!value) return false; + return ( + name === "supabase-auth-token" || + (name.startsWith("sb-") && name.includes("-auth-token")) + ); + }); +} + export async function buildBackendRequestHeaders( request: NextRequest, options?: HeaderBuildOptions, -): Promise { +): Promise { const headers = new Headers({ Accept: "application/json", }); @@ -39,31 +51,19 @@ export async function buildBackendRequestHeaders( } const incomingAuth = extractBearerToken(request.headers.get("authorization")); + if (incomingAuth) { + headers.set("Authorization", `Bearer ${incomingAuth}`); + return { headers, response: null, authUserId: null, authEmail: null }; + } + const includeSupabaseIdentity = options?.includeSupabaseIdentity !== false; if (hasSupabaseServerEnv() && includeSupabaseIdentity) { + if (!hasSupabaseSessionCookie(request)) { + return { headers, response: null, authUserId: null, authEmail: null }; + } + const passthroughResponse = new NextResponse(null, { status: 200 }); const supabase = createSupabaseRouteClient(request, passthroughResponse); - - // Always call getUser() to ensure token is validated and refreshed if expired - const { - data: { user }, - } = await supabase.auth.getUser(); - - const forwardedUserId = String(user?.id || "").trim(); - const forwardedEmail = String(user?.email || "").trim(); - if (forwardedUserId) { - headers.set(FORWARDED_SUPABASE_USER_ID_HEADER, forwardedUserId); - } - if (forwardedEmail) { - headers.set(FORWARDED_SUPABASE_EMAIL_HEADER, forwardedEmail); - } - - if (incomingAuth) { - headers.set("Authorization", `Bearer ${incomingAuth}`); - return { headers, response: passthroughResponse, authUserId: forwardedUserId || null, authEmail: forwardedEmail || null }; - } - - // Call getSession() to get the updated access token (after getUser() has refreshed it if needed) const { data: { session }, } = await supabase.auth.getSession(); @@ -72,12 +72,18 @@ export async function buildBackendRequestHeaders( // Fallback to cookie-backed session when request does not carry bearer. headers.set("Authorization", `Bearer ${accessToken}`); } + const sessionUser = session?.user; + const forwardedUserId = String(sessionUser?.id || "").trim(); + const forwardedEmail = String(sessionUser?.email || "").trim(); + if (forwardedUserId) { + headers.set(FORWARDED_SUPABASE_USER_ID_HEADER, forwardedUserId); + } + if (forwardedEmail) { + headers.set(FORWARDED_SUPABASE_EMAIL_HEADER, forwardedEmail); + } return { headers, response: passthroughResponse, authUserId: forwardedUserId || null, authEmail: forwardedEmail || null }; } - if (incomingAuth) { - headers.set("Authorization", `Bearer ${incomingAuth}`); - } return { headers, response: null, authUserId: null, authEmail: null }; } @@ -94,7 +100,7 @@ export function applyAuthResponseCookies( return target; } -export function requireBackendAuthUser(auth: HeaderBuildResult) { +export function requireBackendAuthUser(auth: BackendHeaderBuildResult) { if (auth.authUserId) return null; return applyAuthResponseCookies( NextResponse.json( diff --git a/frontend/lib/ops-admin.ts b/frontend/lib/ops-admin.ts index 458f2c67..7cd9015b 100644 --- a/frontend/lib/ops-admin.ts +++ b/frontend/lib/ops-admin.ts @@ -1,6 +1,10 @@ import { cookies } from "next/headers"; import { redirect } from "next/navigation"; -import { createSupabaseServerClient, hasSupabaseServerEnv } from "@/lib/supabase/server"; +import { + createSupabaseServerClient, + hasSupabaseServerEnv, + hasSupabaseSessionCookieValues, +} from "@/lib/supabase/server"; function parseAdminEmails() { return String(process.env.POLYWEATHER_OPS_ADMIN_EMAILS || "") @@ -16,12 +20,17 @@ export async function requireOpsAdmin(nextPath = "/ops") { } const cookieStore = await cookies(); + const supabaseCookies = cookieStore.getAll().map((item) => ({ + name: item.name, + value: item.value, + })); + if (!hasSupabaseSessionCookieValues(supabaseCookies)) { + redirect(`/auth/login?next=${encodeURIComponent(nextPath)}`); + } + const supabase = createSupabaseServerClient({ getAll() { - return cookieStore.getAll().map((item) => ({ - name: item.name, - value: item.value, - })); + return supabaseCookies; }, setAll() { // Server components cannot persist refreshed cookies. Route handlers keep @@ -30,10 +39,11 @@ export async function requireOpsAdmin(nextPath = "/ops") { }); const { - data: { user }, - } = await supabase.auth.getUser(); + data, + error, + } = await supabase.auth.getClaims(); - const email = String(user?.email || "").trim().toLowerCase(); + const email = error ? "" : String(data?.claims?.email || "").trim().toLowerCase(); if (!email) { redirect(`/auth/login?next=${encodeURIComponent(nextPath)}`); } diff --git a/frontend/lib/ops-api.ts b/frontend/lib/ops-api.ts index 65098c12..29503b6a 100644 --- a/frontend/lib/ops-api.ts +++ b/frontend/lib/ops-api.ts @@ -67,6 +67,13 @@ export const opsApi = { memberships() { return opsFetch>("/api/ops/memberships?limit=200"); }, + membershipsOverview(limit = 200, days = 90) { + return opsFetch<{ + memberships?: Array>; + days?: number; + daily?: { date: string; trial: number; paid: number; total: number; cumulative: number }[]; + }>(`/api/ops/memberships/overview?limit=${limit}&days=${days}`); + }, membershipsGrowth(days = 90) { return opsFetch<{ days: number; diff --git a/frontend/lib/ops-proxy-auth.ts b/frontend/lib/ops-proxy-auth.ts new file mode 100644 index 00000000..f976d86c --- /dev/null +++ b/frontend/lib/ops-proxy-auth.ts @@ -0,0 +1,27 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + type BackendHeaderBuildResult, +} from "@/lib/backend-auth"; + +function hasBearerAuth(request: NextRequest) { + const raw = request.headers.get("authorization"); + if (!raw) return false; + const parts = raw.trim().split(/\s+/); + return parts.length === 2 && parts[0].toLowerCase() === "bearer" && Boolean(parts[1]); +} + +export function requireOpsProxyAuth( + request: NextRequest, + auth: BackendHeaderBuildResult, +): NextResponse | null { + if (auth.authUserId || hasBearerAuth(request)) return null; + return applyAuthResponseCookies( + NextResponse.json( + { error: "Unauthorized", detail: "Supabase session required" }, + { status: 401 }, + ), + auth.response, + ); +} diff --git a/frontend/lib/supabase/server.ts b/frontend/lib/supabase/server.ts index f5dc06e5..ad05866e 100644 --- a/frontend/lib/supabase/server.ts +++ b/frontend/lib/supabase/server.ts @@ -17,6 +17,20 @@ export function hasSupabaseServerEnv() { return Boolean(url && anonKey); } +export function hasSupabaseSessionCookieValues( + cookies: { name: string; value: string }[], +) { + return cookies.some((cookie) => { + const name = cookie.name.toLowerCase(); + const value = String(cookie.value || "").trim(); + if (!value) return false; + return ( + name === "supabase-auth-token" || + (name.startsWith("sb-") && name.includes("-auth-token")) + ); + }); +} + export function createSupabaseServerClient( cookieAdapter: CookieAdapter, ) { @@ -79,12 +93,17 @@ export async function refreshMiddlewareSession(request: NextRequest) { return { response, user: null }; } + const supabaseCookies = request.cookies.getAll().map((item) => ({ + name: item.name, + value: item.value, + })); + if (!hasSupabaseSessionCookieValues(supabaseCookies)) { + return { response, user: null }; + } + const supabase = createSupabaseServerClient({ getAll() { - return request.cookies.getAll().map((item) => ({ - name: item.name, - value: item.value, - })); + return supabaseCookies; }, setAll(cookiesToSet) { for (const cookie of cookiesToSet) { @@ -103,11 +122,18 @@ export async function refreshMiddlewareSession(request: NextRequest) { try { const { - data: { user }, - } = await supabase.auth.getUser(); + data, + error, + } = await supabase.auth.getClaims(); + if (error || !data?.claims?.sub) { + return { response, user: null }; + } + const user = { + id: String(data.claims.sub || ""), + email: String(data.claims.email || ""), + }; return { response, user }; } catch { return { response, user: null }; } } - diff --git a/frontend/lib/system-priority-proxy.ts b/frontend/lib/system-priority-proxy.ts index 082c28de..98829d02 100644 --- a/frontend/lib/system-priority-proxy.ts +++ b/frontend/lib/system-priority-proxy.ts @@ -34,7 +34,9 @@ export async function forwardPriorityWarmHint(req: NextRequest) { } try { - const auth = await buildBackendRequestHeaders(req); + const auth = await buildBackendRequestHeaders(req, { + includeSupabaseIdentity: false, + }); const res = await fetch( `${API_BASE}/api/system/priority-warm${ params.size ? `?${params.toString()}` : "" diff --git a/frontend/middleware.ts b/frontend/middleware.ts index f5aac208..79ee73ac 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { + hasSupabaseSessionCookieValues, hasSupabaseServerEnv, refreshMiddlewareSession, } from "@/lib/supabase/server"; @@ -33,7 +34,9 @@ function isPublicApi(pathname: string) { pathname === "/api/auth/me" || pathname === "/api/analytics/events" || pathname === "/api/cities" || + pathname === "/api/payments/config" || pathname === "/api/scan/terminal" || + pathname === "/api/system/status" || pathname === "/api/vitals" || /^\/api\/city\/[^/]+$/i.test(pathname) || /^\/api\/city\/[^/]+\/summary$/i.test(pathname) || @@ -45,10 +48,31 @@ function isPublicApi(pathname: string) { function shouldRefreshOptionalSupabaseSession(pathname: string) { return ( pathname.startsWith("/account") || - pathname.startsWith("/ops") || - pathname.startsWith("/api/ops/") || - pathname.startsWith("/api/payments/") || - pathname === "/api/system/status" + pathname.startsWith("/ops") + ); +} + +function hasSupabaseSessionCookie(request: NextRequest) { + return hasSupabaseSessionCookieValues( + request.cookies.getAll().map((cookie) => ({ + name: cookie.name, + value: cookie.value, + })), + ); +} + +function redirectToLogin(request: NextRequest, pathname: string) { + const loginUrl = request.nextUrl.clone(); + loginUrl.pathname = "/auth/login"; + loginUrl.search = ""; + loginUrl.searchParams.set("next", pathname); + return NextResponse.redirect(loginUrl); +} + +function unauthorizedSupabaseSessionResponse() { + return NextResponse.json( + { error: "Unauthorized", detail: "Supabase session required" }, + { status: 401 }, ); } @@ -69,6 +93,10 @@ async function handleTerminalGate(request: NextRequest): Promise { return NextResponse.next(); } + if (!hasSupabaseSessionCookie(request)) { + return redirectToLogin(request, pathname); + } + const { response, user } = await refreshMiddlewareSession(request); if (user) { @@ -77,11 +105,7 @@ async function handleTerminalGate(request: NextRequest): Promise { } // Layer 1: Not logged in → redirect to /auth/login?next=/terminal - const loginUrl = request.nextUrl.clone(); - loginUrl.pathname = "/auth/login"; - loginUrl.search = ""; - loginUrl.searchParams.set("next", pathname); - return NextResponse.redirect(loginUrl); + return redirectToLogin(request, pathname); } async function handleSupabaseAuthGate(request: NextRequest) { @@ -90,6 +114,13 @@ async function handleSupabaseAuthGate(request: NextRequest) { return NextResponse.next(); } + if (!hasSupabaseSessionCookie(request)) { + if (pathname.startsWith("/api/")) { + return unauthorizedSupabaseSessionResponse(); + } + return redirectToLogin(request, pathname); + } + const { response, user } = await refreshMiddlewareSession(request); if (user) { @@ -97,17 +128,10 @@ async function handleSupabaseAuthGate(request: NextRequest) { } if (pathname.startsWith("/api/")) { - return NextResponse.json( - { error: "Unauthorized", detail: "Supabase session required" }, - { status: 401 }, - ); + return unauthorizedSupabaseSessionResponse(); } - const loginUrl = request.nextUrl.clone(); - loginUrl.pathname = "/auth/login"; - loginUrl.search = ""; - loginUrl.searchParams.set("next", pathname); - return NextResponse.redirect(loginUrl); + return redirectToLogin(request, pathname); } async function handleSupabaseOptionalSession(request: NextRequest) { @@ -120,6 +144,10 @@ async function handleSupabaseOptionalSession(request: NextRequest) { return NextResponse.next(); } + if (!hasSupabaseSessionCookie(request)) { + return NextResponse.next(); + } + const { response } = await refreshMiddlewareSession(request); return response; } diff --git a/scripts/grant_subscription_by_email.py b/scripts/grant_subscription_by_email.py index e5ba62c7..0224e748 100644 --- a/scripts/grant_subscription_by_email.py +++ b/scripts/grant_subscription_by_email.py @@ -49,6 +49,22 @@ def _select_exact_user_id(payload: object, email: str) -> str: def _lookup_user_id_by_email(email: str) -> str: from src.payments.contract_checkout import PAYMENT_CHECKOUT + normalized_email = str(email or "").strip().lower() + profile_rows = PAYMENT_CHECKOUT._rest( # noqa: SLF001 + "GET", + "profiles", + params={ + "select": "id", + "email": f"eq.{normalized_email}", + "limit": "1", + }, + allowed_status=[200], + ) + if isinstance(profile_rows, list) and profile_rows: + user_id = str((profile_rows[0] or {}).get("id") or "").strip() + if user_id: + return user_id + payload = PAYMENT_CHECKOUT._auth_admin_request( # noqa: SLF001 "GET", f"/admin/users?email={email}", @@ -117,7 +133,7 @@ def main() -> int: "GET", "subscriptions", params={ - "select": "id,expires_at,status,plan_code,starts_at,source,created_at", + "select": "id,plan_code,source,starts_at,expires_at", "user_id": f"eq.{user_id}", "status": "eq.active", "order": "expires_at.desc", @@ -169,37 +185,38 @@ def main() -> int: expires_at = starts_at + timedelta(days=days) if isinstance(upcoming, dict) and str(upcoming.get("id") or "").strip(): - updated = PAYMENT_CHECKOUT._rest( # noqa: SLF001 + subscription_payload = { + "starts_at": starts_at.isoformat(), + "expires_at": expires_at.isoformat(), + "updated_at": now.isoformat(), + } + PAYMENT_CHECKOUT._rest( # noqa: SLF001 "PATCH", "subscriptions", params={"id": f"eq.{upcoming['id']}"}, - payload={ - "starts_at": starts_at.isoformat(), - "expires_at": expires_at.isoformat(), - "updated_at": now.isoformat(), - }, - prefer="return=representation", + payload=subscription_payload, + prefer="return=minimal", allowed_status=[200], ) - subscription = updated[0] if isinstance(updated, list) and updated else {} + subscription = {**upcoming, **subscription_payload} else: - created = PAYMENT_CHECKOUT._rest( # noqa: SLF001 + subscription = { + "user_id": user_id, + "plan_code": plan_code, + "status": "active", + "starts_at": starts_at.isoformat(), + "expires_at": expires_at.isoformat(), + "source": actor, + "created_at": now.isoformat(), + "updated_at": now.isoformat(), + } + PAYMENT_CHECKOUT._rest( # noqa: SLF001 "POST", "subscriptions", - payload={ - "user_id": user_id, - "plan_code": plan_code, - "status": "active", - "starts_at": starts_at.isoformat(), - "expires_at": expires_at.isoformat(), - "source": actor, - "created_at": now.isoformat(), - "updated_at": now.isoformat(), - }, - prefer="return=representation", + payload=subscription, + prefer="return=minimal", allowed_status=[201], ) - subscription = created[0] if isinstance(created, list) and created else {} PAYMENT_CHECKOUT._rest( # noqa: SLF001 "POST", @@ -219,7 +236,7 @@ def main() -> int: }, "created_at": now.isoformat(), }, - prefer="return=representation", + prefer="return=minimal", allowed_status=[201], ) SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id) diff --git a/scripts/reconcile_payment_tx.py b/scripts/reconcile_payment_tx.py index 024ea9a0..6b1f56d9 100644 --- a/scripts/reconcile_payment_tx.py +++ b/scripts/reconcile_payment_tx.py @@ -50,31 +50,48 @@ def _list_recent_intents(user_id: str, limit: int = 20) -> List[Dict[str, Any]]: def _find_intent_by_tx(user_id: str, tx_hash: str) -> Optional[Dict[str, Any]]: - rows = _list_recent_intents(user_id, limit=50) tx_norm = str(tx_hash or "").strip().lower() - for row in rows: - if str(row.get("tx_hash") or "").strip().lower() == tx_norm: - return row - return None - - -def _find_intent_by_order_id(user_id: str, order_id_hex: str) -> Optional[Dict[str, Any]]: + if not tx_norm: + return None rows = PAYMENT_CHECKOUT._rest( "GET", "payment_intents", params={ - "select": ( - "id,user_id,plan_code,status,tx_hash,order_id_hex," - "amount_units,token_address,created_at,updated_at" - ), - "user_id": f"eq.{user_id}", - "order_id_hex": f"eq.{order_id_hex.lower()}", - "order": "created_at.desc", - "limit": "5", + "select": "id,user_id", + "tx_hash": f"eq.{tx_norm}", + "limit": "1", }, allowed_status=[200], ) - return rows[0] if isinstance(rows, list) and rows else None + row = rows[0] if isinstance(rows, list) and rows else None + if not isinstance(row, dict): + return None + if str(row.get("user_id") or "").strip() != str(user_id or "").strip(): + return None + return row + + +def _find_intent_by_order_id(user_id: str, order_id_hex: str) -> Optional[Dict[str, Any]]: + normalized_user_id = str(user_id or "").strip() + normalized_order_id = str(order_id_hex or "").strip().lower() + if not normalized_user_id or not normalized_order_id: + return None + rows = PAYMENT_CHECKOUT._rest( + "GET", + "payment_intents", + params={ + "select": "id,user_id", + "order_id_hex": f"eq.{normalized_order_id}", + "limit": "1", + }, + allowed_status=[200], + ) + row = rows[0] if isinstance(rows, list) and rows else None + if not isinstance(row, dict): + return None + if str(row.get("user_id") or "").strip() != normalized_user_id: + return None + return row def _decode_pay_call(tx_hash: str) -> Optional[Dict[str, Any]]: diff --git a/scripts/reconcile_subscription_by_email.py b/scripts/reconcile_subscription_by_email.py index 8e03cc33..7681adde 100644 --- a/scripts/reconcile_subscription_by_email.py +++ b/scripts/reconcile_subscription_by_email.py @@ -16,6 +16,21 @@ def _lookup_user_id_by_email(email: str) -> str: from src.payments.contract_checkout import PAYMENT_CHECKOUT, PaymentCheckoutError normalized_email = str(email or "").strip().lower() + profile_rows = PAYMENT_CHECKOUT._rest( # noqa: SLF001 + "GET", + "profiles", + params={ + "select": "id", + "email": f"eq.{normalized_email}", + "limit": "1", + }, + allowed_status=[200], + ) + if isinstance(profile_rows, list) and profile_rows: + user_id = str((profile_rows[0] or {}).get("id") or "").strip() + if user_id: + return user_id + payload = PAYMENT_CHECKOUT._auth_admin_request( # noqa: SLF001 "GET", f"/admin/users?email={email}", diff --git a/scripts/supabase/disk_io_diagnostics.sql b/scripts/supabase/disk_io_diagnostics.sql new file mode 100644 index 00000000..2a371542 --- /dev/null +++ b/scripts/supabase/disk_io_diagnostics.sql @@ -0,0 +1,98 @@ +-- Supabase Disk IO diagnostics for PolyWeather. +-- Run sections independently if your project does not expose every stats view. + +-- 1) Tables with high sequential scan pressure. +select + schemaname, + relname, + seq_scan, + seq_tup_read, + idx_scan, + n_tup_ins, + n_tup_upd, + n_tup_del, + n_dead_tup +from pg_stat_user_tables +where schemaname = 'public' +order by seq_tup_read desc +limit 30; + +-- 2) Largest public tables and indexes. +select + relname, + pg_size_pretty(pg_total_relation_size(relid)) as total_size, + pg_size_pretty(pg_relation_size(relid)) as table_size, + pg_size_pretty(pg_indexes_size(relid)) as index_size +from pg_catalog.pg_statio_user_tables +where schemaname = 'public' +order by pg_total_relation_size(relid) desc +limit 30; + +-- 3) Slow/high-read statements, if pg_stat_statements is available. +select + calls, + round(total_exec_time::numeric, 2) as total_exec_ms, + round(mean_exec_time::numeric, 2) as mean_exec_ms, + rows, + shared_blks_read, + shared_blks_hit, + temp_blks_read, + temp_blks_written, + left(query, 500) as query_sample +from pg_stat_statements +order by shared_blks_read desc, total_exec_time desc +limit 30; + +-- 4) Dead tuple pressure that can trigger heavier autovacuum work. +select + schemaname, + relname, + n_live_tup, + n_dead_tup, + last_vacuum, + last_autovacuum, + last_analyze, + last_autoanalyze +from pg_stat_user_tables +where schemaname = 'public' +order by n_dead_tup desc +limit 30; + +-- 5) Indexes that still read the most disk blocks. +select + s.schemaname, + s.relname, + s.indexrelname, + s.idx_scan, + s.idx_tup_read, + s.idx_tup_fetch, + io.idx_blks_read, + io.idx_blks_hit, + pg_size_pretty(pg_relation_size(s.indexrelid)) as index_size +from pg_stat_user_indexes s +join pg_statio_user_indexes io + on io.indexrelid = s.indexrelid +where s.schemaname = 'public' +order by io.idx_blks_read desc, s.idx_scan desc +limit 50; + +-- 6) Large non-unique indexes with no recorded scans since stats reset. +-- Treat this as a candidate list only; confirm with production traffic history +-- before dropping anything. +select + s.schemaname, + s.relname, + s.indexrelname, + s.idx_scan, + pg_size_pretty(pg_relation_size(s.indexrelid)) as index_size, + i.indisunique, + i.indisprimary +from pg_stat_user_indexes s +join pg_index i + on i.indexrelid = s.indexrelid +where s.schemaname = 'public' + and s.idx_scan = 0 + and not i.indisprimary + and not i.indisunique +order by pg_relation_size(s.indexrelid) desc +limit 30; diff --git a/scripts/supabase/io_budget_indexes.sql b/scripts/supabase/io_budget_indexes.sql new file mode 100644 index 00000000..8da27b2c --- /dev/null +++ b/scripts/supabase/io_budget_indexes.sql @@ -0,0 +1,89 @@ +-- PolyWeather Supabase Disk IO mitigation indexes. +-- Run this in the Supabase SQL Editor for an existing production project. + +drop index if exists public.idx_subscriptions_user_status_expiry; +create index if not exists idx_subscriptions_user_status_expiry + on public.subscriptions(user_id, expires_at desc) + include (id, starts_at, plan_code, source) + where status = 'active'; + +drop index if exists public.idx_profiles_email; +create index if not exists idx_profiles_email + on public.profiles(email) + include (id); + +drop index if exists public.idx_profiles_id_lookup; +create index if not exists idx_profiles_id_lookup + on public.profiles(id) + include (email, created_at); + +drop index if exists public.idx_subscriptions_status_expiry; +create index if not exists idx_subscriptions_status_expiry + on public.subscriptions(expires_at asc) + include (user_id, starts_at, plan_code) + where status = 'active'; + +drop index if exists public.idx_subscriptions_user_created; +create index if not exists idx_subscriptions_user_created + on public.subscriptions(user_id, created_at desc) + include (id, status, plan_code, source, starts_at, expires_at, updated_at); + +drop index if exists public.idx_payments_created_at; +create index if not exists idx_payments_created_at + on public.payments(created_at desc) + include (id, user_id, amount, currency, chain, tx_hash, status); + +drop index if exists public.idx_user_wallets_user_chain; +create index if not exists idx_user_wallets_user_chain + on public.user_wallets(user_id, chain_id, is_primary desc, verified_at desc) + include (id, address) + where status = 'active'; + +drop index if exists public.idx_user_wallets_chain_address_owner; +create index if not exists idx_user_wallets_chain_address_owner + on public.user_wallets(chain_id, address) + include (user_id, status); + +drop index if exists public.idx_wallet_link_challenges_lookup; + +drop index if exists public.idx_payment_intents_user_status; + +drop index if exists public.idx_payment_intents_status_updated; +create index if not exists idx_payment_intents_status_updated + on public.payment_intents(status, updated_at desc) + include (user_id) + where status in ('submitted', 'confirmed'); + +create index if not exists idx_payment_intents_user_status_updated + on public.payment_intents(user_id, status, updated_at desc); + +drop index if exists public.idx_payment_intents_submitted_tx_updated; +create index if not exists idx_payment_intents_submitted_tx_updated + on public.payment_intents(updated_at asc) + include (id, user_id, tx_hash, chain_id) + where status = 'submitted' and tx_hash is not null; + +create index if not exists idx_payment_intents_user_created + on public.payment_intents(user_id, created_at desc); + +drop index if exists public.idx_payment_intents_tx_hash; +create index if not exists idx_payment_intents_tx_hash + on public.payment_intents(tx_hash) + include (id, user_id) + where tx_hash is not null; + +create index if not exists idx_payment_transactions_intent + on public.payment_transactions(intent_id, created_at desc); + +drop index if exists public.idx_payment_transactions_tx_hash_intent; +create index if not exists idx_payment_transactions_tx_hash_intent + on public.payment_transactions(tx_hash) + include (intent_id); + +analyze public.subscriptions; +analyze public.profiles; +analyze public.payments; +analyze public.user_wallets; +analyze public.wallet_link_challenges; +analyze public.payment_intents; +analyze public.payment_transactions; diff --git a/scripts/supabase/schema.sql b/scripts/supabase/schema.sql index 190bda23..259eb996 100644 --- a/scripts/supabase/schema.sql +++ b/scripts/supabase/schema.sql @@ -12,6 +12,14 @@ create table if not exists public.profiles ( updated_at timestamptz not null default now() ); +create index if not exists idx_profiles_email + on public.profiles(email) + include (id); + +create index if not exists idx_profiles_id_lookup + on public.profiles(id) + include (email, created_at); + create table if not exists public.subscriptions ( id bigserial primary key, user_id uuid not null references auth.users(id) on delete cascade, @@ -25,7 +33,18 @@ create table if not exists public.subscriptions ( ); create index if not exists idx_subscriptions_user_status_expiry - on public.subscriptions(user_id, status, expires_at desc); + on public.subscriptions(user_id, expires_at desc) + include (id, starts_at, plan_code, source) + where status = 'active'; + +create index if not exists idx_subscriptions_status_expiry + on public.subscriptions(expires_at asc) + include (user_id, starts_at, plan_code) + where status = 'active'; + +create index if not exists idx_subscriptions_user_created + on public.subscriptions(user_id, created_at desc) + include (id, status, plan_code, source, starts_at, expires_at, updated_at); create table if not exists public.payments ( id bigserial primary key, @@ -40,6 +59,10 @@ create table if not exists public.payments ( updated_at timestamptz not null default now() ); +create index if not exists idx_payments_created_at + on public.payments(created_at desc) + include (id, user_id, amount, currency, chain, tx_hash, status); + create table if not exists public.entitlement_events ( id bigserial primary key, user_id uuid not null references auth.users(id) on delete cascade, @@ -65,7 +88,13 @@ create table if not exists public.user_wallets ( ); create index if not exists idx_user_wallets_user_chain - on public.user_wallets(user_id, chain_id, status, is_primary desc, verified_at desc); + on public.user_wallets(user_id, chain_id, is_primary desc, verified_at desc) + include (id, address) + where status = 'active'; + +create index if not exists idx_user_wallets_chain_address_owner + on public.user_wallets(chain_id, address) + include (user_id, status); create table if not exists public.wallet_link_challenges ( id bigserial primary key, @@ -79,9 +108,6 @@ create table if not exists public.wallet_link_challenges ( created_at timestamptz not null default now() ); -create index if not exists idx_wallet_link_challenges_lookup - on public.wallet_link_challenges(user_id, chain_id, address, nonce, created_at desc); - create table if not exists public.payment_intents ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id) on delete cascade, @@ -103,11 +129,26 @@ create table if not exists public.payment_intents ( updated_at timestamptz not null default now() ); -create index if not exists idx_payment_intents_user_status - on public.payment_intents(user_id, status, created_at desc); +create index if not exists idx_payment_intents_status_updated + on public.payment_intents(status, updated_at desc) + include (user_id) + where status in ('submitted', 'confirmed'); + +create index if not exists idx_payment_intents_user_status_updated + on public.payment_intents(user_id, status, updated_at desc); + +create index if not exists idx_payment_intents_submitted_tx_updated + on public.payment_intents(updated_at asc) + include (id, user_id, tx_hash, chain_id) + where status = 'submitted' and tx_hash is not null; + +create index if not exists idx_payment_intents_user_created + on public.payment_intents(user_id, created_at desc); create index if not exists idx_payment_intents_tx_hash - on public.payment_intents(tx_hash); + on public.payment_intents(tx_hash) + include (id, user_id) + where tx_hash is not null; create table if not exists public.payment_transactions ( id bigserial primary key, @@ -128,6 +169,10 @@ create table if not exists public.payment_transactions ( create index if not exists idx_payment_transactions_intent on public.payment_transactions(intent_id, created_at desc); +create index if not exists idx_payment_transactions_tx_hash_intent + on public.payment_transactions(tx_hash) + include (intent_id); + create or replace function public.sync_profile_from_auth() returns trigger language plpgsql diff --git a/src/auth/supabase_entitlement.py b/src/auth/supabase_entitlement.py index 5bdf82ea..d92b358a 100644 --- a/src/auth/supabase_entitlement.py +++ b/src/auth/supabase_entitlement.py @@ -69,6 +69,14 @@ class SupabaseEntitlementService: self._identity_cache_lock = threading.Lock() self._sub_cache: Dict[str, Dict[str, object]] = {} self._sub_cache_lock = threading.Lock() + self._latest_subscription_cache: Dict[str, Dict[str, object]] = {} + self._latest_subscription_cache_lock = threading.Lock() + self._active_subscription_bool_cache: Dict[str, Dict[str, object]] = {} + self._active_subscription_bool_cache_lock = threading.Lock() + self._active_subscriptions_cache: Dict[str, object] = {} + self._active_subscriptions_cache_lock = threading.Lock() + self._auth_users_cache: Dict[str, Dict[str, object]] = {} + self._auth_users_cache_lock = threading.Lock() def invalidate_subscription_cache(self, user_id: str) -> None: key = str(user_id or "").strip() @@ -76,6 +84,12 @@ class SupabaseEntitlementService: return with self._sub_cache_lock: self._sub_cache.pop(key, None) + with self._latest_subscription_cache_lock: + self._latest_subscription_cache.pop(key, None) + with self._active_subscription_bool_cache_lock: + self._active_subscription_bool_cache.pop(key, None) + with self._active_subscriptions_cache_lock: + self._active_subscriptions_cache.clear() @property def configured(self) -> bool: @@ -90,6 +104,9 @@ class SupabaseEntitlementService: def _entitlement_events_endpoint(self) -> str: return f"{self.supabase_url}/rest/v1/entitlement_events" + def _profiles_endpoint(self) -> str: + return f"{self.supabase_url}/rest/v1/profiles" + def _request_headers_for_user(self, access_token: str) -> Dict[str, str]: return { "apikey": self.anon_key, @@ -116,8 +133,7 @@ class SupabaseEntitlementService: cached = self._identity_cache.get(access_token) if cached and now_ts - float(cached.get("ts") or 0) < self.cache_ttl_sec: identity = cached.get("identity") - if isinstance(identity, SupabaseIdentity): - return identity + return identity if isinstance(identity, SupabaseIdentity) else None if not self.configured: return None @@ -129,10 +145,21 @@ class SupabaseEntitlementService: timeout=self.timeout_sec, ) if response.status_code != 200: + if response.status_code in {401, 403}: + with self._identity_cache_lock: + self._identity_cache[access_token] = { + "identity": None, + "ts": now_ts, + } return None data = response.json() if response.content else {} user_id = str(data.get("id") or "").strip() if not user_id: + with self._identity_cache_lock: + self._identity_cache[access_token] = { + "identity": None, + "ts": now_ts, + } return None # Extract points from user_metadata @@ -176,17 +203,26 @@ class SupabaseEntitlementService: if isinstance(row, dict): return row return None + with self._active_subscription_bool_cache_lock: + cached_bool = self._active_subscription_bool_cache.get(user_id) + if ( + cached_bool + and now_ts - float(cached_bool.get("ts") or 0) < self.sub_cache_ttl_sec + and cached_bool.get("active") is False + ): + return None try: now = datetime.now(timezone.utc) now_iso = now.isoformat() params = { - "select": "id,user_id,status,plan_code,starts_at,expires_at", + "select": "plan_code,source,starts_at,expires_at", "user_id": f"eq.{user_id}", "status": "eq.active", + "starts_at": f"lte.{now_iso}", "expires_at": f"gt.{now_iso}", "order": "expires_at.desc", - "limit": "20", + "limit": "1", } response = requests.get( self._subscription_endpoint(), @@ -205,13 +241,12 @@ class SupabaseEntitlementService: else: data = response.json() if response.content else [] rows = [item for item in data if isinstance(item, dict)] if isinstance(data, list) else [] - row = self._pick_latest_current_subscription(rows, now=now) + row = rows[0] if rows else None with self._sub_cache_lock: self._sub_cache[user_id] = { "active": bool(row), "row": row, - "rows": rows, "ts": now_ts, } return row @@ -238,16 +273,12 @@ class SupabaseEntitlementService: rows = cached.get("rows") if isinstance(rows, list): return [row for row in rows if isinstance(row, dict)] - row = cached.get("row") - if isinstance(row, dict): - return [row] - return [] try: now = datetime.now(timezone.utc) now_iso = now.isoformat() params = { - "select": "id,user_id,status,plan_code,starts_at,expires_at,source,created_at,updated_at", + "select": "plan_code,source,starts_at,expires_at", "user_id": f"eq.{user_id}", "status": "eq.active", "expires_at": f"gt.{now_iso}", @@ -290,9 +321,15 @@ class SupabaseEntitlementService: ) -> Optional[Dict[str, object]]: if not user_id or not self.service_role_key: return None + now_ts = time.time() + with self._latest_subscription_cache_lock: + cached = self._latest_subscription_cache.get(user_id) + if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec: + row = cached.get("row") + return row if isinstance(row, dict) else None try: params = { - "select": "id,user_id,status,plan_code,starts_at,expires_at,source,created_at,updated_at", + "select": "plan_code,starts_at,expires_at", "user_id": f"eq.{user_id}", "order": "created_at.desc", "limit": "1", @@ -312,7 +349,13 @@ class SupabaseEntitlementService: return None data = response.json() if response.content else [] row = data[0] if isinstance(data, list) and data else None - return row if isinstance(row, dict) else None + result = row if isinstance(row, dict) else None + with self._latest_subscription_cache_lock: + self._latest_subscription_cache[user_id] = { + "row": result, + "ts": now_ts, + } + return result except Exception as exc: logger.warning(f"supabase subscription history query error user_id={user_id}: {exc}") return None @@ -359,7 +402,68 @@ class SupabaseEntitlementService: return None def _query_active_subscription(self, user_id: str) -> bool: - return self._query_latest_active_subscription(user_id) is not None + if not user_id: + return False + if not self.service_role_key: + logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing") + return False + + now_ts = time.time() + with self._sub_cache_lock: + cached_detail = self._sub_cache.get(user_id) + if cached_detail and now_ts - float(cached_detail.get("ts") or 0) < self.sub_cache_ttl_sec: + rows = cached_detail.get("rows") + if isinstance(rows, list): + return self._pick_latest_current_subscription( + [row for row in rows if isinstance(row, dict)] + ) is not None + if "row" in cached_detail: + return isinstance(cached_detail.get("row"), dict) + + with self._active_subscription_bool_cache_lock: + cached = self._active_subscription_bool_cache.get(user_id) + if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec: + return bool(cached.get("active")) + + try: + now = datetime.now(timezone.utc) + now_iso = now.isoformat() + params = { + "select": "expires_at", + "user_id": f"eq.{user_id}", + "status": "eq.active", + "starts_at": f"lte.{now_iso}", + "expires_at": f"gt.{now_iso}", + "order": "expires_at.desc", + "limit": "1", + } + response = requests.get( + self._subscription_endpoint(), + headers=self._request_headers_for_service_role(), + params=params, + timeout=self.timeout_sec, + ) + if response.status_code != 200: + logger.warning( + "supabase active subscription bool query failed user_id={} status={}", + user_id, + response.status_code, + ) + active = False + else: + data = response.json() if response.content else [] + rows = [row for row in data if isinstance(row, dict)] if isinstance(data, list) else [] + active = bool(rows) + + with self._active_subscription_bool_cache_lock: + self._active_subscription_bool_cache[user_id] = { + "active": bool(active), + "ts": now_ts, + } + return bool(active) + except Exception as exc: + logger.warning(f"supabase active subscription bool query error user_id={user_id}: {exc}") + return False def get_latest_active_subscription( self, @@ -385,9 +489,14 @@ class SupabaseEntitlementService: if respect_requirement and not self.require_subscription: return {} rows = self._query_active_subscription_rows(user_id, bypass_cache=bypass_cache) + return self._subscription_window_from_rows(rows) + + def _subscription_window_from_rows( + self, + rows: List[Dict[str, object]], + ) -> Dict[str, object]: if not rows: return {} - now = datetime.now(timezone.utc) current = self._pick_latest_current_subscription(rows, now=now) total_expiry: Optional[datetime] = None @@ -422,6 +531,153 @@ class SupabaseEntitlementService: "rows": rows, } + def list_subscription_windows( + self, + user_ids: List[str], + bypass_cache: bool = False, + ) -> Dict[str, Dict[str, object]]: + keys: List[str] = [] + for item in user_ids or []: + key = str(item or "").strip().lower() + if key and key not in keys: + keys.append(key) + if not keys: + return {} + + out: Dict[str, Dict[str, object]] = {} + if not bypass_cache: + missing: List[str] = [] + now_ts = time.time() + with self._sub_cache_lock: + for key in keys: + cached = self._sub_cache.get(key) + if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec: + rows = cached.get("rows") + if isinstance(rows, list): + out[key] = self._subscription_window_from_rows( + [row for row in rows if isinstance(row, dict)] + ) + continue + missing.append(key) + keys = missing + if not keys: + return out + + if not self.service_role_key: + logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing") + return out + + try: + now = datetime.now(timezone.utc) + now_iso = now.isoformat() + params = { + "select": "user_id,plan_code,source,starts_at,expires_at", + "user_id": f"in.({','.join(keys)})", + "status": "eq.active", + "expires_at": f"gt.{now_iso}", + "order": "user_id.asc,expires_at.desc", + "limit": str(max(1, min(len(keys) * 20, 1000))), + } + response = requests.get( + self._subscription_endpoint(), + headers=self._request_headers_for_service_role(), + params=params, + timeout=self.timeout_sec, + ) + if response.status_code != 200: + logger.warning( + "supabase subscription window batch query failed users={} status={}", + len(keys), + response.status_code, + ) + return out + + data = response.json() if response.content else [] + rows = [row for row in data if isinstance(row, dict)] if isinstance(data, list) else [] + grouped: Dict[str, List[Dict[str, object]]] = {key: [] for key in keys} + for row in rows: + key = str(row.get("user_id") or "").strip().lower() + if key in grouped: + grouped[key].append(row) + + now_ts = time.time() + with self._sub_cache_lock: + for key, user_rows in grouped.items(): + current_row = self._pick_latest_current_subscription(user_rows, now=now) + self._sub_cache[key] = { + "active": bool(current_row), + "row": current_row, + "rows": user_rows, + "ts": now_ts, + } + out[key] = self._subscription_window_from_rows(user_rows) + return out + except Exception as exc: + logger.warning(f"supabase subscription window batch query error users={len(keys)}: {exc}") + return out + + def list_active_subscription_windows(self, limit: int = 200) -> Dict[str, object]: + if not self.service_role_key: + logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing") + return {"subscriptions": [], "windows": {}} + safe_limit = max(1, min(int(limit or 200), 1000)) + try: + now = datetime.now(timezone.utc) + now_iso = now.isoformat() + params = { + "select": "user_id,plan_code,source,starts_at,expires_at", + "status": "eq.active", + "expires_at": f"gt.{now_iso}", + "order": "user_id.asc,expires_at.desc", + "limit": str(max(1, min(safe_limit * 20, 5000))), + } + response = requests.get( + self._subscription_endpoint(), + headers=self._request_headers_for_service_role(), + params=params, + timeout=self.timeout_sec, + ) + if response.status_code != 200: + logger.warning( + "supabase active subscription window query failed status={}", + response.status_code, + ) + return {"subscriptions": [], "windows": {}} + data = response.json() if response.content else [] + rows = [row for row in data if isinstance(row, dict)] if isinstance(data, list) else [] + grouped: Dict[str, List[Dict[str, object]]] = {} + for row in rows: + key = str(row.get("user_id") or "").strip().lower() + if key: + grouped.setdefault(key, []).append(row) + + windows: Dict[str, Dict[str, object]] = {} + current_rows: List[Dict[str, object]] = [] + now_ts = time.time() + with self._sub_cache_lock: + for key, user_rows in grouped.items(): + current_row = self._pick_latest_current_subscription(user_rows, now=now) + self._sub_cache[key] = { + "active": bool(current_row), + "row": current_row, + "rows": user_rows, + "ts": now_ts, + } + windows[key] = self._subscription_window_from_rows(user_rows) + if isinstance(current_row, dict): + current_rows.append(current_row) + current_rows.sort(key=lambda row: str(row.get("expires_at") or "")) + current_rows = current_rows[:safe_limit] + with self._active_subscriptions_cache_lock: + self._active_subscriptions_cache[str(safe_limit)] = { + "rows": current_rows, + "ts": now_ts, + } + return {"subscriptions": current_rows, "windows": windows} + except Exception as exc: + logger.warning(f"supabase active subscription window query error: {exc}") + return {"subscriptions": [], "windows": {}} + def has_active_subscription( self, user_id: str, @@ -435,12 +691,20 @@ class SupabaseEntitlementService: if not self.service_role_key: logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing") return [] + safe_limit = max(1, min(int(limit or 200), 1000)) + cache_key = str(safe_limit) + now_ts = time.time() + with self._active_subscriptions_cache_lock: + cached = self._active_subscriptions_cache.get(cache_key) + if isinstance(cached, dict) and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec: + rows = cached.get("rows") + if isinstance(rows, list): + return [row for row in rows if isinstance(row, dict)] try: now = datetime.now(timezone.utc) - safe_limit = max(1, min(int(limit or 200), 1000)) now_iso = now.isoformat() params = { - "select": "id,user_id,status,plan_code,starts_at,expires_at", + "select": "user_id,plan_code,starts_at,expires_at", "status": "eq.active", "expires_at": f"gt.{now_iso}", "order": "expires_at.asc", @@ -461,11 +725,17 @@ class SupabaseEntitlementService: data = response.json() if response.content else [] if not isinstance(data, list): return [] - return [ + rows = [ row for row in data if isinstance(row, dict) and self._is_subscription_started(row, now=now) ] + with self._active_subscriptions_cache_lock: + self._active_subscriptions_cache[cache_key] = { + "rows": rows, + "ts": now_ts, + } + return rows except Exception as exc: logger.warning(f"supabase active subscriptions query error: {exc}") return [] @@ -484,6 +754,29 @@ class SupabaseEntitlementService: return {} out: Dict[str, Dict[str, object]] = {} + now_ts = time.time() + missing_keys: List[str] = [] + with self._auth_users_cache_lock: + for key in keys: + cached = self._auth_users_cache.get(key) + if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec: + user = cached.get("user") + if isinstance(user, dict): + out[key] = dict(user) + continue + missing_keys.append(key) + keys = missing_keys + if not keys: + return out + + profile_users = self._get_profile_users(keys) + if profile_users: + self._remember_auth_users(profile_users) + out.update(profile_users) + keys = [key for key in keys if key not in out] + if not keys: + return out + for user_id in keys: try: response = requests.get( @@ -506,9 +799,69 @@ class SupabaseEntitlementService: "email": str(payload.get("email") or "").strip(), "created_at": payload.get("created_at"), } + self._remember_auth_users({user_id: out[user_id]}) except Exception as exc: logger.warning(f"supabase admin user query error user_id={user_id}: {exc}") return out + def _remember_auth_users(self, users: Dict[str, Dict[str, object]]) -> None: + if not users: + return + now_ts = time.time() + with self._auth_users_cache_lock: + for raw_key, user in users.items(): + key = str(raw_key or "").strip().lower() + if key and isinstance(user, dict): + self._auth_users_cache[key] = { + "user": dict(user), + "ts": now_ts, + } + if len(self._auth_users_cache) > 4096: + oldest_keys = sorted( + self._auth_users_cache, + key=lambda key: float( + self._auth_users_cache[key].get("ts") or 0.0 + ), + ) + for key in oldest_keys[: len(self._auth_users_cache) - 4096]: + self._auth_users_cache.pop(key, None) + + def _get_profile_users(self, user_ids: List[str]) -> Dict[str, Dict[str, object]]: + if not user_ids or not self.service_role_key: + return {} + try: + response = requests.get( + self._profiles_endpoint(), + headers=self._request_headers_for_service_role(), + params={ + "select": "id,email,created_at", + "id": f"in.({','.join(user_ids)})", + "limit": str(max(1, min(len(user_ids), 1000))), + }, + timeout=self.timeout_sec, + ) + if response.status_code != 200: + logger.warning( + "supabase profile users batch query failed users={} status={}", + len(user_ids), + response.status_code, + ) + return {} + data = response.json() if response.content else [] + rows = [row for row in data if isinstance(row, dict)] if isinstance(data, list) else [] + out: Dict[str, Dict[str, object]] = {} + for row in rows: + user_id = str(row.get("id") or "").strip().lower() + if not user_id: + continue + out[user_id] = { + "email": str(row.get("email") or "").strip(), + "created_at": row.get("created_at"), + } + return out + except Exception as exc: + logger.warning(f"supabase profile users batch query error users={len(user_ids)}: {exc}") + return {} + SUPABASE_ENTITLEMENT = SupabaseEntitlementService() diff --git a/src/bot/handlers/basic.py b/src/bot/handlers/basic.py index 5e597f1b..506ddb41 100644 --- a/src/bot/handlers/basic.py +++ b/src/bot/handlers/basic.py @@ -457,6 +457,7 @@ class BasicCommandHandler: for row in rows: if self._subscription_row_is_paid(row): return True + return False if hasattr(self.entitlement_service, "get_latest_active_subscription"): row = self.entitlement_service.get_latest_active_subscription( supabase_user_id, diff --git a/src/bot/weekly_reward_loop.py b/src/bot/weekly_reward_loop.py index 5a28e558..83dc8452 100644 --- a/src/bot/weekly_reward_loop.py +++ b/src/bot/weekly_reward_loop.py @@ -169,7 +169,7 @@ def _grant_bonus_subscription_days( } ins = requests.post( f"{base}/rest/v1/subscriptions", - headers={**headers, "Prefer": "return=representation"}, + headers={**headers, "Prefer": "return=minimal"}, json=create_payload, timeout=timeout_sec, ) diff --git a/src/database/db_manager.py b/src/database/db_manager.py index 3ffe3ddd..ead09121 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -4,6 +4,7 @@ import hashlib import json import secrets import threading +import time from datetime import datetime, timedelta from typing import Optional, Dict, Any, List, Set @@ -14,6 +15,10 @@ from loguru import logger class DBManager: _init_lock = threading.Lock() _initialized_paths: Set[str] = set() + _points_sync_lock = threading.Lock() + _points_sync_cache: Dict[str, Dict[str, Any]] = {} + _profile_sync_lock = threading.Lock() + _profile_sync_cache: Dict[str, Dict[str, Any]] = {} def __init__(self, db_path: Optional[str] = None): self.db_path = self._resolve_db_path(db_path) @@ -67,7 +72,135 @@ class DBManager: return "" return f"{supabase_url}/auth/v1/admin/users" - def _sync_points_to_supabase_user_metadata(self, telegram_id: int) -> bool: + def _profile_sync_min_interval_sec(self) -> float: + raw = str( + os.getenv("POLYWEATHER_SUPABASE_PROFILE_SYNC_MIN_INTERVAL_SEC", "3600") + or "" + ).strip() + try: + return max(0.0, float(raw)) + except ValueError: + return 3600.0 + + def _profile_sync_cache_key(self, supabase_user_id: str) -> str: + endpoint = self._supabase_profiles_endpoint() + return f"{endpoint}:{str(supabase_user_id or '').strip().lower()}" + + def _should_skip_profile_sync( + self, + *, + supabase_user_id: str, + telegram_id: Optional[int], + telegram_username: Optional[str], + force: bool = False, + ) -> bool: + if force: + return False + min_interval = self._profile_sync_min_interval_sec() + if min_interval <= 0: + return False + payload_key = { + "telegram_id": int(telegram_id) if telegram_id is not None else None, + "telegram_username": str(telegram_username or "").strip() or None, + } + cache_key = self._profile_sync_cache_key(supabase_user_id) + now_ts = time.monotonic() + with self._profile_sync_lock: + cached = self._profile_sync_cache.get(cache_key) + if not isinstance(cached, dict): + return False + cached_payload = cached.get("payload") + cached_ts = float(cached.get("ts") or 0.0) + return cached_payload == payload_key and now_ts - cached_ts < min_interval + + def _remember_profile_sync( + self, + *, + supabase_user_id: str, + telegram_id: Optional[int], + telegram_username: Optional[str], + ) -> None: + cache_key = self._profile_sync_cache_key(supabase_user_id) + payload_key = { + "telegram_id": int(telegram_id) if telegram_id is not None else None, + "telegram_username": str(telegram_username or "").strip() or None, + } + with self._profile_sync_lock: + self._profile_sync_cache[cache_key] = { + "payload": payload_key, + "ts": time.monotonic(), + } + if len(self._profile_sync_cache) > 4096: + oldest_key = min( + self._profile_sync_cache, + key=lambda key: float( + self._profile_sync_cache[key].get("ts") or 0.0 + ), + ) + self._profile_sync_cache.pop(oldest_key, None) + + def _points_sync_cache_key(self, telegram_id: int) -> str: + return f"{os.path.abspath(self.db_path)}:{int(telegram_id)}" + + def _points_sync_min_interval_sec(self) -> float: + raw = str( + os.getenv("POLYWEATHER_SUPABASE_POINTS_SYNC_MIN_INTERVAL_SEC", "60") + or "" + ).strip() + try: + return max(0.0, float(raw)) + except Exception: + return 60.0 + + def _should_skip_points_metadata_sync( + self, + *, + telegram_id: int, + points: int, + force: bool, + ) -> bool: + if force: + return False + cache_key = self._points_sync_cache_key(telegram_id) + now_ts = time.monotonic() + min_interval = self._points_sync_min_interval_sec() + with self._points_sync_lock: + cached = self._points_sync_cache.get(cache_key) + if not cached: + return False + cached_points = int(cached.get("points") or 0) + cached_ts = float(cached.get("ts") or 0.0) + if cached_points == int(points): + return True + return min_interval > 0 and (now_ts - cached_ts) < min_interval + + def _remember_points_metadata_sync( + self, + *, + telegram_id: int, + points: int, + ) -> None: + cache_key = self._points_sync_cache_key(telegram_id) + with self._points_sync_lock: + self._points_sync_cache[cache_key] = { + "points": int(points), + "ts": time.monotonic(), + } + if len(self._points_sync_cache) > 4096: + oldest_key = min( + self._points_sync_cache, + key=lambda key: float( + self._points_sync_cache[key].get("ts") or 0.0 + ), + ) + self._points_sync_cache.pop(oldest_key, None) + + def _sync_points_to_supabase_user_metadata( + self, + telegram_id: int, + *, + force: bool = False, + ) -> bool: supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/") if not supabase_url: return False @@ -104,6 +237,13 @@ class DBManager: if pts_row: points = max(0, int(pts_row["points"] or 0)) + if self._should_skip_points_metadata_sync( + telegram_id=int(telegram_id), + points=points, + force=force, + ): + return False + try: resp = requests.patch( f"{endpoint}/{supabase_user_id}", @@ -120,6 +260,10 @@ class DBManager: (resp.text or "")[:200], ) return False + self._remember_points_metadata_sync( + telegram_id=int(telegram_id), + points=points, + ) return True except Exception as exc: logger.warning( @@ -136,12 +280,20 @@ class DBManager: supabase_user_id: str, telegram_id: Optional[int], telegram_username: Optional[str], + force: bool = False, ) -> bool: normalized_uid = str(supabase_user_id or "").strip().lower() endpoint = self._supabase_profiles_endpoint() headers = self._supabase_service_headers() if not normalized_uid or not endpoint or not headers: return False + if self._should_skip_profile_sync( + supabase_user_id=normalized_uid, + telegram_id=telegram_id, + telegram_username=telegram_username, + force=force, + ): + return False payload = { "telegram_user_id": int(telegram_id) if telegram_id is not None else None, @@ -164,6 +316,11 @@ class DBManager: (response.text or "")[:240], ) return False + self._remember_profile_sync( + supabase_user_id=normalized_uid, + telegram_id=telegram_id, + telegram_username=telegram_username, + ) return True except Exception as exc: logger.warning( @@ -1274,7 +1431,7 @@ class DBManager: (after, telegram_id), ) conn.commit() - self._sync_points_to_supabase_user_metadata(telegram_id) + self._sync_points_to_supabase_user_metadata(telegram_id, force=True) return { "ok": True, "telegram_id": telegram_id, @@ -1326,7 +1483,7 @@ class DBManager: (after, telegram_id), ) conn.commit() - self._sync_points_to_supabase_user_metadata(telegram_id) + self._sync_points_to_supabase_user_metadata(telegram_id, force=True) return { "ok": True, "telegram_id": telegram_id, @@ -1471,6 +1628,7 @@ class DBManager: supabase_user_id=normalized_uid, telegram_id=int(telegram_id), telegram_username=str((user_row["username"] if user_row else "") or "").strip(), + force=True, ) return {"ok": True, "reason": "already_bound_same"} @@ -1496,6 +1654,7 @@ class DBManager: supabase_user_id=normalized_uid, telegram_id=int(telegram_id), telegram_username=str((user_row["username"] if user_row else "") or "").strip(), + force=True, ) return {"ok": True, "reason": "bound"} @@ -1551,6 +1710,7 @@ class DBManager: supabase_user_id=user_id, telegram_id=None, telegram_username=None, + force=True, ) return {"ok": True, "reason": "unbound", "previous_supabase_user_id": current_uid} @@ -1940,7 +2100,7 @@ class DBManager: (new_balance, telegram_id), ) conn.commit() - self._sync_points_to_supabase_user_metadata(telegram_id) + self._sync_points_to_supabase_user_metadata(telegram_id, force=True) return {"ok": True, "balance": new_balance, "spent": amount} def spend_points_by_supabase_user_id(self, supabase_user_id: str, amount: int) -> Dict[str, Any]: @@ -1978,7 +2138,7 @@ class DBManager: (new_balance, telegram_id), ) conn.commit() - self._sync_points_to_supabase_user_metadata(telegram_id) + self._sync_points_to_supabase_user_metadata(telegram_id, force=True) return {"ok": True, "balance": new_balance, "spent": amount} def set_premium(self, telegram_id: int, plan: str, months: int = 1): @@ -2290,7 +2450,10 @@ class DBManager: ) conn.commit() if bonus > 0: - self._sync_points_to_supabase_user_metadata(int(telegram_id)) + self._sync_points_to_supabase_user_metadata( + int(telegram_id), + force=True, + ) return True def append_airport_obs( diff --git a/src/payments/contract_checkout.py b/src/payments/contract_checkout.py index b3043c36..8a7dcae0 100644 --- a/src/payments/contract_checkout.py +++ b/src/payments/contract_checkout.py @@ -4,6 +4,7 @@ import json import os import secrets import threading +import uuid from dataclasses import dataclass from datetime import datetime, timedelta, timezone from decimal import Decimal, InvalidOperation, ROUND_FLOOR @@ -1307,7 +1308,7 @@ class PaymentContractCheckoutService: "GET", "user_wallets", params={ - "select": "chain_id,address,status,is_primary,verified_at", + "select": "chain_id,address,is_primary,verified_at", "user_id": f"eq.{user_id}", "chain_id": f"eq.{self.chain_id}", "status": "eq.active", @@ -1323,7 +1324,7 @@ class PaymentContractCheckoutService: WalletBindingRecord( chain_id=int(row.get("chain_id") or self.chain_id), address=_normalize_address(row.get("address") or ""), - status=str(row.get("status") or "active"), + status="active", is_primary=bool(row.get("is_primary")), verified_at=row.get("verified_at"), ) @@ -1338,7 +1339,7 @@ class PaymentContractCheckoutService: "GET", "user_wallets", params={ - "select": "id,user_id,address,chain_id,status,is_primary", + "select": "status", "user_id": f"eq.{user_id}", "chain_id": f"eq.{self.chain_id}", "address": f"eq.{normalized}", @@ -1381,7 +1382,7 @@ class PaymentContractCheckoutService: "message": message, "expires_at": _to_iso(expires), }, - prefer="return=representation", + prefer="return=minimal", allowed_status=[201], ) return { @@ -1414,13 +1415,12 @@ class PaymentContractCheckoutService: "GET", "wallet_link_challenges", params={ - "select": "id,user_id,address,nonce,message,expires_at,consumed_at", + "select": "id,message,expires_at", "user_id": f"eq.{user_id}", "chain_id": f"eq.{self.chain_id}", "address": f"eq.{normalized}", "nonce": f"eq.{nonce_text}", "consumed_at": "is.null", - "order": "created_at.desc", "limit": "1", }, allowed_status=[200], @@ -1455,7 +1455,7 @@ class PaymentContractCheckoutService: "GET", "user_wallets", params={ - "select": "id,user_id,address,status,is_primary", + "select": "user_id,status", "chain_id": f"eq.{self.chain_id}", "address": f"eq.{normalized}", "limit": "1", @@ -1501,7 +1501,7 @@ class PaymentContractCheckoutService: "verified_at": now_iso, "updated_at": now_iso, }, - prefer="resolution=merge-duplicates,return=representation", + prefer="resolution=merge-duplicates,return=minimal", allowed_status=[200, 201], ) self._rest( @@ -1509,7 +1509,7 @@ class PaymentContractCheckoutService: "wallet_link_challenges", params={"id": f"eq.{challenge.get('id')}"}, payload={"consumed_at": now_iso}, - prefer="return=representation", + prefer="return=minimal", allowed_status=[200], ) return WalletBindingRecord( @@ -1543,7 +1543,7 @@ class PaymentContractCheckoutService: "is_primary": False, "updated_at": now_iso, }, - prefer="return=representation", + prefer="return=minimal", allowed_status=[200], ) @@ -1591,7 +1591,7 @@ class PaymentContractCheckoutService: "user_wallets", params={"id": f"eq.{candidate_id}"}, payload={"is_primary": True, "updated_at": now_iso}, - prefer="return=representation", + prefer="return=minimal", allowed_status=[200], ) new_primary = candidate_addr @@ -1780,32 +1780,32 @@ class PaymentContractCheckoutService: order_id_hex = "0x" + secrets.token_hex(32) now = _now_utc() expires_at = now + timedelta(seconds=self.intent_ttl_sec) - rows = self._rest( + intent_payload = { + "id": str(uuid.uuid4()), + "user_id": user_id, + "plan_code": plan["plan_code"], + "plan_id": plan["plan_id"], + "chain_id": selected_chain_id, + "token_address": selected_token.address, + "receiver_address": receiver_address, + "amount_units": str(amount_units), + "payment_mode": mode, + "allowed_wallet": target_wallet or None, + "order_id_hex": order_id_hex, + "status": "created", + "expires_at": _to_iso(expires_at), + "metadata": combined_metadata, + "created_at": _to_iso(now), + "updated_at": _to_iso(now), + } + self._rest( "POST", "payment_intents", - payload={ - "user_id": user_id, - "plan_code": plan["plan_code"], - "plan_id": plan["plan_id"], - "chain_id": selected_chain_id, - "token_address": selected_token.address, - "receiver_address": receiver_address, - "amount_units": str(amount_units), - "payment_mode": mode, - "allowed_wallet": target_wallet or None, - "order_id_hex": order_id_hex, - "status": "created", - "expires_at": _to_iso(expires_at), - "metadata": combined_metadata, - "created_at": _to_iso(now), - "updated_at": _to_iso(now), - }, - prefer="return=representation", + payload=intent_payload, + prefer="return=minimal", allowed_status=[201], ) - if not isinstance(rows, list) or not rows: - raise PaymentCheckoutError(500, "failed to create payment intent") - intent = self._serialize_intent(rows[0]) + intent = self._serialize_intent(intent_payload) response = { "intent": intent.__dict__, "tx_payload": None if mode == "direct" else self._build_tx_payload(intent), @@ -1885,7 +1885,7 @@ class PaymentContractCheckoutService: "GET", "payment_intents", params={ - "select": "id,user_id,tx_hash,status,updated_at,created_at,chain_id", + "select": "id,user_id,tx_hash,chain_id", "status": "eq.submitted", "tx_hash": "not.is.null", "order": "updated_at.asc", @@ -1911,9 +1911,6 @@ class PaymentContractCheckoutService: "user_id": user_id, "tx_hash": tx_hash, "chain_id": int(row.get("chain_id") or self.chain_id), - "status": str(row.get("status") or ""), - "updated_at": row.get("updated_at"), - "created_at": row.get("created_at"), } ) return out @@ -1937,8 +1934,7 @@ class PaymentContractCheckoutService: "payment_intents", params={ "select": ( - "id,user_id,status,tx_hash,order_id_hex,plan_id,token_address," - "amount_units,payment_mode,allowed_wallet,chain_id,created_at,updated_at" + "id,user_id,status,tx_hash,plan_id,token_address,amount_units" ), "order_id_hex": f"eq.{normalized_order}", "status": "in.(created,submitted,confirmed)", @@ -1965,15 +1961,9 @@ class PaymentContractCheckoutService: "user_id": user_id, "status": status, "tx_hash": str(row.get("tx_hash") or "").strip().lower(), - "order_id_hex": str(row.get("order_id_hex") or "").strip().lower(), "plan_id": int(row.get("plan_id") or 0), - "chain_id": int(row.get("chain_id") or self.chain_id), "token_address": _normalize_address(row.get("token_address")), "amount_units": int(row.get("amount_units") or 0), - "payment_mode": str(row.get("payment_mode") or "").strip().lower(), - "allowed_wallet": _normalize_address(row.get("allowed_wallet")), - "created_at": row.get("created_at"), - "updated_at": row.get("updated_at"), } ) return out @@ -1986,7 +1976,7 @@ class PaymentContractCheckoutService: "GET", "payment_transactions", params={ - "select": "intent_id,tx_hash,status", + "select": "intent_id", "tx_hash": f"eq.{tx_hash_text}", "limit": "5", }, @@ -2018,7 +2008,7 @@ class PaymentContractCheckoutService: return {} now_iso = _to_iso(_now_utc()) try: - rows = self._rest( + self._rest( "POST", "payment_transactions", params={"on_conflict": "tx_hash"}, @@ -2040,10 +2030,10 @@ class PaymentContractCheckoutService: }, "updated_at": now_iso, }, - prefer="resolution=merge-duplicates,return=representation", + prefer="resolution=merge-duplicates,return=minimal", allowed_status=[200, 201], ) - return rows[0] if isinstance(rows, list) and rows else {} + return {} except Exception: return {} @@ -2060,6 +2050,13 @@ class PaymentContractCheckoutService: """ self._ensure_enabled() intent = self.get_intent(user_id, intent_id) + return self._validate_loaded_intent_tx(intent, tx_hash) + + def _validate_loaded_intent_tx( + self, + intent: PaymentIntentRecord, + tx_hash: str, + ) -> Dict[str, Any]: tx_hash_text = str(tx_hash or "").strip().lower() if not (tx_hash_text.startswith("0x") and len(tx_hash_text) == 66): return { @@ -2266,7 +2263,7 @@ class PaymentContractCheckoutService: "payment_intents", params={"id": f"eq.{intent.intent_id}", "user_id": f"eq.{user_id}"}, payload={"status": "expired", "updated_at": _to_iso(now)}, - prefer="return=representation", + prefer="return=minimal", allowed_status=[200], ) raise PaymentCheckoutError(409, "payment intent expired") @@ -2283,7 +2280,7 @@ class PaymentContractCheckoutService: self._require_user_wallet(user_id, from_addr) try: - validation = self.validate_intent_tx(user_id, intent.intent_id, tx_hash_text) + validation = self._validate_loaded_intent_tx(intent, tx_hash_text) except Exception as exc: raise PaymentCheckoutError( 400, @@ -2305,26 +2302,25 @@ class PaymentContractCheckoutService: "tx_hash": tx_hash_text, "updated_at": now_iso, }, - prefer="return=representation", + prefer="return=minimal", allowed_status=[200], ) - tx_rows = self._rest( + tx_payload = { + "intent_id": intent.intent_id, + "chain_id": int(intent.chain_id), + "tx_hash": tx_hash_text, + "from_address": from_addr, + "to_address": intent.receiver_address, + "payment_method": "direct" if intent.payment_mode == "direct" else "wallet", + "status": "submitted", + "updated_at": now_iso, + } + self._rest( "POST", "payment_transactions", params={"on_conflict": "tx_hash"}, - payload={ - "intent_id": intent.intent_id, - "chain_id": int(intent.chain_id), - "tx_hash": tx_hash_text, - "from_address": from_addr, - "to_address": intent.receiver_address, - "payment_method": "direct" - if intent.payment_mode == "direct" - else "wallet", - "status": "submitted", - "updated_at": now_iso, - }, - prefer="resolution=merge-duplicates,return=representation", + payload=tx_payload, + prefer="resolution=merge-duplicates,return=minimal", allowed_status=[200, 201], ) return { @@ -2332,9 +2328,7 @@ class PaymentContractCheckoutService: "status": "submitted", "tx_hash": tx_hash_text, "from_address": from_addr, - "transaction": tx_rows[0] - if isinstance(tx_rows, list) and tx_rows - else None, + "transaction": tx_payload, } def _wait_receipt(self, tx_hash: str, chain_id: Optional[int] = None) -> Any: @@ -2466,24 +2460,25 @@ class PaymentContractCheckoutService: token_decimals = self._token_decimals_for(token_address, payment_chain_id) amount_dec = _units_to_decimal(amount_units, token_decimals) currency = self._token_symbol_for(token_address, payment_chain_id) - rows = self._rest( + payment_payload = { + "user_id": user_id, + "amount": str(amount_dec), + "currency": currency, + "chain": self._chain_label_for(payment_chain_id), + "tx_hash": tx_hash, + "status": "confirmed", + "raw_payload": payload, + "updated_at": _to_iso(_now_utc()), + } + self._rest( "POST", "payments", params={"on_conflict": "tx_hash"}, - payload={ - "user_id": user_id, - "amount": str(amount_dec), - "currency": currency, - "chain": self._chain_label_for(payment_chain_id), - "tx_hash": tx_hash, - "status": "confirmed", - "raw_payload": payload, - "updated_at": _to_iso(_now_utc()), - }, - prefer="resolution=merge-duplicates,return=representation", + payload=payment_payload, + prefer="resolution=merge-duplicates,return=minimal", allowed_status=[200, 201], ) - return rows[0] if isinstance(rows, list) and rows else {} + return payment_payload def _grant_subscription( self, @@ -2498,7 +2493,7 @@ class PaymentContractCheckoutService: "GET", "subscriptions", params={ - "select": "id,expires_at,status,plan_code,source,starts_at", + "select": "starts_at,expires_at", "user_id": f"eq.{user_id}", "status": "eq.active", "order": "expires_at.desc", @@ -2539,20 +2534,21 @@ class PaymentContractCheckoutService: except Exception: pass expires = starts + timedelta(days=max(1, duration_days)) - sub_rows = self._rest( + subscription_payload = { + "user_id": user_id, + "plan_code": plan_code, + "status": "active", + "starts_at": _to_iso(starts), + "expires_at": _to_iso(expires), + "source": "payment_contract", + "created_at": _to_iso(now), + "updated_at": _to_iso(now), + } + self._rest( "POST", "subscriptions", - payload={ - "user_id": user_id, - "plan_code": plan_code, - "status": "active", - "starts_at": _to_iso(starts), - "expires_at": _to_iso(expires), - "source": "payment_contract", - "created_at": _to_iso(now), - "updated_at": _to_iso(now), - }, - prefer="return=representation", + payload=subscription_payload, + prefer="return=minimal", allowed_status=[201], ) self._rest( @@ -2566,11 +2562,11 @@ class PaymentContractCheckoutService: "payload": {"tx_hash": tx_hash, **payload}, "created_at": _to_iso(now), }, - prefer="return=representation", + prefer="return=minimal", allowed_status=[201], ) SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id) - return sub_rows[0] if isinstance(sub_rows, list) and sub_rows else {} + return subscription_payload def _ensure_confirmed_subscription( self, @@ -2578,7 +2574,6 @@ class PaymentContractCheckoutService: intent: PaymentIntentRecord, tx_hash: str, ) -> Optional[Dict[str, Any]]: - SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id) latest_subscription = SUPABASE_ENTITLEMENT.get_latest_active_subscription( user_id, respect_requirement=False, @@ -2696,7 +2691,7 @@ class PaymentContractCheckoutService: "metadata": metadata, "updated_at": now_iso, }, - prefer="return=representation", + prefer="return=minimal", allowed_status=[200], ) if tx_hash: @@ -2713,7 +2708,7 @@ class PaymentContractCheckoutService: "status": "failed", "updated_at": now_iso, }, - prefer="resolution=merge-duplicates,return=representation", + prefer="resolution=merge-duplicates,return=minimal", allowed_status=[200, 201], ) self._db.append_payment_audit_event( @@ -2931,6 +2926,7 @@ class PaymentContractCheckoutService: "PATCH", "payment_intents", params={ + "select": "id", "id": f"eq.{intent.intent_id}", "user_id": f"eq.{user_id}", "status": "in.(created,submitted,failed)", @@ -2972,24 +2968,25 @@ class PaymentContractCheckoutService: raise PaymentCheckoutError( 409, f"intent status is {refreshed.status}, cannot confirm" ) - tx_rows = self._rest( + tx_payload = { + "intent_id": intent.intent_id, + "tx_hash": tx_hash_text, + "chain_id": int(intent.chain_id), + "from_address": tx_from, + "to_address": tx_to, + "block_number": block_number, + "payment_method": "direct" if is_direct else "wallet", + "status": "confirmed", + "raw_receipt": json.loads(Web3.to_json(receipt)), + "raw_tx": json.loads(Web3.to_json(tx)) if tx is not None else None, + "updated_at": now_iso, + } + self._rest( "POST", "payment_transactions", params={"on_conflict": "tx_hash"}, - payload={ - "intent_id": intent.intent_id, - "tx_hash": tx_hash_text, - "chain_id": int(intent.chain_id), - "from_address": tx_from, - "to_address": tx_to, - "block_number": block_number, - "payment_method": "direct" if is_direct else "wallet", - "status": "confirmed", - "raw_receipt": json.loads(Web3.to_json(receipt)), - "raw_tx": json.loads(Web3.to_json(tx)) if tx is not None else None, - "updated_at": now_iso, - }, - prefer="resolution=merge-duplicates,return=representation", + payload=tx_payload, + prefer="resolution=merge-duplicates,return=minimal", allowed_status=[200, 201], ) payload = { @@ -3036,12 +3033,17 @@ class PaymentContractCheckoutService: amount_usdc=intent.amount_usdc, tx_hash=tx_hash_text, ) - refreshed = self.get_intent(user_id, intent.intent_id) + refreshed = PaymentIntentRecord( + **{ + **intent.__dict__, + "status": "confirmed", + "tx_hash": tx_hash_text, + "metadata": confirmed_metadata, + } + ) return { "intent": refreshed.__dict__, - "transaction": tx_rows[0] - if isinstance(tx_rows, list) and tx_rows - else None, + "transaction": tx_payload, "payment": payment_row, "subscription": subscription_row, "points_redemption": points_result, @@ -3091,11 +3093,10 @@ class PaymentContractCheckoutService: repaired = self._ensure_confirm_side_effects( user_id, intent, tx_hash_text ) - refreshed = self.get_intent(user_id, intent.intent_id) return { "ok": True, "action": "reconciled_confirmed_intent", - "intent": refreshed.__dict__, + "intent": intent.__dict__, "payment": repaired.get("payment"), "subscription": repaired.get("subscription"), } @@ -3109,7 +3110,6 @@ class PaymentContractCheckoutService: } ) - SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id) latest_subscription = SUPABASE_ENTITLEMENT.get_latest_active_subscription( user_id, respect_requirement=False, @@ -3128,7 +3128,7 @@ class PaymentContractCheckoutService: "GET", "payment_intents", params={ - "select": "id,user_id,status,updated_at", + "select": "user_id", "status": "in.(submitted,confirmed)", "order": "updated_at.desc", "limit": str(safe_limit), diff --git a/tests/test_bot_basic_handler.py b/tests/test_bot_basic_handler.py index c04841e2..b8005a19 100644 --- a/tests/test_bot_basic_handler.py +++ b/tests/test_bot_basic_handler.py @@ -381,6 +381,46 @@ def test_join_request_approves_trial_user_with_queued_paid_subscription(monkeypa assert bot.approved_join_requests == [{"chat_id": -100123, "user_id": 12345}] +def test_join_request_uses_subscription_window_without_latest_fallback(monkeypatch): + monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-100123") + bot = DummyBot() + db = SimpleNamespace(list_supabase_user_ids_for_telegram=lambda telegram_id: ["user-1"]) + io_layer = SimpleNamespace( + build_welcome_text=lambda: "WELCOME", + build_points_rank_text=lambda _user: "TOP", + db=db, + ) + + def _fail_latest(*_args, **_kwargs): + raise AssertionError("subscription window rows should avoid latest subscription fallback") + + entitlement = SimpleNamespace( + get_subscription_window=lambda user_id, respect_requirement=False: { + "rows": [ + {"plan_code": "signup_trial_3d", "source": "signup_trial"}, + ] + }, + get_latest_active_subscription=_fail_latest, + ) + handler = BasicCommandHandler( + bot=bot, + io_layer=io_layer, + runtime_status_provider=lambda: RuntimeStatus( + started_at="2026-03-12 00:00:00 UTC", + loops=[], + command_access_mode="group_member", + protected_commands=["/city", "/deb"], + required_group_chat_id="-100123", + ), + entitlement_service=entitlement, + ) + + result = handler.handle_chat_join_request(_join_request()) + + assert result == "pending:no_active_subscription" + assert bot.approved_join_requests == [] + + def test_join_request_can_decline_ineligible_user_when_configured(monkeypatch): monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-100123") monkeypatch.setenv("POLYWEATHER_TELEGRAM_JOIN_INELIGIBLE_ACTION", "decline") diff --git a/tests/test_direct_payment.py b/tests/test_direct_payment.py index cd446141..04064ba2 100644 --- a/tests/test_direct_payment.py +++ b/tests/test_direct_payment.py @@ -186,14 +186,15 @@ def test_confirm_direct_transfer_uses_intent_chain_rpc(monkeypatch, tmp_path): ) confirmed_intent = PaymentIntentRecord(**{**intent.__dict__, "status": "confirmed"}) intents = [intent, confirmed_intent] + get_intent_calls = [] requested_chains = [] tx_rows = [] - monkeypatch.setattr( - service, - "get_intent", - lambda user_id, intent_id: intents.pop(0) if intents else confirmed_intent, - ) + def fake_get_intent(user_id, intent_id): + get_intent_calls.append((user_id, intent_id)) + return intents.pop(0) if intents else confirmed_intent + + monkeypatch.setattr(service, "get_intent", fake_get_intent) class _Eth: chain_id = 1 @@ -260,7 +261,9 @@ def test_confirm_direct_transfer_uses_intent_chain_rpc(monkeypatch, tmp_path): if method == "GET" and table == "payment_transactions": return [] if method == "PATCH" and table == "payment_intents": - return [{"id": intent.intent_id, "status": "confirmed"}] + assert kwargs["prefer"] == "return=representation" + assert kwargs["params"]["select"] == "id" + return [{"id": intent.intent_id}] if method == "POST" and table == "payment_transactions": tx_rows.append(kwargs["payload"]) return [kwargs["payload"]] @@ -273,6 +276,7 @@ def test_confirm_direct_transfer_uses_intent_chain_rpc(monkeypatch, tmp_path): assert 1 in requested_chains assert tx_rows[0]["chain_id"] == 1 assert result["payment"]["chain_id"] == 1 + assert get_intent_calls == [("user-1", intent.intent_id)] def test_direct_intent_does_not_require_bound_wallet(monkeypatch, tmp_path): @@ -344,7 +348,7 @@ def test_direct_submit_tx_does_not_require_from_address(monkeypatch, tmp_path): monkeypatch.setattr(service, "_rest", fake_rest) monkeypatch.setattr( service, - "validate_intent_tx", + "_validate_loaded_intent_tx", lambda *args, **kwargs: {"valid": True, "checks": {"tx_mined": True}}, ) @@ -384,7 +388,7 @@ def test_submit_rejects_direct_tx_until_validation_passes(monkeypatch, tmp_path) ) monkeypatch.setattr( service, - "validate_intent_tx", + "_validate_loaded_intent_tx", lambda *args, **kwargs: { "valid": False, "reason": "tx_not_mined", @@ -437,7 +441,7 @@ def test_submit_rejects_mined_direct_tx_when_receiver_mismatches(monkeypatch, tm ) monkeypatch.setattr( service, - "validate_intent_tx", + "_validate_loaded_intent_tx", lambda *args, **kwargs: { "valid": False, "reason": "receiver_mismatch", @@ -543,7 +547,7 @@ def test_confirm_direct_transfer_uses_erc20_transfer_without_wallet_binding(monk if method == "PATCH" and table == "payment_intents": return [{"id": intent.intent_id, "status": "confirmed"}] if method == "POST" and table == "payment_transactions": - return [kwargs["payload"]] + return [] raise AssertionError((method, table, kwargs)) monkeypatch.setattr(service, "_rest", fake_rest) @@ -552,8 +556,14 @@ def test_confirm_direct_transfer_uses_erc20_transfer_without_wallet_binding(monk result = service.confirm_intent_tx("user-1", intent.intent_id, tx_hash) assert result["subscription"]["status"] == "active" + assert result["transaction"]["tx_hash"] == tx_hash assert result["tx"]["event"]["amount_units"] == 5000000 - assert any(call[1] == "payment_transactions" for call in rest_calls) + transaction_write = next( + call + for call in rest_calls + if call[0] == "POST" and call[1] == "payment_transactions" + ) + assert transaction_write[2]["prefer"] == "resolution=merge-duplicates,return=minimal" def test_submit_rejects_tx_hash_used_by_another_intent(monkeypatch, tmp_path): _setup_env(monkeypatch, tmp_path) diff --git a/tests/test_payments_runtime.py b/tests/test_payments_runtime.py index 2599e2a5..ce4b09f7 100644 --- a/tests/test_payments_runtime.py +++ b/tests/test_payments_runtime.py @@ -21,6 +21,517 @@ def _payment_env(monkeypatch, tmp_path): monkeypatch.delenv("POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS", raising=False) +def test_wallet_challenge_insert_uses_minimal_return(monkeypatch, tmp_path): + _payment_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + calls = [] + + def _fake_rest(method, table, **kwargs): + calls.append({"method": method, "table": table, **kwargs}) + return [] + + monkeypatch.setattr(service, "_rest", _fake_rest) + + result = service.create_wallet_challenge( + "user-1", + "0x1111111111111111111111111111111111111111", + ) + + assert result["address"] == "0x1111111111111111111111111111111111111111" + assert calls[0]["table"] == "wallet_link_challenges" + assert calls[0]["prefer"] == "return=minimal" + + +def test_entitlement_event_insert_uses_minimal_return(monkeypatch, tmp_path): + _payment_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + calls = [] + + def _fake_rest(method, table, **kwargs): + calls.append({"method": method, "table": table, **kwargs}) + if method == "GET" and table == "subscriptions": + return [] + if method == "POST" and table == "subscriptions": + return [kwargs.get("payload") or {}] + return [] + + monkeypatch.setattr(service, "_rest", _fake_rest) + + service._grant_subscription( + user_id="user-1", + plan_code="pro_monthly", + duration_days=30, + tx_hash="0x" + "1" * 64, + payload={"kind": "test"}, + ) + + entitlement_event = next(call for call in calls if call["table"] == "entitlement_events") + assert entitlement_event["prefer"] == "return=minimal" + + +def test_subscription_insert_uses_minimal_return_and_local_payload(monkeypatch, tmp_path): + _payment_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + calls = [] + + def _fake_rest(method, table, **kwargs): + calls.append({"method": method, "table": table, **kwargs}) + if method == "GET" and table == "subscriptions": + return [] + if method == "POST" and table == "subscriptions": + return [] + if method == "POST" and table == "entitlement_events": + return [] + raise AssertionError((method, table, kwargs)) + + monkeypatch.setattr(service, "_rest", _fake_rest) + + result = service._grant_subscription( + user_id="user-1", + plan_code="pro_monthly", + duration_days=30, + tx_hash="0x" + "1" * 64, + payload={"kind": "test"}, + ) + + subscription_write = next( + call for call in calls if call["method"] == "POST" and call["table"] == "subscriptions" + ) + assert subscription_write["prefer"] == "return=minimal" + assert result == subscription_write["payload"] + assert result["user_id"] == "user-1" + assert result["plan_code"] == "pro_monthly" + assert result["status"] == "active" + + +def test_wallet_binding_writes_use_minimal_return(monkeypatch, tmp_path): + _payment_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + address = "0x1111111111111111111111111111111111111111" + calls = [] + + monkeypatch.setattr( + "src.payments.contract_checkout.Account.recover_message", + lambda *args, **kwargs: address, + ) + + def _fake_rest(method, table, **kwargs): + calls.append({"method": method, "table": table, **kwargs}) + if method == "GET" and table == "wallet_link_challenges": + return [ + { + "id": "challenge-1", + "user_id": "user-1", + "address": address, + "nonce": "nonce-1", + "message": "message", + "expires_at": "2099-01-01T00:00:00+00:00", + "consumed_at": None, + } + ] + if method == "GET" and table == "user_wallets": + return [] + return [] + + monkeypatch.setattr(service, "_rest", _fake_rest) + + result = service.verify_wallet_binding("user-1", address, "nonce-1", "0xsig") + + assert result.address == address + writes = [call for call in calls if call["method"] in {"POST", "PATCH"}] + assert writes[0]["table"] == "user_wallets" + assert writes[0]["prefer"] == "resolution=merge-duplicates,return=minimal" + assert writes[1]["table"] == "wallet_link_challenges" + assert writes[1]["prefer"] == "return=minimal" + + +def test_require_user_wallet_selects_only_status(monkeypatch, tmp_path): + _payment_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + calls = [] + + def _fake_rest(method, table, **kwargs): + calls.append({"method": method, "table": table, **kwargs}) + return [{"status": "active"}] + + monkeypatch.setattr(service, "_rest", _fake_rest) + + result = service._require_user_wallet( + "user-1", + "0x1111111111111111111111111111111111111111", + ) + + assert result == {"status": "active"} + assert calls[0]["params"]["select"] == "status" + + +def test_list_wallets_omits_status_from_active_wallet_query(monkeypatch, tmp_path): + _payment_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + calls = [] + + def _fake_rest(method, table, **kwargs): + calls.append({"method": method, "table": table, **kwargs}) + return [ + { + "chain_id": 137, + "address": "0x1111111111111111111111111111111111111111", + "is_primary": True, + "verified_at": "2099-01-01T00:00:00+00:00", + } + ] + + monkeypatch.setattr(service, "_rest", _fake_rest) + + wallets = service.list_wallets("user-1") + + assert calls[0]["params"]["select"] == "chain_id,address,is_primary,verified_at" + assert wallets[0].status == "active" + + +def test_wallet_binding_existing_lookup_selects_only_owner_status( + monkeypatch, + tmp_path, +): + _payment_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + address = "0x1111111111111111111111111111111111111111" + calls = [] + + monkeypatch.setattr( + "src.payments.contract_checkout.Account.recover_message", + lambda *args, **kwargs: address, + ) + + def _fake_rest(method, table, **kwargs): + calls.append({"method": method, "table": table, **kwargs}) + if method == "GET" and table == "wallet_link_challenges": + return [ + { + "id": "challenge-1", + "user_id": "user-1", + "address": address, + "nonce": "nonce-1", + "message": "message", + "expires_at": "2099-01-01T00:00:00+00:00", + "consumed_at": None, + } + ] + if method == "GET" and table == "user_wallets": + return [] + return [] + + monkeypatch.setattr(service, "_rest", _fake_rest) + + service.verify_wallet_binding("user-1", address, "nonce-1", "0xsig") + + existing_lookup = [ + call + for call in calls + if call["method"] == "GET" + and call["table"] == "user_wallets" + and "address" in call["params"] + ][0] + assert existing_lookup["params"]["select"] == "user_id,status" + + challenge_lookup = [ + call + for call in calls + if call["method"] == "GET" + and call["table"] == "wallet_link_challenges" + ][0] + assert challenge_lookup["params"]["select"] == "id,message,expires_at" + assert "order" not in challenge_lookup["params"] + + +def test_wallet_unbind_writes_use_minimal_return(monkeypatch, tmp_path): + _payment_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + calls = [] + + monkeypatch.setattr(service, "_require_user_wallet", lambda *args, **kwargs: {}) + + def _fake_rest(method, table, **kwargs): + calls.append({"method": method, "table": table, **kwargs}) + if method == "GET" and table == "user_wallets" and kwargs["params"].get("is_primary") == "eq.true": + return [] + if method == "GET" and table == "user_wallets": + return [{"id": "wallet-2", "address": "0x2222222222222222222222222222222222222222"}] + return [] + + monkeypatch.setattr(service, "_rest", _fake_rest) + + result = service.unbind_wallet("user-1", "0x1111111111111111111111111111111111111111") + + assert result["new_primary"] == "0x2222222222222222222222222222222222222222" + writes = [call for call in calls if call["method"] == "PATCH"] + assert [call["prefer"] for call in writes] == ["return=minimal", "return=minimal"] + + +def test_submit_intent_status_patch_uses_minimal_return(monkeypatch, tmp_path): + _payment_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + calls = [] + + monkeypatch.setattr( + service, + "get_intent", + lambda user_id, intent_id: service._serialize_intent( + { + "id": intent_id, + "plan_code": "pro_monthly", + "plan_id": 101, + "chain_id": 137, + "token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32", + "amount_units": "5000000", + "payment_mode": "direct", + "allowed_wallet": None, + "order_id_hex": "0x" + "1" * 64, + "status": "created", + "expires_at": "2099-01-01T00:00:00+00:00", + "tx_hash": None, + "metadata": {}, + } + ), + ) + monkeypatch.setattr( + service, + "_validate_loaded_intent_tx", + lambda *args, **kwargs: {"valid": True, "checks": {"tx_mined": True}}, + ) + + def _fake_rest(method, table, **kwargs): + calls.append({"method": method, "table": table, **kwargs}) + if method == "GET" and table == "payment_transactions": + return [] + if method == "POST" and table == "payment_transactions": + return [] + return [] + + monkeypatch.setattr(service, "_rest", _fake_rest) + + result = service.submit_intent_tx("user-1", "intent-1", "0x" + "2" * 64, "") + + assert result["status"] == "submitted" + intent_patch = next(call for call in calls if call["method"] == "PATCH") + assert intent_patch["table"] == "payment_intents" + assert intent_patch["prefer"] == "return=minimal" + transaction_write = next( + call + for call in calls + if call["method"] == "POST" and call["table"] == "payment_transactions" + ) + assert transaction_write["prefer"] == "resolution=merge-duplicates,return=minimal" + assert result["transaction"]["tx_hash"] == "0x" + "2" * 64 + + +def test_submit_intent_tx_reuses_loaded_intent_for_validation(monkeypatch, tmp_path): + _payment_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + intent = service._serialize_intent( + { + "id": "intent-1", + "plan_code": "pro_monthly", + "plan_id": 101, + "chain_id": 137, + "token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32", + "amount_units": "5000000", + "payment_mode": "direct", + "allowed_wallet": None, + "order_id_hex": "0x" + "1" * 64, + "status": "created", + "expires_at": "2099-01-01T00:00:00+00:00", + "tx_hash": None, + "metadata": {}, + } + ) + calls = {"get_intent": 0} + rest_calls = [] + + class _FakeEth: + def get_transaction_receipt(self, tx_hash): + return {"status": 1, "to": intent.receiver_address, "blockNumber": 123} + + class _FakeWeb3: + eth = _FakeEth() + + def _fake_get_intent(user_id, intent_id): + calls["get_intent"] += 1 + return intent + + def _fake_rest(method, table, **kwargs): + rest_calls.append({"method": method, "table": table, **kwargs}) + if method == "GET" and table == "payment_transactions": + return [] + if method == "POST" and table == "payment_transactions": + return [kwargs["payload"]] + return [] + + monkeypatch.setattr(service, "get_intent", _fake_get_intent) + monkeypatch.setattr(service, "_get_web3", lambda *args, **kwargs: _FakeWeb3()) + monkeypatch.setattr( + service, + "_extract_direct_transfer_event", + lambda receipt, loaded_intent: { + "from": "0x2222222222222222222222222222222222222222", + "to": loaded_intent.receiver_address, + "amount_units": int(loaded_intent.amount_units), + }, + ) + monkeypatch.setattr(service, "_rest", _fake_rest) + + result = service.submit_intent_tx("user-1", "intent-1", "0x" + "2" * 64, "") + + assert result["status"] == "submitted" + assert calls["get_intent"] == 1 + assert [call["table"] for call in rest_calls].count("payment_transactions") == 2 + + +def test_failed_intent_writes_use_minimal_return(monkeypatch, tmp_path): + _payment_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + calls = [] + intent = PaymentIntentRecord( + intent_id="intent-1", + order_id_hex="0x" + "1" * 64, + plan_code="pro_monthly", + plan_id=101, + chain_id=137, + amount_units=5_000_000, + amount_usdc="5", + token_address="0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + token_decimals=6, + token_symbol="USDC", + receiver_address="0xed2f13aa5ff033c58fb436e178451cd07f693f32", + status="submitted", + payment_mode="direct", + allowed_wallet=None, + expires_at="2099-01-01T00:00:00+00:00", + tx_hash=None, + metadata={}, + ) + + def _fake_rest(method, table, **kwargs): + calls.append({"method": method, "table": table, **kwargs}) + return [] + + monkeypatch.setattr(service, "_rest", _fake_rest) + + service._mark_intent_failed( + user_id="user-1", + intent=intent, + tx_hash="0x" + "3" * 64, + reason="test_failure", + detail="test", + ) + + assert calls[0]["table"] == "payment_intents" + assert calls[0]["prefer"] == "return=minimal" + assert calls[1]["table"] == "payment_transactions" + assert calls[1]["prefer"] == "resolution=merge-duplicates,return=minimal" + + +def test_duplicate_transaction_write_uses_minimal_return(monkeypatch, tmp_path): + _payment_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + calls = [] + intent = PaymentIntentRecord( + intent_id="intent-1", + order_id_hex="0x" + "1" * 64, + plan_code="pro_monthly", + plan_id=101, + chain_id=137, + amount_units=5_000_000, + amount_usdc="5", + token_address="0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + token_decimals=6, + token_symbol="USDC", + receiver_address="0xed2f13aa5ff033c58fb436e178451cd07f693f32", + status="confirmed", + payment_mode="direct", + allowed_wallet=None, + expires_at="2099-01-01T00:00:00+00:00", + tx_hash="0x" + "2" * 64, + metadata={}, + ) + + def _fake_rest(method, table, **kwargs): + calls.append({"method": method, "table": table, **kwargs}) + return [] + + monkeypatch.setattr(service, "_rest", _fake_rest) + + result = service._record_duplicate_transaction( + intent=intent, + tx_hash="0x" + "3" * 64, + from_address="0x2222222222222222222222222222222222222222", + status="refund_required", + ) + + assert result == {} + assert calls[0]["table"] == "payment_transactions" + assert calls[0]["prefer"] == "resolution=merge-duplicates,return=minimal" + + +def test_payment_record_upsert_uses_minimal_return(monkeypatch, tmp_path): + _payment_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + calls = [] + + def _fake_rest(method, table, **kwargs): + calls.append({"method": method, "table": table, **kwargs}) + return [] + + monkeypatch.setattr(service, "_rest", _fake_rest) + + result = service._insert_payment_record( + user_id="user-1", + tx_hash="0x" + "4" * 64, + amount_units=5_000_000, + token_address="0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + chain_id=137, + payload={"kind": "test"}, + ) + + assert calls[0]["table"] == "payments" + assert calls[0]["prefer"] == "resolution=merge-duplicates,return=minimal" + assert result["tx_hash"] == "0x" + "4" * 64 + assert result["status"] == "confirmed" + assert result["amount"] == "5" + + +def test_create_intent_insert_uses_minimal_return_and_local_payload( + monkeypatch, tmp_path +): + _payment_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + calls = [] + + def _fake_rest(method, table, **kwargs): + calls.append({"method": method, "table": table, **kwargs}) + if method == "POST" and table == "payment_intents": + return [] + raise AssertionError((method, table, kwargs)) + + monkeypatch.setattr(service, "_rest", _fake_rest) + + result = service.create_intent( + "00000000-0000-0000-0000-000000000001", + "pro_monthly", + payment_mode="direct", + ) + + intent_write = calls[0] + payload = intent_write["payload"] + assert intent_write["prefer"] == "return=minimal" + assert payload["id"] == result["intent"]["intent_id"] + assert payload["order_id_hex"] == result["intent"]["order_id_hex"] + assert result["intent"]["status"] == "created" + assert result["direct_payment"]["amount_units"] == str(payload["amount_units"]) + + def test_payment_runtime_state_and_audit_event_roundtrip(tmp_path): db_path = tmp_path / "payments.db" db = DBManager(str(db_path)) @@ -246,6 +757,121 @@ def test_reconcile_latest_intent_confirms_submitted_first(monkeypatch, tmp_path) assert result["action"] == "confirmed_submitted_intent" +def test_reconcile_latest_intent_reuses_confirmed_row_after_repair(monkeypatch, tmp_path): + monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true") + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + monkeypatch.setenv("POLYWEATHER_PAYMENT_RPC_URL", "https://rpc-1.example") + monkeypatch.setenv( + "POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON", + '[{"code":"usdc_e","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32","is_default":true}]', + ) + monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "payments.db")) + + service = PaymentContractCheckoutService() + monkeypatch.setattr( + service, + "_rest", + lambda method, table, **kwargs: [ + { + "id": "intent-1", + "plan_code": "pro_monthly", + "plan_id": 101, + "chain_id": 137, + "token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32", + "amount_units": "5000000", + "payment_mode": "strict", + "allowed_wallet": "0x1111111111111111111111111111111111111111", + "order_id_hex": "0x" + "1" * 64, + "status": "confirmed", + "expires_at": "2099-01-01T00:00:00+00:00", + "tx_hash": "0x" + "2" * 64, + "metadata": {}, + } + ], + ) + monkeypatch.setattr( + service, + "get_intent", + lambda user_id, intent_id: (_ for _ in ()).throw( + AssertionError("confirmed repair should reuse loaded row") + ), + ) + monkeypatch.setattr( + service, + "_ensure_confirm_side_effects", + lambda user_id, local_intent, tx_hash: { + "payment": {"tx_hash": tx_hash}, + "subscription": {"plan_code": local_intent.plan_code}, + }, + ) + + result = service.reconcile_latest_intent("user-1") + + assert result["ok"] is True + assert result["action"] == "reconciled_confirmed_intent" + assert result["intent"]["intent_id"] == "intent-1" + + +def test_reconcile_latest_intent_without_candidates_does_not_clear_subscription_cache( + monkeypatch, + tmp_path, +): + monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true") + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + monkeypatch.setenv("POLYWEATHER_PAYMENT_RPC_URL", "https://rpc-1.example") + monkeypatch.setenv( + "POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON", + '[{"code":"usdc_e","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32","is_default":true}]', + ) + monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "payments.db")) + + service = PaymentContractCheckoutService() + monkeypatch.setattr( + service, + "_rest", + lambda method, table, **kwargs: [ + { + "id": "intent-created", + "plan_code": "pro_monthly", + "plan_id": 101, + "chain_id": 137, + "token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32", + "amount_units": "5000000", + "payment_mode": "strict", + "allowed_wallet": "0x1111111111111111111111111111111111111111", + "order_id_hex": "0x" + "1" * 64, + "status": "created", + "expires_at": "2099-01-01T00:00:00+00:00", + "tx_hash": None, + "metadata": {}, + } + ], + ) + + invalidations = [] + monkeypatch.setattr( + "src.payments.contract_checkout.SUPABASE_ENTITLEMENT.invalidate_subscription_cache", + lambda user_id: invalidations.append(user_id), + ) + monkeypatch.setattr( + "src.payments.contract_checkout.SUPABASE_ENTITLEMENT.get_latest_active_subscription", + lambda user_id, respect_requirement=False: { + "plan_code": "pro_monthly", + "expires_at": "2099-01-01T00:00:00+00:00", + }, + ) + + result = service.reconcile_latest_intent("user-1") + + assert result["ok"] is True + assert result["action"] == "checked_without_repair" + assert invalidations == [] + + def test_confirm_intent_tx_repairs_side_effect_failure(monkeypatch, tmp_path): monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true") monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") @@ -386,6 +1012,117 @@ def test_reconcile_recent_intents_dedupes_users(monkeypatch, tmp_path): assert seen == ["user-1", "user-2"] +def test_reconcile_recent_intents_selects_only_user_ids(monkeypatch, tmp_path): + _payment_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + calls = [] + + def _fake_rest(method, table, **kwargs): + calls.append({"method": method, "table": table, **kwargs}) + return [{"user_id": "user-1"}, {"user_id": "user-1"}, {"user_id": "user-2"}] + + seen = [] + monkeypatch.setattr(service, "_rest", _fake_rest) + monkeypatch.setattr( + service, + "reconcile_latest_intent", + lambda user_id: seen.append(user_id) or {"ok": True, "subscription": {"user_id": user_id}}, + ) + + result = service.reconcile_recent_intents(limit=10) + + assert calls[0]["params"]["select"] == "user_id" + assert seen == ["user-1", "user-2"] + assert result["processed_users"] == 2 + + +def test_pending_confirm_intents_selects_only_confirm_loop_fields(monkeypatch, tmp_path): + _payment_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + calls = [] + + def _fake_rest(method, table, **kwargs): + calls.append({"method": method, "table": table, **kwargs}) + return [ + { + "id": "intent-1", + "user_id": "user-1", + "chain_id": 137, + "tx_hash": "0x" + "2" * 64, + } + ] + + monkeypatch.setattr(service, "_rest", _fake_rest) + + result = service.list_pending_confirm_intents(limit=20) + + assert result == [ + { + "intent_id": "intent-1", + "user_id": "user-1", + "chain_id": 137, + "tx_hash": "0x" + "2" * 64, + } + ] + assert calls[0]["params"]["select"] == "id,user_id,tx_hash,chain_id" + + +def test_open_intents_by_order_id_selects_only_event_loop_fields(monkeypatch, tmp_path): + _payment_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + calls = [] + + def _fake_rest(method, table, **kwargs): + calls.append({"method": method, "table": table, **kwargs}) + return [ + { + "id": "intent-1", + "user_id": "user-1", + "status": "submitted", + "tx_hash": "0x" + "2" * 64, + "plan_id": 101, + "token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "amount_units": "5000000", + } + ] + + monkeypatch.setattr(service, "_rest", _fake_rest) + + result = service.list_open_intents_by_order_id("0x" + "1" * 64) + + assert result == [ + { + "intent_id": "intent-1", + "user_id": "user-1", + "status": "submitted", + "tx_hash": "0x" + "2" * 64, + "plan_id": 101, + "token_address": "0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + "amount_units": 5000000, + } + ] + assert ( + calls[0]["params"]["select"] + == "id,user_id,status,tx_hash,plan_id,token_address,amount_units" + ) + + +def test_tx_hash_unused_check_selects_only_intent_id(monkeypatch, tmp_path): + _payment_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + calls = [] + + def _fake_rest(method, table, **kwargs): + calls.append({"method": method, "table": table, **kwargs}) + return [{"intent_id": "intent-1"}] + + monkeypatch.setattr(service, "_rest", _fake_rest) + + service._ensure_tx_hash_unused("0x" + "1" * 64, "intent-1") + + assert calls[0]["params"]["select"] == "intent_id" + + def test_grant_subscription_starts_after_trial_when_only_trial_is_active(monkeypatch, tmp_path): monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true") monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") @@ -403,12 +1140,9 @@ def test_grant_subscription_starts_after_trial_when_only_trial_is_active(monkeyp def _fake_rest(method, table, **kwargs): if method == "GET" and table == "subscriptions": + assert kwargs["params"]["select"] == "starts_at,expires_at" return [ { - "id": 1, - "status": "active", - "plan_code": "signup_trial_3d", - "source": "signup_trial", "starts_at": (datetime.now(timezone.utc) - timedelta(days=1)).isoformat(), "expires_at": trial_end.isoformat(), } diff --git a/tests/test_reconcile_payment_tx_io.py b/tests/test_reconcile_payment_tx_io.py new file mode 100644 index 00000000..71ad7c48 --- /dev/null +++ b/tests/test_reconcile_payment_tx_io.py @@ -0,0 +1,119 @@ +import importlib.util +from pathlib import Path + + +def _load_script(): + root = Path(__file__).resolve().parents[1] + path = root / "scripts" / "reconcile_payment_tx.py" + spec = importlib.util.spec_from_file_location("reconcile_payment_tx", path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +def test_find_intent_by_tx_queries_tx_hash_directly(monkeypatch): + module = _load_script() + tx_hash = "0x" + "a" * 64 + calls = [] + + class FakeCheckout: + def _rest(self, method, table, *, params, allowed_status): + calls.append( + { + "method": method, + "table": table, + "params": params, + "allowed_status": allowed_status, + } + ) + return [{"id": "intent-1", "user_id": "user-1", "tx_hash": tx_hash}] + + monkeypatch.setattr(module, "PAYMENT_CHECKOUT", FakeCheckout()) + + result = module._find_intent_by_tx("user-1", tx_hash.upper()) + + assert result["id"] == "intent-1" + assert calls == [ + { + "method": "GET", + "table": "payment_intents", + "params": { + "select": "id,user_id", + "tx_hash": f"eq.{tx_hash}", + "limit": "1", + }, + "allowed_status": [200], + } + ] + + +def test_find_intent_by_tx_rejects_other_user(monkeypatch): + module = _load_script() + + class FakeCheckout: + def _rest(self, method, table, *, params, allowed_status): + return [{"id": "intent-other", "user_id": "user-2"}] + + monkeypatch.setattr(module, "PAYMENT_CHECKOUT", FakeCheckout()) + + assert module._find_intent_by_tx("user-1", "0x" + "a" * 64) is None + + +def test_find_intent_by_order_id_uses_unique_order_lookup(monkeypatch): + module = _load_script() + calls = [] + + class FakeCheckout: + def _rest(self, method, table, *, params, allowed_status): + calls.append( + { + "method": method, + "table": table, + "params": params, + "allowed_status": allowed_status, + } + ) + return [ + { + "id": "intent-1", + "user_id": "user-1", + "order_id_hex": "0x" + "b" * 64, + } + ] + + monkeypatch.setattr(module, "PAYMENT_CHECKOUT", FakeCheckout()) + + result = module._find_intent_by_order_id("user-1", "0X" + "B" * 64) + + assert result["id"] == "intent-1" + assert calls == [ + { + "method": "GET", + "table": "payment_intents", + "params": { + "select": "id,user_id", + "order_id_hex": "eq.0x" + "b" * 64, + "limit": "1", + }, + "allowed_status": [200], + } + ] + + +def test_find_intent_by_order_id_rejects_other_user(monkeypatch): + module = _load_script() + + class FakeCheckout: + def _rest(self, method, table, *, params, allowed_status): + return [ + { + "id": "intent-other", + "user_id": "user-2", + "order_id_hex": "0x" + "b" * 64, + } + ] + + monkeypatch.setattr(module, "PAYMENT_CHECKOUT", FakeCheckout()) + + assert module._find_intent_by_order_id("user-1", "0x" + "b" * 64) is None diff --git a/tests/test_supabase_email_lookup_scripts.py b/tests/test_supabase_email_lookup_scripts.py new file mode 100644 index 00000000..0266f298 --- /dev/null +++ b/tests/test_supabase_email_lookup_scripts.py @@ -0,0 +1,104 @@ +import importlib.util +from pathlib import Path + + +def _load_script(name: str): + root = Path(__file__).resolve().parents[1] + path = root / "scripts" / f"{name}.py" + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +grant_script = _load_script("grant_subscription_by_email") +reconcile_script = _load_script("reconcile_subscription_by_email") + + +class _Checkout: + supabase_url = "https://example.supabase.co" + supabase_service_role_key = "service-role" + + def __init__(self): + self.rest_calls = [] + self.admin_calls = [] + + def _rest(self, method, table, params=None, **kwargs): + self.rest_calls.append((method, table, params)) + assert table == "profiles" + return [{"id": "user-1", "email": "user@example.com"}] + + def _auth_admin_request(self, *args, **kwargs): + self.admin_calls.append((args, kwargs)) + raise AssertionError("profile-backed lookup should avoid Auth Admin") + + +def test_grant_script_email_lookup_prefers_profiles(monkeypatch): + checkout = _Checkout() + monkeypatch.setattr("src.payments.contract_checkout.PAYMENT_CHECKOUT", checkout) + + assert grant_script._lookup_user_id_by_email("user@example.com") == "user-1" + assert checkout.rest_calls == [ + ( + "GET", + "profiles", + { + "select": "id", + "email": "eq.user@example.com", + "limit": "1", + }, + ) + ] + assert checkout.admin_calls == [] + + +def test_reconcile_script_email_lookup_prefers_profiles(monkeypatch): + checkout = _Checkout() + monkeypatch.setattr("src.payments.contract_checkout.PAYMENT_CHECKOUT", checkout) + + assert reconcile_script._lookup_user_id_by_email("user@example.com") == "user-1" + assert checkout.rest_calls == [ + ( + "GET", + "profiles", + { + "select": "id", + "email": "eq.user@example.com", + "limit": "1", + }, + ) + ] + assert checkout.admin_calls == [] + + +def test_grant_script_entitlement_event_uses_minimal_return(): + root = Path(__file__).resolve().parents[1] + source = (root / "scripts" / "grant_subscription_by_email.py").read_text( + encoding="utf-8" + ) + + entitlement_event_pos = source.index('"entitlement_events"') + minimal_pos = source.index('prefer="return=minimal"', entitlement_event_pos) + assert minimal_pos > entitlement_event_pos + + +def test_grant_script_subscription_writes_use_minimal_return(): + root = Path(__file__).resolve().parents[1] + source = (root / "scripts" / "grant_subscription_by_email.py").read_text( + encoding="utf-8" + ) + + subscription_write_count = source.count('prefer="return=minimal"') + assert subscription_write_count >= 3 + assert 'prefer="return=representation"' not in source + + +def test_grant_script_subscription_lookup_selects_only_grant_fields(): + root = Path(__file__).resolve().parents[1] + source = (root / "scripts" / "grant_subscription_by_email.py").read_text( + encoding="utf-8" + ) + + assert '"select": "id,plan_code,source,starts_at,expires_at"' in source + assert '"select": "id,expires_at,status,plan_code,starts_at,source,created_at"' not in source diff --git a/tests/test_supabase_entitlement.py b/tests/test_supabase_entitlement.py index ed84f44b..a7981b5b 100644 --- a/tests/test_supabase_entitlement.py +++ b/tests/test_supabase_entitlement.py @@ -30,17 +30,11 @@ def test_latest_active_subscription_ignores_future_start(monkeypatch): "starts_at": (now - timedelta(days=1)).isoformat(), "expires_at": (now + timedelta(days=2)).isoformat(), } - future_paid = { - "id": 2, - "user_id": "user-1", - "status": "active", - "plan_code": "pro_monthly", - "starts_at": (now + timedelta(days=2)).isoformat(), - "expires_at": (now + timedelta(days=32)).isoformat(), - } - def _fake_get(url, headers=None, params=None, timeout=None): - return _Response(200, [future_paid, current_trial]) + assert params["select"] == "plan_code,source,starts_at,expires_at" + assert str(params["starts_at"]).startswith("lte.") + assert params["limit"] == "1" + return _Response(200, [current_trial]) monkeypatch.setattr(entitlement_module.requests, "get", _fake_get) @@ -50,6 +44,44 @@ def test_latest_active_subscription_ignores_future_start(monkeypatch): assert result["plan_code"] == "signup_trial_3d" +def test_get_identity_caches_invalid_token_result(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + + service = SupabaseEntitlementService() + calls = {"count": 0} + + def _fake_get(url, headers=None, params=None, timeout=None): + calls["count"] += 1 + return _Response(401, {"message": "invalid token"}) + + monkeypatch.setattr(entitlement_module.requests, "get", _fake_get) + + assert service.get_identity("bad-token") is None + assert service.get_identity("bad-token") is None + assert calls["count"] == 1 + + +def test_get_identity_does_not_cache_transient_auth_errors(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + + service = SupabaseEntitlementService() + calls = {"count": 0} + + def _fake_get(url, headers=None, params=None, timeout=None): + calls["count"] += 1 + return _Response(503, {"message": "temporarily unavailable"}) + + monkeypatch.setattr(entitlement_module.requests, "get", _fake_get) + + assert service.get_identity("temporarily-bad-token") is None + assert service.get_identity("temporarily-bad-token") is None + assert calls["count"] == 2 + + def test_subscription_window_keeps_queued_renewal_after_current_cache(monkeypatch): monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") @@ -75,7 +107,16 @@ def test_subscription_window_keeps_queued_renewal_after_current_cache(monkeypatc "expires_at": (now + timedelta(days=31)).isoformat(), } + calls = [] + def _fake_get(url, headers=None, params=None, timeout=None): + calls.append(params) + assert params["select"] == "plan_code,source,starts_at,expires_at" + if params["limit"] == "1": + assert str(params["starts_at"]).startswith("lte.") + return _Response(200, [current]) + assert params["limit"] == "100" + assert "starts_at" not in params return _Response(200, [queued, current]) monkeypatch.setattr(entitlement_module.requests, "get", _fake_get) @@ -88,3 +129,412 @@ def test_subscription_window_keeps_queued_renewal_after_current_cache(monkeypatc assert window["total_expires_at"] == queued["expires_at"] assert window["queued_days"] == 30 assert window["queued_count"] == 1 + assert len(calls) == 2 + + +def test_subscription_window_query_selects_only_window_fields(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + + service = SupabaseEntitlementService() + + def _fake_get(url, headers=None, params=None, timeout=None): + assert params["select"] == "plan_code,source,starts_at,expires_at" + return _Response( + 200, + [ + { + "plan_code": "pro_monthly", + "source": "payment_contract", + "starts_at": "2026-03-01T00:00:00+00:00", + "expires_at": "2099-04-01T00:00:00+00:00", + } + ], + ) + + monkeypatch.setattr(entitlement_module.requests, "get", _fake_get) + + window = service.get_subscription_window( + "user-1", + respect_requirement=False, + bypass_cache=True, + ) + + assert window["current"]["plan_code"] == "pro_monthly" + + +def test_list_subscription_windows_selects_only_batch_window_fields(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + + service = SupabaseEntitlementService() + + def _fake_get(url, headers=None, params=None, timeout=None): + assert params["select"] == "user_id,plan_code,source,starts_at,expires_at" + assert params["user_id"] == "in.(user-1,user-2)" + return _Response( + 200, + [ + { + "user_id": "user-1", + "plan_code": "pro_monthly", + "source": "payment_contract", + "starts_at": "2026-03-01T00:00:00+00:00", + "expires_at": "2099-04-01T00:00:00+00:00", + }, + { + "user_id": "user-2", + "plan_code": "pro_monthly", + "source": "payment_contract", + "starts_at": "2026-03-02T00:00:00+00:00", + "expires_at": "2099-04-02T00:00:00+00:00", + }, + ], + ) + + monkeypatch.setattr(entitlement_module.requests, "get", _fake_get) + + windows = service.list_subscription_windows( + ["user-1", "user-2"], + bypass_cache=True, + ) + + assert set(windows) == {"user-1", "user-2"} + + +def test_list_active_subscription_windows_uses_single_window_query(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + + service = SupabaseEntitlementService() + calls = [] + now = datetime.now(timezone.utc) + current = { + "user_id": "user-1", + "plan_code": "pro_monthly", + "source": "payment_contract", + "starts_at": (now - timedelta(days=1)).isoformat(), + "expires_at": (now + timedelta(days=10)).isoformat(), + } + queued = { + "user_id": "user-1", + "plan_code": "pro_monthly", + "source": "payment_contract", + "starts_at": (now + timedelta(days=10)).isoformat(), + "expires_at": (now + timedelta(days=40)).isoformat(), + } + + def _fake_get(url, headers=None, params=None, timeout=None): + calls.append(params) + assert params["select"] == "user_id,plan_code,source,starts_at,expires_at" + assert params["status"] == "eq.active" + assert params["order"] == "user_id.asc,expires_at.desc" + return _Response(200, [queued, current]) + + monkeypatch.setattr(entitlement_module.requests, "get", _fake_get) + + result = service.list_active_subscription_windows(limit=200) + + assert result["subscriptions"] == [current] + assert result["windows"]["user-1"]["queued_count"] == 1 + assert calls and len(calls) == 1 + + +def test_latest_subscription_any_status_uses_cache(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + + service = SupabaseEntitlementService() + calls = {"count": 0} + latest = { + "id": 3, + "user_id": "user-1", + "status": "expired", + "plan_code": "pro_monthly", + "starts_at": "2026-03-01T00:00:00+00:00", + "expires_at": "2026-04-01T00:00:00+00:00", + "created_at": "2026-03-01T00:00:00+00:00", + "updated_at": "2026-04-01T00:00:00+00:00", + } + + def _fake_get(url, headers=None, params=None, timeout=None): + calls["count"] += 1 + assert params["user_id"] == "eq.user-1" + assert params["order"] == "created_at.desc" + assert params["select"] == "plan_code,starts_at,expires_at" + return _Response(200, [latest]) + + monkeypatch.setattr(entitlement_module.requests, "get", _fake_get) + + assert service.get_latest_subscription_any_status("user-1") == latest + assert service.get_latest_subscription_any_status("user-1") == latest + assert calls["count"] == 1 + + +def test_get_auth_users_batches_profiles_before_admin_fallback(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + + service = SupabaseEntitlementService() + calls = [] + + def _fake_get(url, headers=None, params=None, timeout=None): + calls.append((url, params)) + if url.endswith("/rest/v1/profiles"): + assert params["id"] == "in.(user-1,user-2)" + return _Response( + 200, + [ + { + "id": "user-1", + "email": "one@example.com", + "created_at": "2026-03-01T00:00:00+00:00", + }, + { + "id": "user-2", + "email": "two@example.com", + "created_at": "2026-03-02T00:00:00+00:00", + }, + ], + ) + raise AssertionError(f"unexpected admin fallback call: {url}") + + monkeypatch.setattr(entitlement_module.requests, "get", _fake_get) + + result = service.get_auth_users(["user-1", "user-2"]) + + assert result == { + "user-1": { + "email": "one@example.com", + "created_at": "2026-03-01T00:00:00+00:00", + }, + "user-2": { + "email": "two@example.com", + "created_at": "2026-03-02T00:00:00+00:00", + }, + } + assert len(calls) == 1 + + +def test_get_auth_users_uses_short_cache_for_profile_results(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + + service = SupabaseEntitlementService() + calls = {"count": 0} + + def _fake_get(url, headers=None, params=None, timeout=None): + calls["count"] += 1 + assert url.endswith("/rest/v1/profiles") + return _Response( + 200, + [ + { + "id": "user-1", + "email": "one@example.com", + "created_at": "2026-03-01T00:00:00+00:00", + }, + ], + ) + + monkeypatch.setattr(entitlement_module.requests, "get", _fake_get) + + assert service.get_auth_users(["user-1"]) == { + "user-1": { + "email": "one@example.com", + "created_at": "2026-03-01T00:00:00+00:00", + }, + } + assert service.get_auth_users(["user-1"]) == { + "user-1": { + "email": "one@example.com", + "created_at": "2026-03-01T00:00:00+00:00", + }, + } + assert calls["count"] == 1 + + +def test_list_active_subscriptions_uses_cache_and_invalidation(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + + service = SupabaseEntitlementService() + calls = {"count": 0} + row = { + "id": 1, + "user_id": "user-1", + "status": "active", + "plan_code": "pro_monthly", + "starts_at": "2026-03-01T00:00:00+00:00", + "expires_at": "2099-04-01T00:00:00+00:00", + } + + def _fake_get(url, headers=None, params=None, timeout=None): + calls["count"] += 1 + assert params["status"] == "eq.active" + assert params["select"] == "user_id,plan_code,starts_at,expires_at" + return _Response(200, [row]) + + monkeypatch.setattr(entitlement_module.requests, "get", _fake_get) + + assert service.list_active_subscriptions(limit=200) == [row] + assert service.list_active_subscriptions(limit=200) == [row] + assert calls["count"] == 1 + + service.invalidate_subscription_cache("user-1") + + assert service.list_active_subscriptions(limit=200) == [row] + assert calls["count"] == 2 + + +def test_has_active_subscription_uses_lightweight_query_without_polluting_detail_cache( + monkeypatch, +): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + + service = SupabaseEntitlementService() + calls = [] + + def _fake_get(url, headers=None, params=None, timeout=None): + calls.append(params["select"]) + if params["select"] == "expires_at": + assert str(params["starts_at"]).startswith("lte.") + assert params["limit"] == "1" + return _Response( + 200, + [ + { + "expires_at": "2099-04-01T00:00:00+00:00", + } + ], + ) + if params["select"] == "plan_code,source,starts_at,expires_at": + return _Response( + 200, + [ + { + "plan_code": "pro_monthly", + "source": "payment_contract", + "starts_at": "2026-03-01T00:00:00+00:00", + "expires_at": "2099-04-01T00:00:00+00:00", + } + ], + ) + raise AssertionError(params["select"]) + + monkeypatch.setattr(entitlement_module.requests, "get", _fake_get) + + assert service.has_active_subscription("user-1", respect_requirement=False) is True + assert service.has_active_subscription("user-1", respect_requirement=False) is True + assert service.get_latest_active_subscription( + "user-1", + respect_requirement=False, + )["plan_code"] == "pro_monthly" + + assert calls == [ + "expires_at", + "plan_code,source,starts_at,expires_at", + ] + + +def test_has_active_subscription_lightweight_cache_invalidates(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + + service = SupabaseEntitlementService() + calls = {"count": 0} + + def _fake_get(url, headers=None, params=None, timeout=None): + calls["count"] += 1 + assert params["select"] == "expires_at" + assert str(params["starts_at"]).startswith("lte.") + assert params["limit"] == "1" + return _Response( + 200, + [ + { + "expires_at": "2099-04-01T00:00:00+00:00", + } + ], + ) + + monkeypatch.setattr(entitlement_module.requests, "get", _fake_get) + + assert service.has_active_subscription("user-1", respect_requirement=False) is True + assert service.has_active_subscription("user-1", respect_requirement=False) is True + assert calls["count"] == 1 + + service.invalidate_subscription_cache("user-1") + + assert service.has_active_subscription("user-1", respect_requirement=False) is True + assert calls["count"] == 2 + + +def test_has_active_subscription_reuses_detailed_subscription_cache(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + + service = SupabaseEntitlementService() + calls = [] + + def _fake_get(url, headers=None, params=None, timeout=None): + calls.append(params["select"]) + assert params["select"] == "plan_code,source,starts_at,expires_at" + return _Response( + 200, + [ + { + "plan_code": "pro_monthly", + "source": "payment_contract", + "starts_at": "2026-03-01T00:00:00+00:00", + "expires_at": "2099-04-01T00:00:00+00:00", + } + ], + ) + + monkeypatch.setattr(entitlement_module.requests, "get", _fake_get) + + assert service.get_latest_active_subscription( + "user-1", + respect_requirement=False, + )["plan_code"] == "pro_monthly" + assert service.has_active_subscription("user-1", respect_requirement=False) is True + + assert calls == ["plan_code,source,starts_at,expires_at"] + + +def test_latest_active_subscription_reuses_negative_lightweight_cache(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + + service = SupabaseEntitlementService() + calls = [] + + def _fake_get(url, headers=None, params=None, timeout=None): + calls.append(params["select"]) + assert params["select"] == "expires_at" + assert str(params["starts_at"]).startswith("lte.") + assert params["limit"] == "1" + return _Response(200, []) + + monkeypatch.setattr(entitlement_module.requests, "get", _fake_get) + + assert service.has_active_subscription("user-1", respect_requirement=False) is False + assert service.get_latest_active_subscription( + "user-1", + respect_requirement=False, + ) is None + + assert calls == ["expires_at"] diff --git a/tests/test_supabase_points_sync.py b/tests/test_supabase_points_sync.py new file mode 100644 index 00000000..ce67c995 --- /dev/null +++ b/tests/test_supabase_points_sync.py @@ -0,0 +1,93 @@ +from types import SimpleNamespace + +import src.database.db_manager as db_manager_module +from src.database.db_manager import DBManager + + +def _bound_db(tmp_path, monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + db = DBManager(str(tmp_path / "points-sync.db")) + db.upsert_user(1001, "eraer") + with db._get_connection() as conn: # noqa: SLF001 + conn.execute( + """ + UPDATE users + SET supabase_user_id = ?, supabase_email = ? + WHERE telegram_id = ? + """, + ("supabase-user-1", "eraer@example.com", 1001), + ) + conn.execute( + """ + INSERT INTO supabase_bindings (supabase_user_id, telegram_id, supabase_email) + VALUES (?, ?, ?) + """, + ("supabase-user-1", 1001, "eraer@example.com"), + ) + conn.commit() + return db + + +def test_message_points_sync_to_supabase_metadata_is_throttled(tmp_path, monkeypatch): + db = _bound_db(tmp_path, monkeypatch) + calls = [] + now = {"value": 1000.0} + + monkeypatch.setattr( + db_manager_module, + "time", + SimpleNamespace(monotonic=lambda: now["value"]), + raising=False, + ) + monkeypatch.setattr( + db_manager_module.requests, + "patch", + lambda *args, **kwargs: calls.append((args, kwargs)) + or SimpleNamespace(status_code=204, text="", content=b""), + ) + + first = db.add_message_activity( + 1001, + "第一条有效发言", + cooldown_sec=0, + daily_cap=1000, + ) + now["value"] += 10.0 + second = db.add_message_activity( + 1001, + "第二条有效发言", + cooldown_sec=0, + daily_cap=1000, + ) + + assert first["awarded"] is True + assert second["awarded"] is True + assert len(calls) == 1 + + +def test_manual_point_grant_forces_supabase_metadata_sync(tmp_path, monkeypatch): + db = _bound_db(tmp_path, monkeypatch) + calls = [] + now = {"value": 1000.0} + + monkeypatch.setattr( + db_manager_module, + "time", + SimpleNamespace(monotonic=lambda: now["value"]), + raising=False, + ) + monkeypatch.setattr( + db_manager_module.requests, + "patch", + lambda *args, **kwargs: calls.append((args, kwargs)) + or SimpleNamespace(status_code=204, text="", content=b""), + ) + + db.add_message_activity(1001, "第一条有效发言", cooldown_sec=0, daily_cap=1000) + now["value"] += 10.0 + result = db.grant_points_by_supabase_email("eraer@example.com", 300) + + assert result["ok"] is True + assert len(calls) == 2 + assert calls[-1][1]["json"]["user_metadata"]["points"] == result["points_after"] diff --git a/tests/test_supabase_profile_sync.py b/tests/test_supabase_profile_sync.py new file mode 100644 index 00000000..e88c5ac2 --- /dev/null +++ b/tests/test_supabase_profile_sync.py @@ -0,0 +1,83 @@ +from types import SimpleNamespace + +import src.database.db_manager as db_manager_module +from src.database.db_manager import DBManager + + +def _bound_db(tmp_path, monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + monkeypatch.setenv("POLYWEATHER_SUPABASE_PROFILE_SYNC_MIN_INTERVAL_SEC", "3600") + DBManager._profile_sync_cache.clear() # noqa: SLF001 + db = DBManager(str(tmp_path / "profile-sync.db")) + db.upsert_user(1001, "eraer") + with db._get_connection() as conn: # noqa: SLF001 + conn.execute( + """ + UPDATE users + SET supabase_user_id = ?, supabase_email = ? + WHERE telegram_id = ? + """, + ("supabase-user-1", "eraer@example.com", 1001), + ) + conn.execute( + """ + INSERT INTO supabase_bindings (supabase_user_id, telegram_id, supabase_email) + VALUES (?, ?, ?) + """, + ("supabase-user-1", 1001, "eraer@example.com"), + ) + conn.commit() + return db + + +def test_repeated_user_upsert_coalesces_supabase_profile_sync(tmp_path, monkeypatch): + db = _bound_db(tmp_path, monkeypatch) + calls = [] + now = {"value": 1000.0} + + monkeypatch.setattr( + db_manager_module, + "time", + SimpleNamespace(monotonic=lambda: now["value"]), + raising=False, + ) + monkeypatch.setattr( + db_manager_module.requests, + "patch", + lambda *args, **kwargs: calls.append((args, kwargs)) + or SimpleNamespace(status_code=204, text="", content=b""), + ) + + db.upsert_user(1001, "eraer") + now["value"] += 10.0 + db.upsert_user(1001, "eraer") + + assert len(calls) == 1 + assert calls[0][1]["headers"]["Prefer"] == "return=minimal" + + +def test_changed_username_bypasses_supabase_profile_sync_coalescing(tmp_path, monkeypatch): + db = _bound_db(tmp_path, monkeypatch) + calls = [] + now = {"value": 1000.0} + + monkeypatch.setattr( + db_manager_module, + "time", + SimpleNamespace(monotonic=lambda: now["value"]), + raising=False, + ) + monkeypatch.setattr( + db_manager_module.requests, + "patch", + lambda *args, **kwargs: calls.append((args, kwargs)) + or SimpleNamespace(status_code=204, text="", content=b""), + ) + + db.upsert_user(1001, "eraer") + now["value"] += 10.0 + db.upsert_user(1001, "new-name") + + assert len(calls) == 2 + assert calls[-1][1]["json"]["telegram_username"] == "new-name" diff --git a/tests/test_supabase_schema_io.py b/tests/test_supabase_schema_io.py new file mode 100644 index 00000000..513e1a08 --- /dev/null +++ b/tests/test_supabase_schema_io.py @@ -0,0 +1,137 @@ +from pathlib import Path + + +def _schema_sql() -> str: + root = Path(__file__).resolve().parents[1] + return (root / "scripts" / "supabase" / "schema.sql").read_text(encoding="utf-8").lower() + + +def test_supabase_schema_has_io_friendly_indexes_for_hot_ops_queries(): + schema = _schema_sql() + + assert ( + "create index if not exists idx_profiles_email\n" + " on public.profiles(email)\n" + " include (id)" + ) in schema + assert ( + "create index if not exists idx_profiles_id_lookup\n" + " on public.profiles(id)\n" + " include (email, created_at)" + ) in schema + assert "idx_subscriptions_status_expiry" in schema + assert "on public.subscriptions(expires_at asc)" in schema + assert "idx_subscriptions_user_status_expiry" in schema + assert "on public.subscriptions(user_id, expires_at desc)" in schema + assert "include (id, starts_at, plan_code, source)" in schema + assert schema.count("where status = 'active'") >= 2 + assert "idx_subscriptions_user_created" in schema + assert "on public.subscriptions(user_id, created_at desc)" in schema + assert "include (id, status, plan_code, source, starts_at, expires_at, updated_at)" in schema + assert "idx_payment_intents_status_updated" in schema + assert "on public.payment_intents(status, updated_at desc)" in schema + assert "include (user_id)" in schema + assert "where status in ('submitted', 'confirmed')" in schema + assert "idx_payment_intents_user_status_updated" in schema + assert "on public.payment_intents(user_id, status, updated_at desc)" in schema + assert "idx_payment_intents_user_status\n" not in schema + assert "idx_payment_intents_submitted_tx_updated" in schema + assert "include (id, user_id, tx_hash, chain_id)" in schema + assert "where status = 'submitted' and tx_hash is not null" in schema + assert "idx_payment_intents_user_created" in schema + assert "on public.payment_intents(user_id, created_at desc)" in schema + assert "idx_payment_intents_tx_hash" in schema + assert "on public.payment_intents(tx_hash)" in schema + assert "include (id, user_id)" in schema + assert "where tx_hash is not null" in schema + assert "idx_payments_created_at" in schema + assert "on public.payments(created_at desc)" in schema + assert "include (id, user_id, amount, currency, chain, tx_hash, status)" in schema + assert "idx_user_wallets_user_chain" in schema + assert "on public.user_wallets(user_id, chain_id, is_primary desc, verified_at desc)" in schema + assert "include (id, address)" in schema + assert ( + "create index if not exists idx_user_wallets_chain_address_owner\n" + " on public.user_wallets(chain_id, address)\n" + " include (user_id, status)" + ) in schema + assert schema.count("where status = 'active'") >= 3 + assert "idx_wallet_link_challenges_lookup" not in schema + assert ( + "create index if not exists idx_payment_transactions_tx_hash_intent\n" + " on public.payment_transactions(tx_hash)\n" + " include (intent_id)" + ) in schema + + +def test_supabase_io_budget_scripts_are_production_runnable(): + root = Path(__file__).resolve().parents[1] + indexes = (root / "scripts" / "supabase" / "io_budget_indexes.sql").read_text(encoding="utf-8").lower() + diagnostics = (root / "scripts" / "supabase" / "disk_io_diagnostics.sql").read_text(encoding="utf-8").lower() + + assert "drop index if exists public.idx_profiles_email" in indexes + assert ( + "create index if not exists idx_profiles_email\n" + " on public.profiles(email)\n" + " include (id)" + ) in indexes + assert "drop index if exists public.idx_profiles_id_lookup" in indexes + assert ( + "create index if not exists idx_profiles_id_lookup\n" + " on public.profiles(id)\n" + " include (email, created_at)" + ) in indexes + assert "idx_subscriptions_user_created" in indexes + assert "drop index if exists public.idx_subscriptions_user_status_expiry" in indexes + assert "drop index if exists public.idx_subscriptions_status_expiry" in indexes + assert "on public.subscriptions(user_id, expires_at desc)" in indexes + assert "on public.subscriptions(expires_at asc)" in indexes + assert "include (id, starts_at, plan_code, source)" in indexes + assert "include (user_id, starts_at, plan_code)" in indexes + assert "include (id, status, plan_code, source, starts_at, expires_at, updated_at)" in indexes + assert "drop index if exists public.idx_payment_intents_user_status" in indexes + assert "drop index if exists public.idx_payment_intents_status_updated" in indexes + assert "include (user_id)" in indexes + assert "where status in ('submitted', 'confirmed')" in indexes + assert "drop index if exists public.idx_payment_intents_submitted_tx_updated" in indexes + assert "include (id, user_id, tx_hash, chain_id)" in indexes + assert "drop index if exists public.idx_payment_intents_tx_hash" in indexes + assert "include (id, user_id)" in indexes + assert "where tx_hash is not null" in indexes + assert "drop index if exists public.idx_payments_created_at" in indexes + assert "include (id, user_id, amount, currency, chain, tx_hash, status)" in indexes + assert "drop index if exists public.idx_user_wallets_user_chain" in indexes + assert "on public.user_wallets(user_id, chain_id, is_primary desc, verified_at desc)" in indexes + assert "include (id, address)" in indexes + assert "drop index if exists public.idx_user_wallets_chain_address_owner" in indexes + assert ( + "create index if not exists idx_user_wallets_chain_address_owner\n" + " on public.user_wallets(chain_id, address)\n" + " include (user_id, status)" + ) in indexes + assert "drop index if exists public.idx_wallet_link_challenges_lookup" in indexes + assert "create index if not exists idx_wallet_link_challenges_lookup" not in indexes + assert "drop index if exists public.idx_payment_transactions_tx_hash_intent" in indexes + assert ( + "create index if not exists idx_payment_transactions_tx_hash_intent\n" + " on public.payment_transactions(tx_hash)\n" + " include (intent_id)" + ) in indexes + assert "analyze public.payment_intents" in indexes + assert "pg_stat_user_tables" in diagnostics + assert "pg_stat_statements" in diagnostics + assert "shared_blks_read" in diagnostics + assert "pg_stat_user_indexes" in diagnostics + assert "pg_statio_user_indexes" in diagnostics + assert "indexrelname" in diagnostics + assert "idx_blks_read" in diagnostics + assert "idx_scan = 0" in diagnostics + + +def test_supabase_setup_doc_includes_io_budget_runbook(): + root = Path(__file__).resolve().parents[1] + doc = (root / "docs" / "SUPABASE_SETUP_ZH.md").read_text(encoding="utf-8") + + assert "scripts/supabase/io_budget_indexes.sql" in doc + assert "scripts/supabase/disk_io_diagnostics.sql" in doc + assert "Disk IO" in doc diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 0852cdd5..9b38f6e7 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -1,10 +1,14 @@ +from datetime import datetime + from fastapi.testclient import TestClient from starlette.requests import Request import web.core as web_core +import web.services.auth_api as auth_api from web.app import app import web.routes as routes +import web.services.ops_api as ops_api import web.scan_terminal_cache as scan_terminal_cache import web.scan_terminal_service as scan_terminal_service from web.scan_terminal_cache import scan_terminal_cache_key @@ -94,6 +98,138 @@ def test_payment_runtime_endpoint_returns_shape(): assert 'recent_audit_events' in payload +def test_payment_config_does_not_require_entitlement(monkeypatch): + monkeypatch.setattr( + routes, + "_assert_entitlement", + lambda request: (_ for _ in ()).throw( + AssertionError("public payment config should not validate Supabase auth"), + ), + ) + monkeypatch.setattr( + routes.PAYMENT_CHECKOUT, + "get_config_payload", + lambda: {"enabled": True, "plans": []}, + ) + + response = client.get("/api/payments/config") + + assert response.status_code == 200 + assert response.json()["enabled"] is True + + +def test_payment_wallets_requires_identity_without_subscription_gate(monkeypatch): + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True) + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", True) + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co") + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key") + monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", True) + + class _Identity: + user_id = "user-1" + email = "user@example.com" + points = 0 + created_at = "2026-05-01T00:00:00+00:00" + + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "get_identity", lambda token: _Identity()) + monkeypatch.setattr( + web_core.SUPABASE_ENTITLEMENT, + "has_active_subscription", + lambda user_id: (_ for _ in ()).throw( + AssertionError("payment identity endpoints should not query subscription gate"), + ), + ) + monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "list_wallets", lambda user_id: []) + monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "chain_id", 137) + + response = client.get( + "/api/payments/wallets", + headers={"Authorization": "Bearer access-token"}, + ) + + assert response.status_code == 200 + assert response.json() == {"wallets": [], "chain_id": 137} + + +def test_telegram_identity_endpoints_skip_subscription_gate(monkeypatch): + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True) + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", True) + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co") + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key") + monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", True) + + class _Identity: + user_id = "user-1" + email = "user@example.com" + points = 0 + created_at = "2026-05-01T00:00:00+00:00" + + class _FakePricing: + configured = True + + @staticmethod + def verify_login_payload(payload): + return {"telegram_id": int(payload["id"]), "username": "tester"} + + @staticmethod + def get_member_status(telegram_id): + return "member" + + @staticmethod + def resolve_price_for_telegram_id(telegram_id): + return {"telegram_id": telegram_id, "pricing_source": "telegram_group_member"} + + class _FakeDB: + @staticmethod + def upsert_user(telegram_id, username): + return None + + @staticmethod + def bind_supabase_identity(*, telegram_id, supabase_user_id, supabase_email): + return {"ok": True} + + @staticmethod + def consume_bind_token(token): + return 12345 + + @staticmethod + def create_web_bind_token(*, supabase_user_id, supabase_email, ttl_minutes): + return "bind-token" + + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "get_identity", lambda token: _Identity()) + monkeypatch.setattr( + web_core.SUPABASE_ENTITLEMENT, + "has_active_subscription", + lambda user_id: (_ for _ in ()).throw( + AssertionError("telegram identity endpoints should not query subscription gate"), + ), + ) + monkeypatch.setattr(auth_api, "TelegramGroupPricing", lambda: _FakePricing()) + monkeypatch.setattr(auth_api, "DBManager", lambda: _FakeDB()) + + auth_headers = {"Authorization": "Bearer access-token"} + + login_response = client.post( + "/api/auth/telegram/login", + headers=auth_headers, + json={"id": 12345, "username": "tester", "auth_date": 1770000000, "hash": "x" * 64}, + ) + assert login_response.status_code == 200 + + token_response = client.post( + "/api/auth/telegram/bind-by-token", + headers=auth_headers, + json={"token": "token-12345"}, + ) + assert token_response.status_code == 200 + + link_response = client.post( + "/api/auth/telegram/bot-bind-link", + headers=auth_headers, + ) + assert link_response.status_code == 200 + + def test_auth_me_does_not_reconcile_on_status_probe(monkeypatch): monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None) @@ -106,21 +242,21 @@ def test_auth_me_does_not_reconcile_on_status_probe(monkeypatch): monkeypatch.setattr(routes, "_resolve_weekly_profile", lambda request: {"weekly_points": 0, "weekly_rank": None}) monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "enabled", True) - calls = {"count": 0} reconcile_calls = {"count": 0} - def _latest_subscription(user_id, respect_requirement=False): - calls["count"] += 1 + def _subscription_window(user_id, respect_requirement=False): return { - "plan_code": "pro_monthly", - "starts_at": "2026-03-22T00:00:00+00:00", - "expires_at": "2026-04-21T00:00:00+00:00", + "current": { + "plan_code": "pro_monthly", + "starts_at": "2026-03-22T00:00:00+00:00", + "expires_at": "2026-04-21T00:00:00+00:00", + }, } monkeypatch.setattr( routes.SUPABASE_ENTITLEMENT, - "get_latest_active_subscription", - _latest_subscription, + "get_subscription_window", + _subscription_window, ) monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "enabled", True) @@ -143,6 +279,265 @@ def test_auth_me_does_not_reconcile_on_status_probe(monkeypatch): assert reconcile_calls["count"] == 0 +def test_auth_me_reuses_identity_bound_by_entitlement(monkeypatch): + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True) + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", True) + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co") + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key") + monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", True) + monkeypatch.setattr(routes, "_resolve_weekly_profile", lambda request: {"weekly_points": 0, "weekly_rank": None}) + + class _Identity: + user_id = "user-1" + email = "user@example.com" + points = 7 + created_at = "2026-05-01T00:00:00+00:00" + + calls = {"identity": 0} + + def _get_identity(token): + calls["identity"] += 1 + assert token == "access-token" + return _Identity() + + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "get_identity", _get_identity) + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "has_active_subscription", lambda user_id: True) + monkeypatch.setattr( + web_core.SUPABASE_ENTITLEMENT, + "get_subscription_window", + lambda user_id, respect_requirement=False: { + "current": { + "plan_code": "pro_monthly", + "starts_at": "2026-03-22T00:00:00+00:00", + "expires_at": "2026-04-21T00:00:00+00:00", + }, + }, + ) + + response = client.get( + "/api/auth/me", + headers={"Authorization": "Bearer access-token"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["authenticated"] is True + assert payload["points"] == 7 + assert calls["identity"] == 1 + + +def test_auth_me_uses_subscription_window_as_required_subscription_gate(monkeypatch): + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True) + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", True) + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co") + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key") + monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", True) + monkeypatch.setattr(routes, "_resolve_weekly_profile", lambda request: {"weekly_points": 0, "weekly_rank": None}) + + class _Identity: + user_id = "user-1" + email = "user@example.com" + points = 7 + created_at = "2026-05-01T00:00:00+00:00" + + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "get_identity", lambda token: _Identity()) + monkeypatch.setattr( + web_core.SUPABASE_ENTITLEMENT, + "has_active_subscription", + lambda user_id: (_ for _ in ()).throw( + AssertionError("auth/me should not run a separate lightweight subscription gate"), + ), + ) + monkeypatch.setattr( + web_core.SUPABASE_ENTITLEMENT, + "get_latest_active_subscription", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("auth/me should derive current subscription from the window query"), + ), + ) + monkeypatch.setattr( + web_core.SUPABASE_ENTITLEMENT, + "get_subscription_window", + lambda user_id, respect_requirement=False: { + "current": { + "plan_code": "pro_monthly", + "starts_at": "2026-03-22T00:00:00+00:00", + "expires_at": "2026-04-21T00:00:00+00:00", + }, + "total_expires_at": "2026-05-21T00:00:00+00:00", + "queued_days": 30, + "queued_count": 1, + }, + ) + + response = client.get( + "/api/auth/me", + headers={"Authorization": "Bearer access-token"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["subscription_active"] is True + assert payload["subscription_plan_code"] == "pro_monthly" + assert payload["subscription_queued_days"] == 30 + + +def test_auth_me_uses_window_rows_for_non_required_latest_known_subscription(monkeypatch): + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True) + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", False) + monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", False) + monkeypatch.setattr(routes, "_resolve_weekly_profile", lambda request: {"weekly_points": 0, "weekly_rank": None}) + monkeypatch.setattr(routes, "_resolve_auth_points", lambda request: 0) + + def _bind_identity(request): + request.state.auth_user_id = "user-1" + request.state.auth_email = "user@example.com" + + monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None) + monkeypatch.setattr(routes, "_bind_optional_supabase_identity", _bind_identity) + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "get_subscription_window", + lambda user_id, respect_requirement=False: { + "current": None, + "rows": [ + { + "plan_code": "pro_monthly", + "starts_at": "2026-06-01T00:00:00+00:00", + "expires_at": "2026-07-01T00:00:00+00:00", + } + ], + "total_expires_at": "2026-07-01T00:00:00+00:00", + "queued_days": 0, + "queued_count": 0, + }, + ) + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "get_latest_active_subscription", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("auth/me should reuse window rows before latest active fallback"), + ), + ) + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "get_latest_subscription_any_status", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("auth/me should reuse window rows before historical fallback"), + ), + ) + + response = client.get("/api/auth/me") + + assert response.status_code == 200 + payload = response.json() + assert payload["subscription_active"] is False + assert payload["subscription_plan_code"] == "pro_monthly" + assert payload["subscription_expires_at"] == "2026-07-01T00:00:00+00:00" + + +def test_auth_me_skips_latest_active_after_empty_non_required_window(monkeypatch): + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True) + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", False) + monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", False) + monkeypatch.setattr(routes, "_resolve_weekly_profile", lambda request: {"weekly_points": 0, "weekly_rank": None}) + monkeypatch.setattr(routes, "_resolve_auth_points", lambda request: 0) + + def _bind_identity(request): + request.state.auth_user_id = "user-1" + request.state.auth_email = "user@example.com" + + latest_any_calls = {"count": 0} + + def _latest_any_status(user_id): + latest_any_calls["count"] += 1 + return { + "plan_code": "expired_pro", + "starts_at": "2026-03-01T00:00:00+00:00", + "expires_at": "2026-04-01T00:00:00+00:00", + } + + monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None) + monkeypatch.setattr(routes, "_bind_optional_supabase_identity", _bind_identity) + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "get_subscription_window", + lambda user_id, respect_requirement=False: {}, + ) + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "get_latest_active_subscription", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("empty subscription window should skip latest active fallback"), + ), + ) + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "get_latest_subscription_any_status", + _latest_any_status, + ) + + response = client.get("/api/auth/me") + + assert response.status_code == 200 + payload = response.json() + assert payload["subscription_active"] is False + assert payload["subscription_plan_code"] == "expired_pro" + assert latest_any_calls["count"] == 1 + + +def test_auth_me_preserves_required_subscription_403_from_window(monkeypatch): + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True) + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", True) + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co") + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key") + monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", True) + + class _Identity: + user_id = "user-1" + email = "user@example.com" + points = 0 + created_at = "2026-05-01T00:00:00+00:00" + + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "get_identity", lambda token: _Identity()) + monkeypatch.setattr( + web_core.SUPABASE_ENTITLEMENT, + "has_active_subscription", + lambda user_id: (_ for _ in ()).throw( + AssertionError("auth/me should not run a separate lightweight subscription gate"), + ), + ) + monkeypatch.setattr( + web_core.SUPABASE_ENTITLEMENT, + "get_subscription_window", + lambda user_id, respect_requirement=False: {}, + ) + latest_any_calls = {"count": 0} + + def _latest_any_status(user_id): + latest_any_calls["count"] += 1 + return { + "plan_code": "expired_pro", + "starts_at": "2026-03-01T00:00:00+00:00", + "expires_at": "2026-04-01T00:00:00+00:00", + } + + monkeypatch.setattr( + web_core.SUPABASE_ENTITLEMENT, + "get_latest_subscription_any_status", + _latest_any_status, + ) + + response = client.get( + "/api/auth/me", + headers={"Authorization": "Bearer access-token"}, + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "Subscription required" + assert latest_any_calls["count"] == 0 + + def test_backend_entitlement_token_binds_forwarded_supabase_identity(monkeypatch): monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True) monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co") @@ -167,6 +562,45 @@ def test_backend_entitlement_token_binds_forwarded_supabase_identity(monkeypatch assert request.state.auth_email == "user@example.com" +def test_backend_entitlement_token_without_forwarded_identity_validates_bearer(monkeypatch): + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True) + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co") + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key") + monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", False) + monkeypatch.setattr(web_core, "_ENTITLEMENT_TOKEN", "backend-token") + + class _Identity: + user_id = "user-1" + email = "user@example.com" + points = 7 + created_at = "2026-05-01T00:00:00+00:00" + + calls = {"count": 0} + + def _get_identity(token): + calls["count"] += 1 + assert token == "access-token" + return _Identity() + + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "get_identity", _get_identity) + + request = Request( + { + "type": "http", + "headers": [ + (b"x-polyweather-entitlement", b"backend-token"), + (b"authorization", b"Bearer access-token"), + ], + } + ) + + web_core._assert_entitlement(request) + + assert calls["count"] == 1 + assert request.state.auth_user_id == "user-1" + assert request.state.auth_email == "user@example.com" + + def test_ops_memberships_prefers_supabase_auth_email(monkeypatch): monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None) monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None) @@ -216,6 +650,504 @@ def test_ops_memberships_prefers_supabase_auth_email(monkeypatch): assert payload["memberships"][0]["email"] == "fresh@example.com" +def test_ops_memberships_uses_batched_subscription_windows(monkeypatch): + monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None) + monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None) + monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "enabled", False) + + class _FakeDB: + @staticmethod + def get_users_by_supabase_user_ids(user_ids): + return { + "user-1": {"supabase_email": "one@example.com"}, + "user-2": {"supabase_email": "two@example.com"}, + } + + import src.database.db_manager as db_module + + monkeypatch.setattr(db_module, "DBManager", lambda: _FakeDB()) + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "list_active_subscriptions", + lambda limit=200: [ + { + "user_id": "user-1", + "plan_code": "pro_monthly", + "starts_at": "2026-03-01T00:00:00+00:00", + "expires_at": "2026-04-01T00:00:00+00:00", + }, + { + "user_id": "user-2", + "plan_code": "pro_monthly", + "starts_at": "2026-03-02T00:00:00+00:00", + "expires_at": "2026-04-02T00:00:00+00:00", + }, + ], + ) + monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "get_auth_users", lambda user_ids: {}) + + def _fail_per_user_window(*args, **kwargs): + raise AssertionError("per-user subscription window query should not run") + + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "get_subscription_window", + _fail_per_user_window, + ) + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "list_subscription_windows", + lambda user_ids, bypass_cache=True: { + "user-1": {"total_expires_at": "2026-04-15T00:00:00+00:00", "queued_days": 14, "queued_count": 1}, + "user-2": {"total_expires_at": "2026-04-02T00:00:00+00:00", "queued_days": 0, "queued_count": 0}, + }, + raising=False, + ) + + response = client.get("/api/ops/memberships") + + assert response.status_code == 200 + payload = response.json() + rows = {row["user_id"]: row for row in payload["memberships"]} + assert rows["user-1"]["queued_days"] == 14 + assert rows["user-2"]["queued_days"] == 0 + + +def test_ops_memberships_prefers_single_active_subscription_window_query(monkeypatch): + monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None) + monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None) + monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "enabled", False) + + class _FakeDB: + @staticmethod + def get_users_by_supabase_user_ids(user_ids): + return {"user-1": {"supabase_email": "one@example.com"}} + + import src.database.db_manager as db_module + + monkeypatch.setattr(db_module, "DBManager", lambda: _FakeDB()) + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "list_active_subscriptions", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("memberships should not run a separate active subscription query"), + ), + ) + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "list_subscription_windows", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("memberships should not run a second window query"), + ), + ) + monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "get_auth_users", lambda user_ids: {}) + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "list_active_subscription_windows", + lambda limit=200: { + "subscriptions": [ + { + "user_id": "user-1", + "plan_code": "pro_monthly", + "source": "payment_contract", + "starts_at": "2026-03-01T00:00:00+00:00", + "expires_at": "2026-04-01T00:00:00+00:00", + } + ], + "windows": { + "user-1": { + "total_expires_at": "2026-05-01T00:00:00+00:00", + "queued_days": 30, + "queued_count": 1, + } + }, + }, + raising=False, + ) + + response = client.get("/api/ops/memberships") + + assert response.status_code == 200 + row = response.json()["memberships"][0] + assert row["queued_days"] == 30 + assert row["expires_at"] == "2026-05-01T00:00:00+00:00" + + +def test_ops_memberships_growth_reuses_active_subscription_window_query(monkeypatch): + monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None) + monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None) + + starts_at = datetime.utcnow().replace(microsecond=0).isoformat() + + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "list_active_subscriptions", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("growth should not run a separate active subscription query"), + ), + ) + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "list_active_subscription_windows", + lambda limit=5000: { + "subscriptions": [ + { + "user_id": "user-1", + "plan_code": "pro_monthly", + "source": "payment_contract", + "starts_at": starts_at, + "expires_at": "2099-01-01T00:00:00+00:00", + } + ], + "windows": {}, + }, + raising=False, + ) + + response = client.get("/api/ops/memberships/growth?days=7") + + assert response.status_code == 200 + assert any(day["paid"] == 1 for day in response.json()["daily"]) + + +def test_ops_telegram_audit_reuses_active_subscription_window_query(monkeypatch): + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "bot-token") + monkeypatch.setenv("TELEGRAM_CHAT_IDS", "chat-1") + monkeypatch.delenv("TELEGRAM_CHAT_ID", raising=False) + monkeypatch.delenv("POLYWEATHER_TELEGRAM_GROUP_ID", raising=False) + monkeypatch.delenv("POLYWEATHER_TELEGRAM_TOPICS_GROUP_ID", raising=False) + monkeypatch.setattr(ops_api, "_require_ops", lambda request: None) + + class _FakeRows(list): + def fetchall(self): + return self + + class _FakeConnection: + row_factory = None + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def execute(self, sql): + if "FROM users" in sql: + return _FakeRows([{"telegram_id": 1, "username": "tester"}]) + if "FROM supabase_bindings" in sql: + return _FakeRows( + [ + { + "telegram_id": 1, + "supabase_user_id": "user-1", + "supabase_email": "one@example.com", + } + ] + ) + raise AssertionError(sql) + + class _FakeDB: + @staticmethod + def _get_connection(): + return _FakeConnection() + + class _Response: + status_code = 200 + + @staticmethod + def json(): + return {"ok": True, "result": {"status": "member"}} + + import requests as requests_module + import src.database.db_manager as db_module + + monkeypatch.setattr(db_module, "DBManager", lambda: _FakeDB()) + monkeypatch.setattr(requests_module, "get", lambda *args, **kwargs: _Response()) + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "list_active_subscriptions", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("telegram audit should not run a separate active subscription query"), + ), + ) + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "list_active_subscription_windows", + lambda limit=5000: { + "subscriptions": [ + { + "user_id": "user-1", + "plan_code": "pro_monthly", + "source": "payment_contract", + "starts_at": "2026-03-01T00:00:00+00:00", + "expires_at": "2099-01-01T00:00:00+00:00", + } + ], + "windows": {}, + }, + raising=False, + ) + + result = ops_api.get_ops_telegram_audit(object()) + + assert result["valid_count"] == 1 + assert result["anomaly_count"] == 0 + + +def test_ops_memberships_overview_combines_memberships_and_growth_in_one_subscription_query(monkeypatch): + monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None) + monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None) + monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "enabled", False) + + starts_at = datetime.utcnow().replace(microsecond=0).isoformat() + calls = {"active_windows": 0} + + class _FakeDB: + @staticmethod + def get_users_by_supabase_user_ids(user_ids): + return {"user-1": {"supabase_email": "one@example.com"}} + + import src.database.db_manager as db_module + + monkeypatch.setattr(db_module, "DBManager", lambda: _FakeDB()) + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "list_active_subscriptions", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("overview should not run a separate active subscription query"), + ), + ) + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "list_subscription_windows", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("overview should not run a second window query"), + ), + ) + monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "get_auth_users", lambda user_ids: {}) + + def _active_windows(limit=5000): + calls["active_windows"] += 1 + assert limit == 5000 + return { + "subscriptions": [ + { + "user_id": "user-1", + "plan_code": "pro_monthly", + "source": "payment_contract", + "starts_at": starts_at, + "expires_at": "2099-01-01T00:00:00+00:00", + }, + { + "user_id": "user-2", + "plan_code": "signup_trial_3d", + "source": "signup_trial", + "starts_at": starts_at, + "expires_at": "2099-01-02T00:00:00+00:00", + }, + ], + "windows": { + "user-1": { + "total_expires_at": "2099-01-01T00:00:00+00:00", + "queued_days": 0, + "queued_count": 0, + }, + "user-2": { + "total_expires_at": "2099-01-02T00:00:00+00:00", + "queued_days": 0, + "queued_count": 0, + }, + }, + } + + monkeypatch.setattr( + routes.SUPABASE_ENTITLEMENT, + "list_active_subscription_windows", + _active_windows, + raising=False, + ) + + response = client.get("/api/ops/memberships/overview?limit=1&days=7") + + assert response.status_code == 200 + payload = response.json() + assert len(payload["memberships"]) == 1 + assert payload["memberships"][0]["user_id"] == "user-1" + assert any(day["paid"] == 1 and day["trial"] == 1 for day in payload["daily"]) + assert calls["active_windows"] == 1 + + +def test_ops_memberships_does_not_reconcile_payments_by_default(monkeypatch): + monkeypatch.delenv("POLYWEATHER_OPS_MEMBERSHIPS_RECONCILE_ENABLED", raising=False) + monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None) + monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None) + monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "enabled", True) + + calls = {"count": 0} + + def _count_reconcile(*args, **kwargs): + calls["count"] += 1 + return {"ok": True} + + monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "reconcile_recent_intents", _count_reconcile) + + class _FakeDB: + @staticmethod + def get_users_by_supabase_user_ids(user_ids): + return {} + + import src.database.db_manager as db_module + + monkeypatch.setattr(db_module, "DBManager", lambda: _FakeDB()) + monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "list_active_subscriptions", lambda limit=200: []) + monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "get_auth_users", lambda user_ids: {}) + monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "list_subscription_windows", lambda user_ids, bypass_cache=True: {}) + + response = client.get("/api/ops/memberships") + + assert response.status_code == 200 + assert response.json()["memberships"] == [] + assert calls["count"] == 0 + + +def test_ops_email_lookup_prefers_profiles_over_auth_admin(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + + calls = [] + + class _Response: + ok = True + status_code = 200 + content = b"1" + text = "" + + def __init__(self, payload): + self._payload = payload + + def json(self): + return self._payload + + def _fake_get(url, headers=None, params=None, timeout=None): + calls.append((url, params)) + if url.endswith("/rest/v1/profiles"): + assert headers is not None + assert headers.get("Prefer") != "return=representation" + assert params["select"] == "id" + assert params["email"] == "eq.user@example.com" + return _Response([{"id": "user-1"}]) + raise AssertionError(f"unexpected auth admin lookup: {url}") + + monkeypatch.setattr(ops_api._requests, "get", _fake_get) + + assert ( + ops_api._lookup_supabase_user_id_by_email( + "https://example.supabase.co", + "service-role", + "user@example.com", + ) + == "user-1" + ) + assert len(calls) == 1 + + +def test_ops_subscription_grant_invalidates_subscription_cache(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + monkeypatch.setattr(ops_api, "_require_ops", lambda request: {"email": "admin@example.com"}) + + class _Response: + ok = True + status_code = 200 + content = b"1" + text = "" + + def __init__(self, payload): + self._payload = payload + + def json(self): + return self._payload + + def _fake_get(url, headers=None, params=None, timeout=None): + assert url.endswith("/rest/v1/profiles") + assert params["select"] == "id" + return _Response([{"id": "user-1"}]) + + def _fake_post(url, headers=None, json=None, timeout=None): + assert url.endswith("/rest/v1/subscriptions") + assert headers["Prefer"] == "return=minimal" + return _Response([{"id": 1, "user_id": "user-1"}]) + + invalidated = [] + monkeypatch.setattr(ops_api._requests, "get", _fake_get) + monkeypatch.setattr(ops_api._requests, "post", _fake_post) + monkeypatch.setattr( + ops_api.legacy_routes.SUPABASE_ENTITLEMENT, + "invalidate_subscription_cache", + lambda user_id: invalidated.append(user_id), + ) + + result = ops_api.grant_ops_subscription(object(), "user@example.com") + + assert result["ok"] is True + assert invalidated == ["user-1"] + + +def test_ops_subscription_extend_uses_minimal_return_and_invalidates_cache(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + monkeypatch.setattr(ops_api, "_require_ops", lambda request: {"email": "admin@example.com"}) + + class _Response: + ok = True + status_code = 200 + content = b"1" + text = "" + + def __init__(self, payload): + self._payload = payload + + def json(self): + return self._payload + + def _fake_get(url, headers=None, params=None, timeout=None): + if url.endswith("/rest/v1/profiles"): + assert params["select"] == "id" + return _Response([{"id": "user-1"}]) + if url.endswith("/rest/v1/subscriptions"): + assert params["select"] == "id,expires_at" + return _Response( + [ + { + "id": 7, + "expires_at": "2026-04-01T00:00:00+00:00", + } + ] + ) + raise AssertionError(url) + + def _fake_patch(url, headers=None, json=None, timeout=None): + assert url.endswith("/rest/v1/subscriptions?id=eq.7") + assert headers["Prefer"] == "return=minimal" + assert "expires_at" in json + return _Response([]) + + invalidated = [] + monkeypatch.setattr(ops_api._requests, "get", _fake_get) + monkeypatch.setattr(ops_api._requests, "patch", _fake_patch) + monkeypatch.setattr( + ops_api.legacy_routes.SUPABASE_ENTITLEMENT, + "invalidate_subscription_cache", + lambda user_id: invalidated.append(user_id), + ) + + result = ops_api.extend_ops_subscription(object(), "user@example.com", additional_days=7) + + assert result["ok"] is True + assert result["new_expires_at"].startswith("2026-04-08") + assert invalidated == ["user-1"] + + def test_ops_truth_history_returns_filtered_rows(monkeypatch): monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None) monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None) diff --git a/tests/test_weekly_reward_supabase_io.py b/tests/test_weekly_reward_supabase_io.py new file mode 100644 index 00000000..751a4aa9 --- /dev/null +++ b/tests/test_weekly_reward_supabase_io.py @@ -0,0 +1,42 @@ +from datetime import datetime + +import src.bot.weekly_reward_loop as weekly_reward_loop + + +class _Response: + def __init__(self, status_code=200, payload=None): + self.status_code = status_code + self._payload = payload + self.content = b"1" + + def json(self): + return self._payload + + +def test_weekly_reward_bonus_subscription_insert_uses_minimal_return(monkeypatch): + calls = [] + + def _fake_get(url, headers=None, params=None, timeout=None): + calls.append(("GET", url, headers, params)) + return _Response(200, []) + + def _fake_post(url, headers=None, json=None, timeout=None): + calls.append(("POST", url, headers, json)) + assert headers["Prefer"] == "return=minimal" + return _Response(201, []) + + monkeypatch.setattr(weekly_reward_loop.requests, "get", _fake_get) + monkeypatch.setattr(weekly_reward_loop.requests, "post", _fake_post) + + ok, reason, expires_at = weekly_reward_loop._grant_bonus_subscription_days( + supabase_url="https://example.supabase.co", + service_role_key="service-role", + user_id="user-1", + days=7, + timeout_sec=5, + ) + + assert ok is True + assert reason == "" + assert datetime.fromisoformat(str(expires_at)).tzinfo is not None + assert [call[0] for call in calls] == ["GET", "POST"] diff --git a/web/core.py b/web/core.py index 6023d71d..e5f1f109 100644 --- a/web/core.py +++ b/web/core.py @@ -303,8 +303,10 @@ def _resolve_weekly_profile(request: Request) -> Dict[str, Any]: def _assert_entitlement(request: Request) -> None: if SUPABASE_ENTITLEMENT.enabled: if _legacy_service_token_valid(request): - _bind_forwarded_supabase_identity(request) - return + if _bind_forwarded_supabase_identity(request): + return + if not extract_bearer_token(request.headers.get("authorization")): + return if not _SUPABASE_AUTH_REQUIRED: _bind_optional_supabase_identity(request) return @@ -321,11 +323,19 @@ def _assert_entitlement(request: Request) -> None: identity = SUPABASE_ENTITLEMENT.get_identity(access_token) if not identity: raise HTTPException(status_code=401, detail="Unauthorized") - if not SUPABASE_ENTITLEMENT.has_active_subscription(identity.user_id): + skip_subscription_gate = bool( + getattr(request.state, "skip_subscription_gate", False) + ) + if ( + not skip_subscription_gate + and not SUPABASE_ENTITLEMENT.has_active_subscription(identity.user_id) + ): raise HTTPException(status_code=403, detail="Subscription required") request.state.auth_user_id = identity.user_id request.state.auth_email = identity.email + request.state.auth_points = identity.points + request.state.auth_created_at = identity.created_at from src.utils.online_tracker import record_activity record_activity(identity.user_id) return diff --git a/web/routers/ops.py b/web/routers/ops.py index 4313d887..cf9ee520 100644 --- a/web/routers/ops.py +++ b/web/routers/ops.py @@ -8,6 +8,7 @@ from web.services.ops_api import ( get_ops_analytics_funnel, get_ops_config, get_ops_memberships_growth, + get_ops_memberships_overview, get_ops_health_check, get_ops_logs, get_ops_truth_history, @@ -55,6 +56,15 @@ async def ops_memberships_growth(request: Request, days: int = 90): return get_ops_memberships_growth(request, days=days) +@router.get("/api/ops/memberships/overview") +async def ops_memberships_overview( + request: Request, + limit: int = 200, + days: int = 90, +): + return get_ops_memberships_overview(request, limit=limit, days=days) + + @router.get("/api/ops/payments/incidents") async def ops_payment_incidents( request: Request, diff --git a/web/services/auth_api.py b/web/services/auth_api.py index 1d75bacc..1e8fd8f2 100644 --- a/web/services/auth_api.py +++ b/web/services/auth_api.py @@ -12,9 +12,17 @@ from web.core import TelegramLoginRequest import web.routes as legacy_routes -def get_auth_me_payload(request: Request) -> Dict[str, Any]: +def _require_auth_identity_without_subscription_gate(request: Request) -> Dict[str, str]: + request.state.skip_subscription_gate = True legacy_routes._assert_entitlement(request) - legacy_routes._bind_optional_supabase_identity(request) + return legacy_routes._require_supabase_identity(request) + + +def get_auth_me_payload(request: Request) -> Dict[str, Any]: + request.state.skip_subscription_gate = True + legacy_routes._assert_entitlement(request) + if not str(getattr(request.state, "auth_user_id", "") or "").strip(): + legacy_routes._bind_optional_supabase_identity(request) user_id = getattr(request.state, "auth_user_id", None) subscription_required = bool( @@ -31,25 +39,48 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]: if legacy_routes.SUPABASE_ENTITLEMENT.enabled and user_id: try: - latest_subscription = ( - legacy_routes.SUPABASE_ENTITLEMENT.get_latest_active_subscription( - user_id, - respect_requirement=False, - ) + subscription_window = legacy_routes.SUPABASE_ENTITLEMENT.get_subscription_window( + user_id, + respect_requirement=False, ) + latest_subscription = None + latest_known_subscription = None + subscription_window_known = isinstance(subscription_window, dict) + if isinstance(subscription_window, dict): + current_subscription = subscription_window.get("current") + if isinstance(current_subscription, dict): + latest_subscription = current_subscription + rows = subscription_window.get("rows") + if not latest_subscription and isinstance(rows, list): + latest_known_subscription = next( + (row for row in rows if isinstance(row, dict)), + None, + ) + if ( + not latest_subscription + and not latest_known_subscription + and not subscription_window_known + and not subscription_required + ): + latest_subscription = ( + legacy_routes.SUPABASE_ENTITLEMENT.get_latest_active_subscription( + user_id, + respect_requirement=False, + ) + ) - latest_known_subscription = latest_subscription + subscription_active = bool(latest_subscription) + if subscription_required and not subscription_active: + raise HTTPException(status_code=403, detail="Subscription required") + + if not latest_known_subscription: + latest_known_subscription = latest_subscription if not latest_known_subscription: latest_known_subscription = ( legacy_routes.SUPABASE_ENTITLEMENT.get_latest_subscription_any_status( user_id ) ) - subscription_window = legacy_routes.SUPABASE_ENTITLEMENT.get_subscription_window( - user_id, - respect_requirement=False, - ) - subscription_active = bool(latest_subscription) if isinstance(latest_subscription, dict): subscription_plan_code = latest_subscription.get("plan_code") subscription_starts_at = latest_subscription.get("starts_at") @@ -62,7 +93,11 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]: subscription_total_expires_at = subscription_window.get("total_expires_at") subscription_queued_days = int(subscription_window.get("queued_days") or 0) subscription_queued_count = int(subscription_window.get("queued_count") or 0) + except HTTPException: + raise except Exception: + if subscription_required: + raise HTTPException(status_code=403, detail="Subscription required") subscription_active = None subscription_plan_code = None subscription_starts_at = None @@ -124,8 +159,7 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]: def login_with_telegram(request: Request, body: TelegramLoginRequest) -> Dict[str, Any]: - legacy_routes._assert_entitlement(request) - identity = legacy_routes._require_supabase_identity(request) + identity = _require_auth_identity_without_subscription_gate(request) pricing = TelegramGroupPricing() if not pricing.configured: raise HTTPException(status_code=503, detail="telegram login is not configured") @@ -156,8 +190,7 @@ def login_with_telegram(request: Request, body: TelegramLoginRequest) -> Dict[st def bind_telegram_by_token(request: Request, body) -> Dict[str, Any]: """Bind Telegram identity using a one-time token from the bot /bind command.""" - legacy_routes._assert_entitlement(request) - identity = legacy_routes._require_supabase_identity(request) + identity = _require_auth_identity_without_subscription_gate(request) token = str(getattr(body, "token", "") or "").strip() if not token: @@ -196,8 +229,7 @@ def bind_telegram_by_token(request: Request, body) -> Dict[str, Any]: def create_telegram_bot_bind_link(request: Request) -> Dict[str, Any]: """Create a one-time web-to-bot bind deep link for the authenticated account.""" - legacy_routes._assert_entitlement(request) - identity = legacy_routes._require_supabase_identity(request) + identity = _require_auth_identity_without_subscription_gate(request) db = DBManager() token = db.create_web_bind_token( diff --git a/web/services/ops_api.py b/web/services/ops_api.py index 39a84a26..10794205 100644 --- a/web/services/ops_api.py +++ b/web/services/ops_api.py @@ -2,9 +2,11 @@ from __future__ import annotations +import os from typing import Any, Dict from fastapi import HTTPException, Request +import requests as _requests from src.database.db_manager import DBManager from web.core import GrantPointsRequest @@ -29,19 +31,46 @@ def get_ops_weekly_leaderboard(request: Request, limit: int = 20) -> Dict[str, A return {"leaderboard": db.get_weekly_leaderboard(limit=limit)} -def list_ops_memberships(request: Request, limit: int = 200) -> Dict[str, Any]: - _require_ops(request) - db = DBManager() - if getattr(legacy_routes.PAYMENT_CHECKOUT, "enabled", False): - try: - legacy_routes.PAYMENT_CHECKOUT.reconcile_recent_intents( - limit=min(max(int(limit or 200), 20), 200) - ) - except Exception: - pass - subscriptions = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions( - limit=limit +def _list_active_subscriptions_with_windows( + limit: int, +) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]], bool]: + active_window_query = getattr( + legacy_routes.SUPABASE_ENTITLEMENT, + "list_active_subscription_windows", + None, ) + if not callable(active_window_query): + return [], {}, False + try: + active_window_payload = active_window_query(limit=limit) + except Exception: + return [], {}, False + if not isinstance(active_window_payload, dict): + return [], {}, False + + active_window_subscriptions = active_window_payload.get("subscriptions") + active_window_windows = active_window_payload.get("windows") + if not isinstance(active_window_subscriptions, list): + return [], {}, False + if not active_window_subscriptions and not ( + isinstance(active_window_windows, dict) and active_window_windows + ): + return [], {}, False + + subscriptions = [ + item for item in active_window_subscriptions if isinstance(item, dict) + ] + subscription_windows = ( + active_window_windows if isinstance(active_window_windows, dict) else {} + ) + return subscriptions, subscription_windows, True + + +def _build_membership_rows( + db: DBManager, + subscriptions: list[dict[str, Any]], + subscription_windows: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: subscription_user_ids = [str(item.get("user_id") or "") for item in subscriptions] user_map = db.get_users_by_supabase_user_ids(subscription_user_ids) unresolved_user_ids = [ @@ -56,18 +85,17 @@ def list_ops_memberships(request: Request, limit: int = 200) -> Dict[str, Any]: auth_user_map = legacy_routes.SUPABASE_ENTITLEMENT.get_auth_users( unresolved_user_ids ) + if not subscription_windows: + subscription_windows = legacy_routes.SUPABASE_ENTITLEMENT.list_subscription_windows( + subscription_user_ids, + bypass_cache=True, + ) deduped: dict[str, dict] = {} for item in subscriptions: user_id = str(item.get("user_id") or "").strip().lower() local_user = user_map.get(user_id, {}) auth_user = auth_user_map.get(user_id, {}) - subscription_window = ( - legacy_routes.SUPABASE_ENTITLEMENT.get_subscription_window( - user_id, - respect_requirement=False, - bypass_cache=True, - ) - ) + subscription_window = subscription_windows.get(user_id, {}) current_expires_at = item.get("expires_at") total_expires_at = ( subscription_window.get("total_expires_at") @@ -112,22 +140,20 @@ def list_ops_memberships(request: Request, limit: int = 200) -> Dict[str, Any]: current_expires = str(row.get("expires_at") or "") if existing is None or current_expires > existing_expires: deduped[user_id] = row - rows = sorted( + return sorted( deduped.values(), key=lambda item: str(item.get("expires_at") or ""), ) - return {"memberships": rows} -def get_ops_memberships_growth(request: Request, days: int = 90) -> dict[str, Any]: - _require_ops(request) +def _build_membership_growth( + subscriptions: list[dict[str, Any]], + days: int, +) -> dict[str, Any]: from collections import defaultdict from datetime import datetime, timedelta safe_days = max(7, min(365, int(days or 90))) - subscriptions = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions( - limit=5000 - ) now = datetime.utcnow() cutoff = now - timedelta(days=safe_days) @@ -178,6 +204,78 @@ def get_ops_memberships_growth(request: Request, days: int = 90) -> dict[str, An return {"days": safe_days, "daily": daily} +def list_ops_memberships(request: Request, limit: int = 200) -> Dict[str, Any]: + _require_ops(request) + db = DBManager() + reconcile_enabled = ( + str(os.getenv("POLYWEATHER_OPS_MEMBERSHIPS_RECONCILE_ENABLED") or "") + .strip() + .lower() + in {"1", "true", "yes", "on"} + ) + if reconcile_enabled and getattr(legacy_routes.PAYMENT_CHECKOUT, "enabled", False): + try: + legacy_routes.PAYMENT_CHECKOUT.reconcile_recent_intents( + limit=min(max(int(limit or 200), 20), 200) + ) + except Exception: + pass + subscriptions, subscription_windows, used_active_window_query = ( + _list_active_subscriptions_with_windows(limit) + ) + if not used_active_window_query: + subscriptions = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions( + limit=limit + ) + return { + "memberships": _build_membership_rows( + db, + subscriptions, + subscription_windows, + ) + } + + +def get_ops_memberships_growth(request: Request, days: int = 90) -> dict[str, Any]: + _require_ops(request) + + subscriptions, _, used_active_window_query = _list_active_subscriptions_with_windows( + limit=5000 + ) + if not used_active_window_query: + subscriptions = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions( + limit=5000 + ) + return _build_membership_growth(subscriptions, days) + + +def get_ops_memberships_overview( + request: Request, + limit: int = 200, + days: int = 90, +) -> dict[str, Any]: + _require_ops(request) + db = DBManager() + safe_limit = max(1, min(int(limit or 200), 1000)) + query_limit = max(safe_limit, 5000) + subscriptions, subscription_windows, used_active_window_query = ( + _list_active_subscriptions_with_windows(limit=query_limit) + ) + if not used_active_window_query: + subscriptions = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions( + limit=query_limit + ) + membership_subscriptions = subscriptions[:safe_limit] + return { + "memberships": _build_membership_rows( + db, + membership_subscriptions, + subscription_windows, + ), + **_build_membership_growth(subscriptions, days), + } + + def list_ops_payment_incidents( request: Request, limit: int = 50, @@ -415,6 +513,59 @@ def update_ops_config(request: Request, key: str, value: str) -> dict[str, Any]: # ── Subscriptions ─────────────────────────────────────────────────── +def _supabase_service_headers( + service_role_key: str, + *, + prefer: str | None = None, +) -> dict[str, str]: + headers = { + "apikey": service_role_key, + "Authorization": f"Bearer {service_role_key}", + "Content-Type": "application/json", + } + if prefer: + headers["Prefer"] = prefer + return headers + + +def _lookup_supabase_user_id_by_email( + supabase_url: str, + service_role_key: str, + email: str, +) -> str: + normalized_email = str(email or "").strip().lower() + if not normalized_email: + return "" + base = str(supabase_url or "").strip().rstrip("/") + headers = _supabase_service_headers(service_role_key) + + profile_resp = _requests.get( + f"{base}/rest/v1/profiles", + headers=headers, + params={ + "select": "id", + "email": f"eq.{normalized_email}", + "limit": "1", + }, + timeout=10, + ) + if profile_resp.ok: + profiles = profile_resp.json() if profile_resp.content else [] + if isinstance(profiles, list) and profiles: + user_id = str((profiles[0] or {}).get("id") or "").strip() + if user_id: + return user_id + + user_resp = _requests.get( + f"{base}/auth/v1/admin/users", + headers=headers, + params={"filter": f"email.eq.{normalized_email}"}, + timeout=10, + ) + users = user_resp.json().get("users", []) if user_resp.ok else [] + return str(users[0].get("id") or "").strip() if users else "" + + def grant_ops_subscription( request: Request, email: str, @@ -423,9 +574,7 @@ def grant_ops_subscription( deduct_points: int = 0, ) -> dict[str, Any]: _require_ops(request) - import os from datetime import datetime, timedelta - import requests as _requests supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/") service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip() @@ -444,22 +593,11 @@ def grant_ops_subscription( if not normalized_email: raise HTTPException(status_code=400, detail="email is required") - headers = { - "apikey": service_role_key, - "Authorization": f"Bearer {service_role_key}", - "Content-Type": "application/json", - "Prefer": "return=representation", - } - - # Look up user by email - user_resp = _requests.get( - f"{supabase_url}/auth/v1/admin/users", - headers=headers, - params={"filter": f"email.eq.{normalized_email}"}, - timeout=10, + user_id = _lookup_supabase_user_id_by_email( + supabase_url, + service_role_key, + normalized_email, ) - users = user_resp.json().get("users", []) if user_resp.ok else [] - user_id = users[0].get("id") if users else None if not user_id: raise HTTPException( status_code=404, detail=f"user not found: {normalized_email}" @@ -481,7 +619,7 @@ def grant_ops_subscription( resp = _requests.post( f"{supabase_url}/rest/v1/subscriptions", - headers=headers, + headers=_supabase_service_headers(service_role_key, prefer="return=minimal"), json=payload, timeout=10, ) @@ -489,6 +627,7 @@ def grant_ops_subscription( raise HTTPException( status_code=500, detail=f"Supabase insert failed: {resp.text[:200]}" ) + legacy_routes.SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id) result: dict[str, Any] = { "ok": True, @@ -516,9 +655,7 @@ def extend_ops_subscription( additional_days: int = 30, ) -> dict[str, Any]: _require_ops(request) - import os from datetime import datetime, timedelta - import requests as _requests supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/") service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip() @@ -530,20 +667,26 @@ def extend_ops_subscription( if not normalized_email: raise HTTPException(status_code=400, detail="email is required") - headers = { - "apikey": service_role_key, - "Authorization": f"Bearer {service_role_key}", - "Content-Type": "application/json", - "Prefer": "return=representation", - } + headers = _supabase_service_headers(service_role_key) + + user_id = _lookup_supabase_user_id_by_email( + supabase_url, + service_role_key, + normalized_email, + ) + if not user_id: + raise HTTPException( + status_code=404, detail=f"user not found: {normalized_email}" + ) # Find latest active subscription subs_resp = _requests.get( f"{supabase_url}/rest/v1/subscriptions", headers=headers, params={ - "select": "*", - "email": f"eq.{normalized_email}", + "select": "id,expires_at", + "user_id": f"eq.{user_id}", + "status": "eq.active", "order": "expires_at.desc", "limit": "1", }, @@ -565,11 +708,12 @@ def extend_ops_subscription( patch_resp = _requests.patch( f"{supabase_url}/rest/v1/subscriptions?id=eq.{sub['id']}", - headers=headers, + headers=_supabase_service_headers(service_role_key, prefer="return=minimal"), json={"expires_at": new_expiry}, timeout=10, ) if patch_resp.ok: + legacy_routes.SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id) return { "ok": True, "email": normalized_email, @@ -587,8 +731,6 @@ def get_ops_user_subscriptions( ) -> dict[str, Any]: """Return ALL subscription rows for a user (by email), regardless of status.""" _require_ops(request) - import os - import requests as _requests supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/") service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip() @@ -599,21 +741,13 @@ def get_ops_user_subscriptions( if not normalized_email: raise HTTPException(status_code=400, detail="email is required") - headers = { - "apikey": service_role_key, - "Authorization": f"Bearer {service_role_key}", - "Content-Type": "application/json", - } + headers = _supabase_service_headers(service_role_key) - # Resolve user_id from email via auth admin API - user_resp = _requests.get( - f"{supabase_url}/auth/v1/admin/users", - headers=headers, - params={"filter": f"email.eq.{normalized_email}"}, - timeout=10, + user_id = _lookup_supabase_user_id_by_email( + supabase_url, + service_role_key, + normalized_email, ) - users = user_resp.json().get("users", []) if user_resp.ok else [] - user_id = users[0].get("id") if users else None if not user_id: raise HTTPException( status_code=404, detail=f"user not found: {normalized_email}" @@ -1134,11 +1268,13 @@ def get_ops_telegram_audit(request: Request) -> Dict[str, Any]: anomalies = [] valid_members = [] - from web.services.ops_api import legacy_routes - - active_subs = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions( + active_subs, _, used_active_window_query = _list_active_subscriptions_with_windows( limit=5000 ) + if not used_active_window_query: + active_subs = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions( + limit=5000 + ) active_subs_map = {} for sub in active_subs: uid = str(sub.get("user_id") or "").strip().lower() diff --git a/web/services/payment_api.py b/web/services/payment_api.py index c08bc89d..a595969c 100644 --- a/web/services/payment_api.py +++ b/web/services/payment_api.py @@ -27,6 +27,7 @@ def _raise_payment_error(exc: Exception) -> None: def _require_payment_identity(request: Request) -> Dict[str, Any]: + request.state.skip_subscription_gate = True legacy_routes._assert_entitlement(request) identity = legacy_routes._require_supabase_identity(request) user_id = str(identity.get("user_id") or "").strip() @@ -36,7 +37,6 @@ def _require_payment_identity(request: Request) -> Dict[str, Any]: def get_payment_config(request: Request) -> Dict[str, Any]: - legacy_routes._assert_entitlement(request) try: return legacy_routes.PAYMENT_CHECKOUT.get_config_payload() except legacy_routes.PaymentCheckoutError as exc: