Add trial and referral subscription program

This commit is contained in:
2569718930@qq.com
2026-05-29 19:24:46 +08:00
parent f8f5035225
commit 522e35de7f
20 changed files with 1629 additions and 54 deletions
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(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 body = await req.text();
const res = await fetch(`${API_BASE}/api/auth/referral/apply`, {
method: "POST",
headers: {
...auth.headers,
"Content-Type": "application/json",
},
body,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to apply referral code",
});
}
}
+207 -3
View File
@@ -84,6 +84,8 @@ export function AccountCenter() {
const [updatedAt, setUpdatedAt] = useState<string>("");
const [user, setUser] = useState<User | null>(null);
const [backend, setBackend] = useState<AuthMeResponse | null>(null);
const [referralCodeInput, setReferralCodeInput] = useState("");
const [referralApplying, setReferralApplying] = useState(false);
const supabaseReady = hasSupabasePublicEnv();
const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID);
@@ -135,6 +137,7 @@ export function AccountCenter() {
// Shared setters
setSelectedTokenAddress,
setSelectedPaymentChainId,
setSelectedPlanCode,
setSelectedWallet,
setSelectedInjectedProviderKey,
setProviderMode,
@@ -299,8 +302,16 @@ export function AccountCenter() {
backend.authenticated === false),
);
const isSubscribed = backend?.subscription_active === true;
const subscriptionSource = String(backend?.subscription_source || "").trim();
const isTrialSubscription = Boolean(
backend?.subscription_is_trial === true ||
String(backend?.subscription_plan_code || "").toLowerCase().includes("trial") ||
subscriptionSource.toLowerCase().includes("trial"),
);
const subscriptionStatusLabel = isSubscribed
? copy.proMember
? isTrialSubscription
? copy.trialBadge
: copy.proMember
: isSubscriptionUnknown
? copy.subscriptionChecking
: isEn
@@ -325,7 +336,7 @@ export function AccountCenter() {
const hasQueuedExtension = Boolean(
isSubscribed && queuedExtensionDays > 0,
);
const canAccessPaidTelegramGroup = Boolean(isSubscribed);
const canAccessPaidTelegramGroup = Boolean(isSubscribed && !isTrialSubscription);
const telegramBound =
Number(backend?.telegram_pricing?.telegram_id || 0) > 0;
const displayExpiryRaw = isSubscribed
@@ -384,6 +395,21 @@ export function AccountCenter() {
const expiryLabel = hasQueuedExtension
? copy.accessUntil
: copy.renewalDate;
const displayPlanList = effectivePlanList.length
? effectivePlanList
: [
{ plan_code: "pro_monthly", plan_id: 101, amount_usdc: "29.9", duration_days: 30 },
{ plan_code: "pro_quarterly", plan_id: 102, amount_usdc: "79.9", duration_days: 90 },
];
const referral = backend?.referral;
const referralCode = String(referral?.code || "").trim();
const appliedReferralCode = String(referral?.applied_code || "").trim();
const canApplyReferralCode = Boolean(
isAuthenticated &&
!isSubscribed &&
!appliedReferralCode &&
referralCodeInput.trim(),
);
// ── Payment overlay tracking effect ──────────────────────
useEffect(() => {
@@ -432,6 +458,55 @@ export function AccountCenter() {
});
};
const applyReferralCode = useCallback(async () => {
const code = referralCodeInput.trim();
if (!code || referralApplying) return;
setReferralApplying(true);
setPaymentError("");
setPaymentInfo("");
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (supabaseReady) {
const {
data: { session },
} = await getSupabaseBrowserClient().auth.getSession();
const token = String(session?.access_token || "").trim();
if (token) headers.Authorization = `Bearer ${token}`;
}
const res = await fetch("/api/auth/referral/apply", {
method: "POST",
headers,
body: JSON.stringify({ code }),
});
if (!res.ok) {
const raw = (await res.text()).slice(0, 240);
throw new Error(raw || copy.referralApplyFailed);
}
setPaymentInfo(copy.referralApplied);
setReferralCodeInput("");
await loadSnapshot();
await loadPaymentSnapshot();
} catch (error) {
setPaymentError(
error instanceof Error ? error.message : copy.referralApplyFailed,
);
} finally {
setReferralApplying(false);
}
}, [
copy.referralApplied,
copy.referralApplyFailed,
loadPaymentSnapshot,
loadSnapshot,
referralApplying,
referralCodeInput,
setPaymentError,
setPaymentInfo,
supabaseReady,
]);
// ── Render ────────────────────────────────────────────
if (loading && !refreshing) {
@@ -826,6 +901,31 @@ export function AccountCenter() {
{/* Telegram Bot Section & Payment Details */}
{showSecondarySections ? (
<div className="lg:col-span-12 grid grid-cols-1 md:flex gap-6">
{isTrialSubscription && (
<section className="group relative flex-1 overflow-hidden rounded-2xl border border-amber-200 bg-amber-50 p-8 shadow-sm">
<Bot
size={140}
className="absolute -right-8 -bottom-8 -rotate-12 text-amber-100"
/>
<div className="relative z-10">
<h3 className="mb-2 flex items-center gap-2 text-lg font-bold text-amber-800">
<Bot size={22} /> {copy.telegramBind}
</h3>
<p className="text-sm leading-6 text-amber-900">
{copy.trialPaidGroupLocked}
</p>
<button
type="button"
onClick={() => setShowOverlay(true)}
disabled={!canOpenCheckoutOverlay}
className="mt-5 inline-flex items-center gap-2 rounded-xl border border-amber-700 bg-amber-600 px-4 py-3 text-xs font-bold text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Crown size={14} />
{copy.upgradePro}
</button>
</div>
</section>
)}
{canAccessPaidTelegramGroup && (
<section className="group relative flex-1 overflow-hidden rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<Bot
@@ -907,7 +1007,7 @@ export function AccountCenter() {
{/* Payment Details / Wallet Management */}
<section
className={`flex flex-col justify-between rounded-2xl border border-slate-200 bg-white p-8 shadow-sm ${
canAccessPaidTelegramGroup ? "w-full md:w-96" : "w-full"
canAccessPaidTelegramGroup || isTrialSubscription ? "w-full md:w-96" : "w-full"
}`}
>
<div>
@@ -943,6 +1043,110 @@ export function AccountCenter() {
)}
</div>
) : null}
<div className="mb-5">
<p className="mb-2 text-[11px] uppercase text-slate-500">
{copy.proPlan}
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{displayPlanList.map((plan) => {
const code = String(plan.plan_code || "");
const active = code === selectedPlanCode;
const isQuarterly = code === "pro_quarterly";
return (
<button
type="button"
key={code}
onClick={() => setSelectedPlanCode(code)}
disabled={paymentBusy}
className={`rounded-xl border px-3 py-3 text-left transition-all ${
active
? "border-blue-300 bg-blue-50 text-blue-900"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
}`}
>
<div className="flex items-center justify-between gap-2 text-xs font-bold">
<span>
{isQuarterly ? copy.quarterlyPlan : copy.monthlyPlan}
</span>
<span>{plan.amount_usdc} USDC</span>
</div>
<div className="mt-1 text-[10px] opacity-80">
{code} · {plan.duration_days}
</div>
</button>
);
})}
</div>
{appliedReferralCode && selectedPlanCode === "pro_monthly" ? (
<p className="mt-2 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-[11px] text-emerald-800">
{copy.referralDiscountHint}
</p>
) : null}
</div>
<div className="mb-5 rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-xs font-bold uppercase text-slate-700">
{copy.referralTitle}
</p>
<span className="text-[10px] text-slate-500">
{copy.referralInviteLimit}
</span>
</div>
<div className="grid gap-3 lg:grid-cols-2">
<div>
<p className="mb-1 text-[10px] uppercase text-slate-500">
{copy.referralMyCode}
</p>
<div className="flex gap-2">
<code className="min-w-0 flex-1 truncate rounded-xl border border-slate-200 bg-white px-3 py-2 font-mono text-xs text-blue-700">
{referralCode || "--"}
</code>
{referralCode ? (
<button
type="button"
onClick={() => handleCopy(referralCode)}
className="rounded-xl border border-blue-700 bg-blue-600 px-3 text-xs font-bold text-white hover:bg-blue-700"
>
{copied ? <CheckCircle2 size={15} /> : <Copy size={15} />}
</button>
) : null}
</div>
<p className="mt-2 text-[11px] leading-5 text-slate-500">
{copy.referralRewardHint}
</p>
</div>
<div>
<p className="mb-1 text-[10px] uppercase text-slate-500">
{copy.referralApplyLabel}
</p>
{appliedReferralCode ? (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-semibold text-emerald-800">
{appliedReferralCode}
</div>
) : (
<div className="flex gap-2">
<input
value={referralCodeInput}
onChange={(event) => setReferralCodeInput(event.target.value)}
placeholder={copy.referralApplyPlaceholder}
className="min-w-0 flex-1 rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs text-slate-950 outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"
/>
<button
type="button"
onClick={() => void applyReferralCode()}
disabled={!canApplyReferralCode || referralApplying}
className="rounded-xl border border-slate-900 bg-slate-900 px-3 text-xs font-bold text-white hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{referralApplying ? "..." : copy.referralApplyButton}
</button>
</div>
)}
<p className="mt-2 text-[11px] leading-5 text-slate-500">
{copy.referralDiscountHint}
</p>
</div>
</div>
</div>
<div className="mb-5 space-y-3">
<InfoRow
icon={Mail}
@@ -0,0 +1,60 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const accountCenter = fs.readFileSync(
path.join(projectRoot, "components", "account", "AccountCenter.tsx"),
"utf8",
);
const accountCopy = fs.readFileSync(
path.join(projectRoot, "components", "account", "account-copy.ts"),
"utf8",
);
const useAccountPayment = fs.readFileSync(
path.join(projectRoot, "components", "account", "useAccountPayment.ts"),
"utf8",
);
const usePaymentFlow = fs.readFileSync(
path.join(projectRoot, "components", "account", "usePaymentFlow.ts"),
"utf8",
);
const types = fs.readFileSync(
path.join(projectRoot, "components", "account", "types.ts"),
"utf8",
);
assert(
accountCopy.includes("3天试用") &&
accountCopy.includes("付费 Telegram 群") &&
accountCopy.includes("邀请码"),
"account copy must describe trial limits and referral code UI",
);
assert(
accountCenter.includes("copy.trialPaidGroupLocked") &&
accountCenter.includes("copy.referralInviteLimit") &&
accountCenter.includes("applyReferralCode"),
"account center must expose trial paid-group gating and referral controls",
);
assert(
accountCenter.includes("pro_quarterly") &&
accountCenter.includes("79.9") &&
accountCenter.includes("29.9"),
"account center must show monthly and quarterly Pro prices",
);
assert(
!useAccountPayment.includes("monthlyPlanList") &&
!usePaymentFlow.includes("monthlyPlanList"),
"payment hooks must not filter checkout plans down to monthly only",
);
assert(
types.includes("ReferralSummary") &&
types.includes("referral?: ReferralSummary | null") &&
types.includes("duration_days: number"),
"account auth and payment types must include referral summary and plan durations",
);
}
+27 -2
View File
@@ -57,6 +57,31 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
: "城市实测温度群",
copyCommand: isEn ? "Copy fallback command" : "复制兜底命令",
paymentMgmt: isEn ? "Payment Management" : "支付管理",
proPlan: isEn ? "Pro Plan" : "Pro 套餐",
monthlyPlan: isEn ? "Monthly" : "月付",
quarterlyPlan: isEn ? "Quarterly" : "季度",
trialBadge: isEn ? "3-day trial" : "3天试用",
trialPaidGroupLocked: isEn
? "Trial users can use the core product experience, but the paid Telegram group is available to full Pro subscriptions only."
: "3天试用用户可体验核心产品,但无法进入付费 Telegram 群;付费群仅对正式 Pro 开放。",
referralTitle: isEn ? "Referral Code" : "邀请码",
referralMyCode: isEn ? "My invite code" : "我的邀请码",
referralApplyLabel: isEn ? "Use invite code" : "使用邀请码",
referralApplyPlaceholder: isEn ? "Enter invite code" : "输入邀请码",
referralApplyButton: isEn ? "Apply" : "应用",
referralApplied: isEn
? "Referral code applied. Your first monthly payment is now discounted."
: "邀请码已应用,首月 Pro 将按邀请价结算。",
referralApplyFailed: isEn ? "Failed to apply referral code" : "邀请码应用失败",
referralDiscountHint: isEn
? "Invite discount: first monthly Pro is 26.9 USDC."
: "邀请首月价:Pro 月付 26.9 USDC。",
referralRewardHint: isEn
? "When an invited user pays for Pro, you receive +3 days Pro."
: "被邀请人成功付费后,邀请人获得 +3 天 Pro。",
referralInviteLimit: isEn
? "Monthly referral reward cap: 10 paid invites, up to +30 days Pro."
: "每月最多 10 个有效付费邀请奖励,最高 +30 天 Pro。",
paymentToken: isEn ? "Payment Token" : "支付币种",
paymentAccount: isEn ? "Subscription Account" : "订阅归属账号",
paymentWallet: isEn ? "Paying Wallet" : "付款钱包",
@@ -246,8 +271,8 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
verifyUnknown: isEn ? "Unknown error" : "未知错误",
// ── Telegram bind messages ────────────────────────────────────────
telegramVerifySuccess: isEn
? "Telegram group membership verified. Current membership price: {amount} U."
: "Telegram 群成员验证成功,当前会员价 {amount}U。",
? "Telegram group membership verified. Checkout follows the selected Pro plan."
: "Telegram 群成员验证成功,结算金额以当前选择的 Pro 套餐为准。",
telegramBindClickHint: isEn
? "Open the Telegram Bot, click Start, and confirm binding. Then refresh this page to request group entry."
: "已打开 Telegram Bot,请在 Bot 内点击 Start 并确认绑定;完成后刷新本页再申请入群。",
+16
View File
@@ -10,12 +10,28 @@ export type AuthMeResponse = {
subscription_required?: boolean;
subscription_active?: boolean | null;
subscription_plan_code?: string | null;
subscription_source?: string | null;
subscription_is_trial?: boolean | null;
subscription_starts_at?: string | null;
subscription_expires_at?: string | null;
subscription_total_expires_at?: string | null;
subscription_queued_days?: number | null;
subscription_queued_count?: number | null;
telegram_pricing?: TelegramPricing | null;
referral?: ReferralSummary | null;
};
export type ReferralSummary = {
code?: string;
discount_usdc?: string;
discounted_monthly_amount_usdc?: string;
reward_days?: number;
monthly_reward_limit?: number;
monthly_reward_days_limit?: number;
monthly_reward_count?: number;
monthly_reward_days?: number;
applied_code?: string;
attribution_status?: string;
};
export type TelegramPricing = {
@@ -320,10 +320,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
]);
// ── Selected plan (derived, shared across sub-hooks) ───
const monthlyPlanList = (paymentConfig?.plans || []).filter(
(p) => String(p.plan_code || "").trim().toLowerCase() === "pro_monthly",
);
const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : (paymentConfig?.plans || []);
const effectivePlanList = paymentConfig?.plans || [];
const selectedPlan = effectivePlanList.find((p) => p.plan_code === selectedPlanCode) || effectivePlanList[0];
// ── useWalletBind ──────────────────────────────────────
@@ -496,6 +493,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
// Setters for shared state
setSelectedTokenAddress,
setSelectedPaymentChainId,
setSelectedPlanCode,
setSelectedWallet,
setSelectedInjectedProviderKey,
setProviderMode,
+30 -4
View File
@@ -116,10 +116,31 @@ export function useBilling(params: UseBillingParams) {
// ── Billing ──────────────────────────────────────────────
const billing = useMemo(() => {
const parsedPlanAmount = Number(
backend?.telegram_pricing?.amount_usdc ?? selectedPlan?.amount_usdc ?? 10,
const listAmountRaw = Number(selectedPlan?.amount_usdc ?? 29.9);
const listAmount =
Number.isFinite(listAmountRaw) && listAmountRaw > 0 ? listAmountRaw : 29.9;
const selectedPlanCode = String(selectedPlan?.plan_code || "").toLowerCase();
const referral = backend?.referral;
const referralPending = Boolean(
referral?.applied_code ||
String(referral?.attribution_status || "").toLowerCase() === "pending",
);
const planAmount = Number.isFinite(parsedPlanAmount) && parsedPlanAmount > 0 ? parsedPlanAmount : 10;
const referralDiscountRaw = Number(referral?.discount_usdc ?? 0);
const referralDiscount = Number.isFinite(referralDiscountRaw)
? Math.max(0, referralDiscountRaw)
: 0;
const discountedMonthlyRaw = Number(
referral?.discounted_monthly_amount_usdc ?? 0,
);
const referralApplies =
selectedPlanCode === "pro_monthly" &&
referralPending &&
backend?.subscription_active !== true;
const planAmount = referralApplies
? Number.isFinite(discountedMonthlyRaw) && discountedMonthlyRaw > 0
? discountedMonthlyRaw
: Math.max(0, listAmount - referralDiscount)
: listAmount;
const pointsCfg = paymentConfig?.points_redemption || {};
const pointsEnabled = pointsCfg.enabled !== false;
@@ -145,6 +166,9 @@ export function useBilling(params: UseBillingParams) {
return {
planAmount,
listAmount,
referralApplied: referralApplies,
referralDiscountAmount: referralApplies ? listAmount - planAmount : 0,
pointsEnabled,
pointsPerUsdc,
maxDiscountUsdc,
@@ -155,7 +179,9 @@ export function useBilling(params: UseBillingParams) {
};
}, [
paymentConfig?.points_redemption,
backend?.telegram_pricing?.amount_usdc,
backend?.referral,
backend?.subscription_active,
selectedPlan?.plan_code,
selectedPlan?.amount_usdc,
totalPoints,
usePoints,
@@ -170,10 +170,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
// ── Derived payment values ──────────────────────────────
const planList = paymentConfig?.plans || [];
const monthlyPlanList = planList.filter(
(plan) => String(plan.plan_code || "").trim().toLowerCase() === "pro_monthly",
);
const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : planList;
const effectivePlanList = planList;
const selectedPlan = effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) || effectivePlanList[0];
const availableChainList: PaymentChainOption[] = useMemo(() => {
@@ -319,35 +319,58 @@ function InstitutionalLandingScreen() {
</h2>
<p className="mx-auto mt-4 max-w-2xl text-base text-slate-500">
{isEn
? "One plan. Full access. No hidden fees."
: "一个方案,全部功能,无隐藏费用。"}
? "Start with a 3-day trial, then choose monthly or quarterly Pro access."
: "新用户可先领 3 天免费试用,再选择月付或季度 Pro。"}
</p>
<div className="mx-auto mt-16 max-w-lg">
<div className="relative flex flex-col rounded-3xl border-2 border-blue-500/80 bg-white p-8 shadow-[0_20px_60px_rgba(37,99,235,0.12)] text-left animate-fade-up opacity-0 transition-transform hover:-translate-y-1 hover:shadow-[0_30px_80px_rgba(37,99,235,0.2)] duration-500" style={{ animationDelay: "500ms", animationFillMode: "forwards" }}>
<div className="mx-auto mt-16 grid max-w-5xl gap-4 text-left md:grid-cols-3">
<div className="relative flex flex-col rounded-2xl border border-slate-200 bg-slate-50 p-6 shadow-sm animate-fade-up opacity-0" style={{ animationDelay: "420ms", animationFillMode: "forwards" }}>
<div className="mb-4 inline-flex w-fit rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-bold text-emerald-700">
{isEn ? "Trial" : "试用"}
</div>
<h3 className="text-2xl font-black text-slate-900">
{isEn ? "3-day free trial" : "3 天免费试用"}
</h3>
<p className="mt-3 text-sm leading-relaxed text-slate-500">
{isEn
? "New users receive one signup trial for core product workflows. Paid Telegram group, high-frequency refresh, batch alerts, and API access require Pro."
: "新用户首次注册/登录后自动开通一次 3 天免费试用,可体验核心产品;付费 Telegram 群、高频刷新、批量提醒与 API 需 Pro。"}
</p>
<Link
href="/auth/login?next=%2Fterminal&mode=signup"
className="mt-auto inline-flex items-center justify-center gap-2 rounded-xl border border-emerald-700 bg-emerald-600 px-4 py-3 text-sm font-bold text-white transition hover:bg-emerald-700"
>
{isEn ? "Start trial" : "开始试用"}
<ArrowRight size={15} />
</Link>
</div>
<div className="relative flex flex-col rounded-2xl border-2 border-blue-500/80 bg-white p-6 shadow-[0_20px_60px_rgba(37,99,235,0.12)] animate-fade-up opacity-0 transition-transform hover:-translate-y-1 hover:shadow-[0_30px_80px_rgba(37,99,235,0.18)] duration-500" style={{ animationDelay: "500ms", animationFillMode: "forwards" }}>
<div className="absolute -top-4 left-1/2 -translate-x-1/2 rounded-full bg-gradient-to-r from-blue-600 to-indigo-600 px-5 py-1.5 text-xs font-bold uppercase tracking-widest text-white shadow-md">
{isEn ? "Pro Workspace" : "专业决策分析台"}
</div>
<div>
<h3 className="text-2xl font-black text-slate-900 tracking-tight">
PolyWeather Pro
{isEn ? "Pro Monthly" : "Pro 月付"}
</h3>
<p className="mt-3 text-sm text-slate-500 leading-relaxed">
{isEn
? "Full access to the institutional weather intelligence workspace. Live METAR, DEB forecasts, probability distribution, realtime terminal charts, and alerts."
: "完整访问机构级天气决策分析台。实时 METAR、DEB 预报、概率分布、实时终端图表、实时通知。"}
? "Full Pro access for 30 days, including paid Telegram group eligibility."
: "完整 Pro 权限 30 天,包含付费 Telegram 群准入资格。"}
</p>
<div className="mt-6 flex items-baseline">
<span className="text-5xl font-black tracking-tight text-slate-900">
$10
29.9
</span>
<span className="ml-1 text-sm font-semibold text-slate-500">
/ {isEn ? "month" : "月"}
USDC / 30
</span>
</div>
<p className="mt-1 text-xs text-slate-400">
{isEn ? "Billed monthly. Cancel anytime." : "按月计费,随时可取消。"}
{isEn
? "Referral first-month price: 26.9 USDC."
: "使用邀请码首月 26.9 USDC。"}
</p>
<div className="mt-8 border-t border-slate-100 pt-6">
@@ -369,7 +392,7 @@ function InstitutionalLandingScreen() {
href="/account"
className="group flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-slate-900 to-slate-800 py-3.5 text-center text-sm font-bold text-white shadow-lg shadow-slate-900/20 transition-all hover:scale-[1.02] hover:shadow-slate-900/30 hover:from-blue-600 hover:to-indigo-600 duration-300 active:scale-[0.98]"
>
<span>{isEn ? "Subscribe for $10/month" : "立即订阅 $10/月"}</span>
<span>{isEn ? "Subscribe monthly" : "订阅月付 Pro"}</span>
<ArrowRight size={16} className="transition-transform group-hover:translate-x-1" />
</Link>
<p className="mt-4 text-center text-xs text-slate-400">
@@ -379,6 +402,40 @@ function InstitutionalLandingScreen() {
</p>
</div>
</div>
<div className="relative flex flex-col rounded-2xl border border-slate-200 bg-white p-6 shadow-sm animate-fade-up opacity-0" style={{ animationDelay: "580ms", animationFillMode: "forwards" }}>
<div className="mb-4 inline-flex w-fit rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-bold text-indigo-700">
{isEn ? "Quarterly" : "季度"}
</div>
<h3 className="text-2xl font-black text-slate-900">
{isEn ? "Pro Quarterly" : "Pro 季度"}
</h3>
<p className="mt-3 text-sm leading-relaxed text-slate-500">
{isEn
? "90 days of Pro access at a lower effective monthly cost."
: "90 天 Pro 权限,适合稳定使用,折算月成本更低。"}
</p>
<div className="mt-6 flex items-baseline">
<span className="text-5xl font-black tracking-tight text-slate-900">
79.9
</span>
<span className="ml-1 text-sm font-semibold text-slate-500">
USDC / 90
</span>
</div>
<div className="mt-6 rounded-xl border border-violet-200 bg-violet-50 px-4 py-3 text-sm font-semibold text-violet-800">
{isEn
? "Invite reward: invited user pays Pro, referrer gets +3 days Pro."
: "邀请奖励:被邀请人成功付费后,邀请人 +3 天 Pro。"}
</div>
<Link
href="/account"
className="mt-auto inline-flex items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-4 py-3 text-sm font-bold text-slate-800 transition hover:border-slate-400 hover:bg-slate-50"
>
{isEn ? "Choose quarterly" : "选择季度 Pro"}
<ArrowRight size={15} />
</Link>
</div>
</div>
</div>
</section>
@@ -0,0 +1,23 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const source = fs.readFileSync(
path.join(projectRoot(), "components", "landing", "InstitutionalLandingPage.tsx"),
"utf8",
);
assert(source.includes("3 天免费试用"), "landing page must advertise the 3-day trial");
assert(source.includes("29.9") && source.includes("30 天"), "landing page must show monthly Pro pricing");
assert(source.includes("79.9") && source.includes("90 天"), "landing page must show quarterly Pro pricing");
assert(source.includes("26.9") && source.includes("+3 天 Pro"), "landing page must describe referral discount and reward");
assert(!source.includes("$10"), "legacy $10/month pricing must be removed from landing page");
}
function projectRoot() {
return process.cwd();
}
+87
View File
@@ -96,6 +96,93 @@ 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.trial_claims (
id bigserial primary key,
user_id uuid not null references auth.users(id) on delete cascade,
email text not null default '',
telegram_user_id bigint,
primary_wallet_address text,
metadata jsonb not null default '{}'::jsonb,
claimed_at timestamptz not null default now(),
created_at timestamptz not null default now()
);
create unique index if not exists uq_trial_claims_user
on public.trial_claims(user_id);
create unique index if not exists uq_trial_claims_email
on public.trial_claims(lower(email))
where email <> '';
create unique index if not exists uq_trial_claims_telegram
on public.trial_claims(telegram_user_id)
where telegram_user_id is not null;
create table if not exists public.trial_claim_wallets (
id bigserial primary key,
trial_claim_id bigint not null references public.trial_claims(id) on delete cascade,
wallet_address text not null,
created_at timestamptz not null default now()
);
create unique index if not exists uq_trial_claim_wallets_address
on public.trial_claim_wallets(lower(wallet_address));
create table if not exists public.referral_codes (
id bigserial primary key,
user_id uuid not null references auth.users(id) on delete cascade,
code text not null,
status text not null default 'active' check (status in ('active', 'revoked')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index if not exists uq_referral_codes_user
on public.referral_codes(user_id);
create unique index if not exists uq_referral_codes_code
on public.referral_codes(upper(code));
create table if not exists public.referral_attributions (
id bigserial primary key,
referrer_user_id uuid not null references auth.users(id) on delete cascade,
referred_user_id uuid not null references auth.users(id) on delete cascade,
code text not null,
status text not null default 'pending' check (status in ('pending', 'converted', 'capped', 'cancelled')),
converted_payment_intent_id text,
converted_tx_hash text,
converted_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
check (referrer_user_id <> referred_user_id)
);
create unique index if not exists uq_referral_attributions_referred
on public.referral_attributions(referred_user_id);
create index if not exists idx_referral_attributions_pending
on public.referral_attributions(referred_user_id, created_at desc)
include (id, code, referrer_user_id)
where status = 'pending';
create table if not exists public.referral_rewards (
id bigserial primary key,
referral_attribution_id bigint not null references public.referral_attributions(id) on delete cascade,
referrer_user_id uuid not null references auth.users(id) on delete cascade,
referred_user_id uuid not null references auth.users(id) on delete cascade,
payment_intent_id text not null,
tx_hash text,
reward_days integer not null default 3 check (reward_days > 0 and reward_days <= 30),
created_at timestamptz not null default now()
);
create unique index if not exists uq_referral_rewards_attribution
on public.referral_rewards(referral_attribution_id);
create index if not exists idx_referral_rewards_referrer_month
on public.referral_rewards(referrer_user_id, created_at desc)
include (id, reward_days);
create table if not exists public.wallet_link_challenges (
id bigserial primary key,
user_id uuid not null references auth.users(id) on delete cascade,
@@ -0,0 +1,95 @@
-- PolyWeather trial and referral program.
-- Run in Supabase SQL editor before enabling signup trials/referral checkout.
create table if not exists public.trial_claims (
id bigserial primary key,
user_id uuid not null references auth.users(id) on delete cascade,
email text not null default '',
telegram_user_id bigint,
primary_wallet_address text,
metadata jsonb not null default '{}'::jsonb,
claimed_at timestamptz not null default now(),
created_at timestamptz not null default now()
);
create unique index if not exists uq_trial_claims_user
on public.trial_claims(user_id);
create unique index if not exists uq_trial_claims_email
on public.trial_claims(lower(email))
where email <> '';
create unique index if not exists uq_trial_claims_telegram
on public.trial_claims(telegram_user_id)
where telegram_user_id is not null;
create table if not exists public.trial_claim_wallets (
id bigserial primary key,
trial_claim_id bigint not null references public.trial_claims(id) on delete cascade,
wallet_address text not null,
created_at timestamptz not null default now()
);
create unique index if not exists uq_trial_claim_wallets_address
on public.trial_claim_wallets(lower(wallet_address));
create table if not exists public.referral_codes (
id bigserial primary key,
user_id uuid not null references auth.users(id) on delete cascade,
code text not null,
status text not null default 'active' check (status in ('active', 'revoked')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index if not exists uq_referral_codes_user
on public.referral_codes(user_id);
create unique index if not exists uq_referral_codes_code
on public.referral_codes(upper(code));
create table if not exists public.referral_attributions (
id bigserial primary key,
referrer_user_id uuid not null references auth.users(id) on delete cascade,
referred_user_id uuid not null references auth.users(id) on delete cascade,
code text not null,
status text not null default 'pending' check (status in ('pending', 'converted', 'capped', 'cancelled')),
converted_payment_intent_id text,
converted_tx_hash text,
converted_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
check (referrer_user_id <> referred_user_id)
);
create unique index if not exists uq_referral_attributions_referred
on public.referral_attributions(referred_user_id);
create index if not exists idx_referral_attributions_pending
on public.referral_attributions(referred_user_id, created_at desc)
include (id, code, referrer_user_id)
where status = 'pending';
create table if not exists public.referral_rewards (
id bigserial primary key,
referral_attribution_id bigint not null references public.referral_attributions(id) on delete cascade,
referrer_user_id uuid not null references auth.users(id) on delete cascade,
referred_user_id uuid not null references auth.users(id) on delete cascade,
payment_intent_id text not null,
tx_hash text,
reward_days integer not null default 3 check (reward_days > 0 and reward_days <= 30),
created_at timestamptz not null default now()
);
create unique index if not exists uq_referral_rewards_attribution
on public.referral_rewards(referral_attribution_id);
create index if not exists idx_referral_rewards_referrer_month
on public.referral_rewards(referrer_user_id, created_at desc)
include (id, reward_days);
analyze public.trial_claims;
analyze public.trial_claim_wallets;
analyze public.referral_codes;
analyze public.referral_attributions;
analyze public.referral_rewards;
+601 -2
View File
@@ -1,15 +1,28 @@
from __future__ import annotations
import os
import secrets
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Dict, List, Optional
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
import requests
from loguru import logger
from src.database.db_manager import DBManager
SIGNUP_TRIAL_PLAN_CODE = "signup_trial_3d"
SIGNUP_TRIAL_SOURCE = "signup_trial"
SIGNUP_TRIAL_DAYS = 3
REFERRAL_REWARD_DAYS = 3
REFERRAL_MONTHLY_REWARD_LIMIT = 10
REFERRAL_MONTHLY_DAY_LIMIT = 30
REFERRAL_DISCOUNT_USDC = "3"
REFERRAL_MONTHLY_DISCOUNTED_AMOUNT_USDC = "26.9"
def _env_bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
@@ -121,9 +134,595 @@ class SupabaseEntitlementService:
"Accept": "application/json",
}
def _service_rest_headers(self, prefer: Optional[str] = None) -> Dict[str, str]:
headers = self._request_headers_for_service_role()
headers["Content-Type"] = "application/json"
if prefer:
headers["Prefer"] = prefer
return headers
def _rest(
self,
method: str,
table: str,
*,
params: Optional[Dict[str, Any]] = None,
payload: Optional[Any] = None,
prefer: Optional[str] = None,
allowed_status: Optional[List[int]] = None,
) -> Any:
if not self.supabase_url or not self.service_role_key:
raise RuntimeError("supabase service role is not configured")
status_ok = allowed_status or [200, 201, 204]
response = requests.request(
method=method.upper(),
url=f"{self.supabase_url}/rest/v1/{table}",
headers=self._service_rest_headers(prefer=prefer),
params=params,
json=payload,
timeout=self.timeout_sec,
)
if response.status_code not in status_ok:
detail = response.text[:350] if response.text else response.reason
raise RuntimeError(
f"supabase {method.upper()} {table} failed: "
f"{response.status_code} {detail}"
)
if not response.content:
return None
try:
return response.json()
except Exception:
return None
def _admin_user_endpoint(self, user_id: str) -> str:
return f"{self.supabase_url}/auth/v1/admin/users/{user_id}"
@staticmethod
def _to_iso(dt: datetime) -> str:
return dt.astimezone(timezone.utc).isoformat()
@staticmethod
def _normalize_email(value: Optional[str]) -> str:
return str(value or "").strip().lower()
@staticmethod
def _is_trial_subscription_row(row: Optional[Dict[str, object]]) -> bool:
if not isinstance(row, dict):
return False
plan_code = str(row.get("plan_code") or "").strip().lower()
source = str(row.get("source") or "").strip().lower()
return "trial" in plan_code or "trial" in source
@staticmethod
def _is_paid_subscription_row(row: Optional[Dict[str, object]]) -> bool:
if not isinstance(row, dict):
return False
if SupabaseEntitlementService._is_trial_subscription_row(row):
return False
source = str(row.get("source") or "").strip().lower()
if "referral_reward" in source:
return False
return "payment" in source or source in {"payment_contract", "payment_manual"}
def _telegram_user_id_for(self, user_id: str) -> Optional[int]:
try:
linked = DBManager().get_user_by_supabase_user_id(user_id)
if not isinstance(linked, dict):
return None
telegram_id = int(linked.get("telegram_id") or 0)
return telegram_id or None
except Exception:
return None
def _active_wallet_addresses_for(self, user_id: str) -> List[str]:
try:
rows = self._rest(
"GET",
"user_wallets",
params={
"select": "address",
"user_id": f"eq.{user_id}",
"status": "eq.active",
"limit": "50",
},
allowed_status=[200],
)
except Exception:
return []
out: List[str] = []
if isinstance(rows, list):
for row in rows:
if not isinstance(row, dict):
continue
address = str(row.get("address") or "").strip().lower()
if address and address not in out:
out.append(address)
return out
def _trial_claim_exists(
self,
*,
user_id: str,
email: str,
telegram_user_id: Optional[int],
wallet_addresses: List[str],
) -> bool:
checks = [f"user_id.eq.{user_id}"]
if email:
checks.append(f"email.eq.{email}")
if telegram_user_id:
checks.append(f"telegram_user_id.eq.{telegram_user_id}")
try:
rows = self._rest(
"GET",
"trial_claims",
params={
"select": "id",
"or": f"({','.join(checks)})",
"limit": "1",
},
allowed_status=[200],
)
if isinstance(rows, list) and rows:
return True
except Exception:
raise
if not wallet_addresses:
return False
try:
wallet_rows = self._rest(
"GET",
"trial_claim_wallets",
params={
"select": "id",
"wallet_address": f"in.({','.join(wallet_addresses)})",
"limit": "1",
},
allowed_status=[200],
)
return bool(isinstance(wallet_rows, list) and wallet_rows)
except Exception:
return False
def ensure_signup_trial(self, user_id: str, email: Optional[str] = None) -> Dict[str, object]:
user_key = str(user_id or "").strip()
if not user_key:
return {"created": False, "reason": "missing_user_id"}
if not _env_bool("POLYWEATHER_SIGNUP_TRIAL_ENABLED", True):
return {"created": False, "reason": "disabled"}
if not self.supabase_url or not self.service_role_key:
return {"created": False, "reason": "supabase_not_configured"}
normalized_email = self._normalize_email(email)
try:
telegram_user_id = self._telegram_user_id_for(user_key)
wallet_addresses = self._active_wallet_addresses_for(user_key)
if self._trial_claim_exists(
user_id=user_key,
email=normalized_email,
telegram_user_id=telegram_user_id,
wallet_addresses=wallet_addresses,
):
return {"created": False, "reason": "already_claimed"}
now = datetime.now(timezone.utc)
expires = now + timedelta(days=SIGNUP_TRIAL_DAYS)
claim_payload = {
"user_id": user_key,
"email": normalized_email,
"telegram_user_id": telegram_user_id,
"primary_wallet_address": wallet_addresses[0] if wallet_addresses else None,
"claimed_at": self._to_iso(now),
"metadata": {"wallet_addresses": wallet_addresses},
}
claim_rows = self._rest(
"POST",
"trial_claims",
payload=claim_payload,
prefer="return=representation",
allowed_status=[200, 201],
)
claim_id = None
if isinstance(claim_rows, list) and claim_rows and isinstance(claim_rows[0], dict):
claim_id = claim_rows[0].get("id")
if wallet_addresses and claim_id is not None:
self._rest(
"POST",
"trial_claim_wallets",
payload=[
{
"trial_claim_id": claim_id,
"wallet_address": address,
"created_at": self._to_iso(now),
}
for address in wallet_addresses
],
prefer="return=minimal",
allowed_status=[201],
)
subscription_payload = {
"user_id": user_key,
"plan_code": SIGNUP_TRIAL_PLAN_CODE,
"status": "active",
"starts_at": self._to_iso(now),
"expires_at": self._to_iso(expires),
"source": SIGNUP_TRIAL_SOURCE,
"created_at": self._to_iso(now),
"updated_at": self._to_iso(now),
}
self._rest(
"POST",
"subscriptions",
payload=subscription_payload,
prefer="return=minimal",
allowed_status=[201],
)
self._rest(
"POST",
"entitlement_events",
payload={
"user_id": user_key,
"action": "signup_trial_granted",
"reason": "first_auth",
"actor": "supabase_auth",
"payload": {
"plan_code": SIGNUP_TRIAL_PLAN_CODE,
"expires_at": self._to_iso(expires),
},
"created_at": self._to_iso(now),
},
prefer="return=minimal",
allowed_status=[201],
)
self.invalidate_subscription_cache(user_key)
return {
"created": True,
"plan_code": SIGNUP_TRIAL_PLAN_CODE,
"expires_at": self._to_iso(expires),
}
except Exception as exc:
logger.warning("signup trial grant failed user_id={}: {}", user_key, exc)
return {"created": False, "reason": "error"}
def has_paid_subscription(self, user_id: str) -> bool:
user_key = str(user_id or "").strip()
if not user_key:
return False
try:
rows = self._rest(
"GET",
"subscriptions",
params={
"select": "plan_code,source,status,starts_at,expires_at",
"user_id": f"eq.{user_key}",
"limit": "100",
},
allowed_status=[200],
)
except Exception:
return False
if not isinstance(rows, list):
return False
return any(self._is_paid_subscription_row(row) for row in rows if isinstance(row, dict))
@staticmethod
def _normalize_referral_code(value: Optional[str]) -> str:
return "".join(str(value or "").strip().upper().split())
def ensure_referral_code(self, user_id: str) -> Optional[Dict[str, object]]:
user_key = str(user_id or "").strip()
if not user_key or not self.service_role_key:
return None
try:
rows = self._rest(
"GET",
"referral_codes",
params={
"select": "code,status,created_at",
"user_id": f"eq.{user_key}",
"status": "eq.active",
"limit": "1",
},
allowed_status=[200],
)
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
return rows[0]
now = datetime.now(timezone.utc)
for _ in range(5):
code = f"PW{secrets.token_hex(4).upper()}"
try:
created = self._rest(
"POST",
"referral_codes",
payload={
"user_id": user_key,
"code": code,
"status": "active",
"created_at": self._to_iso(now),
"updated_at": self._to_iso(now),
},
prefer="return=representation",
allowed_status=[200, 201],
)
if isinstance(created, list) and created and isinstance(created[0], dict):
return created[0]
return {"code": code, "status": "active"}
except Exception:
continue
except Exception as exc:
logger.warning("referral code ensure failed user_id={}: {}", user_key, exc)
return None
def get_pending_referral_attribution(self, user_id: str) -> Optional[Dict[str, object]]:
user_key = str(user_id or "").strip()
if not user_key:
return None
try:
rows = self._rest(
"GET",
"referral_attributions",
params={
"select": "id,code,referrer_user_id,referred_user_id,status,created_at",
"referred_user_id": f"eq.{user_key}",
"status": "eq.pending",
"order": "created_at.desc",
"limit": "1",
},
allowed_status=[200],
)
except Exception:
return None
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
return rows[0]
return None
def _current_month_reward_rows(self, referrer_user_id: str) -> List[Dict[str, object]]:
month_start = datetime.now(timezone.utc).replace(
day=1,
hour=0,
minute=0,
second=0,
microsecond=0,
)
try:
rows = self._rest(
"GET",
"referral_rewards",
params={
"select": "id,reward_days,created_at",
"referrer_user_id": f"eq.{referrer_user_id}",
"created_at": f"gte.{self._to_iso(month_start)}",
"limit": "100",
},
allowed_status=[200],
)
except Exception:
return []
return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else []
def get_referral_summary(self, user_id: str) -> Optional[Dict[str, object]]:
user_key = str(user_id or "").strip()
if not user_key or not self.service_role_key:
return None
try:
code_row = self.ensure_referral_code(user_key) or {}
pending = self.get_pending_referral_attribution(user_key)
rewards = self._current_month_reward_rows(user_key)
reward_count = len(rewards)
reward_days = sum(int(row.get("reward_days") or 0) for row in rewards)
return {
"code": str(code_row.get("code") or ""),
"discount_usdc": REFERRAL_DISCOUNT_USDC,
"discounted_monthly_amount_usdc": REFERRAL_MONTHLY_DISCOUNTED_AMOUNT_USDC,
"reward_days": REFERRAL_REWARD_DAYS,
"monthly_reward_limit": REFERRAL_MONTHLY_REWARD_LIMIT,
"monthly_reward_days_limit": REFERRAL_MONTHLY_DAY_LIMIT,
"monthly_reward_count": reward_count,
"monthly_reward_days": min(reward_days, REFERRAL_MONTHLY_DAY_LIMIT),
"applied_code": str(pending.get("code") or "") if isinstance(pending, dict) else "",
"attribution_status": str(pending.get("status") or "") if isinstance(pending, dict) else "",
}
except Exception as exc:
logger.warning("referral summary failed user_id={}: {}", user_key, exc)
return None
def apply_referral_code(self, user_id: str, code: str) -> Dict[str, object]:
user_key = str(user_id or "").strip()
normalized_code = self._normalize_referral_code(code)
if not user_key:
raise ValueError("user_id required")
if len(normalized_code) < 3:
raise ValueError("invalid referral code")
if self.has_paid_subscription(user_key):
raise ValueError("referral code can only be used before first paid subscription")
rows = self._rest(
"GET",
"referral_codes",
params={
"select": "user_id,code,status",
"code": f"eq.{normalized_code}",
"status": "eq.active",
"limit": "1",
},
allowed_status=[200],
)
if not isinstance(rows, list) or not rows or not isinstance(rows[0], dict):
raise ValueError("referral code not found")
referrer_user_id = str(rows[0].get("user_id") or "").strip()
if not referrer_user_id or referrer_user_id == user_key:
raise ValueError("cannot use your own referral code")
existing = self.get_pending_referral_attribution(user_key)
if isinstance(existing, dict):
return {
"ok": True,
"already_applied": True,
"referral": self.get_referral_summary(user_key),
}
now = datetime.now(timezone.utc)
self._rest(
"POST",
"referral_attributions",
payload={
"referrer_user_id": referrer_user_id,
"referred_user_id": user_key,
"code": normalized_code,
"status": "pending",
"created_at": self._to_iso(now),
"updated_at": self._to_iso(now),
},
prefer="return=minimal",
allowed_status=[201],
)
return {
"ok": True,
"already_applied": False,
"referral": self.get_referral_summary(user_key),
}
def _subscription_extension_start(self, user_id: str) -> datetime:
now = datetime.now(timezone.utc)
try:
rows = self._rest(
"GET",
"subscriptions",
params={
"select": "starts_at,expires_at,plan_code,source",
"user_id": f"eq.{user_id}",
"status": "eq.active",
"expires_at": f"gt.{self._to_iso(now)}",
"order": "expires_at.desc",
"limit": "20",
},
allowed_status=[200],
)
except Exception:
return now
starts = now
if isinstance(rows, list):
for row in rows:
if not isinstance(row, dict) or self._is_trial_subscription_row(row):
continue
exp = self._parse_iso_datetime(str(row.get("expires_at") or ""))
starts_at = self._parse_iso_datetime(str(row.get("starts_at") or ""))
if exp and (starts_at is None or starts_at <= now) and exp > starts:
starts = exp
break
return starts
def settle_referral_reward(
self,
*,
referred_user_id: str,
payment_intent_id: str,
tx_hash: str,
) -> Dict[str, object]:
referred_key = str(referred_user_id or "").strip()
attribution = self.get_pending_referral_attribution(referred_key)
if not isinstance(attribution, dict):
return {"awarded": False, "reason": "no_pending_referral"}
referrer_key = str(attribution.get("referrer_user_id") or "").strip()
if not referrer_key or referrer_key == referred_key:
return {"awarded": False, "reason": "invalid_referrer"}
now = datetime.now(timezone.utc)
monthly_rewards = self._current_month_reward_rows(referrer_key)
if len(monthly_rewards) >= REFERRAL_MONTHLY_REWARD_LIMIT:
self._rest(
"PATCH",
"referral_attributions",
params={"id": f"eq.{attribution.get('id')}"},
payload={
"status": "capped",
"updated_at": self._to_iso(now),
"converted_payment_intent_id": payment_intent_id,
"converted_tx_hash": tx_hash,
},
prefer="return=minimal",
allowed_status=[204],
)
return {"awarded": False, "reason": "monthly_cap_reached"}
starts = self._subscription_extension_start(referrer_key)
expires = starts + timedelta(days=REFERRAL_REWARD_DAYS)
subscription_payload = {
"user_id": referrer_key,
"plan_code": "pro_monthly",
"status": "active",
"starts_at": self._to_iso(starts),
"expires_at": self._to_iso(expires),
"source": "referral_reward",
"created_at": self._to_iso(now),
"updated_at": self._to_iso(now),
}
self._rest(
"POST",
"subscriptions",
payload=subscription_payload,
prefer="return=minimal",
allowed_status=[201],
)
self._rest(
"POST",
"referral_rewards",
payload={
"referral_attribution_id": attribution.get("id"),
"referrer_user_id": referrer_key,
"referred_user_id": referred_key,
"payment_intent_id": payment_intent_id,
"tx_hash": tx_hash,
"reward_days": REFERRAL_REWARD_DAYS,
"created_at": self._to_iso(now),
},
prefer="return=minimal",
allowed_status=[201],
)
self._rest(
"PATCH",
"referral_attributions",
params={"id": f"eq.{attribution.get('id')}"},
payload={
"status": "converted",
"converted_payment_intent_id": payment_intent_id,
"converted_tx_hash": tx_hash,
"converted_at": self._to_iso(now),
"updated_at": self._to_iso(now),
},
prefer="return=minimal",
allowed_status=[204],
)
self._rest(
"POST",
"entitlement_events",
payload={
"user_id": referrer_key,
"action": "referral_reward_granted",
"reason": "referred_user_paid",
"actor": "payment_contract_checkout",
"payload": {
"referred_user_id": referred_key,
"payment_intent_id": payment_intent_id,
"tx_hash": tx_hash,
"reward_days": REFERRAL_REWARD_DAYS,
},
"created_at": self._to_iso(now),
},
prefer="return=minimal",
allowed_status=[201],
)
self.invalidate_subscription_cache(referrer_key)
return {
"awarded": True,
"reward_days": REFERRAL_REWARD_DAYS,
"referrer_user_id": referrer_key,
"subscription": subscription_payload,
}
def get_identity(self, access_token: str) -> Optional[SupabaseIdentity]:
if not access_token:
return None
+123 -11
View File
@@ -13,6 +13,7 @@ from typing import Any, Dict, List, Optional
import requests
from eth_account import Account
from eth_account.messages import encode_defunct
from loguru import logger
from web3 import Web3
from src.auth.supabase_entitlement import SUPABASE_ENTITLEMENT
@@ -90,9 +91,12 @@ ERC20_TRANSFER_EVENT_ABI = {
}
DEFAULT_PLAN_CATALOG: Dict[str, Dict[str, Any]] = {
"pro_monthly": {"plan_id": 101, "amount_usdc": "10", "duration_days": 30},
"pro_monthly": {"plan_id": 101, "amount_usdc": "29.9", "duration_days": 30},
"pro_quarterly": {"plan_id": 102, "amount_usdc": "79.9", "duration_days": 90},
}
REFERRAL_FIRST_MONTH_DISCOUNT_USDC = Decimal("3")
def _env_bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
@@ -195,13 +199,13 @@ def _parse_plan_catalog(raw: str) -> Dict[str, Dict[str, Any]]:
def _parse_allowed_plan_codes(raw: str) -> List[str]:
text = str(raw or "").strip()
if not text:
return ["pro_monthly"]
return ["pro_monthly", "pro_quarterly"]
out: List[str] = []
for part in text.split(","):
code = str(part or "").strip().lower()
if code and code not in out:
out.append(code)
return out or ["pro_monthly"]
return out or ["pro_monthly", "pro_quarterly"]
@dataclass
@@ -363,6 +367,10 @@ class PaymentContractCheckoutService:
self.notify_telegram = _env_bool(
"POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED", True
)
self.telegram_payment_pricing_enabled = _env_bool(
"POLYWEATHER_PAYMENT_TELEGRAM_PRICING_ENABLED",
False,
)
self.points_enabled = _env_bool("POLYWEATHER_PAYMENT_POINTS_ENABLED", True)
self.points_per_usdc = max(
1, _env_int("POLYWEATHER_PAYMENT_POINTS_PER_USDC", 500)
@@ -1650,6 +1658,55 @@ class PaymentContractCheckoutService:
out["telegram_pricing"] = price_payload
return out
def _get_pending_referral_attribution(self, user_id: str) -> Optional[Dict[str, Any]]:
try:
row = SUPABASE_ENTITLEMENT.get_pending_referral_attribution(user_id)
return dict(row) if isinstance(row, dict) else None
except Exception:
return None
def _has_prior_paid_subscription(self, user_id: str) -> bool:
try:
return bool(SUPABASE_ENTITLEMENT.has_paid_subscription(user_id))
except Exception:
return False
def _apply_referral_pricing(
self,
user_id: str,
plan: Dict[str, Any],
) -> Dict[str, Any]:
out = dict(plan)
attribution = self._get_pending_referral_attribution(user_id)
if not attribution or self._has_prior_paid_subscription(user_id):
return out
out["referral_attribution"] = {
"id": attribution.get("id"),
"code": str(attribution.get("code") or "").strip().upper(),
"referrer_user_id": str(attribution.get("referrer_user_id") or "").strip(),
"referred_user_id": user_id,
}
if str(out.get("plan_code") or "").strip().lower() != "pro_monthly":
return out
base_amount = _parse_decimal(out.get("amount_usdc_decimal"), Decimal("0"))
discount = min(REFERRAL_FIRST_MONTH_DISCOUNT_USDC, base_amount)
discounted = base_amount - discount
if discount <= 0 or discounted <= 0:
return out
out["amount_before_discount_usdc_decimal"] = base_amount
out["amount_usdc_decimal"] = discounted
out["amount_usdc"] = _format_decimal(discounted)
out["referral_discount"] = {
"discount_usdc": _format_decimal(discount),
"amount_before_discount_usdc": _format_decimal(base_amount),
"amount_after_discount_usdc": _format_decimal(discounted),
"reason": "first_month_referral",
}
return out
def _build_tx_payload(self, intent: PaymentIntentRecord) -> Dict[str, Any]:
contract = self._get_contract(intent.receiver_address, intent.chain_id)
tx_data = contract.encode_abi(
@@ -1687,10 +1744,10 @@ class PaymentContractCheckoutService:
points_to_consume: Optional[int] = None,
) -> Dict[str, Any]:
self._ensure_enabled()
plan = self._apply_telegram_group_pricing(
user_id,
self._select_plan(plan_code),
)
selected_plan = self._select_plan(plan_code)
if self.telegram_payment_pricing_enabled:
selected_plan = self._apply_telegram_group_pricing(user_id, selected_plan)
plan = self._apply_referral_pricing(user_id, selected_plan)
selected_token = self._resolve_supported_token(token_address, chain_id)
selected_chain_id = int(selected_token.chain_id)
mode = str(payment_mode or "strict").strip().lower()
@@ -1728,6 +1785,10 @@ class PaymentContractCheckoutService:
elif target_wallet:
self._require_user_wallet(user_id, target_wallet)
plan_amount_usdc = plan["amount_usdc_decimal"]
amount_before_discount_usdc = plan.get(
"amount_before_discount_usdc_decimal",
plan_amount_usdc,
)
redemption = self._build_points_redemption(
user_id=user_id,
plan_amount_usdc=plan_amount_usdc,
@@ -1748,13 +1809,17 @@ class PaymentContractCheckoutService:
combined_metadata["chain_name"] = selected_token.chain_name
if isinstance(plan.get("telegram_pricing"), dict):
combined_metadata["telegram_pricing"] = plan["telegram_pricing"]
if isinstance(plan.get("referral_attribution"), dict):
combined_metadata["referral_attribution"] = plan["referral_attribution"]
if isinstance(plan.get("referral_discount"), dict):
combined_metadata["referral_discount"] = plan["referral_discount"]
receiver_address = (
selected_token.direct_receiver_address
if mode == "direct"
else selected_token.receiver_contract
)
combined_metadata["amount_before_discount_usdc"] = _format_decimal(
plan_amount_usdc
amount_before_discount_usdc
)
combined_metadata["amount_after_discount_usdc"] = _format_decimal(
final_amount_usdc
@@ -1813,7 +1878,9 @@ class PaymentContractCheckoutService:
"plan_code": plan["plan_code"],
"plan_id": plan["plan_id"],
"duration_days": plan["duration_days"],
"amount_before_discount_usdc": _format_decimal(plan_amount_usdc),
"amount_before_discount_usdc": _format_decimal(
amount_before_discount_usdc
),
"amount_after_discount_usdc": _format_decimal(final_amount_usdc),
},
"token": {
@@ -2487,13 +2554,14 @@ class PaymentContractCheckoutService:
duration_days: int,
tx_hash: str,
payload: Dict[str, Any],
source: str = "payment",
) -> Dict[str, Any]:
now = _now_utc()
latest_rows = self._rest(
"GET",
"subscriptions",
params={
"select": "starts_at,expires_at",
"select": "starts_at,expires_at,plan_code,source",
"user_id": f"eq.{user_id}",
"status": "eq.active",
"order": "expires_at.desc",
@@ -2507,6 +2575,8 @@ class PaymentContractCheckoutService:
for row in latest_rows:
if not isinstance(row, dict):
continue
if self._subscription_row_is_trial(row):
continue
try:
starts_at = datetime.fromisoformat(
str(row.get("starts_at") or "").replace("Z", "+00:00")
@@ -2540,7 +2610,7 @@ class PaymentContractCheckoutService:
"status": "active",
"starts_at": _to_iso(starts),
"expires_at": _to_iso(expires),
"source": "payment_contract",
"source": str(source or "payment").strip() or "payment",
"created_at": _to_iso(now),
"updated_at": _to_iso(now),
}
@@ -2602,6 +2672,31 @@ class PaymentContractCheckoutService:
source = str(row.get("source") or "").strip().lower()
return "trial" in plan_code or "trial" in source
def _settle_referral_reward_for_intent(
self,
user_id: str,
intent: PaymentIntentRecord,
tx_hash: str,
) -> Dict[str, Any]:
metadata = dict(intent.metadata or {})
if not isinstance(metadata.get("referral_attribution"), dict):
return {}
try:
result = SUPABASE_ENTITLEMENT.settle_referral_reward(
referred_user_id=user_id,
payment_intent_id=intent.intent_id,
tx_hash=tx_hash,
)
return dict(result) if isinstance(result, dict) else {}
except Exception as exc:
logger.warning(
"referral reward settlement failed user_id={} intent_id={}: {}",
user_id,
intent.intent_id,
exc,
)
return {"awarded": False, "reason": "settlement_error"}
def _ensure_confirm_side_effects(
self,
user_id: str,
@@ -2624,9 +2719,15 @@ class PaymentContractCheckoutService:
},
)
subscription_row = self._ensure_confirmed_subscription(user_id, intent, tx_hash)
referral_reward = self._settle_referral_reward_for_intent(
user_id,
intent,
tx_hash,
)
return {
"payment": payment_row,
"subscription": subscription_row,
"referral_reward": referral_reward,
}
def _attempt_confirm_repair(
@@ -2779,6 +2880,7 @@ class PaymentContractCheckoutService:
"already_confirmed": True,
"payment": repaired.get("payment"),
"subscription": repaired.get("subscription"),
"referral_reward": repaired.get("referral_reward"),
}
if intent.status in {"cancelled", "expired"}:
raise PaymentCheckoutError(409, f"intent status is {intent.status}")
@@ -2964,6 +3066,7 @@ class PaymentContractCheckoutService:
"duplicate_tx_hash": tx_hash_text,
"payment": repaired.get("payment"),
"subscription": repaired.get("subscription"),
"referral_reward": repaired.get("referral_reward"),
}
raise PaymentCheckoutError(
409, f"intent status is {refreshed.status}, cannot confirm"
@@ -2999,6 +3102,7 @@ class PaymentContractCheckoutService:
plan = self._select_plan(intent.plan_code)
payment_row = {}
subscription_row = {}
referral_reward = {}
try:
payment_row = self._insert_payment_record(
user_id=user_id,
@@ -3015,6 +3119,12 @@ class PaymentContractCheckoutService:
tx_hash=tx_hash_text,
payload=payload,
)
intent.metadata = confirmed_metadata
referral_reward = self._settle_referral_reward_for_intent(
user_id,
intent,
tx_hash_text,
)
except PaymentCheckoutError as exc:
repaired = self._attempt_confirm_repair(
user_id=user_id,
@@ -3025,6 +3135,7 @@ class PaymentContractCheckoutService:
)
payment_row = repaired.get("payment") or payment_row
subscription_row = repaired.get("subscription") or subscription_row
referral_reward = repaired.get("referral_reward") or referral_reward
if not subscription_row:
raise
self._notify_telegram(
@@ -3046,6 +3157,7 @@ class PaymentContractCheckoutService:
"transaction": tx_payload,
"payment": payment_row,
"subscription": subscription_row,
"referral_reward": referral_reward,
"points_redemption": points_result,
"tx": payload,
}
+1 -1
View File
@@ -303,7 +303,7 @@ def test_direct_intent_does_not_require_bound_wallet(monkeypatch, tmp_path):
assert result["intent"]["allowed_wallet"] is None
assert "direct_payment" in result
assert result["direct_payment"]["receiver_address"] == "0xed2f13aa5ff033c58fb436e178451cd07f693f32"
assert result["direct_payment"]["amount_usdc"] in ("5", "10")
assert result["direct_payment"]["amount_usdc"] == "29.9"
assert posts[0]["payment_mode"] == "direct"
+5 -5
View File
@@ -548,7 +548,7 @@ def test_payment_runtime_state_and_audit_event_roundtrip(tmp_path):
assert events[0]["payload"]["events"] == 2
def test_paid_subscription_starts_after_active_trial(monkeypatch, tmp_path):
def test_paid_subscription_replaces_active_trial_immediately(monkeypatch, tmp_path):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
now = datetime.now(timezone.utc)
@@ -586,8 +586,8 @@ def test_paid_subscription_starts_after_active_trial(monkeypatch, tmp_path):
starts_at = datetime.fromisoformat(str(row["starts_at"]))
expires_at = datetime.fromisoformat(str(row["expires_at"]))
assert starts_at == trial_expires
assert expires_at == trial_expires + timedelta(days=30)
assert starts_at.date() == datetime.now(timezone.utc).date()
assert expires_at == starts_at + timedelta(days=30)
def test_confirm_side_effect_repair_does_not_treat_trial_as_paid(monkeypatch, tmp_path):
@@ -1123,7 +1123,7 @@ def test_tx_hash_unused_check_selects_only_intent_id(monkeypatch, tmp_path):
assert calls[0]["params"]["select"] == "intent_id"
def test_grant_subscription_starts_after_trial_when_only_trial_is_active(monkeypatch, tmp_path):
def test_grant_subscription_keeps_unknown_active_subscription_extension(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")
@@ -1140,7 +1140,7 @@ 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"
assert kwargs["params"]["select"] == "starts_at,expires_at,plan_code,source"
return [
{
"starts_at": (datetime.now(timezone.utc) - timedelta(days=1)).isoformat(),
+179
View File
@@ -0,0 +1,179 @@
from datetime import datetime, timezone
from src.auth.supabase_entitlement import SupabaseEntitlementService
from src.payments.contract_checkout import PaymentContractCheckoutService
def _payment_env(monkeypatch, tmp_path):
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon")
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","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x351a1bca5f49dd0046a7cf0bafa7e12fa6441c3a","direct_receiver_address":"0x351a1bca5f49dd0046a7cf0bafa7e12fa6441c3a","is_default":true}]',
)
monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "payments.db"))
monkeypatch.delenv("POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON", raising=False)
monkeypatch.delenv("POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES", raising=False)
def test_default_payment_catalog_has_monthly_and_quarterly_prices(monkeypatch, tmp_path):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
plans = {
row["plan_code"]: row for row in service.get_config_payload()["plans"]
}
assert plans["pro_monthly"]["amount_usdc"] == "29.9"
assert plans["pro_monthly"]["duration_days"] == 30
assert plans["pro_quarterly"]["amount_usdc"] == "79.9"
assert plans["pro_quarterly"]["duration_days"] == 90
def test_paid_subscription_replaces_active_signup_trial_immediately(monkeypatch, tmp_path):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
writes = []
def fake_rest(method, table, **kwargs):
if method == "GET" and table == "subscriptions":
return [
{
"starts_at": "2026-05-29T00:00:00+00:00",
"expires_at": "2026-06-01T00:00:00+00:00",
"plan_code": "signup_trial_3d",
"source": "signup_trial",
}
]
if method == "POST" and table in {"subscriptions", "entitlement_events"}:
writes.append({"table": table, "payload": kwargs["payload"]})
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": "paid"},
)
starts = datetime.fromisoformat(result["starts_at"])
expires = datetime.fromisoformat(result["expires_at"])
assert result["source"] == "payment"
assert starts.date() == datetime.now(timezone.utc).date()
assert 29 <= (expires - starts).days <= 30
def test_signup_trial_claim_creates_three_day_trial_once(monkeypatch):
monkeypatch.setenv("POLYWEATHER_AUTH_ENABLED", "true")
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon")
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
service = SupabaseEntitlementService()
calls = []
def fake_rest(method, table, **kwargs):
calls.append({"method": method, "table": table, **kwargs})
if method == "GET" and table in {"trial_claims", "user_wallets"}:
return []
if method == "POST" and table in {"trial_claims", "subscriptions", "entitlement_events"}:
return [kwargs.get("payload") or {}]
raise AssertionError((method, table, kwargs))
monkeypatch.setattr(service, "_rest", fake_rest)
result = service.ensure_signup_trial("user-1", "USER@example.com")
assert result["created"] is True
sub_write = next(call for call in calls if call["table"] == "subscriptions")
payload = sub_write["payload"]
assert payload["plan_code"] == "signup_trial_3d"
assert payload["source"] == "signup_trial"
assert payload["status"] == "active"
assert payload["user_id"] == "user-1"
def test_referral_discount_applies_to_first_monthly_payment(monkeypatch, tmp_path):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
intent_posts = []
monkeypatch.setattr(
service,
"_get_pending_referral_attribution",
lambda user_id: {
"id": 7,
"code": "YUAN2026",
"referrer_user_id": "referrer-1",
},
raising=False,
)
monkeypatch.setattr(
service,
"_has_prior_paid_subscription",
lambda user_id: False,
raising=False,
)
def fake_rest(method, table, **kwargs):
if method == "POST" and table == "payment_intents":
intent_posts.append(kwargs["payload"])
return []
raise AssertionError((method, table, kwargs))
monkeypatch.setattr(service, "_rest", fake_rest)
result = service.create_intent(
user_id="referred-1",
plan_code="pro_monthly",
payment_mode="direct",
)
assert result["plan"]["amount_before_discount_usdc"] == "29.9"
assert result["plan"]["amount_after_discount_usdc"] == "26.9"
assert intent_posts[0]["amount_units"] == "26900000"
assert intent_posts[0]["metadata"]["referral_discount"]["discount_usdc"] == "3"
def test_referral_reward_respects_monthly_ten_invite_cap(monkeypatch):
monkeypatch.setenv("POLYWEATHER_AUTH_ENABLED", "true")
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon")
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
service = SupabaseEntitlementService()
writes = []
def fake_rest(method, table, **kwargs):
if method == "GET" and table == "referral_attributions":
return [
{
"id": 77,
"referrer_user_id": "referrer-1",
"referred_user_id": "referred-1",
"status": "pending",
}
]
if method == "GET" and table == "referral_rewards":
return [{"id": i} for i in range(10)]
if method in {"POST", "PATCH"}:
writes.append({"method": method, "table": table, **kwargs})
return []
raise AssertionError((method, table, kwargs))
monkeypatch.setattr(service, "_rest", fake_rest)
result = service.settle_referral_reward(
referred_user_id="referred-1",
payment_intent_id="intent-1",
tx_hash="0x" + "2" * 64,
)
assert result["awarded"] is False
assert result["reason"] == "monthly_cap_reached"
assert not any(call["table"] == "subscriptions" for call in writes)
+4
View File
@@ -466,6 +466,10 @@ class ConfirmPaymentTxRequest(BaseModel):
tx_hash: Optional[str] = None
class ReferralApplyRequest(BaseModel):
code: str = Field(..., min_length=3, max_length=32)
class TelegramLoginRequest(BaseModel):
id: int
first_name: Optional[str] = None
+7 -1
View File
@@ -2,8 +2,9 @@
from fastapi import APIRouter, Request
from web.core import TelegramBindTokenRequest, TelegramLoginRequest
from web.core import ReferralApplyRequest, TelegramBindTokenRequest, TelegramLoginRequest
from web.services.auth_api import (
apply_referral_code,
bind_telegram_by_token,
create_telegram_bot_bind_link,
get_auth_me_payload,
@@ -31,3 +32,8 @@ async def auth_telegram_bind_by_token(request: Request, body: TelegramBindTokenR
@router.post("/api/auth/telegram/bot-bind-link")
async def auth_telegram_bot_bind_link(request: Request):
return create_telegram_bot_bind_link(request)
@router.post("/api/auth/referral/apply")
async def auth_referral_apply(request: Request, body: ReferralApplyRequest):
return apply_referral_code(request, body)
+48 -6
View File
@@ -8,7 +8,7 @@ from fastapi import HTTPException, Request
from src.auth.telegram_group_pricing import TelegramGroupPricing
from src.database.db_manager import DBManager
from web.core import TelegramLoginRequest
from web.core import ReferralApplyRequest, TelegramLoginRequest
import web.routes as legacy_routes
@@ -18,6 +18,14 @@ def _require_auth_identity_without_subscription_gate(request: Request) -> Dict[s
return legacy_routes._require_supabase_identity(request)
def _subscription_row_is_trial(row: Any) -> bool:
if not isinstance(row, dict):
return False
plan_code = str(row.get("plan_code") or "").strip().lower()
source = str(row.get("source") or "").strip().lower()
return "trial" in plan_code or "trial" in source
def get_auth_me_payload(request: Request) -> Dict[str, Any]:
request.state.skip_subscription_gate = True
legacy_routes._assert_entitlement(request)
@@ -25,24 +33,36 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
legacy_routes._bind_optional_supabase_identity(request)
user_id = getattr(request.state, "auth_user_id", None)
email = getattr(request.state, "auth_email", None)
subscription_required = bool(
legacy_routes.SUPABASE_ENTITLEMENT.enabled
and legacy_routes.SUPABASE_ENTITLEMENT.require_subscription
)
subscription_active = None
subscription_plan_code = None
subscription_source = None
subscription_is_trial = False
subscription_starts_at = None
subscription_expires_at = None
subscription_total_expires_at = None
subscription_queued_days = 0
subscription_queued_count = 0
referral = None
if legacy_routes.SUPABASE_ENTITLEMENT.enabled and user_id:
try:
subscription_window = legacy_routes.SUPABASE_ENTITLEMENT.get_subscription_window(
user_id,
respect_requirement=False,
)
legacy_routes.SUPABASE_ENTITLEMENT.ensure_signup_trial(user_id, email)
try:
subscription_window = legacy_routes.SUPABASE_ENTITLEMENT.get_subscription_window(
user_id,
respect_requirement=False,
bypass_cache=True,
)
except TypeError:
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)
@@ -83,16 +103,21 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
)
if isinstance(latest_subscription, dict):
subscription_plan_code = latest_subscription.get("plan_code")
subscription_source = latest_subscription.get("source")
subscription_is_trial = _subscription_row_is_trial(latest_subscription)
subscription_starts_at = latest_subscription.get("starts_at")
subscription_expires_at = latest_subscription.get("expires_at")
elif isinstance(latest_known_subscription, dict):
subscription_plan_code = latest_known_subscription.get("plan_code")
subscription_source = latest_known_subscription.get("source")
subscription_is_trial = _subscription_row_is_trial(latest_known_subscription)
subscription_starts_at = latest_known_subscription.get("starts_at")
subscription_expires_at = latest_known_subscription.get("expires_at")
if isinstance(subscription_window, dict):
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)
referral = legacy_routes.SUPABASE_ENTITLEMENT.get_referral_summary(user_id)
except HTTPException:
raise
except Exception:
@@ -100,11 +125,14 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
raise HTTPException(status_code=403, detail="Subscription required")
subscription_active = None
subscription_plan_code = None
subscription_source = None
subscription_is_trial = False
subscription_starts_at = None
subscription_expires_at = None
subscription_total_expires_at = None
subscription_queued_days = 0
subscription_queued_count = 0
referral = None
points = legacy_routes._resolve_auth_points(request)
weekly_profile = legacy_routes._resolve_weekly_profile(request)
@@ -128,7 +156,7 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
return {
"authenticated": bool(user_id),
"user_id": user_id,
"email": getattr(request.state, "auth_email", None),
"email": email,
"points": points,
"weekly_points": weekly_profile["weekly_points"],
"weekly_rank": weekly_profile["weekly_rank"],
@@ -149,15 +177,29 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
"subscription_required": subscription_required,
"subscription_active": subscription_active,
"subscription_plan_code": subscription_plan_code,
"subscription_source": subscription_source,
"subscription_is_trial": subscription_is_trial,
"subscription_starts_at": subscription_starts_at,
"subscription_expires_at": subscription_expires_at,
"subscription_total_expires_at": subscription_total_expires_at,
"subscription_queued_days": subscription_queued_days,
"subscription_queued_count": subscription_queued_count,
"telegram_pricing": telegram_pricing,
"referral": referral,
}
def apply_referral_code(request: Request, body: ReferralApplyRequest) -> Dict[str, Any]:
identity = _require_auth_identity_without_subscription_gate(request)
try:
return legacy_routes.SUPABASE_ENTITLEMENT.apply_referral_code(
identity["user_id"],
body.code,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
def login_with_telegram(request: Request, body: TelegramLoginRequest) -> Dict[str, Any]:
identity = _require_auth_identity_without_subscription_gate(request)
pricing = TelegramGroupPricing()