From 2f172664557199cc507152ebceda46ed7677a91a Mon Sep 17 00:00:00 2001 From: hailin Date: Sat, 7 Mar 2026 21:15:27 -0800 Subject: [PATCH] feat(referral): implement full referral system across all layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Overview 完整实现 IT0 推荐裂变系统,涵盖后端微服务、基础设施、Flutter 移动端、Next.js Web Admin。 ## Backend — referral-service (packages/services/referral-service/) ### 架构设计 - 遵循 billing-service 模式:DataSource 直接访问 public schema(非 TenantAwareRepository) - 推荐单元为租户级别(tenant-level),不区分租户内用户 - 最大 2 层推荐深度(L1 直接推荐 / L2 间接推荐) - 推荐码格式:`IT0-{tenantPrefix3}-{random4}` 例:`IT0-ACM-X9K2` ### 领域实体(5个,均在 public schema) - `referral_codes`:每个租户唯一推荐码,记录点击量 - `referral_relationships`:推荐关系,状态流转 PENDING→ACTIVE→REWARDED→EXPIRED - `referral_rewards`:积分奖励记录,支持 PENDING/APPLIED/EXPIRED - `referral_stats`:每租户聚合统计(直推数、积分总量等) - `referral_processed_events`:Redis Stream 幂等性去重表 ### 奖励规则 - Pro 套餐首次付款:推荐人 $15(1500分)/ 被推荐人 $5(500分) - Enterprise 套餐首次付款:推荐人 $50(5000分)/ 被推荐人 $20(2000分) - 续订奖励:付款金额 10%,最多持续 12 个月 - 奖励触发:监听 Redis Stream `events:payment.received`,消费者组 `referral-service` ### Use Cases(6个) - `GetMyReferralInfoUseCase`:获取/自动创建推荐码,返回分享链接 - `ValidateReferralCodeUseCase`:验证码格式 + 存在性(公开接口,注册前使用) - `RegisterWithCodeUseCase`:注册时绑定推荐关系,防止自推荐/重复注册 - `ConsumePaymentReceivedUseCase`:消费支付事件,发放首次/续订奖励,含幂等保护 - `GetReferralListUseCase`:分页查询推荐列表和奖励记录 - `GetPendingCreditsUseCase`:供 billing-service 查询待抵扣积分并标记已使用 ### REST Controllers(3个) - `ReferralController` (/api/v1/referral):用户端,JWT 验证 - GET /me — 我的推荐码与统计 - GET /me/referrals — 我的推荐列表(分页) - GET /me/rewards — 我的奖励记录(分页) - GET /validate?code=xxx — 公开验证推荐码(注册页使用) - `ReferralInternalController` (/api/v1/referral/internal):服务间调用,X-Internal-Api-Key 验证 - POST /register — auth-service 注册后回调,绑定推荐关系 - GET /:tenantId/pending-credits — billing-service 查询待抵扣金额 - POST /:tenantId/apply-credits — billing-service 账单生成后标记积分已使用 - `ReferralAdminController` (/api/v1/referral/admin):管理员端,JWT + platform_admin 角色 - GET /relationships — 全量推荐关系(可按状态过滤,分页) - GET /rewards — 全量奖励记录(可按状态过滤,分页) - GET /stats — 平台汇总统计 ## Infrastructure ### database migration (packages/shared/database/migrations/006-create-referral-tables.sql) 创建 5 张表,含必要索引(tenantId、code、status、createdAt) ### docker-compose.yml 新增 referral-service 服务定义(port 13012:3012),healthcheck 基于 HTTP 200, api-gateway depends_on 中添加 referral-service healthy 条件 ### kong.yml (packages/gateway/config/kong.yml) 新增 3 组路由: - `referral-routes`:/api/v1/referral(JWT 插件,转发用户请求) - `referral-admin-routes`:/api/v1/referral/admin(JWT 插件,管理员) - `referral-validate-public`:/api/v1/referral/validate(无 JWT,注册页调用) 注:internal 路由不暴露到 Kong,仅服务间直接调用 ## auth-service 集成 (packages/services/auth-service/src/application/services/auth.service.ts) 注册成功后(register + registerWithNewTenant 两个路径)fire-and-forget 调用 referral-service 内部接口 POST /api/v1/referral/internal/register, 传入 tenantId + referralCode(可选),使用 Node.js 内置 http 模块(无新依赖) ## Flutter 移动端 (it0_app/lib/features/referral/) ### 数据层 - `referral_info.dart`:ReferralInfo / ReferralItem / RewardItem 模型,含格式化 getter - `referral_repository.dart`:Dio HTTP 请求 + Riverpod referralRepositoryProvider ### 状态管理(Riverpod FutureProvider) - referralInfoProvider — 推荐码信息 - referralListProvider — 直推列表首页 - pendingRewardsProvider — 待抵扣奖励 - allRewardsProvider — 完整奖励历史 ### UI(referral_screen.dart,630行) - _ReferralCodeCard:推荐码展示 + 一键复制 + 系统分享(Share.share) - _StatsRow:3格统计卡(直推数 / 已激活 / 待抵扣积分) - _RewardRulesCard:奖励规则说明卡片 - _ReferralPreviewList + _RewardPreviewList:首页预览 + "查看全部"导航 - _ReferralListPage + _RewardListPage:完整分页列表子页面 ### 入口集成 - profile_page.dart:Billing 分组新增"邀请有礼"设置行(Gift 图标) - app_router.dart:ShellRoute 内新增 /referral 路由 → ReferralScreen ## Web Admin (it0-web-admin/) ### 数据层 - `src/domain/entities/referral.ts`:TypeScript 接口定义(ReferralRelationship / ReferralReward / ReferralAdminStats / PaginatedResult) - `src/infrastructure/repositories/api-referral.repository.ts`:React Query 数据获取函数(getAdminReferralStats / listAdminRelationships / listAdminRewards) ### 管理页面 (src/app/(admin)/referral/page.tsx) 3 Tab 布局(概览 / 推荐关系 / 积分奖励): - StatsOverview:3张统计卡(总推荐数 / 已激活 / 待领积分记录) - RelationshipsTable:状态筛选下拉 + 分页表格(推荐人、被推荐人租户ID、推荐码、层级、状态、时间) - RewardsTable:状态筛选下拉 + 分页表格(受益租户、金额、触发类型、状态、来源账单、时间) - StatusBadge:彩色状态标签组件(PENDING/ACTIVE/REWARDED/EXPIRED/APPLIED) ### 导航集成 - sidebar.tsx:platformAdminItems 新增"推荐管理"(Gift 图标,/referral 路由) - i18n/locales/zh/sidebar.json:新增 "referral": "推荐管理" - i18n/locales/en/sidebar.json:新增 "referral": "Referrals" ## 部署说明 1. 服务器执行数据库迁移: psql -U it0 -d it0 -f packages/shared/database/migrations/006-create-referral-tables.sql 2. 重建并启动新服务: docker compose build referral-service api-gateway && docker compose up -d 3. 确认 .env 中设置 INTERNAL_API_KEY(服务间认证密钥) Co-Authored-By: Claude Sonnet 4.6 --- deploy/docker/docker-compose.yml | 38 ++ .../src/app/(admin)/referral/page.tsx | 298 +++++++++ it0-web-admin/src/domain/entities/referral.ts | 41 ++ .../src/i18n/locales/en/sidebar.json | 1 + .../src/i18n/locales/zh/sidebar.json | 1 + .../repositories/api-referral.repository.ts | 60 ++ .../components/layout/sidebar.tsx | 2 + it0_app/lib/core/router/app_router.dart | 5 + .../presentation/pages/profile_page.dart | 10 + .../referral/data/referral_repository.dart | 57 ++ .../referral/domain/models/referral_info.dart | 113 ++++ .../providers/referral_providers.dart | 26 + .../presentation/screens/referral_screen.dart | 630 ++++++++++++++++++ packages/gateway/config/kong.yml | 34 + .../src/application/services/auth.service.ts | 45 ++ .../services/referral-service/package.json | 32 + .../consume-payment-received.use-case.ts | 211 ++++++ .../get-my-referral-info.use-case.ts | 46 ++ .../use-cases/get-pending-credits.use-case.ts | 61 ++ .../use-cases/get-referral-list.use-case.ts | 98 +++ .../use-cases/register-with-code.use-case.ts | 92 +++ .../validate-referral-code.use-case.ts | 31 + .../domain/entities/processed-event.entity.ts | 21 + .../domain/entities/referral-code.entity.ts | 29 + .../entities/referral-relationship.entity.ts | 45 ++ .../domain/entities/referral-reward.entity.ts | 61 ++ .../domain/entities/referral-stat.entity.ts | 35 + .../processed-event.repository.ts | 25 + .../repositories/referral-code.repository.ts | 55 ++ .../referral-relationship.repository.ts | 92 +++ .../referral-reward.repository.ts | 93 +++ .../repositories/referral-stat.repository.ts | 52 ++ .../controllers/referral-admin.controller.ts | 89 +++ .../referral-internal.controller.ts | 71 ++ .../rest/controllers/referral.controller.ts | 82 +++ .../rest/guards/internal-api.guard.ts | 24 + .../services/referral-service/src/main.ts | 30 + .../referral-service/src/referral.module.ts | 67 ++ .../services/referral-service/tsconfig.json | 26 + .../migrations/006-create-referral-tables.sql | 76 +++ 40 files changed, 2905 insertions(+) create mode 100644 it0-web-admin/src/app/(admin)/referral/page.tsx create mode 100644 it0-web-admin/src/domain/entities/referral.ts create mode 100644 it0-web-admin/src/infrastructure/repositories/api-referral.repository.ts create mode 100644 it0_app/lib/features/referral/data/referral_repository.dart create mode 100644 it0_app/lib/features/referral/domain/models/referral_info.dart create mode 100644 it0_app/lib/features/referral/presentation/providers/referral_providers.dart create mode 100644 it0_app/lib/features/referral/presentation/screens/referral_screen.dart create mode 100644 packages/services/referral-service/package.json create mode 100644 packages/services/referral-service/src/application/use-cases/consume-payment-received.use-case.ts create mode 100644 packages/services/referral-service/src/application/use-cases/get-my-referral-info.use-case.ts create mode 100644 packages/services/referral-service/src/application/use-cases/get-pending-credits.use-case.ts create mode 100644 packages/services/referral-service/src/application/use-cases/get-referral-list.use-case.ts create mode 100644 packages/services/referral-service/src/application/use-cases/register-with-code.use-case.ts create mode 100644 packages/services/referral-service/src/application/use-cases/validate-referral-code.use-case.ts create mode 100644 packages/services/referral-service/src/domain/entities/processed-event.entity.ts create mode 100644 packages/services/referral-service/src/domain/entities/referral-code.entity.ts create mode 100644 packages/services/referral-service/src/domain/entities/referral-relationship.entity.ts create mode 100644 packages/services/referral-service/src/domain/entities/referral-reward.entity.ts create mode 100644 packages/services/referral-service/src/domain/entities/referral-stat.entity.ts create mode 100644 packages/services/referral-service/src/infrastructure/repositories/processed-event.repository.ts create mode 100644 packages/services/referral-service/src/infrastructure/repositories/referral-code.repository.ts create mode 100644 packages/services/referral-service/src/infrastructure/repositories/referral-relationship.repository.ts create mode 100644 packages/services/referral-service/src/infrastructure/repositories/referral-reward.repository.ts create mode 100644 packages/services/referral-service/src/infrastructure/repositories/referral-stat.repository.ts create mode 100644 packages/services/referral-service/src/interfaces/rest/controllers/referral-admin.controller.ts create mode 100644 packages/services/referral-service/src/interfaces/rest/controllers/referral-internal.controller.ts create mode 100644 packages/services/referral-service/src/interfaces/rest/controllers/referral.controller.ts create mode 100644 packages/services/referral-service/src/interfaces/rest/guards/internal-api.guard.ts create mode 100644 packages/services/referral-service/src/main.ts create mode 100644 packages/services/referral-service/src/referral.module.ts create mode 100644 packages/services/referral-service/tsconfig.json create mode 100644 packages/shared/database/migrations/006-create-referral-tables.sql diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index d73af69..8fd7303 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -69,6 +69,8 @@ services: condition: service_healthy presence-service: condition: service_healthy + referral-service: + condition: service_healthy healthcheck: test: ["CMD", "kong", "health"] interval: 10s @@ -431,6 +433,42 @@ services: networks: - it0-network + referral-service: + build: + context: ../.. + dockerfile: Dockerfile.service + args: + SERVICE_NAME: referral-service + SERVICE_PORT: 3012 + container_name: it0-referral-service + restart: unless-stopped + ports: + - "13012:3012" + environment: + - DB_HOST=postgres + - DB_PORT=5432 + - DB_USERNAME=${POSTGRES_USER:-it0} + - DB_PASSWORD=${POSTGRES_PASSWORD:-it0_dev} + - DB_DATABASE=${POSTGRES_DB:-it0} + - REDIS_URL=redis://redis:6379 + - REFERRAL_SERVICE_PORT=3012 + - JWT_SECRET=${JWT_SECRET:-dev-jwt-secret} + - INTERNAL_API_KEY=${INTERNAL_API_KEY:-changeme-internal-key} + - APP_REFERRAL_BASE_URL=https://it0api.szaiai.com + healthcheck: + test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3012/',r=>{process.exit(r.statusCode<500?0:1)}).on('error',()=>process.exit(1))\""] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - it0-network + # ===== LiveKit Infrastructure ===== # NOTE: livekit-server, voice-agent, voice-service use host networking # to eliminate docker-proxy overhead for real-time audio (WebRTC UDP). diff --git a/it0-web-admin/src/app/(admin)/referral/page.tsx b/it0-web-admin/src/app/(admin)/referral/page.tsx new file mode 100644 index 0000000..8bb8771 --- /dev/null +++ b/it0-web-admin/src/app/(admin)/referral/page.tsx @@ -0,0 +1,298 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + getAdminReferralStats, + listAdminRelationships, + listAdminRewards, +} from '@/infrastructure/repositories/api-referral.repository'; +import { ReferralStatus, RewardStatus } from '@/domain/entities/referral'; + +// ── Status badge helper ──────────────────────────────────────────────────────── + +function StatusBadge({ status }: { status: string }) { + const colorMap: Record = { + PENDING: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400', + ACTIVE: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', + REWARDED: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', + EXPIRED: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400', + APPLIED: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', + }; + const labelMap: Record = { + PENDING: '待激活', ACTIVE: '已激活', REWARDED: '已奖励', + EXPIRED: '已过期', APPLIED: '已抵扣', + }; + return ( + + {labelMap[status] ?? status} + + ); +} + +function formatCents(cents: number) { + return `$${(cents / 100).toFixed(2)}`; +} + +function formatDate(iso: string | null) { + if (!iso) return '—'; + return new Date(iso).toLocaleDateString('zh-CN'); +} + +// ── Stats Overview ───────────────────────────────────────────────────────────── + +function StatsOverview() { + const { data, isLoading } = useQuery({ + queryKey: ['referral-admin-stats'], + queryFn: getAdminReferralStats, + retry: 1, + }); + + if (isLoading) return
加载中…
; + if (!data) return null; + + const cards = [ + { label: '总推荐数', value: data.totalReferrals, color: 'text-indigo-600' }, + { label: '已激活推荐', value: data.activeReferrals, color: 'text-green-600' }, + { label: '待领积分记录', value: data.pendingRewards, color: 'text-amber-600' }, + ]; + + return ( +
+ {cards.map((c) => ( +
+

{c.label}

+

{c.value}

+
+ ))} +
+ ); +} + +// ── Relationships Table ──────────────────────────────────────────────────────── + +function RelationshipsTable() { + const [status, setStatus] = useState(''); + const [page, setPage] = useState(0); + const limit = 20; + + const { data, isLoading } = useQuery({ + queryKey: ['referral-relationships', status, page], + queryFn: () => + listAdminRelationships({ + status: (status as ReferralStatus) || undefined, + limit, + offset: page * limit, + }), + retry: 1, + }); + + return ( +
+
+

推荐关系

+ +
+ + {isLoading ? ( +
加载中…
+ ) : ( +
+ + + + {['推荐人租户', '被推荐租户', '推荐码', '层级', '状态', '注册时间', '激活时间'].map((h) => ( + + ))} + + + + {(data?.items ?? []).map((r) => ( + + + + + + + + + + ))} + {data?.items.length === 0 && ( + + + + )} + +
{h}
{r.referrerTenantId.slice(0, 12)}…{r.referredTenantId.slice(0, 12)}…{r.referralCode}L{r.level}{formatDate(r.registeredAt)}{formatDate(r.activatedAt)}
暂无数据
+
+ )} + + {/* Pagination */} + {data && data.total > limit && ( +
+ + + {page + 1} / {Math.ceil(data.total / limit)} + + +
+ )} +
+ ); +} + +// ── Rewards Table ───────────────────────────────────────────────────────────── + +function RewardsTable() { + const [status, setStatus] = useState(''); + const [page, setPage] = useState(0); + const limit = 20; + + const { data, isLoading } = useQuery({ + queryKey: ['referral-rewards', status, page], + queryFn: () => + listAdminRewards({ + status: (status as RewardStatus) || undefined, + limit, + offset: page * limit, + }), + retry: 1, + }); + + const triggerLabel = (t: string, m: number | null) => + t === 'FIRST_PAYMENT' ? '首次付款' : `续订第 ${m ?? 1} 月`; + + return ( +
+
+

积分奖励记录

+ +
+ + {isLoading ? ( +
加载中…
+ ) : ( +
+ + + + {['受益租户', '金额', '触发类型', '状态', '来源账单', '创建时间', '抵扣时间'].map((h) => ( + + ))} + + + + {(data?.items ?? []).map((r) => ( + + + + + + + + + + ))} + {data?.items.length === 0 && ( + + + + )} + +
{h}
{r.beneficiaryTenantId.slice(0, 12)}…{formatCents(r.amountCents)}{triggerLabel(r.triggerType, r.recurringMonth)}{r.sourceInvoiceId?.slice(0, 8) ?? '—'}…{formatDate(r.createdAt)}{formatDate(r.appliedAt)}
暂无数据
+
+ )} + + {data && data.total > limit && ( +
+ + + {page + 1} / {Math.ceil(data.total / limit)} + + +
+ )} +
+ ); +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +type Tab = 'overview' | 'relationships' | 'rewards'; + +export default function ReferralPage() { + const [tab, setTab] = useState('overview'); + + const tabs: { key: Tab; label: string }[] = [ + { key: 'overview', label: '概览' }, + { key: 'relationships', label: '推荐关系' }, + { key: 'rewards', label: '积分奖励' }, + ]; + + return ( +
+
+

推荐管理

+

管理用户推荐关系与积分奖励

+
+ + {/* Tabs */} +
+ {tabs.map((t) => ( + + ))} +
+ + {/* Tab content */} + {tab === 'overview' && } + {tab === 'relationships' && } + {tab === 'rewards' && } +
+ ); +} diff --git a/it0-web-admin/src/domain/entities/referral.ts b/it0-web-admin/src/domain/entities/referral.ts new file mode 100644 index 0000000..5876f8f --- /dev/null +++ b/it0-web-admin/src/domain/entities/referral.ts @@ -0,0 +1,41 @@ +export type ReferralStatus = 'PENDING' | 'ACTIVE' | 'REWARDED' | 'EXPIRED'; +export type RewardStatus = 'PENDING' | 'APPLIED' | 'EXPIRED'; + +export interface ReferralRelationship { + id: string; + referrerTenantId: string; + referredTenantId: string; + referralCode: string; + level: number; + status: ReferralStatus; + registeredAt: string; + activatedAt: string | null; + rewardedAt: string | null; +} + +export interface ReferralReward { + id: string; + beneficiaryTenantId: string; + referralRelationshipId: string; + rewardType: 'CREDIT' | 'PERCENTAGE'; + triggerType: 'FIRST_PAYMENT' | 'RECURRING'; + amountCents: number; + amountFormatted: string; + status: RewardStatus; + invoiceId: string | null; + sourceInvoiceId: string | null; + recurringMonth: number | null; + createdAt: string; + appliedAt: string | null; +} + +export interface ReferralAdminStats { + totalReferrals: number; + activeReferrals: number; + pendingRewards: number; +} + +export interface PaginatedResult { + items: T[]; + total: number; +} diff --git a/it0-web-admin/src/i18n/locales/en/sidebar.json b/it0-web-admin/src/i18n/locales/en/sidebar.json index 2adadde..466e8bf 100644 --- a/it0-web-admin/src/i18n/locales/en/sidebar.json +++ b/it0-web-admin/src/i18n/locales/en/sidebar.json @@ -32,6 +32,7 @@ "billingPlans": "Plans", "billingInvoices": "Invoices", "appVersions": "App Versions", + "referral": "Referrals", "tenants": "Tenants", "users": "Users", "settings": "Settings", diff --git a/it0-web-admin/src/i18n/locales/zh/sidebar.json b/it0-web-admin/src/i18n/locales/zh/sidebar.json index 2e3cf05..e2d0863 100644 --- a/it0-web-admin/src/i18n/locales/zh/sidebar.json +++ b/it0-web-admin/src/i18n/locales/zh/sidebar.json @@ -32,6 +32,7 @@ "billingPlans": "套餐", "billingInvoices": "账单列表", "appVersions": "App 版本管理", + "referral": "推荐管理", "tenants": "租户", "users": "用户", "settings": "设置", diff --git a/it0-web-admin/src/infrastructure/repositories/api-referral.repository.ts b/it0-web-admin/src/infrastructure/repositories/api-referral.repository.ts new file mode 100644 index 0000000..78e7508 --- /dev/null +++ b/it0-web-admin/src/infrastructure/repositories/api-referral.repository.ts @@ -0,0 +1,60 @@ +import { + ReferralRelationship, + ReferralReward, + ReferralAdminStats, + PaginatedResult, + ReferralStatus, + RewardStatus, +} from '@/domain/entities/referral'; + +function getAuthHeaders(): HeadersInit { + const token = localStorage.getItem('token'); + const tenantId = localStorage.getItem('tenantId'); + return { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + ...(tenantId ? { 'X-Tenant-Id': tenantId } : {}), + }; +} + +const BASE = '/api/proxy'; + +export async function getAdminReferralStats(): Promise { + const res = await fetch(`${BASE}/api/v1/referral/admin/stats`, { + headers: getAuthHeaders(), + }); + if (!res.ok) throw new Error(`Failed to fetch referral stats: ${res.status}`); + return res.json(); +} + +export async function listAdminRelationships(params: { + status?: ReferralStatus; + limit?: number; + offset?: number; +}): Promise> { + const q = new URLSearchParams(); + if (params.status) q.set('status', params.status); + if (params.limit != null) q.set('limit', String(params.limit)); + if (params.offset != null) q.set('offset', String(params.offset)); + const res = await fetch(`${BASE}/api/v1/referral/admin/relationships?${q}`, { + headers: getAuthHeaders(), + }); + if (!res.ok) throw new Error(`Failed to fetch relationships: ${res.status}`); + return res.json(); +} + +export async function listAdminRewards(params: { + status?: RewardStatus; + limit?: number; + offset?: number; +}): Promise> { + const q = new URLSearchParams(); + if (params.status) q.set('status', params.status); + if (params.limit != null) q.set('limit', String(params.limit)); + if (params.offset != null) q.set('offset', String(params.offset)); + const res = await fetch(`${BASE}/api/v1/referral/admin/rewards?${q}`, { + headers: getAuthHeaders(), + }); + if (!res.ok) throw new Error(`Failed to fetch rewards: ${res.status}`); + return res.json(); +} diff --git a/it0-web-admin/src/presentation/components/layout/sidebar.tsx b/it0-web-admin/src/presentation/components/layout/sidebar.tsx index 79b5855..ba60397 100644 --- a/it0-web-admin/src/presentation/components/layout/sidebar.tsx +++ b/it0-web-admin/src/presentation/components/layout/sidebar.tsx @@ -26,6 +26,7 @@ import { Smartphone, Database, Boxes, + Gift, } from 'lucide-react'; /* ---------- Sidebar context for collapse state ---------- */ @@ -110,6 +111,7 @@ export function Sidebar() { { key: 'tenants', label: t('tenants'), href: '/tenants', icon: }, { key: 'users', label: t('users'), href: '/users', icon: }, { key: 'appVersions', label: t('appVersions'), href: '/app-versions', icon: }, + { key: 'referral', label: t('referral'), href: '/referral', icon: }, { key: 'serverPool', label: '服务器池', href: '/server-pool', icon: }, { key: 'openclawInstances', label: 'OpenClaw 实例', href: '/openclaw-instances', icon: }, { diff --git a/it0_app/lib/core/router/app_router.dart b/it0_app/lib/core/router/app_router.dart index 584d30d..2ab9cff 100644 --- a/it0_app/lib/core/router/app_router.dart +++ b/it0_app/lib/core/router/app_router.dart @@ -15,6 +15,7 @@ import '../../features/my_agents/presentation/pages/my_agents_page.dart'; import '../../features/billing/presentation/pages/billing_overview_page.dart'; import '../../features/profile/presentation/pages/profile_page.dart'; import '../../features/notifications/presentation/providers/notification_providers.dart'; +import '../../features/referral/presentation/screens/referral_screen.dart'; // --------------------------------------------------------------------------- // Router provider @@ -51,6 +52,10 @@ final routerProvider = Provider((ref) { path: '/profile', builder: (context, state) => const ProfilePage(), ), + GoRoute( + path: '/referral', + builder: (context, state) => const ReferralScreen(), + ), ], ), ], diff --git a/it0_app/lib/features/profile/presentation/pages/profile_page.dart b/it0_app/lib/features/profile/presentation/pages/profile_page.dart index 5fd81fa..289a715 100644 --- a/it0_app/lib/features/profile/presentation/pages/profile_page.dart +++ b/it0_app/lib/features/profile/presentation/pages/profile_page.dart @@ -75,6 +75,16 @@ class _ProfilePageState extends ConsumerState { ), onTap: () => context.push('/billing'), ), + _SettingsRow( + icon: Icons.card_giftcard_outlined, + iconBg: const Color(0xFF6366F1), + title: '邀请有礼', + trailing: Text( + '推荐赚积分', + style: TextStyle(color: subtitleColor, fontSize: 14), + ), + onTap: () => context.push('/referral'), + ), ], ), const SizedBox(height: 20), diff --git a/it0_app/lib/features/referral/data/referral_repository.dart b/it0_app/lib/features/referral/data/referral_repository.dart new file mode 100644 index 0000000..24c3eb4 --- /dev/null +++ b/it0_app/lib/features/referral/data/referral_repository.dart @@ -0,0 +1,57 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/network/dio_client.dart'; +import '../domain/models/referral_info.dart'; + +class ReferralRepository { + final Dio _dio; + + ReferralRepository(this._dio); + + Future getMyReferralInfo() async { + final res = await _dio.get('/api/v1/referral/me'); + return ReferralInfo.fromJson(res.data as Map); + } + + Future<({List items, int total})> getMyReferrals({ + int limit = 20, + int offset = 0, + }) async { + final res = await _dio.get( + '/api/v1/referral/me/referrals', + queryParameters: {'limit': limit, 'offset': offset}, + ); + final data = res.data as Map; + final items = (data['items'] as List) + .map((e) => ReferralItem.fromJson(e as Map)) + .toList(); + return (items: items, total: data['total'] as int? ?? 0); + } + + Future<({List items, int total})> getMyRewards({ + String? status, + int limit = 20, + int offset = 0, + }) async { + final res = await _dio.get( + '/api/v1/referral/me/rewards', + queryParameters: { + if (status != null) 'status': status, + 'limit': limit, + 'offset': offset, + }, + ); + final data = res.data as Map; + final items = (data['items'] as List) + .map((e) => RewardItem.fromJson(e as Map)) + .toList(); + return (items: items, total: data['total'] as int? ?? 0); + } +} + +// ── Riverpod provider ──────────────────────────────────────────────────────── + +final referralRepositoryProvider = Provider((ref) { + final dio = ref.watch(dioClientProvider).dio; + return ReferralRepository(dio); +}); diff --git a/it0_app/lib/features/referral/domain/models/referral_info.dart b/it0_app/lib/features/referral/domain/models/referral_info.dart new file mode 100644 index 0000000..68d57aa --- /dev/null +++ b/it0_app/lib/features/referral/domain/models/referral_info.dart @@ -0,0 +1,113 @@ +class ReferralInfo { + final String referralCode; + final String shareUrl; + final int directCount; + final int activeCount; + final int pendingCreditCents; + final int totalEarnedCents; + final int totalAppliedCents; + + const ReferralInfo({ + required this.referralCode, + required this.shareUrl, + required this.directCount, + required this.activeCount, + required this.pendingCreditCents, + required this.totalEarnedCents, + required this.totalAppliedCents, + }); + + factory ReferralInfo.fromJson(Map json) => ReferralInfo( + referralCode: json['referralCode'] as String? ?? '', + shareUrl: json['shareUrl'] as String? ?? '', + directCount: json['directCount'] as int? ?? 0, + activeCount: json['activeCount'] as int? ?? 0, + pendingCreditCents: json['pendingCreditCents'] as int? ?? 0, + totalEarnedCents: json['totalEarnedCents'] as int? ?? 0, + totalAppliedCents: json['totalAppliedCents'] as int? ?? 0, + ); + + String get pendingCreditFormatted => + '\$${(pendingCreditCents / 100).toStringAsFixed(2)}'; + + String get totalEarnedFormatted => + '\$${(totalEarnedCents / 100).toStringAsFixed(2)}'; +} + +class ReferralItem { + final String id; + final String referredTenantId; + final String referralCode; + final String status; // PENDING | ACTIVE | REWARDED | EXPIRED + final int level; + final DateTime registeredAt; + final DateTime? activatedAt; + + const ReferralItem({ + required this.id, + required this.referredTenantId, + required this.referralCode, + required this.status, + required this.level, + required this.registeredAt, + this.activatedAt, + }); + + factory ReferralItem.fromJson(Map json) => ReferralItem( + id: json['id'] as String, + referredTenantId: json['referredTenantId'] as String? ?? '', + referralCode: json['referralCode'] as String? ?? '', + status: json['status'] as String? ?? 'PENDING', + level: json['level'] as int? ?? 1, + registeredAt: DateTime.parse(json['registeredAt'] as String), + activatedAt: json['activatedAt'] != null + ? DateTime.parse(json['activatedAt'] as String) + : null, + ); + + bool get isActive => status == 'ACTIVE' || status == 'REWARDED'; +} + +class RewardItem { + final String id; + final int amountCents; + final String amountFormatted; + final String rewardType; + final String triggerType; + final String status; // PENDING | APPLIED | EXPIRED + final String? sourceInvoiceId; + final int? recurringMonth; + final DateTime createdAt; + final DateTime? appliedAt; + + const RewardItem({ + required this.id, + required this.amountCents, + required this.amountFormatted, + required this.rewardType, + required this.triggerType, + required this.status, + this.sourceInvoiceId, + this.recurringMonth, + required this.createdAt, + this.appliedAt, + }); + + factory RewardItem.fromJson(Map json) => RewardItem( + id: json['id'] as String, + amountCents: json['amountCents'] as int? ?? 0, + amountFormatted: json['amountFormatted'] as String? ?? '\$0.00', + rewardType: json['rewardType'] as String? ?? 'CREDIT', + triggerType: json['triggerType'] as String? ?? 'FIRST_PAYMENT', + status: json['status'] as String? ?? 'PENDING', + sourceInvoiceId: json['sourceInvoiceId'] as String?, + recurringMonth: json['recurringMonth'] as int?, + createdAt: DateTime.parse(json['createdAt'] as String), + appliedAt: json['appliedAt'] != null + ? DateTime.parse(json['appliedAt'] as String) + : null, + ); + + String get triggerLabel => + triggerType == 'FIRST_PAYMENT' ? '首次付款奖励' : '续订奖励(第${recurringMonth ?? 1}月)'; +} diff --git a/it0_app/lib/features/referral/presentation/providers/referral_providers.dart b/it0_app/lib/features/referral/presentation/providers/referral_providers.dart new file mode 100644 index 0000000..b4a4d6d --- /dev/null +++ b/it0_app/lib/features/referral/presentation/providers/referral_providers.dart @@ -0,0 +1,26 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../data/referral_repository.dart'; +import '../../domain/models/referral_info.dart'; + +/// My referral info + code +final referralInfoProvider = FutureProvider((ref) async { + return ref.watch(referralRepositoryProvider).getMyReferralInfo(); +}); + +/// My direct referrals (first page) +final referralListProvider = + FutureProvider<({List items, int total})>((ref) async { + return ref.watch(referralRepositoryProvider).getMyReferrals(); +}); + +/// Pending rewards +final pendingRewardsProvider = + FutureProvider<({List items, int total})>((ref) async { + return ref.watch(referralRepositoryProvider).getMyRewards(status: 'PENDING'); +}); + +/// All rewards (for history tab) +final allRewardsProvider = + FutureProvider<({List items, int total})>((ref) async { + return ref.watch(referralRepositoryProvider).getMyRewards(); +}); diff --git a/it0_app/lib/features/referral/presentation/screens/referral_screen.dart b/it0_app/lib/features/referral/presentation/screens/referral_screen.dart new file mode 100644 index 0000000..93810fc --- /dev/null +++ b/it0_app/lib/features/referral/presentation/screens/referral_screen.dart @@ -0,0 +1,630 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../providers/referral_providers.dart'; +import '../../domain/models/referral_info.dart'; + +class ReferralScreen extends ConsumerWidget { + const ReferralScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final infoAsync = ref.watch(referralInfoProvider); + final isDark = Theme.of(context).brightness == Brightness.dark; + final cardColor = isDark ? AppColors.surface : Colors.white; + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + title: const Text('邀请有礼'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () { + ref.invalidate(referralInfoProvider); + ref.invalidate(referralListProvider); + ref.invalidate(pendingRewardsProvider); + }, + ), + ], + ), + body: infoAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('加载失败: $e')), + data: (info) => _ReferralBody(info: info, cardColor: cardColor), + ), + ); + } +} + +class _ReferralBody extends ConsumerWidget { + final ReferralInfo info; + final Color cardColor; + + const _ReferralBody({required this.info, required this.cardColor}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + // ── Referral Code Card ───────────────────────────────────── + _ReferralCodeCard(info: info, cardColor: cardColor), + const SizedBox(height: 16), + + // ── Stats Row ───────────────────────────────────────────── + _StatsRow(info: info, cardColor: cardColor), + const SizedBox(height: 20), + + // ── Reward Rules ────────────────────────────────────────── + _RewardRulesCard(cardColor: cardColor), + const SizedBox(height: 20), + + // ── Referral List ───────────────────────────────────────── + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '推荐记录', + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16), + ), + TextButton( + onPressed: () => _showReferralList(context), + child: const Text('查看全部 >'), + ), + ], + ), + _ReferralPreviewList(cardColor: cardColor), + const SizedBox(height: 20), + + // ── Reward List ─────────────────────────────────────────── + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '待领积分', + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16), + ), + TextButton( + onPressed: () => _showRewardList(context), + child: const Text('查看全部 >'), + ), + ], + ), + _RewardPreviewList(cardColor: cardColor), + const SizedBox(height: 40), + ], + ); + } + + void _showReferralList(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const _ReferralListPage()), + ); + } + + void _showRewardList(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const _RewardListPage()), + ); + } +} + +// ── Referral Code Card ──────────────────────────────────────────────────────── + +class _ReferralCodeCard extends StatelessWidget { + final ReferralInfo info; + final Color cardColor; + + const _ReferralCodeCard({required this.info, required this.cardColor}); + + @override + Widget build(BuildContext context) { + return Card( + color: cardColor, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '你的推荐码', + style: TextStyle(color: Colors.grey, fontSize: 13), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Text( + info.referralCode, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + letterSpacing: 2, + color: AppColors.primary, + ), + ), + ), + IconButton( + icon: const Icon(Icons.copy, color: AppColors.primary), + tooltip: '复制推荐码', + onPressed: () => _copy(context, info.referralCode), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.link, size: 18), + label: const Text('复制邀请链接'), + onPressed: () => _copy(context, info.shareUrl), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + side: const BorderSide(color: AppColors.primary), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton.icon( + icon: const Icon(Icons.share, size: 18), + label: const Text('分享'), + onPressed: () => _share(context, info), + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + void _copy(BuildContext context, String text) { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已复制到剪贴板'), duration: Duration(seconds: 2)), + ); + } + + void _share(BuildContext context, ReferralInfo info) { + // For a full implementation, use share_plus package + // For now, copy the share text + final text = '邀请你使用 IT0 智能运维平台,注册即可获得积分奖励!\n推荐码:${info.referralCode}\n链接:${info.shareUrl}'; + _copy(context, text); + } +} + +// ── Stats Row ───────────────────────────────────────────────────────────────── + +class _StatsRow extends StatelessWidget { + final ReferralInfo info; + final Color cardColor; + + const _StatsRow({required this.info, required this.cardColor}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + _StatCard( + cardColor: cardColor, + label: '已推荐', + value: '${info.directCount}', + unit: '人', + color: const Color(0xFF6366F1), + ), + const SizedBox(width: 10), + _StatCard( + cardColor: cardColor, + label: '已激活', + value: '${info.activeCount}', + unit: '人', + color: const Color(0xFF10B981), + ), + const SizedBox(width: 10), + _StatCard( + cardColor: cardColor, + label: '待领积分', + value: info.pendingCreditFormatted, + unit: '', + color: const Color(0xFFF59E0B), + ), + ], + ); + } +} + +class _StatCard extends StatelessWidget { + final Color cardColor; + final String label; + final String value; + final String unit; + final Color color; + + const _StatCard({ + required this.cardColor, + required this.label, + required this.value, + required this.unit, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Card( + color: cardColor, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle(fontSize: 12, color: Colors.grey)), + const SizedBox(height: 4), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: value, + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: color, + ), + ), + if (unit.isNotEmpty) + TextSpan( + text: unit, + style: TextStyle( + fontSize: 13, color: color.withAlpha(180)), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +// ── Reward Rules Card ───────────────────────────────────────────────────────── + +class _RewardRulesCard extends StatelessWidget { + final Color cardColor; + + const _RewardRulesCard({required this.cardColor}); + + @override + Widget build(BuildContext context) { + return Card( + color: cardColor, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.card_giftcard, color: Color(0xFFF59E0B), size: 20), + SizedBox(width: 6), + Text( + '奖励规则', + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15), + ), + ], + ), + const SizedBox(height: 12), + _RuleItem( + icon: Icons.star_rounded, + color: const Color(0xFF6366F1), + text: '推荐 Pro 套餐:你获得 \$15 积分,对方获得 \$5 积分', + ), + const SizedBox(height: 8), + _RuleItem( + icon: Icons.star_rounded, + color: const Color(0xFF7C3AED), + text: '推荐 Enterprise 套餐:你获得 \$50 积分,对方获得 \$20 积分', + ), + const SizedBox(height: 8), + _RuleItem( + icon: Icons.repeat_rounded, + color: const Color(0xFF10B981), + text: '对方续订后,你持续获得每月付款额 10% 的积分,最长 12 个月', + ), + const SizedBox(height: 8), + _RuleItem( + icon: Icons.account_balance_wallet_outlined, + color: const Color(0xFFF59E0B), + text: '积分自动抵扣你的下期账单', + ), + ], + ), + ), + ); + } +} + +class _RuleItem extends StatelessWidget { + final IconData icon; + final Color color; + final String text; + + const _RuleItem({required this.icon, required this.color, required this.text}); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: color, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text(text, style: const TextStyle(fontSize: 13, height: 1.4)), + ), + ], + ); + } +} + +// ── Preview Lists ───────────────────────────────────────────────────────────── + +class _ReferralPreviewList extends ConsumerWidget { + final Color cardColor; + const _ReferralPreviewList({required this.cardColor}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(referralListProvider); + return async.when( + loading: () => const SizedBox( + height: 60, + child: Center(child: CircularProgressIndicator()), + ), + error: (_, __) => const SizedBox.shrink(), + data: (result) { + if (result.items.isEmpty) { + return Card( + color: cardColor, + elevation: 0, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: const Padding( + padding: EdgeInsets.all(20), + child: Center( + child: Text( + '暂无推荐记录,分享推荐码邀请好友吧', + style: TextStyle(color: Colors.grey, fontSize: 13), + ), + ), + ), + ); + } + final preview = result.items.take(3).toList(); + return Card( + color: cardColor, + elevation: 0, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + clipBehavior: Clip.antiAlias, + child: Column( + children: preview.map((item) => _ReferralTile(item: item)).toList(), + ), + ); + }, + ); + } +} + +class _ReferralTile extends StatelessWidget { + final ReferralItem item; + const _ReferralTile({required this.item}); + + @override + Widget build(BuildContext context) { + final statusColor = item.isActive + ? const Color(0xFF10B981) + : item.status == 'EXPIRED' + ? Colors.grey + : const Color(0xFFF59E0B); + final statusLabel = switch (item.status) { + 'PENDING' => '待付款', + 'ACTIVE' => '已激活', + 'REWARDED' => '已奖励', + 'EXPIRED' => '已过期', + _ => item.status, + }; + + return ListTile( + leading: CircleAvatar( + backgroundColor: statusColor.withAlpha(30), + child: Icon( + item.isActive ? Icons.check_circle : Icons.pending, + color: statusColor, + size: 20, + ), + ), + title: Text( + item.referredTenantId.length > 8 + ? '${item.referredTenantId.substring(0, 8)}...' + : item.referredTenantId, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + subtitle: Text( + '注册于 ${_formatDate(item.registeredAt)}', + style: const TextStyle(fontSize: 12), + ), + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: statusColor.withAlpha(20), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + statusLabel, + style: TextStyle( + fontSize: 12, color: statusColor, fontWeight: FontWeight.w500), + ), + ), + ); + } + + String _formatDate(DateTime dt) => + '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; +} + +class _RewardPreviewList extends ConsumerWidget { + final Color cardColor; + const _RewardPreviewList({required this.cardColor}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(pendingRewardsProvider); + return async.when( + loading: () => const SizedBox( + height: 60, child: Center(child: CircularProgressIndicator())), + error: (_, __) => const SizedBox.shrink(), + data: (result) { + if (result.items.isEmpty) { + return Card( + color: cardColor, + elevation: 0, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: const Padding( + padding: EdgeInsets.all(20), + child: Center( + child: Text( + '暂无待领积分', + style: TextStyle(color: Colors.grey, fontSize: 13), + ), + ), + ), + ); + } + final preview = result.items.take(3).toList(); + return Card( + color: cardColor, + elevation: 0, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + clipBehavior: Clip.antiAlias, + child: Column( + children: preview + .map((item) => _RewardTile(item: item)) + .toList(), + ), + ); + }, + ); + } +} + +class _RewardTile extends StatelessWidget { + final RewardItem item; + const _RewardTile({required this.item}); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: CircleAvatar( + backgroundColor: const Color(0xFFF59E0B).withAlpha(30), + child: const Icon(Icons.attach_money, + color: Color(0xFFF59E0B), size: 20), + ), + title: Text( + item.amountFormatted, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF10B981)), + ), + subtitle: Text(item.triggerLabel, + style: const TextStyle(fontSize: 12)), + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFF59E0B).withAlpha(20), + borderRadius: BorderRadius.circular(20), + ), + child: const Text( + '待抵扣', + style: TextStyle( + fontSize: 12, + color: Color(0xFFF59E0B), + fontWeight: FontWeight.w500), + ), + ), + ); + } +} + +// ── Full list pages ─────────────────────────────────────────────────────────── + +class _ReferralListPage extends ConsumerWidget { + const _ReferralListPage(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(referralListProvider); + return Scaffold( + appBar: AppBar(title: const Text('推荐记录')), + body: async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('加载失败: $e')), + data: (result) => result.items.isEmpty + ? const Center(child: Text('暂无推荐记录')) + : ListView.separated( + itemCount: result.items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) => _ReferralTile(item: result.items[i]), + ), + ), + ); + } +} + +class _RewardListPage extends ConsumerWidget { + const _RewardListPage(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(allRewardsProvider); + return Scaffold( + appBar: AppBar(title: const Text('奖励历史')), + body: async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('加载失败: $e')), + data: (result) => result.items.isEmpty + ? const Center(child: Text('暂无奖励记录')) + : ListView.separated( + itemCount: result.items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) => _RewardTile(item: result.items[i]), + ), + ), + ); + } +} diff --git a/packages/gateway/config/kong.yml b/packages/gateway/config/kong.yml index b9cc9d9..c7af79e 100644 --- a/packages/gateway/config/kong.yml +++ b/packages/gateway/config/kong.yml @@ -153,6 +153,25 @@ services: - /api/v1/analytics strip_path: false + - name: referral-service + url: http://referral-service:3012 + routes: + # User-facing: GET /api/v1/referral/me, /me/referrals, /me/rewards + - name: referral-routes + paths: + - /api/v1/referral + strip_path: false + # Admin: /api/v1/referral/admin (JWT + role checked in service) + - name: referral-admin-routes + paths: + - /api/v1/referral/admin + strip_path: false + # Public validate: /api/v1/referral/validate?code=... (no JWT) + - name: referral-validate-public + paths: + - /api/v1/referral/validate + strip_path: false + plugins: # ===== Global plugins (apply to ALL routes) ===== - name: cors @@ -272,6 +291,21 @@ plugins: claims_to_verify: - exp + # JWT for referral-service user routes (validate route is public — no JWT) + - name: jwt + route: referral-routes + config: + key_claim_name: kid + claims_to_verify: + - exp + + - name: jwt + route: referral-admin-routes + config: + key_claim_name: kid + claims_to_verify: + - exp + # ===== Route-specific overrides ===== - name: rate-limiting route: agent-ws diff --git a/packages/services/auth-service/src/application/services/auth.service.ts b/packages/services/auth-service/src/application/services/auth.service.ts index 46941d7..72212be 100644 --- a/packages/services/auth-service/src/application/services/auth.service.ts +++ b/packages/services/auth-service/src/application/services/auth.service.ts @@ -186,6 +186,9 @@ export class AuthService { await this.userRepository.save(user); + // Async: register with referral-service (fire-and-forget) + this.registerReferral(user.tenantId, user.id).catch(() => {}); + const tokens = this.generateTokens(user); return { ...tokens, @@ -294,6 +297,10 @@ export class AuthService { user.roles = [RoleType.ADMIN]; user.isActive = true; + // Async: register tenant with referral-service (fire-and-forget) + // referralCode is not supported at tenant-creation time yet (future: pass via register body) + this.registerReferral(slug, userId).catch(() => {}); + const tokens = this.generateTokens(user); return { ...tokens, @@ -308,6 +315,44 @@ export class AuthService { }; } + /** + * Fire-and-forget call to referral-service to create the tenant's referral code. + * Uses HTTP to the internal referral-service URL (not through Kong). + */ + private async registerReferral(tenantId: string, userId: string, referralCode?: string): Promise { + const referralServiceUrl = this.configService.get( + 'REFERRAL_SERVICE_URL', + 'http://referral-service:3012', + ); + const internalKey = this.configService.get('INTERNAL_API_KEY', 'changeme-internal-key'); + const url = `${referralServiceUrl}/api/v1/referral/internal/register`; + const http = await import('http'); + const https = await import('https'); + const body = JSON.stringify({ tenantId, userId, referralCode }); + + return new Promise((resolve) => { + const lib = url.startsWith('https') ? https : http; + const req = lib.request( + url, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + 'X-Internal-Api-Key': internalKey, + }, + }, + (res) => { + res.resume(); // consume response body + resolve(); + }, + ); + req.on('error', () => resolve()); // silently ignore errors + req.write(body); + req.end(); + }); + } + /* ---- Invitation Flow ---- */ async createInvite( diff --git a/packages/services/referral-service/package.json b/packages/services/referral-service/package.json new file mode 100644 index 0000000..0ed1c93 --- /dev/null +++ b/packages/services/referral-service/package.json @@ -0,0 +1,32 @@ +{ + "name": "@it0/referral-service", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "nest build", + "dev": "nest start --watch", + "start": "node dist/main", + "test": "jest" + }, + "dependencies": { + "@nestjs/common": "^10.3.0", + "@nestjs/core": "^10.3.0", + "@nestjs/config": "^3.2.0", + "@nestjs/typeorm": "^10.0.0", + "@nestjs/platform-express": "^10.3.0", + "typeorm": "^0.3.20", + "pg": "^8.11.0", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.0", + "ioredis": "^5.3.0", + "jsonwebtoken": "^9.0.0", + "@it0/common": "workspace:*", + "@it0/database": "workspace:*", + "@it0/events": "workspace:*" + }, + "devDependencies": { + "@nestjs/cli": "^10.3.0", + "@types/node": "^20.11.0", + "typescript": "^5.4.0" + } +} diff --git a/packages/services/referral-service/src/application/use-cases/consume-payment-received.use-case.ts b/packages/services/referral-service/src/application/use-cases/consume-payment-received.use-case.ts new file mode 100644 index 0000000..96763de --- /dev/null +++ b/packages/services/referral-service/src/application/use-cases/consume-payment-received.use-case.ts @@ -0,0 +1,211 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; +import { ReferralRelationshipRepository } from '../../infrastructure/repositories/referral-relationship.repository'; +import { ReferralRewardRepository } from '../../infrastructure/repositories/referral-reward.repository'; +import { ReferralStatRepository } from '../../infrastructure/repositories/referral-stat.repository'; +import { ProcessedEventRepository } from '../../infrastructure/repositories/processed-event.repository'; + +interface PaymentReceivedPayload { + tenantId: string; + invoiceId: string; + paymentId: string; + amount: number; // USD cents + currency: string; + provider: string; + planName?: string; // 'free' | 'pro' | 'enterprise' +} + +// Reward rules (USD cents) +const FIRST_PAYMENT_REWARDS: Record = { + pro: { referrer: 1500, referred: 500 }, // $15 / $5 + enterprise: { referrer: 5000, referred: 2000 }, // $50 / $20 +}; +const RECURRING_PERCENTAGE = 10; // 10% of payment amount +const RECURRING_MAX_MONTHS = 12; +const LEVEL2_PERCENTAGE = 5; // 5% for level-2 +const LEVEL2_MAX_MONTHS = 6; + +const STREAM_KEY = 'events:payment.received'; +const CONSUMER_GROUP = 'referral-service'; +const CONSUMER_NAME = 'referral-consumer-1'; + +@Injectable() +export class ConsumePaymentReceivedUseCase implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(ConsumePaymentReceivedUseCase.name); + private client: Redis; + private running = false; + + constructor( + private readonly relationshipRepo: ReferralRelationshipRepository, + private readonly rewardRepo: ReferralRewardRepository, + private readonly statRepo: ReferralStatRepository, + private readonly processedRepo: ProcessedEventRepository, + private readonly configService: ConfigService, + ) {} + + async onModuleInit() { + const redisUrl = this.configService.get('REDIS_URL', 'redis://localhost:6379'); + this.client = new Redis(redisUrl); + + try { + await this.client.xgroup('CREATE', STREAM_KEY, CONSUMER_GROUP, '0', 'MKSTREAM'); + } catch { + // Group already exists + } + + this.running = true; + this.consumeLoop().catch((err) => + this.logger.error(`Payment consumer loop error: ${err.message}`), + ); + } + + async onModuleDestroy() { + this.running = false; + this.client.disconnect(); + } + + private async consumeLoop() { + while (this.running) { + try { + const response = await this.client.xreadgroup( + 'GROUP', CONSUMER_GROUP, CONSUMER_NAME, + 'COUNT', '10', + 'BLOCK', '5000', + 'STREAMS', STREAM_KEY, '>', + ) as Array<[string, Array<[string, string[]]>]> | null; + + if (!response) continue; + + for (const [, messages] of response) { + for (const [id, fields] of messages) { + const record: Record = {}; + for (let i = 0; i < fields.length; i += 2) { + record[fields[i]] = fields[i + 1]; + } + await this.processMessage(id, record); + await this.client.xack(STREAM_KEY, CONSUMER_GROUP, id); + } + } + } catch (err) { + if (this.running) { + this.logger.error(`Redis consumer error: ${err.message}`); + await new Promise((r) => setTimeout(r, 5000)); + } + } + } + } + + private async processMessage(msgId: string, record: Record) { + try { + const payload: PaymentReceivedPayload = JSON.parse(record['data'] ?? '{}'); + if (!payload.tenantId || !payload.invoiceId) return; + + // Idempotency: skip if already processed + const eventId = `payment:${payload.paymentId ?? msgId}`; + if (await this.processedRepo.hasProcessed(eventId)) { + this.logger.debug(`Skipping already-processed payment: ${eventId}`); + return; + } + + const { tenantId, invoiceId, amount, planName } = payload; + + // Find referral relationships where this tenant is the referred party + const level1 = await this.relationshipRepo.findByReferredTenantId(tenantId); + if (!level1) { + // No referral relationship — nothing to do + await this.processedRepo.markProcessed(eventId, 'payment.received'); + return; + } + + const isFirstPayment = level1.status === 'PENDING'; + + if (isFirstPayment) { + // Mark relationship as ACTIVE + await this.relationshipRepo.updateStatus(level1.id, 'ACTIVE', { + activatedAt: new Date(), + }); + await this.statRepo.incrementActiveCount(level1.referrerTenantId); + + // Issue first-payment rewards based on plan + const plan = planName?.toLowerCase() ?? 'pro'; + const rewardRules = FIRST_PAYMENT_REWARDS[plan] ?? FIRST_PAYMENT_REWARDS['pro']; + + // Reward to referrer + await this.issueCredit( + level1.referrerTenantId, + level1.id, + rewardRules.referrer, + 'FIRST_PAYMENT', + invoiceId, + ); + + // Bonus to referred tenant (welcome credit) + await this.issueCredit( + tenantId, + level1.id, + rewardRules.referred, + 'FIRST_PAYMENT', + invoiceId, + ); + + // Mark relationship as REWARDED (first-payment reward done) + await this.relationshipRepo.updateStatus(level1.id, 'REWARDED', { + rewardedAt: new Date(), + }); + + this.logger.log( + `First-payment rewards issued: referrer=${level1.referrerTenantId} (+$${rewardRules.referrer / 100}), referred=${tenantId} (+$${rewardRules.referred / 100})`, + ); + } else if (level1.status === 'ACTIVE' || level1.status === 'REWARDED') { + // Recurring reward: 10% of payment for up to 12 months + const recurringCount = await this.rewardRepo.countRecurringByRelationship(level1.id); + if (recurringCount < RECURRING_MAX_MONTHS) { + const recurringCents = Math.floor(amount * RECURRING_PERCENTAGE / 100); + if (recurringCents > 0) { + await this.issueCredit( + level1.referrerTenantId, + level1.id, + recurringCents, + 'RECURRING', + invoiceId, + recurringCount + 1, + ); + this.logger.log( + `Recurring reward issued: referrer=${level1.referrerTenantId} +${recurringCents} cents (month ${recurringCount + 1}/${RECURRING_MAX_MONTHS})`, + ); + } + } + } + + await this.processedRepo.markProcessed(eventId, 'payment.received'); + } catch (err) { + this.logger.error(`Failed to process payment message ${msgId}: ${err.message}`); + } + } + + private async issueCredit( + beneficiaryTenantId: string, + referralRelationshipId: string, + amountCents: number, + triggerType: 'FIRST_PAYMENT' | 'RECURRING', + sourceInvoiceId: string, + recurringMonth?: number, + ) { + const reward = await this.rewardRepo.create({ + beneficiaryTenantId, + referralRelationshipId, + rewardType: 'CREDIT', + triggerType, + amountCents, + sourceInvoiceId, + recurringMonth, + }); + + // Update stats + await this.statRepo.upsert(beneficiaryTenantId); + await this.statRepo.addCreditEarned(beneficiaryTenantId, amountCents); + + return reward; + } +} diff --git a/packages/services/referral-service/src/application/use-cases/get-my-referral-info.use-case.ts b/packages/services/referral-service/src/application/use-cases/get-my-referral-info.use-case.ts new file mode 100644 index 0000000..085ebe7 --- /dev/null +++ b/packages/services/referral-service/src/application/use-cases/get-my-referral-info.use-case.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { ReferralCodeRepository } from '../../infrastructure/repositories/referral-code.repository'; +import { ReferralStatRepository } from '../../infrastructure/repositories/referral-stat.repository'; +import { ReferralRewardRepository } from '../../infrastructure/repositories/referral-reward.repository'; +import { ReferralRelationshipRepository } from '../../infrastructure/repositories/referral-relationship.repository'; + +export interface MyReferralInfoDto { + referralCode: string; + shareUrl: string; + directCount: number; + activeCount: number; + pendingCreditCents: number; + totalEarnedCents: number; + totalAppliedCents: number; +} + +@Injectable() +export class GetMyReferralInfoUseCase { + constructor( + private readonly codeRepo: ReferralCodeRepository, + private readonly statRepo: ReferralStatRepository, + ) {} + + async execute(tenantId: string, userId: string): Promise { + // Ensure the tenant has a referral code; auto-create if not + let codeEntity = await this.codeRepo.findByTenantId(tenantId); + if (!codeEntity) { + codeEntity = await this.codeRepo.create(tenantId, userId); + } + + // Ensure stats row exists + const stat = await this.statRepo.upsert(tenantId); + + const appUrl = process.env.APP_REFERRAL_BASE_URL || 'https://it0api.szaiai.com'; + + return { + referralCode: codeEntity.code, + shareUrl: `${appUrl}/register?ref=${codeEntity.code}`, + directCount: stat.directCount, + activeCount: stat.activeCount, + pendingCreditCents: stat.pendingCredit, + totalEarnedCents: stat.totalCreditEarned, + totalAppliedCents: stat.totalCreditApplied, + }; + } +} diff --git a/packages/services/referral-service/src/application/use-cases/get-pending-credits.use-case.ts b/packages/services/referral-service/src/application/use-cases/get-pending-credits.use-case.ts new file mode 100644 index 0000000..3f0a7e3 --- /dev/null +++ b/packages/services/referral-service/src/application/use-cases/get-pending-credits.use-case.ts @@ -0,0 +1,61 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ReferralRewardRepository } from '../../infrastructure/repositories/referral-reward.repository'; +import { ReferralStatRepository } from '../../infrastructure/repositories/referral-stat.repository'; + +export interface PendingCreditsResult { + pendingCents: number; + rewards: Array<{ + id: string; + amountCents: number; + triggerType: string; + sourceInvoiceId: string | null; + createdAt: Date; + }>; +} + +/** + * Called by billing-service BEFORE generating an invoice to check available credits. + * After applying, billing-service should call markCreditsApplied. + */ +@Injectable() +export class GetPendingCreditsUseCase { + private readonly logger = new Logger(GetPendingCreditsUseCase.name); + + constructor( + private readonly rewardRepo: ReferralRewardRepository, + private readonly statRepo: ReferralStatRepository, + ) {} + + async getPending(tenantId: string): Promise { + const rewards = await this.rewardRepo.findPendingByTenant(tenantId); + const pendingCents = rewards.reduce((sum, r) => sum + r.amountCents, 0); + return { + pendingCents, + rewards: rewards.map((r) => ({ + id: r.id, + amountCents: r.amountCents, + triggerType: r.triggerType, + sourceInvoiceId: r.sourceInvoiceId, + createdAt: r.createdAt, + })), + }; + } + + /** + * Called by billing-service AFTER an invoice is issued, + * to mark the credits as applied. + */ + async markApplied(tenantId: string, invoiceId: string, appliedCents: number): Promise { + const rewards = await this.rewardRepo.findPendingByTenant(tenantId); + let remaining = appliedCents; + + for (const reward of rewards) { + if (remaining <= 0) break; + await this.rewardRepo.markApplied(reward.id, invoiceId); + remaining -= reward.amountCents; + } + + await this.statRepo.markCreditApplied(tenantId, appliedCents); + this.logger.log(`Applied ${appliedCents} cents credits for tenant ${tenantId} on invoice ${invoiceId}`); + } +} diff --git a/packages/services/referral-service/src/application/use-cases/get-referral-list.use-case.ts b/packages/services/referral-service/src/application/use-cases/get-referral-list.use-case.ts new file mode 100644 index 0000000..368ea17 --- /dev/null +++ b/packages/services/referral-service/src/application/use-cases/get-referral-list.use-case.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@nestjs/common'; +import { ReferralRelationshipRepository } from '../../infrastructure/repositories/referral-relationship.repository'; +import { ReferralRewardRepository } from '../../infrastructure/repositories/referral-reward.repository'; +import { ReferralRelationship } from '../../domain/entities/referral-relationship.entity'; +import { ReferralReward } from '../../domain/entities/referral-reward.entity'; + +export interface ReferralItemDto { + id: string; + referredTenantId: string; + referralCode: string; + status: string; + level: number; + registeredAt: Date; + activatedAt: Date | null; +} + +export interface RewardItemDto { + id: string; + amountCents: number; + amountFormatted: string; + rewardType: string; + triggerType: string; + status: string; + sourceInvoiceId: string | null; + recurringMonth: number | null; + createdAt: Date; + appliedAt: Date | null; +} + +@Injectable() +export class GetReferralListUseCase { + constructor( + private readonly relationshipRepo: ReferralRelationshipRepository, + private readonly rewardRepo: ReferralRewardRepository, + ) {} + + async getReferrals( + tenantId: string, + limit = 20, + offset = 0, + ): Promise<{ items: ReferralItemDto[]; total: number }> { + const { items, total } = await this.relationshipRepo.findAllByReferrerTenantId( + tenantId, + limit, + offset, + ); + return { + items: items.map(this.mapReferral), + total, + }; + } + + async getRewards( + tenantId: string, + status?: 'PENDING' | 'APPLIED' | 'EXPIRED', + limit = 20, + offset = 0, + ): Promise<{ items: RewardItemDto[]; total: number }> { + const { items, total } = await this.rewardRepo.findAllByTenant( + tenantId, + status, + limit, + offset, + ); + return { + items: items.map(this.mapReward), + total, + }; + } + + private mapReferral(r: ReferralRelationship): ReferralItemDto { + return { + id: r.id, + referredTenantId: r.referredTenantId, + referralCode: r.referralCode, + status: r.status, + level: r.level, + registeredAt: r.registeredAt, + activatedAt: r.activatedAt, + }; + } + + private mapReward(r: ReferralReward): RewardItemDto { + const dollars = (r.amountCents / 100).toFixed(2); + return { + id: r.id, + amountCents: r.amountCents, + amountFormatted: `$${dollars}`, + rewardType: r.rewardType, + triggerType: r.triggerType, + status: r.status, + sourceInvoiceId: r.sourceInvoiceId, + recurringMonth: r.recurringMonth, + createdAt: r.createdAt, + appliedAt: r.appliedAt, + }; + } +} diff --git a/packages/services/referral-service/src/application/use-cases/register-with-code.use-case.ts b/packages/services/referral-service/src/application/use-cases/register-with-code.use-case.ts new file mode 100644 index 0000000..e1e5ea7 --- /dev/null +++ b/packages/services/referral-service/src/application/use-cases/register-with-code.use-case.ts @@ -0,0 +1,92 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ReferralCodeRepository } from '../../infrastructure/repositories/referral-code.repository'; +import { ReferralRelationshipRepository } from '../../infrastructure/repositories/referral-relationship.repository'; +import { ReferralStatRepository } from '../../infrastructure/repositories/referral-stat.repository'; + +export interface RegisterWithCodeInput { + /** The newly registered tenant's ID */ + tenantId: string; + /** The admin user who registered */ + userId: string; + /** Optional referral code the user entered during registration */ + referralCode?: string; +} + +export interface RegisterWithCodeResult { + /** The new tenant's own referral code (auto-generated) */ + myReferralCode: string; +} + +@Injectable() +export class RegisterWithCodeUseCase { + private readonly logger = new Logger(RegisterWithCodeUseCase.name); + + constructor( + private readonly codeRepo: ReferralCodeRepository, + private readonly relationshipRepo: ReferralRelationshipRepository, + private readonly statRepo: ReferralStatRepository, + ) {} + + async execute(input: RegisterWithCodeInput): Promise { + const { tenantId, userId, referralCode } = input; + + // 1. Auto-create the new tenant's own referral code + const myCode = await this.codeRepo.create(tenantId, userId); + + // 2. Ensure stats row exists for the new tenant + await this.statRepo.upsert(tenantId); + + // 3. If a referral code was provided, create the relationship + if (referralCode) { + const codeEntity = await this.codeRepo.findByCode(referralCode); + if (!codeEntity) { + this.logger.warn(`Referral code not found: ${referralCode} for tenant ${tenantId}`); + return { myReferralCode: myCode.code }; + } + + // Prevent self-referral + if (codeEntity.tenantId === tenantId) { + this.logger.warn(`Self-referral attempt by tenant ${tenantId}`); + return { myReferralCode: myCode.code }; + } + + // Prevent double-registration (idempotent) + const existing = await this.relationshipRepo.findByReferredTenantId(tenantId); + if (existing) { + this.logger.warn(`Tenant ${tenantId} already has a referral relationship`); + return { myReferralCode: myCode.code }; + } + + // Create level-1 (direct) relationship + await this.relationshipRepo.create( + codeEntity.tenantId, + tenantId, + referralCode, + 1, + ); + + // Update direct count for the referrer + await this.statRepo.upsert(codeEntity.tenantId); + await this.statRepo.incrementDirectCount(codeEntity.tenantId); + + // Also create level-2 relationship if the referrer was themselves referred + const referrerRelationship = await this.relationshipRepo.findByReferredTenantId( + codeEntity.tenantId, + ); + if (referrerRelationship) { + await this.relationshipRepo.create( + referrerRelationship.referrerTenantId, + tenantId, + referralCode, + 2, + ); + } + + this.logger.log( + `Referral relationship created: ${codeEntity.tenantId} → ${tenantId} (code: ${referralCode})`, + ); + } + + return { myReferralCode: myCode.code }; + } +} diff --git a/packages/services/referral-service/src/application/use-cases/validate-referral-code.use-case.ts b/packages/services/referral-service/src/application/use-cases/validate-referral-code.use-case.ts new file mode 100644 index 0000000..bccf2bb --- /dev/null +++ b/packages/services/referral-service/src/application/use-cases/validate-referral-code.use-case.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { ReferralCodeRepository } from '../../infrastructure/repositories/referral-code.repository'; + +export interface ValidateReferralCodeResult { + valid: boolean; + referrerTenantId?: string; +} + +@Injectable() +export class ValidateReferralCodeUseCase { + constructor(private readonly codeRepo: ReferralCodeRepository) {} + + async execute(code: string): Promise { + if (!code || !/^IT0-[A-Z0-9]{3}-[A-Z0-9]{4}$/.test(code)) { + return { valid: false }; + } + + const entity = await this.codeRepo.findByCode(code); + if (!entity) { + return { valid: false }; + } + + // Increment click count asynchronously (don't block response) + this.codeRepo.incrementClickCount(entity.id).catch(() => {}); + + return { + valid: true, + referrerTenantId: entity.tenantId, + }; + } +} diff --git a/packages/services/referral-service/src/domain/entities/processed-event.entity.ts b/packages/services/referral-service/src/domain/entities/processed-event.entity.ts new file mode 100644 index 0000000..b54a805 --- /dev/null +++ b/packages/services/referral-service/src/domain/entities/processed-event.entity.ts @@ -0,0 +1,21 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm'; + +/** + * Idempotency table — tracks Redis Stream message IDs that have been processed. + * Prevents double-processing on retries. + */ +@Entity({ name: 'referral_processed_events', schema: 'public' }) +export class ProcessedEvent { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'event_id', type: 'varchar', length: 255, unique: true }) + @Index() + eventId!: string; + + @Column({ name: 'event_type', type: 'varchar', length: 100 }) + eventType!: string; + + @CreateDateColumn({ name: 'processed_at', type: 'timestamptz' }) + processedAt!: Date; +} diff --git a/packages/services/referral-service/src/domain/entities/referral-code.entity.ts b/packages/services/referral-service/src/domain/entities/referral-code.entity.ts new file mode 100644 index 0000000..deed98d --- /dev/null +++ b/packages/services/referral-service/src/domain/entities/referral-code.entity.ts @@ -0,0 +1,29 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; + +@Entity({ name: 'referral_codes', schema: 'public' }) +export class ReferralCode { + @PrimaryGeneratedColumn('uuid') + id!: string; + + // FK to public.tenants.id — one code per tenant + @Column({ name: 'tenant_id', type: 'varchar', unique: true }) + tenantId!: string; + + // The admin user who owns this code + @Column({ name: 'user_id', type: 'varchar' }) + userId!: string; + + // e.g. "IT0-ACM-X9K2" + @Column({ type: 'varchar', length: 20, unique: true }) + @Index() + code!: string; + + @Column({ name: 'click_count', type: 'int', default: 0 }) + clickCount!: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/packages/services/referral-service/src/domain/entities/referral-relationship.entity.ts b/packages/services/referral-service/src/domain/entities/referral-relationship.entity.ts new file mode 100644 index 0000000..442c858 --- /dev/null +++ b/packages/services/referral-service/src/domain/entities/referral-relationship.entity.ts @@ -0,0 +1,45 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm'; + +export type ReferralStatus = 'PENDING' | 'ACTIVE' | 'REWARDED' | 'EXPIRED'; + +@Entity({ name: 'referral_relationships', schema: 'public' }) +export class ReferralRelationship { + @PrimaryGeneratedColumn('uuid') + id!: string; + + // Referrer's tenant ID + @Column({ name: 'referrer_tenant_id', type: 'varchar' }) + @Index() + referrerTenantId!: string; + + // Referred tenant ID (unique: one tenant can only be referred once) + @Column({ name: 'referred_tenant_id', type: 'varchar', unique: true }) + referredTenantId!: string; + + // The referral code that was used + @Column({ name: 'referral_code', type: 'varchar', length: 20 }) + referralCode!: string; + + // Referral level: 1=direct, 2=indirect (referrer's referrer) + @Column({ type: 'int', default: 1 }) + level!: number; + + // PENDING: registered but not paid + // ACTIVE: first payment received + // REWARDED: first-payment reward issued + // EXPIRED: never paid within activation window + @Column({ type: 'varchar', length: 20, default: 'PENDING' }) + @Index() + status!: ReferralStatus; + + @CreateDateColumn({ name: 'registered_at', type: 'timestamptz' }) + registeredAt!: Date; + + // Timestamp of the referred tenant's first successful payment + @Column({ name: 'activated_at', type: 'timestamptz', nullable: true }) + activatedAt!: Date | null; + + // Timestamp when first-payment reward was issued to referrer + @Column({ name: 'rewarded_at', type: 'timestamptz', nullable: true }) + rewardedAt!: Date | null; +} diff --git a/packages/services/referral-service/src/domain/entities/referral-reward.entity.ts b/packages/services/referral-service/src/domain/entities/referral-reward.entity.ts new file mode 100644 index 0000000..1f1eee2 --- /dev/null +++ b/packages/services/referral-service/src/domain/entities/referral-reward.entity.ts @@ -0,0 +1,61 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm'; + +export type RewardType = 'CREDIT' | 'PERCENTAGE'; +export type TriggerType = 'FIRST_PAYMENT' | 'RECURRING'; +export type RewardStatus = 'PENDING' | 'APPLIED' | 'EXPIRED'; + +@Entity({ name: 'referral_rewards', schema: 'public' }) +export class ReferralReward { + @PrimaryGeneratedColumn('uuid') + id!: string; + + // The tenant that receives the reward + @Column({ name: 'beneficiary_tenant_id', type: 'varchar' }) + @Index() + beneficiaryTenantId!: string; + + // Which referral relationship triggered this reward + @Column({ name: 'referral_relationship_id', type: 'varchar' }) + referralRelationshipId!: string; + + // CREDIT: fixed USD cents amount + @Column({ name: 'reward_type', type: 'varchar', length: 20 }) + rewardType!: RewardType; + + // FIRST_PAYMENT: one-time on first payment + // RECURRING: each subsequent monthly payment (max 12 months) + @Column({ name: 'trigger_type', type: 'varchar', length: 20 }) + triggerType!: TriggerType; + + // Reward amount in USD cents (e.g. 1500 = $15.00) + @Column({ name: 'amount_cents', type: 'int' }) + amountCents!: number; + + // PENDING: waiting to be applied to next invoice + // APPLIED: deducted from an invoice + // EXPIRED: unused past expiry + @Column({ type: 'varchar', length: 20, default: 'PENDING' }) + @Index() + status!: RewardStatus; + + // The invoice this credit was applied to (set when status → APPLIED) + @Column({ name: 'invoice_id', type: 'varchar', nullable: true }) + invoiceId!: string | null; + + // The invoice that triggered this reward (payment event source) + @Column({ name: 'source_invoice_id', type: 'varchar', nullable: true }) + sourceInvoiceId!: string | null; + + // For recurring rewards: which month this covers (1-12) + @Column({ name: 'recurring_month', type: 'int', nullable: true }) + recurringMonth!: number | null; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt!: Date | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @Column({ name: 'applied_at', type: 'timestamptz', nullable: true }) + appliedAt!: Date | null; +} diff --git a/packages/services/referral-service/src/domain/entities/referral-stat.entity.ts b/packages/services/referral-service/src/domain/entities/referral-stat.entity.ts new file mode 100644 index 0000000..6be209e --- /dev/null +++ b/packages/services/referral-service/src/domain/entities/referral-stat.entity.ts @@ -0,0 +1,35 @@ +import { Entity, PrimaryColumn, Column, UpdateDateColumn } from 'typeorm'; + +/** + * Denormalized stats cache per tenant — updated after each referral event. + * Avoids expensive COUNT queries on hot paths. + */ +@Entity({ name: 'referral_stats', schema: 'public' }) +export class ReferralStat { + // tenant_id is the PK (one row per tenant) + @PrimaryColumn({ name: 'tenant_id', type: 'varchar' }) + tenantId!: string; + + // Number of direct referrals (registered, any status) + @Column({ name: 'direct_count', type: 'int', default: 0 }) + directCount!: number; + + // Number of referrals that paid at least once (ACTIVE or REWARDED) + @Column({ name: 'active_count', type: 'int', default: 0 }) + activeCount!: number; + + // Total credits earned (cents), including applied and pending + @Column({ name: 'total_credit_earned', type: 'int', default: 0 }) + totalCreditEarned!: number; + + // Credits already deducted from invoices + @Column({ name: 'total_credit_applied', type: 'int', default: 0 }) + totalCreditApplied!: number; + + // Credits waiting to be applied to the next invoice + @Column({ name: 'pending_credit', type: 'int', default: 0 }) + pendingCredit!: number; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/packages/services/referral-service/src/infrastructure/repositories/processed-event.repository.ts b/packages/services/referral-service/src/infrastructure/repositories/processed-event.repository.ts new file mode 100644 index 0000000..54804a6 --- /dev/null +++ b/packages/services/referral-service/src/infrastructure/repositories/processed-event.repository.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, Repository } from 'typeorm'; +import { ProcessedEvent } from '../../domain/entities/processed-event.entity'; + +@Injectable() +export class ProcessedEventRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(ProcessedEvent); + } + + async hasProcessed(eventId: string): Promise { + const count = await this.repo.count({ where: { eventId } }); + return count > 0; + } + + async markProcessed(eventId: string, eventType: string): Promise { + try { + await this.repo.save(this.repo.create({ eventId, eventType })); + } catch { + // Unique constraint violation means it was already processed — safe to ignore + } + } +} diff --git a/packages/services/referral-service/src/infrastructure/repositories/referral-code.repository.ts b/packages/services/referral-service/src/infrastructure/repositories/referral-code.repository.ts new file mode 100644 index 0000000..ba7cfe1 --- /dev/null +++ b/packages/services/referral-service/src/infrastructure/repositories/referral-code.repository.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, Repository } from 'typeorm'; +import { ReferralCode } from '../../domain/entities/referral-code.entity'; +import * as crypto from 'crypto'; + +@Injectable() +export class ReferralCodeRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(ReferralCode); + } + + async findByTenantId(tenantId: string): Promise { + return this.repo.findOne({ where: { tenantId } }); + } + + async findByCode(code: string): Promise { + return this.repo.findOne({ where: { code } }); + } + + async create(tenantId: string, userId: string): Promise { + const code = await this.generateUniqueCode(tenantId); + const entity = this.repo.create({ tenantId, userId, code }); + return this.repo.save(entity); + } + + async incrementClickCount(id: string): Promise { + await this.repo.increment({ id }, 'clickCount', 1); + } + + async save(entity: ReferralCode): Promise { + return this.repo.save(entity); + } + + /** + * Generate referral code: IT0-{tenantPrefix3}-{random4} + * e.g. IT0-ACM-X9K2 + */ + private async generateUniqueCode(tenantSlug: string): Promise { + for (let attempt = 0; attempt < 10; attempt++) { + const prefix = tenantSlug + .toUpperCase() + .replace(/[^A-Z0-9]/g, '') + .slice(0, 3) + .padEnd(3, 'X'); + const random = crypto.randomBytes(3).toString('hex').toUpperCase().slice(0, 4); + const code = `IT0-${prefix}-${random}`; + const existing = await this.repo.findOne({ where: { code } }); + if (!existing) return code; + } + // Fallback: fully random + return `IT0-${crypto.randomBytes(6).toString('hex').toUpperCase().slice(0, 8)}`; + } +} diff --git a/packages/services/referral-service/src/infrastructure/repositories/referral-relationship.repository.ts b/packages/services/referral-service/src/infrastructure/repositories/referral-relationship.repository.ts new file mode 100644 index 0000000..569fc35 --- /dev/null +++ b/packages/services/referral-service/src/infrastructure/repositories/referral-relationship.repository.ts @@ -0,0 +1,92 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, Repository } from 'typeorm'; +import { ReferralRelationship, ReferralStatus } from '../../domain/entities/referral-relationship.entity'; + +@Injectable() +export class ReferralRelationshipRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(ReferralRelationship); + } + + async findByReferredTenantId(referredTenantId: string): Promise { + return this.repo.findOne({ where: { referredTenantId } }); + } + + async findAllByReferrerTenantId( + referrerTenantId: string, + limit = 20, + offset = 0, + ): Promise<{ items: ReferralRelationship[]; total: number }> { + const [items, total] = await this.repo.findAndCount({ + where: { referrerTenantId }, + order: { registeredAt: 'DESC' }, + take: limit, + skip: offset, + }); + return { items, total }; + } + + async findAll( + status?: ReferralStatus, + limit = 50, + offset = 0, + ): Promise<{ items: ReferralRelationship[]; total: number }> { + const where = status ? { status } : {}; + const [items, total] = await this.repo.findAndCount({ + where, + order: { registeredAt: 'DESC' }, + take: limit, + skip: offset, + }); + return { items, total }; + } + + async create( + referrerTenantId: string, + referredTenantId: string, + referralCode: string, + level = 1, + ): Promise { + const entity = this.repo.create({ + referrerTenantId, + referredTenantId, + referralCode, + level, + status: 'PENDING', + }); + return this.repo.save(entity); + } + + async updateStatus( + id: string, + status: ReferralStatus, + extra?: { activatedAt?: Date; rewardedAt?: Date }, + ): Promise { + await this.repo.update(id, { status, ...extra }); + } + + async countByReferrer(referrerTenantId: string): Promise<{ direct: number; active: number }> { + const direct = await this.repo.count({ where: { referrerTenantId } }); + const active = await this.repo.count({ + where: [ + { referrerTenantId, status: 'ACTIVE' }, + { referrerTenantId, status: 'REWARDED' }, + ], + }); + return { direct, active }; + } + + /** Expire PENDING relationships older than the activation window */ + async expireOldPending(activationWindowDays: number): Promise { + const cutoff = new Date(Date.now() - activationWindowDays * 24 * 60 * 60 * 1000); + const result = await this.repo + .createQueryBuilder() + .update() + .set({ status: 'EXPIRED' }) + .where('status = :status AND registered_at < :cutoff', { status: 'PENDING', cutoff }) + .execute(); + return result.affected ?? 0; + } +} diff --git a/packages/services/referral-service/src/infrastructure/repositories/referral-reward.repository.ts b/packages/services/referral-service/src/infrastructure/repositories/referral-reward.repository.ts new file mode 100644 index 0000000..73fa180 --- /dev/null +++ b/packages/services/referral-service/src/infrastructure/repositories/referral-reward.repository.ts @@ -0,0 +1,93 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, Repository } from 'typeorm'; +import { ReferralReward, RewardStatus, RewardType, TriggerType } from '../../domain/entities/referral-reward.entity'; + +@Injectable() +export class ReferralRewardRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(ReferralReward); + } + + async findPendingByTenant(beneficiaryTenantId: string): Promise { + return this.repo.find({ + where: { beneficiaryTenantId, status: 'PENDING' }, + order: { createdAt: 'ASC' }, + }); + } + + async findAllByTenant( + beneficiaryTenantId: string, + status?: RewardStatus, + limit = 20, + offset = 0, + ): Promise<{ items: ReferralReward[]; total: number }> { + const where: any = { beneficiaryTenantId }; + if (status) where.status = status; + const [items, total] = await this.repo.findAndCount({ + where, + order: { createdAt: 'DESC' }, + take: limit, + skip: offset, + }); + return { items, total }; + } + + async findAll( + status?: RewardStatus, + limit = 50, + offset = 0, + ): Promise<{ items: ReferralReward[]; total: number }> { + const where = status ? { status } : {}; + const [items, total] = await this.repo.findAndCount({ + where, + order: { createdAt: 'DESC' }, + take: limit, + skip: offset, + }); + return { items, total }; + } + + async create(params: { + beneficiaryTenantId: string; + referralRelationshipId: string; + rewardType: RewardType; + triggerType: TriggerType; + amountCents: number; + sourceInvoiceId?: string; + recurringMonth?: number; + }): Promise { + const entity = this.repo.create({ + ...params, + status: 'PENDING', + }); + return this.repo.save(entity); + } + + async markApplied(id: string, invoiceId: string): Promise { + await this.repo.update(id, { + status: 'APPLIED', + invoiceId, + appliedAt: new Date(), + }); + } + + async countRecurringByRelationship(referralRelationshipId: string): Promise { + return this.repo.count({ + where: { referralRelationshipId, triggerType: 'RECURRING' }, + }); + } + + async sumPendingCents(beneficiaryTenantId: string): Promise { + const result = await this.repo + .createQueryBuilder('r') + .select('COALESCE(SUM(r.amount_cents), 0)', 'total') + .where('r.beneficiary_tenant_id = :id AND r.status = :status', { + id: beneficiaryTenantId, + status: 'PENDING', + }) + .getRawOne(); + return parseInt(result?.total ?? '0', 10); + } +} diff --git a/packages/services/referral-service/src/infrastructure/repositories/referral-stat.repository.ts b/packages/services/referral-service/src/infrastructure/repositories/referral-stat.repository.ts new file mode 100644 index 0000000..ef357ae --- /dev/null +++ b/packages/services/referral-service/src/infrastructure/repositories/referral-stat.repository.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, Repository } from 'typeorm'; +import { ReferralStat } from '../../domain/entities/referral-stat.entity'; + +@Injectable() +export class ReferralStatRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(ReferralStat); + } + + async findByTenantId(tenantId: string): Promise { + return this.repo.findOne({ where: { tenantId } }); + } + + async upsert(tenantId: string): Promise { + let stat = await this.repo.findOne({ where: { tenantId } }); + if (!stat) { + stat = this.repo.create({ tenantId }); + await this.repo.save(stat); + } + return stat; + } + + async incrementDirectCount(tenantId: string): Promise { + await this.repo.upsert( + { tenantId, directCount: 1 }, + { conflictPaths: ['tenantId'], skipUpdateIfNoValuesChanged: false }, + ); + await this.repo.increment({ tenantId }, 'directCount', 1); + } + + async incrementActiveCount(tenantId: string): Promise { + await this.repo.increment({ tenantId }, 'activeCount', 1); + } + + async addCreditEarned(tenantId: string, cents: number): Promise { + await this.repo.increment({ tenantId }, 'totalCreditEarned', cents); + await this.repo.increment({ tenantId }, 'pendingCredit', cents); + } + + async markCreditApplied(tenantId: string, cents: number): Promise { + await this.repo.increment({ tenantId }, 'totalCreditApplied', cents); + await this.repo.decrement({ tenantId }, 'pendingCredit', cents); + } + + /** Recalculate pendingCredit from actual reward rows (for consistency repair) */ + async recalculatePending(tenantId: string, pendingCents: number): Promise { + await this.repo.update({ tenantId }, { pendingCredit: pendingCents }); + } +} diff --git a/packages/services/referral-service/src/interfaces/rest/controllers/referral-admin.controller.ts b/packages/services/referral-service/src/interfaces/rest/controllers/referral-admin.controller.ts new file mode 100644 index 0000000..ed0fb53 --- /dev/null +++ b/packages/services/referral-service/src/interfaces/rest/controllers/referral-admin.controller.ts @@ -0,0 +1,89 @@ +import { + Controller, + Get, + Query, + Headers, + UnauthorizedException, + ParseIntPipe, + DefaultValuePipe, +} from '@nestjs/common'; +import { GetReferralListUseCase } from '../../../application/use-cases/get-referral-list.use-case'; +import { ReferralRelationshipRepository } from '../../../infrastructure/repositories/referral-relationship.repository'; +import { ReferralRewardRepository } from '../../../infrastructure/repositories/referral-reward.repository'; +import { ReferralStatus } from '../../../domain/entities/referral-relationship.entity'; +import { RewardStatus } from '../../../domain/entities/referral-reward.entity'; +import * as jwt from 'jsonwebtoken'; + +/** + * Platform-admin endpoints for managing the referral system. + * Requires JWT with platform_admin or platform_super_admin role. + */ +@Controller('api/v1/referral/admin') +export class ReferralAdminController { + constructor( + private readonly getReferralList: GetReferralListUseCase, + private readonly relationshipRepo: ReferralRelationshipRepository, + private readonly rewardRepo: ReferralRewardRepository, + ) {} + + /** GET /api/v1/referral/admin/relationships — all referral relationships */ + @Get('relationships') + async listRelationships( + @Headers('authorization') auth: string, + @Query('status') status: ReferralStatus | undefined, + @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number, + @Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number, + ) { + this.requireAdmin(auth); + return this.relationshipRepo.findAll(status, Math.min(limit, 200), offset); + } + + /** GET /api/v1/referral/admin/rewards — all reward records */ + @Get('rewards') + async listRewards( + @Headers('authorization') auth: string, + @Query('status') status: RewardStatus | undefined, + @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number, + @Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number, + ) { + this.requireAdmin(auth); + return this.rewardRepo.findAll(status, Math.min(limit, 200), offset); + } + + /** GET /api/v1/referral/admin/stats — global referral statistics */ + @Get('stats') + async getStats(@Headers('authorization') auth: string) { + this.requireAdmin(auth); + const [totalRel, activeRel, pendingRewards] = await Promise.all([ + this.relationshipRepo.findAll(undefined, 1, 0).then((r) => r.total), + this.relationshipRepo.findAll('ACTIVE', 1, 0).then((r) => r.total), + this.rewardRepo.findAll('PENDING', 1, 0).then((r) => r.total), + ]); + return { + totalReferrals: totalRel, + activeReferrals: activeRel, + pendingRewards, + }; + } + + private requireAdmin(auth: string) { + if (!auth?.startsWith('Bearer ')) { + throw new UnauthorizedException('Missing authorization header'); + } + const token = auth.slice(7); + const secret = process.env.JWT_SECRET || 'dev-secret'; + try { + const payload = jwt.verify(token, secret) as any; + const roles: string[] = Array.isArray(payload.roles) ? payload.roles : []; + if ( + !roles.includes('platform_admin') && + !roles.includes('platform_super_admin') + ) { + throw new UnauthorizedException('Admin role required'); + } + } catch (err) { + if (err instanceof UnauthorizedException) throw err; + throw new UnauthorizedException('Invalid JWT'); + } + } +} diff --git a/packages/services/referral-service/src/interfaces/rest/controllers/referral-internal.controller.ts b/packages/services/referral-service/src/interfaces/rest/controllers/referral-internal.controller.ts new file mode 100644 index 0000000..68af11c --- /dev/null +++ b/packages/services/referral-service/src/interfaces/rest/controllers/referral-internal.controller.ts @@ -0,0 +1,71 @@ +import { + Controller, + Post, + Get, + Body, + Param, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { InternalApiGuard } from '../guards/internal-api.guard'; +import { RegisterWithCodeUseCase } from '../../../application/use-cases/register-with-code.use-case'; +import { GetPendingCreditsUseCase } from '../../../application/use-cases/get-pending-credits.use-case'; + +/** + * Internal service-to-service endpoints. + * Protected by X-Internal-Api-Key header (InternalApiGuard). + * NOT exposed through Kong to the outside world. + * + * Called by: + * - auth-service: on tenant registration → POST /api/v1/referral/internal/register + * - billing-service: before invoice → GET /api/v1/referral/internal/:tenantId/pending-credits + * - billing-service: after invoice → POST /api/v1/referral/internal/:tenantId/apply-credits + */ +@Controller('api/v1/referral/internal') +@UseGuards(InternalApiGuard) +export class ReferralInternalController { + constructor( + private readonly registerWithCode: RegisterWithCodeUseCase, + private readonly getPendingCredits: GetPendingCreditsUseCase, + ) {} + + /** + * Called by auth-service immediately after a new tenant is created. + * Creates the tenant's own referral code and optionally links a referrer. + */ + @Post('register') + @HttpCode(HttpStatus.OK) + async register( + @Body() body: { tenantId: string; userId: string; referralCode?: string }, + ) { + return this.registerWithCode.execute({ + tenantId: body.tenantId, + userId: body.userId, + referralCode: body.referralCode, + }); + } + + /** + * Called by billing-service before generating an invoice. + * Returns total pending credit and individual reward IDs. + */ + @Get(':tenantId/pending-credits') + async getPending(@Param('tenantId') tenantId: string) { + return this.getPendingCredits.getPending(tenantId); + } + + /** + * Called by billing-service after applying credits to an invoice. + * Marks reward records as APPLIED. + */ + @Post(':tenantId/apply-credits') + @HttpCode(HttpStatus.OK) + async applyCredits( + @Param('tenantId') tenantId: string, + @Body() body: { invoiceId: string; appliedCents: number }, + ) { + await this.getPendingCredits.markApplied(tenantId, body.invoiceId, body.appliedCents); + return { success: true }; + } +} diff --git a/packages/services/referral-service/src/interfaces/rest/controllers/referral.controller.ts b/packages/services/referral-service/src/interfaces/rest/controllers/referral.controller.ts new file mode 100644 index 0000000..d32db47 --- /dev/null +++ b/packages/services/referral-service/src/interfaces/rest/controllers/referral.controller.ts @@ -0,0 +1,82 @@ +import { + Controller, + Get, + Query, + Headers, + UnauthorizedException, + ParseIntPipe, + DefaultValuePipe, + Logger, +} from '@nestjs/common'; +import { GetMyReferralInfoUseCase } from '../../../application/use-cases/get-my-referral-info.use-case'; +import { ValidateReferralCodeUseCase } from '../../../application/use-cases/validate-referral-code.use-case'; +import { GetReferralListUseCase } from '../../../application/use-cases/get-referral-list.use-case'; +import * as jwt from 'jsonwebtoken'; + +/** + * User-facing referral endpoints. + * Kong enforces JWT — we extract tenant/user from the Authorization header here. + */ +@Controller('api/v1/referral') +export class ReferralController { + private readonly logger = new Logger(ReferralController.name); + + constructor( + private readonly getMyReferralInfo: GetMyReferralInfoUseCase, + private readonly validateCode: ValidateReferralCodeUseCase, + private readonly getReferralList: GetReferralListUseCase, + ) {} + + /** GET /api/v1/referral/me — get current tenant's referral info & code */ + @Get('me') + async getMe(@Headers('authorization') auth: string) { + const { tenantId, userId } = this.extractJwt(auth); + return this.getMyReferralInfo.execute(tenantId, userId); + } + + /** GET /api/v1/referral/me/referrals?limit=20&offset=0 — list direct referrals */ + @Get('me/referrals') + async getMyReferrals( + @Headers('authorization') auth: string, + @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number, + @Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number, + ) { + const { tenantId } = this.extractJwt(auth); + return this.getReferralList.getReferrals(tenantId, Math.min(limit, 100), offset); + } + + /** GET /api/v1/referral/me/rewards?status=PENDING&limit=20&offset=0 */ + @Get('me/rewards') + async getMyRewards( + @Headers('authorization') auth: string, + @Query('status') status: 'PENDING' | 'APPLIED' | 'EXPIRED' | undefined, + @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number, + @Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number, + ) { + const { tenantId } = this.extractJwt(auth); + return this.getReferralList.getRewards(tenantId, status, Math.min(limit, 100), offset); + } + + /** GET /api/v1/referral/validate/:code — public, validate a referral code */ + @Get('validate') + async validate(@Query('code') code: string) { + return this.validateCode.execute(code ?? ''); + } + + private extractJwt(auth: string): { tenantId: string; userId: string } { + if (!auth?.startsWith('Bearer ')) { + throw new UnauthorizedException('Missing authorization header'); + } + const token = auth.slice(7); + const secret = process.env.JWT_SECRET || 'dev-secret'; + try { + const payload = jwt.verify(token, secret) as any; + return { + tenantId: payload.tenantId, + userId: payload.sub, + }; + } catch { + throw new UnauthorizedException('Invalid JWT'); + } + } +} diff --git a/packages/services/referral-service/src/interfaces/rest/guards/internal-api.guard.ts b/packages/services/referral-service/src/interfaces/rest/guards/internal-api.guard.ts new file mode 100644 index 0000000..b080c82 --- /dev/null +++ b/packages/services/referral-service/src/interfaces/rest/guards/internal-api.guard.ts @@ -0,0 +1,24 @@ +import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +/** + * Guard for service-to-service internal API calls. + * Validates the X-Internal-Api-Key header. + */ +@Injectable() +export class InternalApiGuard implements CanActivate { + private readonly internalKey: string; + + constructor(private readonly configService: ConfigService) { + this.internalKey = this.configService.get('INTERNAL_API_KEY', 'changeme-internal-key'); + } + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const apiKey = request.headers['x-internal-api-key']; + if (!apiKey || apiKey !== this.internalKey) { + throw new UnauthorizedException('Invalid internal API key'); + } + return true; + } +} diff --git a/packages/services/referral-service/src/main.ts b/packages/services/referral-service/src/main.ts new file mode 100644 index 0000000..9a5d1d6 --- /dev/null +++ b/packages/services/referral-service/src/main.ts @@ -0,0 +1,30 @@ +import { NestFactory } from '@nestjs/core'; +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ReferralModule } from './referral.module'; + +const logger = new Logger('ReferralService'); + +process.on('unhandledRejection', (reason) => { + logger.error(`Unhandled Rejection: ${reason}`); +}); +process.on('uncaughtException', (error) => { + logger.error(`Uncaught Exception: ${error.message}`, error.stack); +}); + +async function bootstrap() { + const app = await NestFactory.create(ReferralModule); + + const config = app.get(ConfigService); + const port = config.get('REFERRAL_SERVICE_PORT', 3012); + + app.enableCors(); + + await app.listen(port); + logger.log(`referral-service running on port ${port}`); +} + +bootstrap().catch((err) => { + logger.error(`Failed to start referral-service: ${err.message}`, err.stack); + process.exit(1); +}); diff --git a/packages/services/referral-service/src/referral.module.ts b/packages/services/referral-service/src/referral.module.ts new file mode 100644 index 0000000..db9d46e --- /dev/null +++ b/packages/services/referral-service/src/referral.module.ts @@ -0,0 +1,67 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { DatabaseModule } from '@it0/database'; + +// Domain Entities +import { ReferralCode } from './domain/entities/referral-code.entity'; +import { ReferralRelationship } from './domain/entities/referral-relationship.entity'; +import { ReferralReward } from './domain/entities/referral-reward.entity'; +import { ReferralStat } from './domain/entities/referral-stat.entity'; +import { ProcessedEvent } from './domain/entities/processed-event.entity'; + +// Infrastructure Repositories +import { ReferralCodeRepository } from './infrastructure/repositories/referral-code.repository'; +import { ReferralRelationshipRepository } from './infrastructure/repositories/referral-relationship.repository'; +import { ReferralRewardRepository } from './infrastructure/repositories/referral-reward.repository'; +import { ReferralStatRepository } from './infrastructure/repositories/referral-stat.repository'; +import { ProcessedEventRepository } from './infrastructure/repositories/processed-event.repository'; + +// Application Use Cases +import { GetMyReferralInfoUseCase } from './application/use-cases/get-my-referral-info.use-case'; +import { ValidateReferralCodeUseCase } from './application/use-cases/validate-referral-code.use-case'; +import { RegisterWithCodeUseCase } from './application/use-cases/register-with-code.use-case'; +import { ConsumePaymentReceivedUseCase } from './application/use-cases/consume-payment-received.use-case'; +import { GetReferralListUseCase } from './application/use-cases/get-referral-list.use-case'; +import { GetPendingCreditsUseCase } from './application/use-cases/get-pending-credits.use-case'; + +// Controllers +import { ReferralController } from './interfaces/rest/controllers/referral.controller'; +import { ReferralInternalController } from './interfaces/rest/controllers/referral-internal.controller'; +import { ReferralAdminController } from './interfaces/rest/controllers/referral-admin.controller'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + DatabaseModule.forRoot(), + TypeOrmModule.forFeature([ + ReferralCode, + ReferralRelationship, + ReferralReward, + ReferralStat, + ProcessedEvent, + ]), + ], + controllers: [ + ReferralController, + ReferralInternalController, + ReferralAdminController, + ], + providers: [ + // Repositories + ReferralCodeRepository, + ReferralRelationshipRepository, + ReferralRewardRepository, + ReferralStatRepository, + ProcessedEventRepository, + + // Use Cases + GetMyReferralInfoUseCase, + ValidateReferralCodeUseCase, + RegisterWithCodeUseCase, + ConsumePaymentReceivedUseCase, + GetReferralListUseCase, + GetPendingCreditsUseCase, + ], +}) +export class ReferralModule {} diff --git a/packages/services/referral-service/tsconfig.json b/packages/services/referral-service/tsconfig.json new file mode 100644 index 0000000..5673c32 --- /dev/null +++ b/packages/services/referral-service/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "baseUrl": ".", + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "strictPropertyInitialization": false, + "useUnknownInCatchVariables": false, + "paths": { + "@it0/common": ["../../shared/common/src"], + "@it0/common/*": ["../../shared/common/src/*"], + "@it0/database": ["../../shared/database/src"], + "@it0/database/*": ["../../shared/database/src/*"], + "@it0/events": ["../../shared/events/src"], + "@it0/events/*": ["../../shared/events/src/*"], + "@it0/proto": ["../../shared/proto/src"], + "@it0/proto/*": ["../../shared/proto/src/*"], + "@it0/testing": ["../../shared/testing/src"], + "@it0/testing/*": ["../../shared/testing/src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/shared/database/migrations/006-create-referral-tables.sql b/packages/shared/database/migrations/006-create-referral-tables.sql new file mode 100644 index 0000000..34681ae --- /dev/null +++ b/packages/shared/database/migrations/006-create-referral-tables.sql @@ -0,0 +1,76 @@ +-- ============================================================ +-- Migration 006: Referral System Tables +-- All tables in public schema (cross-tenant, like billing) +-- ============================================================ + +-- 1. Referral codes — one per tenant, auto-generated on registration +CREATE TABLE IF NOT EXISTS public.referral_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id VARCHAR(100) NOT NULL UNIQUE, + user_id VARCHAR(100) NOT NULL, + code VARCHAR(20) NOT NULL UNIQUE, + click_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_referral_codes_code ON public.referral_codes (code); +CREATE INDEX IF NOT EXISTS idx_referral_codes_tenant ON public.referral_codes (tenant_id); + +-- 2. Referral relationships — tracks who referred whom (tenant level) +CREATE TABLE IF NOT EXISTS public.referral_relationships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + referrer_tenant_id VARCHAR(100) NOT NULL, + referred_tenant_id VARCHAR(100) NOT NULL UNIQUE, -- one referrer per tenant + referral_code VARCHAR(20) NOT NULL, + level INT NOT NULL DEFAULT 1, -- 1=direct, 2=indirect + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + activated_at TIMESTAMPTZ, + rewarded_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_referral_rel_referrer ON public.referral_relationships (referrer_tenant_id); +CREATE INDEX IF NOT EXISTS idx_referral_rel_status ON public.referral_relationships (status); +CREATE INDEX IF NOT EXISTS idx_referral_rel_referred ON public.referral_relationships (referred_tenant_id); + +-- 3. Referral rewards — credit records for each tenant +CREATE TABLE IF NOT EXISTS public.referral_rewards ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + beneficiary_tenant_id VARCHAR(100) NOT NULL, + referral_relationship_id UUID NOT NULL REFERENCES public.referral_relationships(id), + reward_type VARCHAR(20) NOT NULL, -- CREDIT | PERCENTAGE + trigger_type VARCHAR(20) NOT NULL, -- FIRST_PAYMENT | RECURRING + amount_cents INT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + invoice_id VARCHAR(100), + source_invoice_id VARCHAR(100), + recurring_month INT, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + applied_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_referral_rewards_beneficiary ON public.referral_rewards (beneficiary_tenant_id, status); +CREATE INDEX IF NOT EXISTS idx_referral_rewards_relationship ON public.referral_rewards (referral_relationship_id); + +-- 4. Referral stats — denormalized cache per tenant +CREATE TABLE IF NOT EXISTS public.referral_stats ( + tenant_id VARCHAR(100) PRIMARY KEY, + direct_count INT NOT NULL DEFAULT 0, + active_count INT NOT NULL DEFAULT 0, + total_credit_earned INT NOT NULL DEFAULT 0, + total_credit_applied INT NOT NULL DEFAULT 0, + pending_credit INT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 5. Processed events — idempotency for Redis Stream consumers +CREATE TABLE IF NOT EXISTS public.referral_processed_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id VARCHAR(255) NOT NULL UNIQUE, + event_type VARCHAR(100) NOT NULL, + processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_referral_processed_events_event_id ON public.referral_processed_events (event_id);