From 90bc895000f130571ed9466c59133ba94de4fb2c Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sat, 30 May 2026 18:04:36 +0800 Subject: [PATCH] Update referral points pricing --- .env.example | 3 +- README_ZH.md | 12 +- docs/COMMERCIALIZATION.md | 15 +- docs/SUPABASE_SETUP_ZH.md | 7 +- docs/TECH_DEBT_ZH.md | 2 +- docs/deep-research-report.md | 4 +- docs/product-review-jun-2026.md | 2 +- frontend/app/page.tsx | 2 +- .../SubscriptionHelpClient.tsx | 24 +- frontend/components/account/AccountCenter.tsx | 70 ++-- .../__tests__/paymentReferralPricing.test.ts | 11 +- frontend/components/account/account-copy.ts | 22 +- frontend/components/account/types.ts | 4 + frontend/components/account/useBilling.ts | 10 +- .../landing/InstitutionalLandingPage.tsx | 10 +- .../__tests__/landingPricingReferral.test.ts | 2 +- .../subscription/UnlockProOverlay.tsx | 10 +- .../referral_points_rewards_20260530.sql | 57 +++ ...eferral_points_rewards_verify_20260530.sql | 94 +++++ scripts/supabase/schema.sql | 33 +- .../subscription_referral_20260529.sql | 5 +- src/auth/supabase_entitlement.py | 329 ++++++++++++++---- src/bot/command_guard.py | 2 +- src/bot/io_layer.py | 41 +-- src/bot/runtime_coordinator.py | 2 +- src/bot/settings.py | 13 +- src/bot/weekly_reward_loop.py | 2 +- src/database/db_manager.py | 53 +++ src/payments/contract_checkout.py | 61 +++- tests/test_bot_io_layer_points.py | 7 +- tests/test_subscription_referral_program.py | 245 ++++++++++++- tests/test_weekly_points_display.py | 10 +- 32 files changed, 940 insertions(+), 224 deletions(-) create mode 100644 scripts/supabase/referral_points_rewards_20260530.sql create mode 100644 scripts/supabase/referral_points_rewards_verify_20260530.sql diff --git a/.env.example b/.env.example index 9c7b02ed..51d41df2 100644 --- a/.env.example +++ b/.env.example @@ -200,7 +200,8 @@ POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true POLYWEATHER_PAYMENT_POINTS_ENABLED=true POLYWEATHER_PAYMENT_POINTS_PER_USDC=500 POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3 -POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly +POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC_BY_PLAN_JSON={"pro_monthly":3,"pro_quarterly":8} +POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly,pro_quarterly POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON= POLYMARKET_SIGNAL_EDGE_PCT=2 diff --git a/README_ZH.md b/README_ZH.md index 0a355444..26fe8a7e 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -14,16 +14,16 @@ ![PolyWeather Telegram 跑道推送](frontend/public/static/tel.png) -## 当前产品状态(2026-05-29) +## 当前产品状态(2026-05-30) -- 已上线订阅制:`Pro 月付 10 USDC`。 -- 已上线积分体系:群内发言赚分 + 首次发言欢迎奖励 (+20) + 每日首条消息奖励 (+2) + 每周全员参与奖。 -- `/city` 与 `/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,最多抵 `3 USDC`)。 -- 周榜奖励已改造:降低赢家积分加成 (200/100/50),所有周活跃用户均享参与奖。 +- 已上线订阅制:`Pro 月付 29.9 USDC / 30 天`,`Pro 季度 79.9 USDC / 90 天`。 +- 积分获取已切换为邀请制度:被邀请人完成首次 Pro 付款后,邀请人获得 `3500` 积分;Telegram 群发言不再获得积分。 +- `/city` 与 `/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,月付最多抵 `3 USDC`,季度最多抵 `8 USDC`)。 +- 邀请首月价:被邀请人首次月付 `20 USDC`;每个邀请人每月最多 10 个有效付费邀请奖励。 - 已上线链上支付:Polygon 合约支付(USDC / USDC.e)+ Ethereum 主网 USDC 直转确认。 - 已上线自动补单:事件监听 + 周期确认双链路。 - 已上线支付运行态与审计接口:`/api/payments/runtime`。 -- 已上线轻量运营后台:`/ops`(会员、周榜、补分、支付异常单)。 +- 已上线轻量运营后台:`/ops`(会员、积分、补分、支付异常单)。 - 已上线轻量可观测性:`/healthz`、`/api/system/status`、`/metrics`。 - 已补最小外部监控栈:Prometheus + Alertmanager + Grafana + Telegram 告警 relay。 - 实时终端已切换到可重放事件流:可见城市图表通过 `/api/events?cities=...&since_revision=...` 订阅 `city_observation_patch.v1`,生产环境使用 Redis Stream 做短窗口 replay,本地/单进程可回退 SQLite event log。 diff --git a/docs/COMMERCIALIZATION.md b/docs/COMMERCIALIZATION.md index 593eda2e..e4fa7451 100644 --- a/docs/COMMERCIALIZATION.md +++ b/docs/COMMERCIALIZATION.md @@ -1,6 +1,6 @@ # 商业化说明(Production) -最后更新:`2026-05-29` +最后更新:`2026-05-30` ## 1. 定位 @@ -18,8 +18,9 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气 | 能力 | 状态 | 备注 | | :-- | :-- | :-- | | 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 | -| 订阅套餐(Pro 月付) | 已上线 | `10 USDC / 30天` | -| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U` | +| 订阅套餐(Pro 月付 / 季度) | 已上线 | `29.9 USDC / 30天`;`79.9 USDC / 90天` | +| 邀请积分 | 已上线 | 被邀请人首月 `20U`;邀请人每个有效付费邀请 `+3500` 积分 | +| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U`;邀请首月价不叠加积分抵扣 | | 合约支付 | 已上线 | Polygon,USDC + USDC.e | | 多链直转支付 | 已上线 | Ethereum 主网 USDC 直转确认 | | 支付自动确认 | 已上线 | Event Loop + Confirm Loop,按 intent.chain_id 查链 | @@ -42,9 +43,11 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气 ## 4. 收费与积分规则(默认) -- 套餐:`pro_monthly`(10 USDC / 30 天) -- 抵扣:500 积分抵 1 USDC,最高抵 3 USDC -- 实付下限:2 USDC(当积分满额时) +- 套餐:`pro_monthly`(29.9 USDC / 30 天)、`pro_quarterly`(79.9 USDC / 90 天) +- 邀请首月:被邀请人首次月付 20 USDC +- 邀请奖励:邀请人 +3500 积分,每月最多 10 个有效付费邀请 +- 抵扣:500 积分抵 1 USDC;月付最高抵 3 USDC,季度最高抵 8 USDC +- 积分来源:仅通过有效付费邀请和后台人工补发,不再通过 Telegram 群发言发放 > 说明:具体运营策略可按阶段调整,生产参数建议放私有仓库。 diff --git a/docs/SUPABASE_SETUP_ZH.md b/docs/SUPABASE_SETUP_ZH.md index 06fe9297..2f8467af 100644 --- a/docs/SUPABASE_SETUP_ZH.md +++ b/docs/SUPABASE_SETUP_ZH.md @@ -92,14 +92,15 @@ POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50 # Ethereum 主网 USDC 当前建议只开 direct transfer,不走 Polygon checkout 合约。 POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_polygon","symbol":"USDC","name":"USDC on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x","direct_receiver_address":"0x","is_default":true},{"code":"usdc_e_polygon","symbol":"USDC.e","name":"USDC.e on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0x","direct_receiver_address":"0x"},{"code":"usdc_ethereum","symbol":"USDC","name":"USDC on Ethereum","chain_id":1,"chain_code":"ethereum","chain_name":"Ethereum Mainnet","address":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","decimals":6,"direct_receiver_address":"0x","supports_contract_checkout":false,"supports_direct_transfer":true,"confirmations":2,"explorer_tx_url":"https://etherscan.io/tx/{tx_hash}"}] -# 套餐(当前只保留月付) -POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON={"pro_monthly":{"plan_id":101,"amount_usdc":"10","duration_days":30}} -POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly +# 套餐 +POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON={"pro_monthly":{"plan_id":101,"amount_usdc":"29.9","duration_days":30},"pro_quarterly":{"plan_id":102,"amount_usdc":"79.9","duration_days":90}} +POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly,pro_quarterly # 积分抵扣 POLYWEATHER_PAYMENT_POINTS_ENABLED=true POLYWEATHER_PAYMENT_POINTS_PER_USDC=500 POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3 +POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC_BY_PLAN_JSON={"pro_monthly":3,"pro_quarterly":8} # 支付自动补单 POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED=true diff --git a/docs/TECH_DEBT_ZH.md b/docs/TECH_DEBT_ZH.md index e3f6354b..c7c728ba 100644 --- a/docs/TECH_DEBT_ZH.md +++ b/docs/TECH_DEBT_ZH.md @@ -60,7 +60,7 @@ flowchart TD | 项目 | 影响 | 建议动作 | | :-- | :-- | :-- | -| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/首次消息奖励/欢迎奖励/周排名奖励/周参与奖/手动补分) | +| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(有效付费邀请/后台人工补发/积分抵扣消费) | | 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 | | 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 | diff --git a/docs/deep-research-report.md b/docs/deep-research-report.md index f92b0e88..79b73e1f 100644 --- a/docs/deep-research-report.md +++ b/docs/deep-research-report.md @@ -15,7 +15,7 @@ DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加 趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。 **城市决策层(Scan Terminal / 结构化实况层)**:地图点击城市后加入城市决策卡,前端拉取 full detail、多模型区间、最新 METAR/官方站点/跑道观测,并通过 `/api/city/{name}/detail` 生成城市级结构化数据。终端图表使用 HTTP snapshot + SSE patch + Redis/SQLite replay,最高温中枢优先使用 DEB hourly consensus,再结合多模型中心、日内 pace 和当前实测。 **市场层(Polymarket 行情对照)**:*[v1.7.0 已移除]* 原先从 Gamma API 发现市场、从 CLOB 读取价格/盘口并计算”模型-市场差”,已于 2026-05-23 随 Polymarket 价格拉取层一并删除。当前 `market_scan` 返回空。 -**商业化与支付**:订阅(`Pro Monthly 10 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e)、Ethereum 主网 USDC 直转确认,并提供“事件监听 + 周期确认”的自动补单机制。 +**商业化与支付**:订阅(`Pro Monthly 29.9 USDC / 30 天`、`Pro Quarterly 79.9 USDC / 90 天`)、邀请积分抵扣、Polygon 链上收款合约(USDC/USDC.e)、Ethereum 主网 USDC 直转确认,并提供“事件监听 + 周期确认”的自动补单机制。 **支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。 **许可证**:仓库根目录 `LICENSE` 当前为 `AGPL-3.0-only`。同时 README 与策略文档明确:品牌、商标、生产私有数据与运营策略不随代码许可证一并授权。 (插图:项目 README 中包含产品截图,可用于快速理解实时终端与 Telegram 推送形态) @@ -148,7 +148,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引 ### 优势 -**产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。2026-05 完成积分制度改造:`/city` `/deb` 改为免费(每日各 10 次),新增首次发言欢迎奖励与每日首条消息奖励,周奖励降低赢家积分差距并增加全员参与奖。 +**产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。2026-05 完成积分制度改造:`/city` `/deb` 改为免费(每日各 10 次),积分获取切换为有效付费邀请奖励;Telegram 群发言不再发放积分。 **复用一套分析内核服务多端**:趋势/概率/DEB 等核心逻辑被抽成分析模块,并被 web 与 bot 共用,避免“两套逻辑漂移”。前端城市决策卡在此基础上补足“机场报文解释 + 市场桶动作口径”,让用户从地图点击可以直接进入可解释决策。 **面向外部 API 的工程防护意识较强**:Open-Meteo 429 冷却期、最小调用间隔、磁盘缓存、缓存 TTL 等措施表明作者已遭遇并处理速率限制与冷启动问题。 同时 AviationWeather 官方文档也明确建议控制频率并可使用 cache 文件降低负载,项目后续可进一步对齐最佳实践。 **支付侧有“事件监听 + 确认补单”的双通路**:支付链路天然存在“交易 pending / RPC 延迟 / 日志索引不完整”等问题,项目通过 event loop 与 confirm loop 双机制提升最终一致性。 diff --git a/docs/product-review-jun-2026.md b/docs/product-review-jun-2026.md index 8c45fa4b..88b0ddde 100644 --- a/docs/product-review-jun-2026.md +++ b/docs/product-review-jun-2026.md @@ -12,7 +12,7 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核 |------|------|------| | 免费 | 交互式全球天气地图 + 城市简报 | 无需登录 | | Pro 试用 | 3 天全功能 | 注册后自动获得 | -| Pro 订阅 | 城市决策卡(结构化实况 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 10 USDC/月(积分抵扣最多 3 USDC) | +| Pro 订阅 | 城市决策卡(结构化实况 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 月付 29.9 USDC / 30 天,季度 79.9 USDC / 90 天(月付最多抵 3 USDC,季度最多抵 8 USDC) | ### 页面结构(9 个路由) diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 1b5757d7..25529f86 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -49,7 +49,7 @@ export default async function HomePage({ price: "29.90", priceCurrency: "USD", description: - "Pro subscription for 30 days. Referral users pay 26.90 USD-equivalent USDC for the first month.", + "Pro subscription for 30 days. Referral users pay 20.00 USD-equivalent USDC for the first month.", availability: "https://schema.org/InStock", }, { diff --git a/frontend/app/subscription-help/SubscriptionHelpClient.tsx b/frontend/app/subscription-help/SubscriptionHelpClient.tsx index a9472d4f..068d4a5d 100644 --- a/frontend/app/subscription-help/SubscriptionHelpClient.tsx +++ b/frontend/app/subscription-help/SubscriptionHelpClient.tsx @@ -12,11 +12,6 @@ import { } from "lucide-react"; import { useI18n } from "@/hooks/useI18n"; -const TELEGRAM_GROUP_URL = String( - process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL || - "https://t.me/+Io5H9oVHFmVjOTQ5", -).trim(); - const FAQ_ITEMS = [ { q_zh: "Pro 包含哪些功能?", @@ -27,14 +22,14 @@ const FAQ_ITEMS = [ { q_zh: "当前订阅价格是多少?", q_en: "What is the current subscription price?", - a_zh: "目前仅提供月付:10 USDC / 30 天。", - a_en: "Monthly plan only: 10 USDC / 30 days.", + a_zh: "Pro 月付 29.9 USDC / 30 天,Pro 季度 79.9 USDC / 90 天。", + a_en: "Pro monthly is 29.9 USDC / 30 days. Pro quarterly is 79.9 USDC / 90 days.", }, { q_zh: "积分如何抵扣?", q_en: "How do points work for discounts?", - a_zh: "满 500 积分起兑,每 500 积分抵 1U,单次最多抵 3U。", - a_en: "500 points minimum — every 500 points = 1 USDC off, up to 3 USDC discount per payment.", + a_zh: "满 500 积分起兑,每 500 积分抵 1U。月付最多抵 3U,季度最多抵 8U。", + a_en: "500 points minimum: every 500 points = 1 USDC off. Monthly orders can use up to 3 USDC off; quarterly orders can use up to 8 USDC off.", }, { q_zh: "支持哪些钱包和支付方式?", @@ -55,11 +50,11 @@ export function SubscriptionHelpClient() { ? "Complete subscription rules and payment guide." : "这里是完整的订阅规则和支付说明。你可以先在页面内绑定钱包,再直接开通 Pro。", priceLabel: isEn ? "Price" : "订阅价格", - priceText: "10 USDC / 30 " + (isEn ? "Days" : "天"), + priceText: isEn ? "29.9 / 30d · 79.9 / 90d" : "29.9 / 30天 · 79.9 / 90天", discountLabel: isEn ? "Points Discount" : "积分抵扣", - discountText: isEn ? "Up to 3 USDC off" : "最多抵 3U", - communityLabel: isEn ? "Community Points" : "社群积分", - communityLink: isEn ? "Join community to earn" : "加入社群即可赚取积分", + discountText: isEn ? "Monthly 3U · Quarterly 8U" : "月付 3U · 季度 8U", + communityLabel: isEn ? "Telegram Group" : "Telegram 群", + communityLink: isEn ? "Open Account Center" : "前往账户中心", faqTitle: isEn ? "FAQ" : "常见问题", }), [isEn]); @@ -102,8 +97,7 @@ export function SubscriptionHelpClient() { {copy.communityLabel} {copy.communityLink} diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index 65642644..07bebbaf 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -431,20 +431,31 @@ export function AccountCenter() { showOverlay, ]); - // ── Weekly points display (component-only derived) ────── - const backendWeeklyPointsRaw = Number(backend?.weekly_points); - const metadataWeeklyPointsRaw = Number( - user?.user_metadata?.weekly_points ?? 0, - ); - const weeklyPointsRaw = Number.isFinite(backendWeeklyPointsRaw) - ? backendWeeklyPointsRaw - : metadataWeeklyPointsRaw; - const weeklyRankRaw = - backend?.weekly_rank ?? user?.user_metadata?.weekly_rank; - const weeklyPoints = Number.isFinite(weeklyPointsRaw) - ? Math.max(0, weeklyPointsRaw) + // ── Referral points display ──────────────────────────── + const referralRewardPointsRaw = Number(referral?.reward_points ?? 3500); + const referralRewardPoints = Number.isFinite(referralRewardPointsRaw) + ? Math.max(0, referralRewardPointsRaw) + : 3500; + const monthlyReferralCountRaw = Number(referral?.monthly_reward_count ?? 0); + const monthlyReferralCount = Number.isFinite(monthlyReferralCountRaw) + ? Math.max(0, monthlyReferralCountRaw) : 0; - const weeklyRank = weeklyRankRaw == null ? "--" : String(weeklyRankRaw); + const monthlyReferralLimitRaw = Number(referral?.monthly_reward_limit ?? 10); + const monthlyReferralLimit = Number.isFinite(monthlyReferralLimitRaw) + ? Math.max(0, monthlyReferralLimitRaw) + : 10; + const monthlyReferralPointsRaw = Number( + referral?.monthly_reward_points ?? monthlyReferralCount * referralRewardPoints, + ); + const monthlyReferralPoints = Number.isFinite(monthlyReferralPointsRaw) + ? Math.max(0, monthlyReferralPointsRaw) + : 0; + const monthlyReferralPointsLimitRaw = Number( + referral?.monthly_reward_points_limit ?? monthlyReferralLimit * referralRewardPoints, + ); + const monthlyReferralPointsLimit = Number.isFinite(monthlyReferralPointsLimitRaw) + ? Math.max(0, monthlyReferralPointsLimitRaw) + : monthlyReferralLimit * referralRewardPoints; // ── Telegram bind command ────────────────────────────── const bindCommand = userId @@ -691,7 +702,7 @@ export function AccountCenter() {

{" "} - {weeklyPoints.toLocaleString()} + {monthlyReferralCount.toLocaleString()}

@@ -700,13 +711,13 @@ export function AccountCenter() {

{" "} - {weeklyRank === "--" ? weeklyRank : `#${weeklyRank}`} + {monthlyReferralCount}/{monthlyReferralLimit}

- {/* Weekly Ranking Motivation */} + {/* Referral rewards */} {showSecondarySections ? (
@@ -717,35 +728,29 @@ export function AccountCenter() {
-
- 1 -
{" "} - Top 1 + {" "} + {copy.referralRewardHint}
- +200 积分 & 7天Pro + +{referralRewardPoints.toLocaleString()}
-
- 2 -
{" "} - Top 2-3 + {" "} + {copy.weeklyPoints}
- +100 积分 + {monthlyReferralCount}/{monthlyReferralLimit}
-
- 4 -
{" "} - Top 4-10 + {" "} + {copy.totalPoints}
- +50 积分 + {monthlyReferralPoints.toLocaleString()}/{monthlyReferralPointsLimit.toLocaleString()}
@@ -753,8 +758,7 @@ export function AccountCenter() {

- 积分规则:群内有效发言(自动防刷检测)+ - 每日首条发言额外奖励。每周一零点结算周榜,所有活跃用户均享参与奖。 + {copy.pointsRule}

diff --git a/frontend/components/account/__tests__/paymentReferralPricing.test.ts b/frontend/components/account/__tests__/paymentReferralPricing.test.ts index 6ec044bf..c348a15c 100644 --- a/frontend/components/account/__tests__/paymentReferralPricing.test.ts +++ b/frontend/components/account/__tests__/paymentReferralPricing.test.ts @@ -46,6 +46,14 @@ export function runTests() { accountCenter.includes("29.9"), "account center must show monthly and quarterly Pro prices", ); + assert( + accountCopy.includes("20 USDC") && + accountCopy.includes("+3500 积分") && + accountCopy.includes("月付订单最多抵扣 3 USDC") && + accountCopy.includes("季度订单最多抵扣 8 USDC") && + !accountCopy.includes("群内有效发言"), + "account copy must describe balanced referral points and remove group-message points", + ); assert( !useAccountPayment.includes("monthlyPlanList") && !usePaymentFlow.includes("monthlyPlanList"), @@ -54,7 +62,8 @@ export function runTests() { assert( types.includes("ReferralSummary") && types.includes("referral?: ReferralSummary | null") && - types.includes("duration_days: number"), + types.includes("duration_days: number") && + types.includes("max_discount_usdc_by_plan"), "account auth and payment types must include referral summary and plan durations", ); } diff --git a/frontend/components/account/account-copy.ts b/frontend/components/account/account-copy.ts index 668d1bc1..ff5a9dd5 100644 --- a/frontend/components/account/account-copy.ts +++ b/frontend/components/account/account-copy.ts @@ -10,9 +10,9 @@ export function createAccountCopy(isEn: boolean): Record { guestUser: isEn ? "Signed-out account" : "未登录账户", joinedAt: isEn ? "Joined" : "加入时间", totalPoints: isEn ? "Total Points" : "总积分 (荣誉)", - weeklyPoints: isEn ? "Weekly Points" : "本周积分 (竞技)", - weeklyRank: isEn ? "Weekly Rank" : "周排行 (竞技)", - weeklyRewards: isEn ? "Weekly Rewards" : "周榜奖励", + weeklyPoints: isEn ? "This Month Invites" : "本月有效邀请", + weeklyRank: isEn ? "Monthly Cap" : "月度上限", + weeklyRewards: isEn ? "Referral Rewards" : "邀请奖励", membershipDetails: isEn ? "Membership Details" : "会员权限详情", identityStatus: isEn ? "Identity Status" : "身份状态", authMode: isEn ? "Auth Mode" : "鉴权模式", @@ -74,14 +74,14 @@ export function createAccountCopy(isEn: boolean): Record { : "邀请码已应用,首月 Pro 将按邀请价结算。", referralApplyFailed: isEn ? "Failed to apply referral code" : "邀请码应用失败", referralDiscountHint: isEn - ? "Invite discount: first monthly Pro is 26.9 USDC." - : "邀请首月价:Pro 月付 26.9 USDC。", + ? "Invite discount: first monthly Pro is 20 USDC." + : "邀请首月价:Pro 月付 20 USDC。", referralRewardHint: isEn - ? "When an invited user pays for Pro, you receive +3 days Pro." - : "被邀请人成功付费后,邀请人获得 +3 天 Pro。", + ? "When an invited user pays for Pro, you receive +3500 points." + : "被邀请人成功付费后,邀请人获得 +3500 积分。", referralInviteLimit: isEn - ? "Monthly referral reward cap: 10 paid invites, up to +30 days Pro." - : "每月最多 10 个有效付费邀请奖励,最高 +30 天 Pro。", + ? "Monthly referral reward cap: 10 paid invites, up to +35000 points." + : "每月最多 10 个有效付费邀请奖励,最高 +35000 积分。", paymentToken: isEn ? "Payment Token" : "支付币种", paymentAccount: isEn ? "Subscription Account" : "订阅归属账号", paymentWallet: isEn ? "Paying Wallet" : "付款钱包", @@ -334,8 +334,8 @@ export function createAccountCopy(isEn: boolean): Record { : "加载支付配置失败: {raw}", // ── Points / Footer ──────────────────────────────────────────────── pointsRule: isEn - ? "Points rule: active messages in group (anti-spam) + daily first message bonus. Weekly leaderboard settles every Monday midnight. All active members receive participation awards." - : "积分规则:群内有效发言(自动防刷检测)+ 每日首条发言额外奖励。每周一零点结算周榜,所有活跃用户均享参与奖。", + ? "Points rule: points are earned through successful paid referrals only. 500 points = 1 USDC. Monthly orders can use up to 3 USDC off; quarterly orders can use up to 8 USDC off." + : "积分规则:积分仅通过有效付费邀请获得。500 积分 = 1 USDC。月付订单最多抵扣 3 USDC,季度订单最多抵扣 8 USDC。", pointsDiscount: isEn ? "Available: {points} points discounting ${amount}. Applied automatically on renewal." : "当前可用 {points} 积分抵扣 ${amount},续费时会自动生效。", diff --git a/frontend/components/account/types.ts b/frontend/components/account/types.ts index 3b23bfb4..1b26fe3a 100644 --- a/frontend/components/account/types.ts +++ b/frontend/components/account/types.ts @@ -26,10 +26,13 @@ export type ReferralSummary = { discount_usdc?: string; discounted_monthly_amount_usdc?: string; reward_days?: number; + reward_points?: number; monthly_reward_limit?: number; monthly_reward_days_limit?: number; + monthly_reward_points_limit?: number; monthly_reward_count?: number; monthly_reward_days?: number; + monthly_reward_points?: number; applied_code?: string; attribution_status?: string; }; @@ -81,6 +84,7 @@ export type PointsRedemptionConfig = { enabled?: boolean; points_per_usdc?: number; max_discount_usdc?: number; + max_discount_usdc_by_plan?: Record; }; export type PaymentConfig = { diff --git a/frontend/components/account/useBilling.ts b/frontend/components/account/useBilling.ts index 0f94e92d..cc756af1 100644 --- a/frontend/components/account/useBilling.ts +++ b/frontend/components/account/useBilling.ts @@ -150,7 +150,10 @@ export function useBilling(params: UseBillingParams) { const pointsPerUsdc = Number.isFinite(pointsPerUsdcRaw) && pointsPerUsdcRaw > 0 ? Math.floor(pointsPerUsdcRaw) : 500; - const maxDiscountRaw = Number(pointsCfg.max_discount_usdc ?? 3); + const maxDiscountByPlan = pointsCfg.max_discount_usdc_by_plan || {}; + const planMaxDiscountRaw = + maxDiscountByPlan[selectedPlanCode] ?? pointsCfg.max_discount_usdc ?? 3; + const maxDiscountRaw = Number(planMaxDiscountRaw); const maxDiscountUsdc = Math.max( 0, Math.min( @@ -160,10 +163,11 @@ export function useBilling(params: UseBillingParams) { ); const maxRedeemablePoints = pointsPerUsdc * maxDiscountUsdc; - const actualRedeem = pointsEnabled ? Math.min(totalPoints, maxRedeemablePoints) : 0; + const pointsCanApply = pointsEnabled && !referralApplies; + const actualRedeem = pointsCanApply ? Math.min(totalPoints, maxRedeemablePoints) : 0; const discountUnits = Math.floor(actualRedeem / pointsPerUsdc); const pointsUsed = discountUnits * pointsPerUsdc; - const canRedeem = pointsEnabled && maxDiscountUsdc > 0 && totalPoints >= pointsPerUsdc; + const canRedeem = pointsCanApply && maxDiscountUsdc > 0 && totalPoints >= pointsPerUsdc; const applyDiscount = usePoints && canRedeem && pointsUsed > 0; return { diff --git a/frontend/components/landing/InstitutionalLandingPage.tsx b/frontend/components/landing/InstitutionalLandingPage.tsx index d3dec2e0..43e62814 100644 --- a/frontend/components/landing/InstitutionalLandingPage.tsx +++ b/frontend/components/landing/InstitutionalLandingPage.tsx @@ -131,13 +131,13 @@ function InstitutionalLandingScreen() { { label: "Trial", value: "3 days" }, { label: "Monthly", value: "29.9 USDC" }, { label: "Quarterly", value: "79.9 USDC" }, - { label: "Referral", value: "26.9 USDC" }, + { label: "Referral", value: "20 USDC" }, ] : [ { label: "试用", value: "3 天" }, { label: "月付", value: "29.9 USDC" }, { label: "季度", value: "79.9 USDC" }, - { label: "邀请首月", value: "26.9 USDC" }, + { label: "邀请首月", value: "20 USDC" }, ]; return ( @@ -387,7 +387,7 @@ function InstitutionalLandingScreen() { USDC / 30 天

- {isEn ? "Referral first month: 26.9 USDC" : "使用邀请码首月 26.9 USDC"} + {isEn ? "Referral first month: 20 USDC" : "使用邀请码首月 20 USDC"}

    {(isEn ? PRO_FEATURES_EN : PRO_FEATURES_ZH).map((feature) => ( @@ -425,8 +425,8 @@ function InstitutionalLandingScreen() {
    {isEn - ? "Invite reward: referrer gets +3 days Pro when invitee subscribes." - : "邀请奖励:被邀请人付费后,邀请人 +3 天 Pro。"} + ? "Invite reward: referrer gets +3500 points when invitee subscribes." + : "邀请奖励:被邀请人付费后,邀请人 +3500 积分。"}
    {isEn - ? "Join community to earn points" - : "加入社群即可赚取积分"} + ? "Invite paid users to earn points" + : "邀请付费用户即可获得积分"} ) : ( {isEn - ? "Join community to earn points" - : "加入社群即可赚取积分"} + ? "Invite paid users to earn points" + : "邀请付费用户即可获得积分"} )} diff --git a/scripts/supabase/referral_points_rewards_20260530.sql b/scripts/supabase/referral_points_rewards_20260530.sql new file mode 100644 index 00000000..8427f537 --- /dev/null +++ b/scripts/supabase/referral_points_rewards_20260530.sql @@ -0,0 +1,57 @@ +-- PolyWeather balanced referral points program. +-- Run once before deploying referral point rewards. + +alter table public.referral_rewards + add column if not exists reward_points integer not null default 0; + +alter table public.referral_rewards + alter column reward_days set default 0; + +alter table public.referral_rewards + drop constraint if exists referral_rewards_reward_days_check; + +alter table public.referral_rewards + add constraint referral_rewards_reward_days_check + check (reward_days >= 0 and reward_days <= 30); + +alter table public.referral_rewards + drop constraint if exists referral_rewards_reward_points_check; + +alter table public.referral_rewards + add constraint referral_rewards_reward_points_check + check (reward_points >= 0 and reward_points <= 100000); + +create index if not exists idx_referral_rewards_referrer_month_points + on public.referral_rewards(referrer_user_id, created_at desc) + include (id, reward_points); + +create table if not exists public.points_ledger ( + id bigserial primary key, + user_id uuid not null references auth.users(id) on delete cascade, + delta integer not null check (delta <> 0), + source text not null, + reason text not null, + payment_intent_id text, + referral_attribution_id bigint references public.referral_attributions(id) on delete set null, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create index if not exists idx_points_ledger_user_created + on public.points_ledger(user_id, created_at desc); + +create index if not exists idx_points_ledger_referral + on public.points_ledger(referral_attribution_id) + where referral_attribution_id is not null; + +alter table public.points_ledger enable row level security; + +drop policy if exists points_ledger_select_own on public.points_ledger; +create policy points_ledger_select_own + on public.points_ledger + for select + to authenticated + using (auth.uid() = user_id); + +analyze public.referral_rewards; +analyze public.points_ledger; diff --git a/scripts/supabase/referral_points_rewards_verify_20260530.sql b/scripts/supabase/referral_points_rewards_verify_20260530.sql new file mode 100644 index 00000000..828fc2f3 --- /dev/null +++ b/scripts/supabase/referral_points_rewards_verify_20260530.sql @@ -0,0 +1,94 @@ +-- Verify the balanced referral-points migration. +-- Run after scripts/supabase/referral_points_rewards_20260530.sql. + +with checks(check_name, ok) as ( + values + ( + 'referral_rewards.reward_points column', + exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'referral_rewards' + and column_name = 'reward_points' + and is_nullable = 'NO' + ) + ), + ( + 'referral_rewards.reward_points check', + exists ( + select 1 + from pg_constraint c + join pg_class t on t.oid = c.conrelid + join pg_namespace n on n.oid = t.relnamespace + where n.nspname = 'public' + and t.relname = 'referral_rewards' + and c.conname = 'referral_rewards_reward_points_check' + ) + ), + ( + 'referral_rewards monthly reward index includes points', + exists ( + select 1 + from pg_indexes + where schemaname = 'public' + and tablename = 'referral_rewards' + and indexname = 'idx_referral_rewards_referrer_month' + and indexdef ilike '%reward_points%' + ) + ), + ( + 'points_ledger table', + exists ( + select 1 + from information_schema.tables + where table_schema = 'public' + and table_name = 'points_ledger' + ) + ), + ( + 'points_ledger user index', + exists ( + select 1 + from pg_indexes + where schemaname = 'public' + and tablename = 'points_ledger' + and indexname = 'idx_points_ledger_user_created' + ) + ), + ( + 'points_ledger referral index', + exists ( + select 1 + from pg_indexes + where schemaname = 'public' + and tablename = 'points_ledger' + and indexname = 'idx_points_ledger_referral' + ) + ), + ( + 'points_ledger RLS enabled', + exists ( + select 1 + from pg_class t + join pg_namespace n on n.oid = t.relnamespace + where n.nspname = 'public' + and t.relname = 'points_ledger' + and t.relrowsecurity + ) + ), + ( + 'points_ledger own-row select policy', + exists ( + select 1 + from pg_policies + where schemaname = 'public' + and tablename = 'points_ledger' + and policyname = 'points_ledger_select_own' + and cmd = 'SELECT' + ) + ) +) +select check_name, ok +from checks +order by check_name; diff --git a/scripts/supabase/schema.sql b/scripts/supabase/schema.sql index 58a927b2..51e1df2f 100644 --- a/scripts/supabase/schema.sql +++ b/scripts/supabase/schema.sql @@ -392,7 +392,8 @@ create table if not exists public.referral_rewards ( 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), + reward_days integer not null default 0 check (reward_days >= 0 and reward_days <= 30), + reward_points integer not null default 0 check (reward_points >= 0 and reward_points <= 100000), created_at timestamptz not null default now() ); @@ -401,7 +402,35 @@ create unique index if not exists uq_referral_rewards_attribution create index if not exists idx_referral_rewards_referrer_month on public.referral_rewards(referrer_user_id, created_at desc) - include (id, reward_days); + include (id, reward_days, reward_points); + +create table if not exists public.points_ledger ( + id bigserial primary key, + user_id uuid not null references auth.users(id) on delete cascade, + delta integer not null check (delta <> 0), + source text not null, + reason text not null, + payment_intent_id text, + referral_attribution_id bigint references public.referral_attributions(id) on delete set null, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create index if not exists idx_points_ledger_user_created + on public.points_ledger(user_id, created_at desc); + +create index if not exists idx_points_ledger_referral + on public.points_ledger(referral_attribution_id) + where referral_attribution_id is not null; + +alter table public.points_ledger enable row level security; + +drop policy if exists points_ledger_select_own on public.points_ledger; +create policy points_ledger_select_own + on public.points_ledger + for select + to authenticated + using (auth.uid() = user_id); create table if not exists public.wallet_link_challenges ( id bigserial primary key, diff --git a/scripts/supabase/subscription_referral_20260529.sql b/scripts/supabase/subscription_referral_20260529.sql index 842760d7..1896c5a8 100644 --- a/scripts/supabase/subscription_referral_20260529.sql +++ b/scripts/supabase/subscription_referral_20260529.sql @@ -77,7 +77,8 @@ create table if not exists public.referral_rewards ( 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), + reward_days integer not null default 0 check (reward_days >= 0 and reward_days <= 30), + reward_points integer not null default 0 check (reward_points >= 0 and reward_points <= 100000), created_at timestamptz not null default now() ); @@ -86,7 +87,7 @@ create unique index if not exists uq_referral_rewards_attribution create index if not exists idx_referral_rewards_referrer_month on public.referral_rewards(referrer_user_id, created_at desc) - include (id, reward_days); + include (id, reward_days, reward_points); analyze public.trial_claims; analyze public.trial_claim_wallets; diff --git a/src/auth/supabase_entitlement.py b/src/auth/supabase_entitlement.py index 465f1a65..b33148be 100644 --- a/src/auth/supabase_entitlement.py +++ b/src/auth/supabase_entitlement.py @@ -18,11 +18,13 @@ SIGNUP_TRIAL_PLAN_CODE = "signup_trial_3d" SIGNUP_TRIAL_SOURCE = "signup_trial" SIGNUP_TRIAL_DAYS = 3 -REFERRAL_REWARD_DAYS = 3 +REFERRAL_REWARD_DAYS = 0 REFERRAL_MONTHLY_REWARD_LIMIT = 10 REFERRAL_MONTHLY_DAY_LIMIT = 30 -REFERRAL_DISCOUNT_USDC = "3" -REFERRAL_MONTHLY_DISCOUNTED_AMOUNT_USDC = "26.9" +REFERRAL_REWARD_POINTS = 3500 +REFERRAL_MONTHLY_POINTS_LIMIT = REFERRAL_REWARD_POINTS * REFERRAL_MONTHLY_REWARD_LIMIT +REFERRAL_DISCOUNT_USDC = "9.9" +REFERRAL_MONTHLY_DISCOUNTED_AMOUNT_USDC = "20" def _env_bool(name: str, default: bool = False) -> bool: @@ -204,6 +206,63 @@ class SupabaseEntitlementService: def _admin_user_endpoint(self, user_id: str) -> str: return f"{self.supabase_url}/auth/v1/admin/users/{user_id}" + @staticmethod + def _extract_points_from_metadata(metadata: Optional[Dict[str, object]]) -> int: + if not isinstance(metadata, dict): + return 0 + for key in ("points", "total_points"): + raw = metadata.get(key) + if raw is None: + continue + try: + return max(0, int(raw)) + except Exception: + continue + return 0 + + def _admin_get_user(self, user_id: str) -> Dict[str, object]: + user_key = str(user_id or "").strip() + if not user_key: + raise ValueError("user_id required") + response = requests.get( + self._admin_user_endpoint(user_key), + headers=self._request_headers_for_service_role(), + timeout=self.timeout_sec, + ) + if response.status_code != 200: + detail = response.text[:350] if response.text else response.reason + raise RuntimeError( + f"supabase admin user query failed: {response.status_code} {detail}" + ) + raw = response.json() if response.content else {} + if isinstance(raw, dict) and isinstance(raw.get("user"), dict): + return dict(raw["user"]) + return dict(raw) if isinstance(raw, dict) else {} + + def _admin_update_user_metadata( + self, + user_id: str, + metadata: Dict[str, object], + ) -> Dict[str, object]: + user_key = str(user_id or "").strip() + if not user_key: + raise ValueError("user_id required") + response = requests.put( + self._admin_user_endpoint(user_key), + headers={**self._request_headers_for_service_role(), "Content-Type": "application/json"}, + json={"user_metadata": metadata or {}}, + timeout=self.timeout_sec, + ) + if response.status_code != 200: + detail = response.text[:350] if response.text else response.reason + raise RuntimeError( + f"supabase admin metadata update failed: {response.status_code} {detail}" + ) + raw = response.json() if response.content else {} + if isinstance(raw, dict) and isinstance(raw.get("user"), dict): + return dict(raw["user"]) + return dict(raw) if isinstance(raw, dict) else {} + @staticmethod def _to_iso(dt: datetime) -> str: return dt.astimezone(timezone.utc).isoformat() @@ -797,7 +856,7 @@ class SupabaseEntitlementService: "GET", "referral_rewards", params={ - "select": "id,reward_days,created_at", + "select": "id,reward_days,reward_points,created_at", "referrer_user_id": f"eq.{referrer_user_id}", "created_at": f"gte.{self._to_iso(month_start)}", "limit": "100", @@ -832,12 +891,32 @@ class SupabaseEntitlementService: { "id": row.get("id"), "reward_days": int(payload.get("reward_days") or REFERRAL_REWARD_DAYS), + "reward_points": int(payload.get("reward_points") or REFERRAL_REWARD_POINTS), "created_at": row.get("created_at"), "_storage": "entitlement_events", } ) return out + def _has_referral_reward_for_attribution(self, attribution_id: object) -> bool: + raw_id = str(attribution_id or "").strip() + if not raw_id or raw_id.startswith("event:"): + return False + try: + rows = self._rest( + "GET", + "referral_rewards", + params={ + "select": "id", + "referral_attribution_id": f"eq.{raw_id}", + "limit": "1", + }, + allowed_status=[200], + ) + except Exception: + return False + return bool(isinstance(rows, list) and rows) + 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: @@ -848,15 +927,19 @@ class SupabaseEntitlementService: 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) + reward_points = sum(int(row.get("reward_points") 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, + "reward_points": REFERRAL_REWARD_POINTS, "monthly_reward_limit": REFERRAL_MONTHLY_REWARD_LIMIT, "monthly_reward_days_limit": REFERRAL_MONTHLY_DAY_LIMIT, + "monthly_reward_points_limit": REFERRAL_MONTHLY_POINTS_LIMIT, "monthly_reward_count": reward_count, "monthly_reward_days": min(reward_days, REFERRAL_MONTHLY_DAY_LIMIT), + "monthly_reward_points": min(reward_points, REFERRAL_MONTHLY_POINTS_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 "", } @@ -947,6 +1030,77 @@ class SupabaseEntitlementService: break return starts + def _record_points_ledger( + self, + *, + user_id: str, + delta: int, + source: str, + reason: str, + payment_intent_id: str = "", + referral_attribution_id: Optional[object] = None, + metadata: Optional[Dict[str, object]] = None, + ) -> None: + try: + self._rest( + "POST", + "points_ledger", + payload={ + "user_id": user_id, + "delta": int(delta), + "source": source, + "reason": reason, + "payment_intent_id": payment_intent_id or None, + "referral_attribution_id": referral_attribution_id, + "metadata": metadata or {}, + "created_at": self._to_iso(datetime.now(timezone.utc)), + }, + prefer="return=minimal", + allowed_status=[201], + ) + except Exception as exc: + logger.info("points ledger write skipped user_id={} reason={}", user_id, exc) + + def _grant_referral_points( + self, + referrer_user_id: str, + points: int, + ) -> Dict[str, object]: + user_key = str(referrer_user_id or "").strip().lower() + amount = int(points or 0) + if not user_key: + return {"ok": False, "reason": "invalid_referrer"} + if amount <= 0: + return {"ok": False, "reason": "invalid_points"} + + try: + db_result = DBManager().grant_points_by_supabase_user_id(user_key, amount) + except Exception as exc: + db_result = {"ok": False, "reason": f"bot_db_error:{exc}"} + if bool(db_result.get("ok")): + return { + "ok": True, + "source": "bot_db", + "points_before": int(db_result.get("points_before") or 0), + "points_added": amount, + "points_after": int(db_result.get("points_after") or 0), + } + + user_obj = self._admin_get_user(user_key) + metadata = dict(user_obj.get("user_metadata") or {}) + before = self._extract_points_from_metadata(metadata) + after = before + amount + metadata["points"] = after + metadata["total_points"] = after + self._admin_update_user_metadata(user_key, metadata) + return { + "ok": True, + "source": "supabase_metadata", + "points_before": before, + "points_added": amount, + "points_after": after, + } + def _record_referral_resolution_event( self, *, @@ -958,6 +1112,7 @@ class SupabaseEntitlementService: tx_hash: str, created_at: datetime, reward_days: int = 0, + reward_points: int = 0, ) -> None: self._rest( "POST", @@ -975,6 +1130,7 @@ class SupabaseEntitlementService: "payment_intent_id": payment_intent_id, "tx_hash": tx_hash, "reward_days": reward_days, + "reward_points": reward_points, "storage": "entitlement_events", }, "created_at": self._to_iso(created_at), @@ -1006,24 +1162,27 @@ class SupabaseEntitlementService: ) return {"awarded": False, "reason": "monthly_cap_reached"} - starts = self._subscription_extension_start(referrer_user_id) - expires = starts + timedelta(days=REFERRAL_REWARD_DAYS) - subscription_payload = { - "user_id": referrer_user_id, - "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], + grant_result = self._grant_referral_points( + referrer_user_id, + REFERRAL_REWARD_POINTS, + ) + if not bool(grant_result.get("ok")): + return { + "awarded": False, + "reason": str(grant_result.get("reason") or "points_grant_failed"), + } + self._record_points_ledger( + user_id=referrer_user_id, + delta=REFERRAL_REWARD_POINTS, + source="referral", + reason="referred_user_paid", + payment_intent_id=payment_intent_id, + referral_attribution_id=attribution.get("id"), + metadata={ + "referred_user_id": referred_user_id, + "tx_hash": tx_hash, + "storage": str(attribution.get("_storage") or "entitlement_events"), + }, ) self._record_referral_resolution_event( action="referral_reward_granted", @@ -1034,6 +1193,7 @@ class SupabaseEntitlementService: tx_hash=tx_hash, created_at=now, reward_days=REFERRAL_REWARD_DAYS, + reward_points=REFERRAL_REWARD_POINTS, ) self._record_referral_resolution_event( action="referral_attribution_converted", @@ -1044,13 +1204,14 @@ class SupabaseEntitlementService: tx_hash=tx_hash, created_at=now, reward_days=REFERRAL_REWARD_DAYS, + reward_points=REFERRAL_REWARD_POINTS, ) - self.invalidate_subscription_cache(referrer_user_id) return { "awarded": True, "reward_days": REFERRAL_REWARD_DAYS, + "reward_points": REFERRAL_REWARD_POINTS, "referrer_user_id": referrer_user_id, - "subscription": subscription_payload, + "points": grant_result, "storage": "entitlement_events", } @@ -1096,40 +1257,80 @@ class SupabaseEntitlementService: allowed_status=[204], ) return {"awarded": False, "reason": "monthly_cap_reached"} + if self._has_referral_reward_for_attribution(attribution.get("id")): + 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], + ) + return {"awarded": False, "reason": "already_rewarded"} - 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], + grant_result = self._grant_referral_points( + referrer_key, + REFERRAL_REWARD_POINTS, ) - self._rest( - "POST", - "referral_rewards", - payload={ + if not bool(grant_result.get("ok")): + return { + "awarded": False, + "reason": str(grant_result.get("reason") or "points_grant_failed"), + } + + reward_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, + "reward_points": REFERRAL_REWARD_POINTS, "created_at": self._to_iso(now), + } + try: + self._rest( + "POST", + "referral_rewards", + payload=reward_payload, + prefer="return=minimal", + allowed_status=[201], + ) + except Exception as exc: + logger.warning( + "referral_rewards insert failed attribution_id={} error={}", + attribution.get("id"), + exc, + ) + self._record_referral_resolution_event( + action="referral_reward_granted", + referrer_user_id=referrer_key, + referred_user_id=referred_key, + attribution=attribution, + payment_intent_id=payment_intent_id, + tx_hash=tx_hash, + created_at=now, + reward_days=REFERRAL_REWARD_DAYS, + reward_points=REFERRAL_REWARD_POINTS, + ) + self._record_points_ledger( + user_id=referrer_key, + delta=REFERRAL_REWARD_POINTS, + source="referral", + reason="referred_user_paid", + payment_intent_id=payment_intent_id, + referral_attribution_id=attribution.get("id"), + metadata={ + "referred_user_id": referred_key, + "tx_hash": tx_hash, + "grant_source": str(grant_result.get("source") or ""), }, - prefer="return=minimal", - allowed_status=[201], ) self._rest( "PATCH", @@ -1145,31 +1346,23 @@ class SupabaseEntitlementService: 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._record_referral_resolution_event( + action="referral_attribution_converted", + referrer_user_id=referrer_key, + referred_user_id=referred_key, + attribution=attribution, + payment_intent_id=payment_intent_id, + tx_hash=tx_hash, + created_at=now, + reward_days=REFERRAL_REWARD_DAYS, + reward_points=REFERRAL_REWARD_POINTS, ) - self.invalidate_subscription_cache(referrer_key) return { "awarded": True, "reward_days": REFERRAL_REWARD_DAYS, + "reward_points": REFERRAL_REWARD_POINTS, "referrer_user_id": referrer_key, - "subscription": subscription_payload, + "points": grant_result, } def get_identity(self, access_token: str) -> Optional[SupabaseIdentity]: diff --git a/src/bot/command_guard.py b/src/bot/command_guard.py index 7702eaaf..f161c94f 100644 --- a/src/bot/command_guard.py +++ b/src/bot/command_guard.py @@ -91,7 +91,7 @@ class CommandGuard: used = int(result.get("used") or 0) self.io_layer.send_query_message( message, - f"❌ 今日 /{query_type} 免费次数已用完 ({used}/{limit})\n请明天再来,或通过群内发言赚取积分。", + f"❌ 今日 /{query_type} 免费次数已用完 ({used}/{limit})\n请明天再来;积分可通过邀请付费用户获得。", parse_mode="HTML", ) return False diff --git a/src/bot/io_layer.py b/src/bot/io_layer.py index bdf1370c..ce372797 100644 --- a/src/bot/io_layer.py +++ b/src/bot/io_layer.py @@ -9,6 +9,7 @@ from loguru import logger from src.bot.settings import ( CITY_DAILY_FREE_LIMIT, DEB_DAILY_FREE_LIMIT, + GROUP_MESSAGE_POINTS_ENABLED, MESSAGE_COOLDOWN_SEC, MESSAGE_DAILY_CAP, MESSAGE_MIN_LENGTH, @@ -184,8 +185,8 @@ class BotIOLayer: f"当前积分: {balance}\n" f"需要积分: {required}\n" f"还差积分: {missing}\n\n" - f"积分规则:有效发言满 {MESSAGE_MIN_LENGTH} 字获得 {MESSAGE_POINTS} 积分," - f"每日上限 {MESSAGE_DAILY_CAP} 分。" + "积分现在通过邀请制度获得:被邀请人完成首次 Pro 付款后," + "邀请人获得积分奖励。" ), parse_mode="HTML", ) @@ -207,9 +208,7 @@ class BotIOLayer: "👥 社群: 加入 Telegram 群组\n\n" "📌 私有频道用于接收自动推送;手动查看市场概览请私聊机器人发送 /markets\n\n" "示例: /city 伦敦/pwcity 伦敦\n" - f"💡 提示: 群内有效发言(满 {MESSAGE_MIN_LENGTH} 字)获得 {MESSAGE_POINTS} 积分," - f"每日上限 {MESSAGE_DAILY_CAP} 分。" - f"首次发言额外奖励 20 积分,每日首条消息 +2 积分。" + "💡 提示: 积分现在通过邀请制度获得;有效邀请完成首次 Pro 付款后,邀请人获得积分奖励。" ) def build_points_rank_text(self, user: Any) -> str: @@ -217,33 +216,17 @@ class BotIOLayer: user_info = self.db.get_user(user.id) now = datetime.now() today_str = now.strftime("%Y-%m-%d") - weekly_profile = self.db.get_weekly_profile(user.id) - week_key = str(weekly_profile.get("week_key") or "") - leaderboard = self.db.get_weekly_leaderboard(limit=5) - rank_text = f"🏆 PolyWeather 周活跃度排行榜 ({week_key})\n" + leaderboard = self.db.get_leaderboard(limit=5) + rank_text = "🏆 PolyWeather 用户积分排行\n" rank_text += "────────────────────\n" for i, entry in enumerate(leaderboard): medal = ["🥇", "🥈", "🥉", " ", " "][i] if i < 5 else " " username = (entry.get("username") or "unknown")[:12] - weekly_points = int(entry.get("weekly_points") or 0) - rank_text += f"{medal} {username}: {weekly_points} 点\n" + points = int(entry.get("points") or 0) + rank_text += f"{medal} {username}: {points} 分\n" if user_info: - daily_points = int(user_info.get("daily_points") or 0) - daily_points_date = str(user_info.get("daily_points_date") or "") - if daily_points_date != today_str: - daily_points = 0 - if daily_points > MESSAGE_DAILY_CAP: - daily_points = MESSAGE_DAILY_CAP - - weekly_points = int(weekly_profile.get("weekly_points") or 0) - weekly_rank = weekly_profile.get("weekly_rank") - ranked_count = int(weekly_profile.get("total_ranked") or 0) - weekly_rank_text = ( - f"{weekly_rank}/{ranked_count}" if weekly_rank and ranked_count > 0 else "未上榜" - ) - daily_queries_date = str(user_info.get("daily_queries_date") or "") city_used = int(user_info.get("daily_city_queries") or 0) if daily_queries_date == today_str else 0 deb_used = int(user_info.get("daily_deb_queries") or 0) if daily_queries_date == today_str else 0 @@ -252,15 +235,15 @@ class BotIOLayer: rank_text += ( "👤 我的状态:\n" f"┣ 累计积分: {user_info['points']}\n" - f"┣ 累计发言: {user_info['message_count']} 次\n" - f"┣ 本周排名: {weekly_rank_text}\n" - f"┣ 本周发言积分: {weekly_points}\n" - f"┣ 今日发言积分: {daily_points}/{MESSAGE_DAILY_CAP}\n" + "┣ 积分获取: 邀请付费用户\n" + "┣ 抵扣规则: 500分 = 1 USDC,单笔最多抵3U\n" f"┗ /city 免费 ({city_used}/{CITY_DAILY_FREE_LIMIT}) | /deb 免费 ({deb_used}/{DEB_DAILY_FREE_LIMIT})" ) return rank_text def track_group_text_activity(self, message: Any) -> None: + if not GROUP_MESSAGE_POINTS_ENABLED or MESSAGE_POINTS <= 0: + return text = str(getattr(message, "text", "") or "") if text.startswith("/"): return diff --git a/src/bot/runtime_coordinator.py b/src/bot/runtime_coordinator.py index 94010b71..7ddda0f2 100644 --- a/src/bot/runtime_coordinator.py +++ b/src/bot/runtime_coordinator.py @@ -188,7 +188,7 @@ class StartupCoordinator: ) def _start_weekly_reward_loop(self) -> LoopStatus: - enabled = _env_bool("POLYWEATHER_WEEKLY_REWARD_ENABLED", True) + enabled = _env_bool("POLYWEATHER_WEEKLY_REWARD_ENABLED", False) chat_ids = get_telegram_chat_ids_from_env() settle_weekday = min( 7, max(1, _env_int("POLYWEATHER_WEEKLY_REWARD_SETTLE_WEEKDAY", 1)) diff --git a/src/bot/settings.py b/src/bot/settings.py index 196ed911..08c871b8 100644 --- a/src/bot/settings.py +++ b/src/bot/settings.py @@ -14,7 +14,18 @@ def _env_int(name: str, default: int, min_value: int = 0) -> int: return max(min_value, value) -MESSAGE_POINTS = _env_int("POLYWEATHER_BOT_MESSAGE_POINTS", 4, min_value=1) +def _env_bool(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return str(raw).strip().lower() in {"1", "true", "yes", "on"} + + +GROUP_MESSAGE_POINTS_ENABLED = _env_bool( + "POLYWEATHER_BOT_GROUP_MESSAGE_POINTS_ENABLED", + False, +) +MESSAGE_POINTS = _env_int("POLYWEATHER_BOT_MESSAGE_POINTS", 0, min_value=0) MESSAGE_DAILY_CAP = _env_int("POLYWEATHER_BOT_MESSAGE_DAILY_CAP", 40, min_value=1) MESSAGE_MIN_LENGTH = _env_int("POLYWEATHER_BOT_MESSAGE_MIN_LENGTH", 3, min_value=1) MESSAGE_COOLDOWN_SEC = _env_int("POLYWEATHER_BOT_MESSAGE_COOLDOWN_SEC", 30, min_value=0) diff --git a/src/bot/weekly_reward_loop.py b/src/bot/weekly_reward_loop.py index 83dc8452..8804ff08 100644 --- a/src/bot/weekly_reward_loop.py +++ b/src/bot/weekly_reward_loop.py @@ -243,7 +243,7 @@ def _render_settle_report( def _runner(bot: Any) -> None: - enabled = _env_bool("POLYWEATHER_WEEKLY_REWARD_ENABLED", True) + enabled = _env_bool("POLYWEATHER_WEEKLY_REWARD_ENABLED", False) if not enabled: logger.info("weekly reward loop disabled") return diff --git a/src/database/db_manager.py b/src/database/db_manager.py index ead09121..34368bcc 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -1442,6 +1442,59 @@ class DBManager: "points_after": after, } + def grant_points_by_supabase_user_id( + self, + supabase_user_id: str, + amount: int, + ) -> Dict[str, Any]: + key = str(supabase_user_id or "").strip().lower() + points = int(amount or 0) + if not key: + return {"ok": False, "reason": "invalid_supabase_user_id"} + if points <= 0: + return {"ok": False, "reason": "invalid_amount"} + + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + telegram_id = self._find_telegram_id_by_supabase_user_id(conn, key) + if telegram_id is None: + return {"ok": False, "reason": "user_not_found", "supabase_user_id": key} + row = conn.execute( + """ + SELECT telegram_id, username, points, supabase_email + FROM users + WHERE telegram_id = ? + LIMIT 1 + """, + (int(telegram_id),), + ).fetchone() + if not row: + return {"ok": False, "reason": "user_not_found", "supabase_user_id": key} + + telegram_id = int(row["telegram_id"] or 0) + before = int(row["points"] or 0) + after = before + points + conn.execute( + """ + UPDATE users + SET points = ? + WHERE telegram_id = ? + """, + (after, telegram_id), + ) + conn.commit() + self._sync_points_to_supabase_user_metadata(telegram_id, force=True) + return { + "ok": True, + "telegram_id": telegram_id, + "username": str(row["username"] or ""), + "supabase_user_id": key, + "supabase_email": str(row["supabase_email"] or ""), + "points_before": before, + "points_added": points, + "points_after": after, + } + def deduct_points_by_supabase_email( self, supabase_email: str, diff --git a/src/payments/contract_checkout.py b/src/payments/contract_checkout.py index 6208f9cf..d3ec5520 100644 --- a/src/payments/contract_checkout.py +++ b/src/payments/contract_checkout.py @@ -95,7 +95,11 @@ DEFAULT_PLAN_CATALOG: Dict[str, Dict[str, Any]] = { "pro_quarterly": {"plan_id": 102, "amount_usdc": "79.9", "duration_days": 90}, } -REFERRAL_FIRST_MONTH_DISCOUNT_USDC = Decimal("3") +REFERRAL_FIRST_MONTH_DISCOUNT_USDC = Decimal("9.9") +DEFAULT_POINTS_MAX_DISCOUNT_BY_PLAN: Dict[str, int] = { + "pro_monthly": 3, + "pro_quarterly": 8, +} def _env_bool(name: str, default: bool = False) -> bool: @@ -208,6 +212,29 @@ def _parse_allowed_plan_codes(raw: str) -> List[str]: return out or ["pro_monthly", "pro_quarterly"] +def _parse_points_max_discount_by_plan(raw: str, fallback: int) -> Dict[str, int]: + if not raw: + return dict(DEFAULT_POINTS_MAX_DISCOUNT_BY_PLAN) + try: + parsed = json.loads(raw) + except Exception: + return dict(DEFAULT_POINTS_MAX_DISCOUNT_BY_PLAN) + if not isinstance(parsed, dict): + return dict(DEFAULT_POINTS_MAX_DISCOUNT_BY_PLAN) + + out: Dict[str, int] = {} + for plan_code, raw_value in parsed.items(): + code = str(plan_code or "").strip().lower() + if not code: + continue + try: + value = int(raw_value) + except Exception: + value = fallback + out[code] = max(0, value) + return out or dict(DEFAULT_POINTS_MAX_DISCOUNT_BY_PLAN) + + @dataclass class WalletBindingRecord: chain_id: int @@ -378,6 +405,11 @@ class PaymentContractCheckoutService: self.points_max_discount_usdc = max( 0, _env_int("POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC", 3) ) + self.points_max_discount_usdc_by_plan = _parse_points_max_discount_by_plan( + os.getenv("POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC_BY_PLAN_JSON") + or "", + self.points_max_discount_usdc, + ) self._w3_lock = threading.Lock() self._w3: Optional[Web3] = None self._w3_url: str = "" @@ -919,6 +951,12 @@ class PaymentContractCheckoutService: balance = self._extract_points_from_metadata(metadata) return {"source": "supabase_metadata", "balance": balance, "metadata": metadata} + def _points_max_discount_for_plan(self, plan_code: str) -> int: + code = str(plan_code or "").strip().lower() + if code in self.points_max_discount_usdc_by_plan: + return max(0, int(self.points_max_discount_usdc_by_plan[code])) + return max(0, int(self.points_max_discount_usdc)) + def _auth_admin_get_user(self, user_id: str) -> Dict[str, Any]: user_id_text = str(user_id or "").strip() if not user_id_text: @@ -961,15 +999,21 @@ class PaymentContractCheckoutService: self, *, user_id: str, + plan_code: str, plan_amount_usdc: Decimal, use_points: bool, requested_points_to_consume: Optional[int], ) -> Dict[str, Any]: + max_discount_for_plan = self._points_max_discount_for_plan(plan_code) base = { "enabled": bool(self.points_enabled), "applied": False, "points_per_usdc": int(self.points_per_usdc), - "max_discount_usdc": int(self.points_max_discount_usdc), + "max_discount_usdc": int(max_discount_for_plan), + "max_discount_usdc_by_plan": { + str(code): int(value) + for code, value in self.points_max_discount_usdc_by_plan.items() + }, "points_source": "supabase_metadata", "points_balance_snapshot": 0, "points_to_consume": 0, @@ -990,7 +1034,7 @@ class PaymentContractCheckoutService: return base max_discount_usdc = min( - Decimal(int(self.points_max_discount_usdc)), + Decimal(int(max_discount_for_plan)), plan_amount_usdc, ) max_points_by_plan = int( @@ -1266,6 +1310,10 @@ class PaymentContractCheckoutService: "enabled": bool(self.points_enabled), "points_per_usdc": int(self.points_per_usdc), "max_discount_usdc": int(self.points_max_discount_usdc), + "max_discount_usdc_by_plan": { + str(plan_code): int(self._points_max_discount_for_plan(plan_code)) + for plan_code in sorted(self.plan_catalog.keys()) + }, }, "plans": [ { @@ -1789,10 +1837,12 @@ class PaymentContractCheckoutService: "amount_before_discount_usdc_decimal", plan_amount_usdc, ) + referral_discount_applied = isinstance(plan.get("referral_discount"), dict) redemption = self._build_points_redemption( user_id=user_id, + plan_code=str(plan.get("plan_code") or plan_code), plan_amount_usdc=plan_amount_usdc, - use_points=bool(use_points), + use_points=bool(use_points) and not referral_discount_applied, requested_points_to_consume=points_to_consume, ) final_amount_usdc = redemption["pay_amount_usdc"] @@ -1831,7 +1881,8 @@ class PaymentContractCheckoutService: redemption.get("points_per_usdc") or self.points_per_usdc ), "max_discount_usdc": int( - redemption.get("max_discount_usdc") or self.points_max_discount_usdc + redemption.get("max_discount_usdc") + or self._points_max_discount_for_plan(str(plan.get("plan_code") or plan_code)) ), "points_source": str( redemption.get("points_source") or "supabase_metadata" diff --git a/tests/test_bot_io_layer_points.py b/tests/test_bot_io_layer_points.py index c1b476e1..5354f0e4 100644 --- a/tests/test_bot_io_layer_points.py +++ b/tests/test_bot_io_layer_points.py @@ -27,7 +27,7 @@ def _message(chat_id: int | str, text: str = "有效发言"): ) -def test_group_message_points_include_configured_forum_chat(monkeypatch): +def test_group_message_points_disabled_by_default(monkeypatch): monkeypatch.delenv("POLYWEATHER_BOT_POINTS_CHAT_IDS", raising=False) monkeypatch.delenv("POLYWEATHER_BOT_POINTS_CHAT_ID", raising=False) monkeypatch.setenv("TELEGRAM_CHAT_IDS", "-1003965137823") @@ -37,9 +37,8 @@ def test_group_message_points_include_configured_forum_chat(monkeypatch): io_layer.track_group_text_activity(_message(-1003965137823)) - assert db.upserts == [(123, "alice")] - assert len(db.activities) == 1 - assert db.activities[0]["telegram_id"] == 123 + assert db.upserts == [] + assert db.activities == [] def test_group_message_points_skip_unconfigured_chat_when_allowlist_exists(monkeypatch): diff --git a/tests/test_subscription_referral_program.py b/tests/test_subscription_referral_program.py index f4b68a46..9538cf16 100644 --- a/tests/test_subscription_referral_program.py +++ b/tests/test_subscription_referral_program.py @@ -32,6 +32,63 @@ def test_default_payment_catalog_has_monthly_and_quarterly_prices(monkeypatch, t assert plans["pro_quarterly"]["amount_usdc"] == "79.9" assert plans["pro_quarterly"]["duration_days"] == 90 + points_config = service.get_config_payload()["points_redemption"] + assert points_config["points_per_usdc"] == 500 + assert points_config["max_discount_usdc_by_plan"]["pro_monthly"] == 3 + assert points_config["max_discount_usdc_by_plan"]["pro_quarterly"] == 8 + + +def test_points_redemption_uses_plan_specific_caps(monkeypatch, tmp_path): + _payment_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + intent_posts = [] + + monkeypatch.setattr( + service, + "_get_pending_referral_attribution", + lambda user_id: None, + raising=False, + ) + monkeypatch.setattr( + service, + "_resolve_points_balance", + lambda user_id: { + "source": "supabase_metadata", + "balance": 5000, + "metadata": {"points": 5000}, + }, + 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) + + monthly = service.create_intent( + user_id="user-1", + plan_code="pro_monthly", + payment_mode="direct", + use_points=True, + ) + quarterly = service.create_intent( + user_id="user-1", + plan_code="pro_quarterly", + payment_mode="direct", + use_points=True, + ) + + assert monthly["plan"]["amount_after_discount_usdc"] == "26.9" + assert intent_posts[0]["metadata"]["points_redemption"]["points_to_consume"] == 1500 + assert intent_posts[0]["metadata"]["points_redemption"]["max_discount_usdc"] == 3 + + assert quarterly["plan"]["amount_after_discount_usdc"] == "71.9" + assert intent_posts[1]["metadata"]["points_redemption"]["points_to_consume"] == 4000 + assert intent_posts[1]["metadata"]["points_redemption"]["max_discount_usdc"] == 8 + def test_paid_subscription_replaces_active_signup_trial_immediately(monkeypatch, tmp_path): _payment_env(monkeypatch, tmp_path) @@ -187,9 +244,116 @@ def test_referral_discount_applies_to_first_monthly_payment(monkeypatch, tmp_pat ) 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" + assert result["plan"]["amount_after_discount_usdc"] == "20" + assert intent_posts[0]["amount_units"] == "20000000" + assert intent_posts[0]["metadata"]["referral_discount"]["discount_usdc"] == "9.9" + + +def test_referral_first_month_price_does_not_stack_with_points(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, + ) + monkeypatch.setattr( + service, + "_resolve_points_balance", + lambda user_id: { + "source": "supabase_metadata", + "balance": 5000, + "metadata": {"points": 5000}, + }, + 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", + use_points=True, + points_to_consume=1500, + ) + + assert result["plan"]["amount_after_discount_usdc"] == "20" + assert intent_posts[0]["amount_units"] == "20000000" + assert intent_posts[0]["metadata"]["points_redemption"]["applied"] is False + assert intent_posts[0]["metadata"]["points_redemption"]["points_to_consume"] == 0 + + +def test_referral_reward_grants_points_instead_of_pro_days(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 = [] + point_grants = [] + + 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 [] + if method in {"POST", "PATCH"}: + writes.append({"method": method, "table": table, **kwargs}) + return [] + raise AssertionError((method, table, kwargs)) + + monkeypatch.setattr(service, "_rest", fake_rest) + monkeypatch.setattr( + service, + "_grant_referral_points", + lambda referrer_user_id, points: point_grants.append( + {"referrer_user_id": referrer_user_id, "points": points} + ) + or {"ok": True, "points_added": points, "points_after": points}, + raising=False, + ) + + result = service.settle_referral_reward( + referred_user_id="referred-1", + payment_intent_id="intent-1", + tx_hash="0x" + "2" * 64, + ) + + assert result["awarded"] is True + assert result["reward_points"] == 3500 + assert result["reward_days"] == 0 + assert point_grants == [{"referrer_user_id": "referrer-1", "points": 3500}] + assert not any(call["table"] == "subscriptions" for call in writes) + reward_write = next(call for call in writes if call["table"] == "referral_rewards") + assert reward_write["payload"]["reward_points"] == 3500 + assert reward_write["payload"]["reward_days"] == 0 def test_referral_reward_respects_monthly_ten_invite_cap(monkeypatch): @@ -211,7 +375,7 @@ def test_referral_reward_respects_monthly_ten_invite_cap(monkeypatch): } ] if method == "GET" and table == "referral_rewards": - return [{"id": i} for i in range(10)] + return [{"id": i, "reward_points": 3500} for i in range(10)] if method in {"POST", "PATCH"}: writes.append({"method": method, "table": table, **kwargs}) return [] @@ -227,7 +391,56 @@ def test_referral_reward_respects_monthly_ten_invite_cap(monkeypatch): assert result["awarded"] is False assert result["reason"] == "monthly_cap_reached" - assert not any(call["table"] == "subscriptions" for call in writes) + assert not any(call["table"] == "referral_rewards" for call in writes) + + +def test_referral_reward_existing_reward_row_does_not_grant_points(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() + point_grants = [] + + 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": + params = kwargs.get("params") or {} + if params.get("referral_attribution_id") == "eq.77": + return [{"id": 88, "reward_points": 3500}] + return [] + if method in {"POST", "PATCH"}: + return [] + raise AssertionError((method, table, kwargs)) + + monkeypatch.setattr(service, "_rest", fake_rest) + monkeypatch.setattr( + service, + "_grant_referral_points", + lambda referrer_user_id, points: point_grants.append( + {"referrer_user_id": referrer_user_id, "points": points} + ) + or {"ok": True, "points_added": points, "points_after": points}, + raising=False, + ) + + 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"] == "already_rewarded" + assert point_grants == [] def test_signup_trial_falls_back_to_entitlement_events_without_trial_tables(monkeypatch): @@ -333,6 +546,7 @@ def test_referral_reward_falls_back_to_entitlement_events_without_referral_table } ] writes = [] + point_grants = [] def fake_rest(method, table, **kwargs): if table in {"referral_attributions", "referral_rewards"}: @@ -341,17 +555,23 @@ def test_referral_reward_falls_back_to_entitlement_events_without_referral_table user_filter = str((kwargs.get("params") or {}).get("user_id") or "") user_id = user_filter[3:] if user_filter.startswith("eq.") else user_filter return [event for event in events if event.get("user_id") == user_id] - if method == "GET" and table == "subscriptions": - return [] - if method == "POST" and table in {"subscriptions", "entitlement_events"}: + if method == "POST" and table == "entitlement_events": payload = kwargs.get("payload") or {} writes.append({"table": table, "payload": payload}) - if table == "entitlement_events": - events.append(payload) + events.append(payload) return [payload] raise AssertionError((method, table, kwargs)) monkeypatch.setattr(service, "_rest", fake_rest) + monkeypatch.setattr( + service, + "_grant_referral_points", + lambda referrer_user_id, points: point_grants.append( + {"referrer_user_id": referrer_user_id, "points": points} + ) + or {"ok": True, "points_added": points, "points_after": points}, + raising=False, + ) result = service.settle_referral_reward( referred_user_id=referred_id, @@ -360,9 +580,12 @@ def test_referral_reward_falls_back_to_entitlement_events_without_referral_table ) assert result["awarded"] is True - assert any(call["table"] == "subscriptions" for call in writes) + assert result["reward_points"] == 3500 + assert not any(call["table"] == "subscriptions" for call in writes) + assert point_grants == [{"referrer_user_id": referrer_id, "points": 3500}] assert any( call["table"] == "entitlement_events" and call["payload"]["action"] == "referral_reward_granted" + and call["payload"]["payload"]["reward_points"] == 3500 for call in writes ) diff --git a/tests/test_weekly_points_display.py b/tests/test_weekly_points_display.py index 5162d698..5e700a21 100644 --- a/tests/test_weekly_points_display.py +++ b/tests/test_weekly_points_display.py @@ -16,7 +16,7 @@ class _User: self.first_name = username -def test_weekly_points_display_uses_weekly_profile_archive_fallback(tmp_path): +def test_points_rank_display_uses_invite_points_copy(tmp_path): db = DBManager(str(tmp_path / "test.db")) io = BotIOLayer(_DummyBot(), db) user = _User(1001, "yuan") @@ -46,6 +46,8 @@ def test_weekly_points_display_uses_weekly_profile_archive_fallback(tmp_path): conn.commit() text = io.build_points_rank_text(user) - assert "累计发言" in text - assert "本周发言积分: 192" in text - assert "本周排名: 1/1" in text + assert "用户积分排行" in text + assert "累计积分: 206" in text + assert "积分获取: 邀请付费用户" in text + assert "本周发言积分" not in text + assert "今日发言积分" not in text