Add trial and referral subscription program
This commit is contained in:
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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 并确认绑定;完成后刷新本页再申请入群。",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user