Reduce Supabase disk IO

This commit is contained in:
2569718930@qq.com
2026-05-29 17:22:33 +08:00
parent 9e3a4e2f45
commit 2269aaefd7
63 changed files with 4637 additions and 410 deletions
+10
View File
@@ -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
+17 -3
View File
@@ -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<ReturnType<typeof buildBackendRequestHeaders>> | 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,
@@ -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) {
+7
View File
@@ -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();
@@ -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" } });
@@ -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);
@@ -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);
@@ -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",
});
}
}
@@ -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);
@@ -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",
@@ -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",
@@ -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);
@@ -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<string, string> = { ...auth.headers as Record<string, string>, "Content-Type": "application/json" };
if (ENTITLEMENT_TOKEN) {
@@ -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);
@@ -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" } });
@@ -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" } });
@@ -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);
@@ -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",
+4
View File
@@ -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");
+4
View File
@@ -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");
+1 -1
View File
@@ -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`,
+1 -1
View File
@@ -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`,
});
@@ -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");
@@ -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");
@@ -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<ReturnType<typeof buildBackendRequestHeaders>> | 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",
);
}
@@ -151,14 +151,17 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
setErrorText("");
const attempt = async (retry: boolean): Promise<void> => {
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 {
@@ -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",
);
}
@@ -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);
};
@@ -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<SystemStatusPayload>,
opsApi.memberships() as Promise<MembershipsPayload>,
opsApi.membershipsOverview(200, 30) as Promise<MembershipsPayload & { daily?: { date: string; trial: number; paid: number; total: number; cumulative: number }[] }>,
opsApi.funnel(30),
opsApi.membershipsGrowth(30),
]);
setStatus(s);
setMemberships((m as MembershipsPayload).memberships ?? []);
setFunnel(f);
setGrowth(g?.daily ?? []);
setGrowth(m?.daily ?? []);
} catch { /* */ }
setLoading(false);
};
+33 -27
View File
@@ -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<HeaderBuildResult> {
): Promise<BackendHeaderBuildResult> {
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(
+18 -8
View File
@@ -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)}`);
}
+7
View File
@@ -67,6 +67,13 @@ export const opsApi = {
memberships() {
return opsFetch<Record<string, unknown>>("/api/ops/memberships?limit=200");
},
membershipsOverview(limit = 200, days = 90) {
return opsFetch<{
memberships?: Array<Record<string, unknown>>;
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;
+27
View File
@@ -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,
);
}
+33 -7
View File
@@ -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 };
}
}
+3 -1
View File
@@ -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()}` : ""
+46 -18
View File
@@ -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<NextResponse> {
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<NextResponse> {
}
// 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;
}
+40 -23
View File
@@ -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)
+34 -17
View File
@@ -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]]:
@@ -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}",
+98
View File
@@ -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;
+89
View File
@@ -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;
+53 -8
View File
@@ -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
+371 -18
View File
@@ -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()
+1
View File
@@ -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,
+1 -1
View File
@@ -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,
)
+169 -6
View File
@@ -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(
+123 -123
View File
@@ -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),
+40
View File
@@ -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")
+21 -11
View File
@@ -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)
+738 -4
View File
@@ -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(),
}
+119
View File
@@ -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
+104
View File
@@ -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
+460 -10
View File
@@ -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"]
+93
View File
@@ -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"]
+83
View File
@@ -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"
+137
View File
@@ -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
+940 -8
View File
@@ -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)
+42
View File
@@ -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"]
+13 -3
View File
@@ -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
+10
View File
@@ -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,
+51 -19
View File
@@ -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(
+209 -73
View File
@@ -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()
+1 -1
View File
@@ -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: