From 522e35de7f310928ac6eaa21becbf817e741ab99 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 29 May 2026 19:24:46 +0800 Subject: [PATCH] Add trial and referral subscription program --- frontend/app/api/auth/referral/apply/route.ts | 45 ++ frontend/components/account/AccountCenter.tsx | 210 +++++- .../__tests__/paymentReferralPricing.test.ts | 60 ++ frontend/components/account/account-copy.ts | 29 +- frontend/components/account/types.ts | 16 + .../components/account/useAccountPayment.ts | 6 +- frontend/components/account/useBilling.ts | 34 +- frontend/components/account/usePaymentFlow.ts | 5 +- .../landing/InstitutionalLandingPage.tsx | 79 ++- .../__tests__/landingPricingReferral.test.ts | 23 + scripts/supabase/schema.sql | 87 +++ .../subscription_referral_20260529.sql | 95 +++ src/auth/supabase_entitlement.py | 603 +++++++++++++++++- src/payments/contract_checkout.py | 134 +++- tests/test_direct_payment.py | 2 +- tests/test_payments_runtime.py | 10 +- tests/test_subscription_referral_program.py | 179 ++++++ web/core.py | 4 + web/routers/auth.py | 8 +- web/services/auth_api.py | 54 +- 20 files changed, 1629 insertions(+), 54 deletions(-) create mode 100644 frontend/app/api/auth/referral/apply/route.ts create mode 100644 frontend/components/account/__tests__/paymentReferralPricing.test.ts create mode 100644 frontend/components/landing/__tests__/landingPricingReferral.test.ts create mode 100644 scripts/supabase/subscription_referral_20260529.sql create mode 100644 tests/test_subscription_referral_program.py diff --git a/frontend/app/api/auth/referral/apply/route.ts b/frontend/app/api/auth/referral/apply/route.ts new file mode 100644 index 00000000..a59e3467 --- /dev/null +++ b/frontend/app/api/auth/referral/apply/route.ts @@ -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", + }); + } +} diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index f8acbaa4..7ea5a63d 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -84,6 +84,8 @@ export function AccountCenter() { const [updatedAt, setUpdatedAt] = useState(""); const [user, setUser] = useState(null); const [backend, setBackend] = useState(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 = { + "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 ? (
+ {isTrialSubscription && ( +
+ +
+

+ {copy.telegramBind} +

+

+ {copy.trialPaidGroupLocked} +

+ +
+
+ )} {canAccessPaidTelegramGroup && (
@@ -943,6 +1043,110 @@ export function AccountCenter() { )}
) : null} +
+

+ {copy.proPlan} +

+
+ {displayPlanList.map((plan) => { + const code = String(plan.plan_code || ""); + const active = code === selectedPlanCode; + const isQuarterly = code === "pro_quarterly"; + return ( + + ); + })} +
+ {appliedReferralCode && selectedPlanCode === "pro_monthly" ? ( +

+ {copy.referralDiscountHint} +

+ ) : null} +
+
+
+

+ {copy.referralTitle} +

+ + {copy.referralInviteLimit} + +
+
+
+

+ {copy.referralMyCode} +

+
+ + {referralCode || "--"} + + {referralCode ? ( + + ) : null} +
+

+ {copy.referralRewardHint} +

+
+
+

+ {copy.referralApplyLabel} +

+ {appliedReferralCode ? ( +
+ {appliedReferralCode} +
+ ) : ( +
+ 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" + /> + +
+ )} +

+ {copy.referralDiscountHint} +

+
+
+
{ : "城市实测温度群", 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 { 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 并确认绑定;完成后刷新本页再申请入群。", diff --git a/frontend/components/account/types.ts b/frontend/components/account/types.ts index 1ab2e90a..3b23bfb4 100644 --- a/frontend/components/account/types.ts +++ b/frontend/components/account/types.ts @@ -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 = { diff --git a/frontend/components/account/useAccountPayment.ts b/frontend/components/account/useAccountPayment.ts index 19dc76cb..2d14a512 100644 --- a/frontend/components/account/useAccountPayment.ts +++ b/frontend/components/account/useAccountPayment.ts @@ -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, diff --git a/frontend/components/account/useBilling.ts b/frontend/components/account/useBilling.ts index e2e58719..9adfce89 100644 --- a/frontend/components/account/useBilling.ts +++ b/frontend/components/account/useBilling.ts @@ -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, diff --git a/frontend/components/account/usePaymentFlow.ts b/frontend/components/account/usePaymentFlow.ts index ba542ccc..0f571af2 100644 --- a/frontend/components/account/usePaymentFlow.ts +++ b/frontend/components/account/usePaymentFlow.ts @@ -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(() => { diff --git a/frontend/components/landing/InstitutionalLandingPage.tsx b/frontend/components/landing/InstitutionalLandingPage.tsx index dbd11334..6c75b97c 100644 --- a/frontend/components/landing/InstitutionalLandingPage.tsx +++ b/frontend/components/landing/InstitutionalLandingPage.tsx @@ -319,35 +319,58 @@ function InstitutionalLandingScreen() {

{isEn - ? "One plan. Full access. No hidden fees." - : "一个方案,全部功能,无隐藏费用。"} + ? "Start with a 3-day trial, then choose monthly or quarterly Pro access." + : "新用户可先领 3 天免费试用,再选择月付或季度 Pro。"}

-
-
+
+
+
+ {isEn ? "Trial" : "试用"} +
+

+ {isEn ? "3-day free trial" : "3 天免费试用"} +

+

+ {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。"} +

+ + {isEn ? "Start trial" : "开始试用"} + + +
+ +
{isEn ? "Pro Workspace" : "专业决策分析台"}

- PolyWeather Pro + {isEn ? "Pro Monthly" : "Pro 月付"}

{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 群准入资格。"}

- $10 + 29.9 - / {isEn ? "month" : "月"} + USDC / 30 天

- {isEn ? "Billed monthly. Cancel anytime." : "按月计费,随时可取消。"} + {isEn + ? "Referral first-month price: 26.9 USDC." + : "使用邀请码首月 26.9 USDC。"}

@@ -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]" > - {isEn ? "Subscribe for $10/month" : "立即订阅 $10/月"} + {isEn ? "Subscribe monthly" : "订阅月付 Pro"}

@@ -379,6 +402,40 @@ function InstitutionalLandingScreen() {

+ +
+
+ {isEn ? "Quarterly" : "季度"} +
+

+ {isEn ? "Pro Quarterly" : "Pro 季度"} +

+

+ {isEn + ? "90 days of Pro access at a lower effective monthly cost." + : "90 天 Pro 权限,适合稳定使用,折算月成本更低。"} +

+
+ + 79.9 + + + USDC / 90 天 + +
+
+ {isEn + ? "Invite reward: invited user pays Pro, referrer gets +3 days Pro." + : "邀请奖励:被邀请人成功付费后,邀请人 +3 天 Pro。"} +
+ + {isEn ? "Choose quarterly" : "选择季度 Pro"} + + +
diff --git a/frontend/components/landing/__tests__/landingPricingReferral.test.ts b/frontend/components/landing/__tests__/landingPricingReferral.test.ts new file mode 100644 index 00000000..926f0d1c --- /dev/null +++ b/frontend/components/landing/__tests__/landingPricingReferral.test.ts @@ -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(); +} diff --git a/scripts/supabase/schema.sql b/scripts/supabase/schema.sql index 259eb996..eb099789 100644 --- a/scripts/supabase/schema.sql +++ b/scripts/supabase/schema.sql @@ -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, diff --git a/scripts/supabase/subscription_referral_20260529.sql b/scripts/supabase/subscription_referral_20260529.sql new file mode 100644 index 00000000..842760d7 --- /dev/null +++ b/scripts/supabase/subscription_referral_20260529.sql @@ -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; diff --git a/src/auth/supabase_entitlement.py b/src/auth/supabase_entitlement.py index d92b358a..1ab582ec 100644 --- a/src/auth/supabase_entitlement.py +++ b/src/auth/supabase_entitlement.py @@ -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 diff --git a/src/payments/contract_checkout.py b/src/payments/contract_checkout.py index 8a7dcae0..247348b9 100644 --- a/src/payments/contract_checkout.py +++ b/src/payments/contract_checkout.py @@ -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, } diff --git a/tests/test_direct_payment.py b/tests/test_direct_payment.py index 04064ba2..5322267d 100644 --- a/tests/test_direct_payment.py +++ b/tests/test_direct_payment.py @@ -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" diff --git a/tests/test_payments_runtime.py b/tests/test_payments_runtime.py index ce4b09f7..bee71c0b 100644 --- a/tests/test_payments_runtime.py +++ b/tests/test_payments_runtime.py @@ -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(), diff --git a/tests/test_subscription_referral_program.py b/tests/test_subscription_referral_program.py new file mode 100644 index 00000000..059c22e4 --- /dev/null +++ b/tests/test_subscription_referral_program.py @@ -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) diff --git a/web/core.py b/web/core.py index e5f1f109..69518b37 100644 --- a/web/core.py +++ b/web/core.py @@ -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 diff --git a/web/routers/auth.py b/web/routers/auth.py index 7ec49952..5ab076e2 100644 --- a/web/routers/auth.py +++ b/web/routers/auth.py @@ -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) diff --git a/web/services/auth_api.py b/web/services/auth_api.py index 1e8fd8f2..e20b3edb 100644 --- a/web/services/auth_api.py +++ b/web/services/auth_api.py @@ -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()