diff --git a/README.md b/README.md index f7eb7d43..a670d49d 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。 - 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录。 - 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。 +- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。 - PocketBase schema/数据导入器雏形和导入后校验脚本。 - 本地 Supabase reset、烟测 seed、API 集成测试、完整重构检查命令。 @@ -26,6 +27,7 @@ - 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路已完成本地适配;微信网页登录、QQ 登录、手机号换绑、退款/对账、支付补偿和真实生产账号联调还没接完。 - OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF 预览、防盗链和视频水印还没完成。 - Excel/CSV 导入、分数线/视频批量导入和异步 worker 还没完成。 +- 分佣真实打款、结算导出、发票/凭证、CRM webhook worker 和销售转化看板还没完成。 - Taro 跨端前端还没开始 scaffold。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 @@ -136,6 +138,8 @@ apps/api/src/features/ platform-admin/ 平台方租户、SaaS 套餐、订阅、账单、用量 profile/ 学生个人中心 referral/ 销售/代理客资追踪、CRM 队列 + referral/commission.ts + 分佣设置、汇总、来源明细、结算单、审核/打款状态 scoreline/ 分数线 tenant/ 租户解析 tenant-admin/ 租户后台配置、成员权限、班级学生、活动和审计 diff --git a/apps/api/src/features/referral/commission.ts b/apps/api/src/features/referral/commission.ts new file mode 100644 index 00000000..c5579ee8 --- /dev/null +++ b/apps/api/src/features/referral/commission.ts @@ -0,0 +1,782 @@ +import type pg from 'pg'; +import { HttpError, type RequestContext } from '../../core/http.js'; +import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js'; +import { query, queryOne, transaction } from '../../core/db.js'; +import { hasTenantPermission, requireTenantAdmin, requireTenantPermission, type TenantAdminAuth } from '../tenant-admin/auth.js'; + +const SETTLEMENT_STATUSES = ['draft', 'pending_review', 'approved', 'paid', 'rejected', 'cancelled']; +const RATE_SOURCE_ORDER: Record = { batch: 1, member: 2, default: 3 }; + +type JsonBody = Record; + +function nullableString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function objectValue(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function intValue(value: unknown, fallback = 0) { + const parsed = Number(value ?? fallback); + return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback; +} + +function numberValue(value: unknown, fallback = 0) { + const parsed = Number(value ?? fallback); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function optionalChoice(value: unknown, allowed: string[], fallback: string) { + const candidate = nullableString(value) || fallback; + if (!allowed.includes(candidate)) { + throw new HttpError(400, `Invalid value: ${candidate}`, 'INVALID_FIELD_VALUE'); + } + return candidate; +} + +function rateValue(value: unknown, fallback = 0.2) { + const rate = numberValue(value, fallback); + if (rate < 0 || rate > 1) { + throw new HttpError(400, 'Commission rate must be between 0 and 1', 'INVALID_COMMISSION_RATE'); + } + return Number(rate.toFixed(4)); +} + +function dateValue(value: string, fallback: string) { + const candidate = value || fallback; + if (!/^\d{4}-\d{2}-\d{2}$/.test(candidate)) { + throw new HttpError(400, 'Date must use YYYY-MM-DD format', 'INVALID_DATE'); + } + return candidate; +} + +function shanghaiDateKey(date: Date) { + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + const parts = Object.fromEntries(formatter.formatToParts(date).map(part => [part.type, part.value])); + return `${parts.year}-${parts.month}-${parts.day}`; +} + +function periodFromParams(ctx: RequestContext) { + const today = shanghaiDateKey(new Date()); + const startDate = dateValue(stringParam(ctx, 'startDate'), today.slice(0, 8) + '01'); + const endDate = dateValue(stringParam(ctx, 'endDate'), today); + if (startDate > endDate) { + throw new HttpError(400, 'startDate must be before or equal to endDate', 'INVALID_DATE_RANGE'); + } + return { startDate, endDate }; +} + +function canViewAllReferral(auth: TenantAdminAuth) { + return hasTenantPermission(auth, 'commission:read'); +} + +function canViewSelfReferral(auth: TenantAdminAuth) { + return hasTenantPermission(auth, 'commission:self') || canViewAllReferral(auth); +} + +function canWriteCommission(auth: TenantAdminAuth) { + return hasTenantPermission(auth, 'commission:write'); +} + +function canReviewCommission(auth: TenantAdminAuth) { + return hasTenantPermission(auth, 'commission:review'); +} + +function requireCommissionRead(auth: TenantAdminAuth) { + if (!canViewSelfReferral(auth)) { + throw new HttpError(403, 'Commission access is required', 'COMMISSION_ACCESS_REQUIRED'); + } +} + +function requireCommissionWrite(auth: TenantAdminAuth) { + if (!canWriteCommission(auth)) { + throw new HttpError(403, 'Commission write permission is required', 'TENANT_PERMISSION_REQUIRED'); + } +} + +function assertSettlementTransition(currentStatus: string, nextStatus: string) { + if (currentStatus === 'paid') { + throw new HttpError(409, 'Paid commission settlements are immutable', 'COMMISSION_SETTLEMENT_LOCKED'); + } + if (nextStatus === 'paid' && currentStatus !== 'approved') { + throw new HttpError(409, 'Settlement must be approved before marking paid', 'COMMISSION_SETTLEMENT_NOT_APPROVED'); + } + if (currentStatus === 'approved' && !['paid', 'cancelled'].includes(nextStatus)) { + throw new HttpError(409, 'Approved settlement can only be paid or cancelled', 'INVALID_SETTLEMENT_TRANSITION'); + } + if (['rejected', 'cancelled'].includes(currentStatus) && currentStatus !== nextStatus) { + throw new HttpError(409, 'Closed commission settlement cannot change status', 'COMMISSION_SETTLEMENT_CLOSED'); + } +} + +function restrictReferrerScope(auth: TenantAdminAuth, requestedReferrerId: string | null) { + if (canViewAllReferral(auth)) return requestedReferrerId; + if (requestedReferrerId && requestedReferrerId !== auth.userId) { + throw new HttpError(403, 'Only own commission data can be viewed', 'COMMISSION_SCOPE_REQUIRED'); + } + return auth.userId; +} + +function rowToCommissionItem(row: Record) { + const grossAmountCents = intValue(row.grossAmountCents); + const commissionAmountCents = intValue(row.commissionAmountCents); + const commissionRate = numberValue(row.commissionRate); + return { + sourceType: String(row.sourceType), + sourceId: String(row.sourceId), + sourceNo: row.sourceNo ? String(row.sourceNo) : null, + sourcePaidAt: row.sourcePaidAt, + referrerUserId: String(row.referrerUserId), + referrerName: row.referrerName ? String(row.referrerName) : null, + referrerPhone: row.referrerPhone ? String(row.referrerPhone) : null, + studentUserId: row.studentUserId ? String(row.studentUserId) : null, + studentName: row.studentName ? String(row.studentName) : null, + studentPhone: row.studentPhone ? String(row.studentPhone) : null, + grossAmountCents, + commissionRate, + commissionAmountCents, + rateSource: String(row.rateSource || 'default'), + attributionType: String(row.attributionType || 'protected_lead'), + settlementId: row.settlementId ? String(row.settlementId) : null, + settlementStatus: row.settlementStatus ? String(row.settlementStatus) : null, + }; +} + +function summarizeCommissionRows(rows: Record[]) { + const seenUsers = new Set(); + const byReferrer = new Map(); + + let grossAmountCents = 0; + let commissionAmountCents = 0; + for (const row of rows) { + const referrerUserId = String(row.referrerUserId); + const gross = intValue(row.grossAmountCents); + const commission = intValue(row.commissionAmountCents); + const studentUserId = row.studentUserId ? String(row.studentUserId) : ''; + if (studentUserId) seenUsers.add(studentUserId); + grossAmountCents += gross; + commissionAmountCents += commission; + + const current = byReferrer.get(referrerUserId) || { + referrerUserId, + referrerName: row.referrerName ? String(row.referrerName) : null, + referrerPhone: row.referrerPhone ? String(row.referrerPhone) : null, + sourceCount: 0, + paidUserCount: 0, + grossAmountCents: 0, + commissionAmountCents: 0, + effectiveRate: numberValue(row.commissionRate), + rateSource: String(row.rateSource || 'default'), + }; + current.sourceCount += 1; + current.grossAmountCents += gross; + current.commissionAmountCents += commission; + if (RATE_SOURCE_ORDER[String(row.rateSource)] < RATE_SOURCE_ORDER[current.rateSource]) { + current.rateSource = String(row.rateSource); + current.effectiveRate = numberValue(row.commissionRate); + } + byReferrer.set(referrerUserId, current); + } + + const paidUsersByReferrer = new Map>(); + for (const row of rows) { + const referrerUserId = String(row.referrerUserId); + const studentUserId = row.studentUserId ? String(row.studentUserId) : ''; + if (!studentUserId) continue; + const set = paidUsersByReferrer.get(referrerUserId) || new Set(); + set.add(studentUserId); + paidUsersByReferrer.set(referrerUserId, set); + } + + return { + sourceCount: rows.length, + paidUserCount: seenUsers.size, + grossAmountCents, + commissionAmountCents, + byReferrer: Array.from(byReferrer.values()).map(item => ({ + ...item, + paidUserCount: paidUsersByReferrer.get(item.referrerUserId)?.size || 0, + effectiveRate: Number(item.effectiveRate.toFixed(4)), + })), + }; +} + +async function commissionSettings(tenantId: string) { + const item = await queryOne<{ + defaultRate: string; + minSettlementCents: number; + settlementCycle: string; + config: Record; + }>( + ` + select default_rate as "defaultRate", + min_settlement_cents as "minSettlementCents", + settlement_cycle as "settlementCycle", + config + from public.tenant_commission_settings + where tenant_id = $1 + limit 1 + `, + [tenantId], + ); + return { + defaultRate: numberValue(item?.defaultRate, 0.2), + minSettlementCents: intValue(item?.minSettlementCents), + settlementCycle: item?.settlementCycle || 'monthly', + config: item?.config || {}, + }; +} + +async function commissionRows( + tenantId: string, + startDate: string, + endDate: string, + referrerUserId: string | null, + limit: number | null, +) { + const params: unknown[] = [tenantId, startDate, endDate, referrerUserId]; + const limitSql = limit ? `limit $5` : ''; + if (limit) params.push(limit); + return query>( + ` + with settings as ( + select coalesce(default_rate, 0.2000)::numeric(6,4) as default_rate + from public.tenant_commission_settings + where tenant_id = $1 + union all + select 0.2000::numeric(6,4) + limit 1 + ), + order_sources as ( + select 'order'::text as source_type, + o.id as source_id, + o.order_no as source_no, + coalesce(o.paid_at, o.updated_at, o.created_at) as source_paid_at, + o.user_id as student_user_id, + rl.referrer_user_id, + o.amount_cents::integer as gross_amount_cents, + null::numeric(6,4) as batch_rate, + o.raw_payload as metadata + from public.orders o + join public.referral_leads rl + on rl.tenant_id = o.tenant_id + and rl.student_user_id = o.user_id + and rl.status = 'protected' + where o.tenant_id = $1 + and o.status = 'paid' + and o.user_id is not null + and coalesce(o.paid_at, o.updated_at, o.created_at) >= ($2::date::timestamp at time zone 'Asia/Shanghai') + and coalesce(o.paid_at, o.updated_at, o.created_at) < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai') + and coalesce(o.paid_at, o.updated_at, o.created_at) >= rl.bound_at + ), + code_sources as ( + select 'activation_code'::text as source_type, + ac.id as source_id, + ac.code::text as source_no, + ac.used_at as source_paid_at, + ac.used_by as student_user_id, + rl.referrer_user_id, + greatest(coalesce(ac.unit_price_cents, cb.default_unit_price_cents, 0), 0)::integer as gross_amount_cents, + cb.commission_rate as batch_rate, + jsonb_build_object('saleType', ac.sale_type, 'batchId', ac.batch_id, 'days', ac.days) as metadata + from public.activation_codes ac + left join public.code_batches cb on cb.tenant_id = ac.tenant_id and cb.id = ac.batch_id + join public.referral_leads rl + on rl.tenant_id = ac.tenant_id + and rl.student_user_id = ac.used_by + and rl.status = 'protected' + where ac.tenant_id = $1 + and ac.is_used is true + and ac.used_by is not null + and ac.used_at >= ($2::date::timestamp at time zone 'Asia/Shanghai') + and ac.used_at < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai') + and ac.used_at >= rl.bound_at + ), + sources as ( + select * from order_sources + union all + select * from code_sources + ), + referrer_members as ( + select distinct on (tm.user_id) + tm.user_id, + tm.commission_rate, + tm.role + from public.tenant_memberships tm + where tm.tenant_id = $1 + and tm.status = 'active' + and tm.role in ('tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent') + order by tm.user_id, + case tm.role + when 'sales' then 1 + when 'agent' then 2 + when 'tenant_operator' then 3 + when 'teacher' then 4 + else 9 + end + ) + select s.source_type as "sourceType", + s.source_id as "sourceId", + s.source_no as "sourceNo", + s.source_paid_at as "sourcePaidAt", + s.student_user_id as "studentUserId", + student.name as "studentName", + student.phone as "studentPhone", + s.referrer_user_id as "referrerUserId", + referrer.name as "referrerName", + referrer.phone as "referrerPhone", + s.gross_amount_cents as "grossAmountCents", + coalesce(s.batch_rate, rm.commission_rate, settings.default_rate)::numeric(6,4) as "commissionRate", + round(s.gross_amount_cents * coalesce(s.batch_rate, rm.commission_rate, settings.default_rate))::integer as "commissionAmountCents", + case + when s.batch_rate is not null then 'batch' + when rm.commission_rate is not null then 'member' + else 'default' + end as "rateSource", + 'protected_lead'::text as "attributionType", + csi.settlement_id as "settlementId", + cs.status as "settlementStatus", + s.metadata + from sources s + cross join settings + left join referrer_members rm on rm.user_id = s.referrer_user_id + join public.platform_users referrer on referrer.id = s.referrer_user_id + left join public.platform_users student on student.id = s.student_user_id + left join public.commission_settlement_items csi + on csi.tenant_id = $1 + and csi.source_type = s.source_type + and csi.source_id = s.source_id + left join public.commission_settlements cs + on cs.tenant_id = csi.tenant_id + and cs.id = csi.settlement_id + where s.gross_amount_cents > 0 + and ($4::uuid is null or s.referrer_user_id = $4::uuid) + order by s.source_paid_at desc, s.source_no desc + ${limitSql} + `, + params, + ); +} + +async function recordAudit( + client: pg.PoolClient, + auth: TenantAdminAuth, + action: string, + targetType: string, + targetId: string | null, + details: Record = {}, +) { + await client.query( + ` + insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details) + values ($1, $2, $3, $4, $5, $6::jsonb) + `, + [auth.tenantId, auth.userId, action, targetType, targetId, JSON.stringify(details)], + ); +} + +export async function commissionSettingsRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'commission:read'); + const item = await commissionSettings(auth.tenantId); + return { item }; +} + +export async function updateCommissionSettingsRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'commission:write'); + const body = await readJsonBody(ctx); + const defaultRate = rateValue(body.defaultRate, 0.2); + const minSettlementCents = Math.max(0, intValue(body.minSettlementCents, 0)); + const settlementCycle = optionalChoice(body.settlementCycle, ['manual', 'weekly', 'monthly'], 'monthly'); + + const item = await transaction(async client => { + const result = await client.query( + ` + insert into public.tenant_commission_settings ( + tenant_id, default_rate, min_settlement_cents, settlement_cycle, config, updated_by + ) + values ($1, $2, $3, $4, $5::jsonb, $6) + on conflict (tenant_id) + do update set default_rate = excluded.default_rate, + min_settlement_cents = excluded.min_settlement_cents, + settlement_cycle = excluded.settlement_cycle, + config = excluded.config, + updated_by = excluded.updated_by, + updated_at = now() + returning default_rate as "defaultRate", min_settlement_cents as "minSettlementCents", + settlement_cycle as "settlementCycle", config, updated_at as "updatedAt" + `, + [ + auth.tenantId, + defaultRate, + minSettlementCents, + settlementCycle, + JSON.stringify(objectValue(body.config)), + auth.userId, + ], + ); + await recordAudit(client, auth, 'commission.settings.updated', 'tenant_commission_settings', auth.tenantId, { + defaultRate, + minSettlementCents, + settlementCycle, + }); + return result.rows[0]; + }); + + return { item }; +} + +export async function updateMemberCommissionRateRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'commission:write'); + const body = await readJsonBody(ctx); + const userId = requiredString(body, 'userId'); + const rawRate = body.commissionRate; + const commissionRate = rawRate === null || rawRate === undefined || rawRate === '' + ? null + : rateValue(rawRate, 0); + + const item = await transaction(async client => { + const result = await client.query( + ` + update public.tenant_memberships + set commission_rate = $3, + commission_config = coalesce($4::jsonb, commission_config), + updated_at = now() + where tenant_id = $1 + and user_id = $2 + and role in ('sales', 'agent', 'tenant_operator', 'teacher', 'tenant_admin', 'tenant_owner') + and status = 'active' + returning id, user_id as "userId", role, commission_rate as "commissionRate", + commission_config as "commissionConfig", updated_at as "updatedAt" + `, + [ + auth.tenantId, + userId, + commissionRate, + body.commissionConfig === undefined ? null : JSON.stringify(objectValue(body.commissionConfig)), + ], + ); + if (!result.rows[0]) throw new HttpError(404, 'Commission member not found', 'COMMISSION_MEMBER_NOT_FOUND'); + await recordAudit(client, auth, 'commission.member_rate.updated', 'tenant_memberships', result.rows[0].id, { + userId, + commissionRate, + }); + return result.rows[0]; + }); + + return { item }; +} + +export async function commissionSummaryRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireCommissionRead(auth); + const { startDate, endDate } = periodFromParams(ctx); + const scopedReferrerId = restrictReferrerScope(auth, nullableString(stringParam(ctx, 'referrerUserId'))); + const rows = await commissionRows(auth.tenantId, startDate, endDate, scopedReferrerId, null); + const settings = await commissionSettings(auth.tenantId); + return { + item: { + tenantId: auth.tenantId, + startDate, + endDate, + referrerUserId: scopedReferrerId, + defaultRate: settings.defaultRate, + minSettlementCents: settings.minSettlementCents, + ...summarizeCommissionRows(rows), + }, + }; +} + +export async function commissionOrdersRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireCommissionRead(auth); + const { startDate, endDate } = periodFromParams(ctx); + const scopedReferrerId = restrictReferrerScope(auth, nullableString(stringParam(ctx, 'referrerUserId'))); + const limit = intParam(ctx, 'limit', 100, 500); + const rows = await commissionRows(auth.tenantId, startDate, endDate, scopedReferrerId, limit); + return { + items: rows.map(rowToCommissionItem), + scope: { tenantId: auth.tenantId, startDate, endDate, referrerUserId: scopedReferrerId }, + }; +} + +export async function commissionSettlementsRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireCommissionRead(auth); + const limit = intParam(ctx, 'limit', 100, 500); + const status = stringParam(ctx, 'status'); + const requestedReferrerId = nullableString(stringParam(ctx, 'referrerUserId')); + const scopedReferrerId = restrictReferrerScope(auth, requestedReferrerId); + const params: unknown[] = [auth.tenantId]; + const filters = ['cs.tenant_id = $1']; + if (status) { + if (!SETTLEMENT_STATUSES.includes(status)) { + throw new HttpError(400, 'Invalid settlement status', 'INVALID_SETTLEMENT_STATUS'); + } + params.push(status); + filters.push(`cs.status = $${params.length}`); + } + if (scopedReferrerId) { + params.push(scopedReferrerId); + filters.push(`cs.referrer_user_id = $${params.length}::uuid`); + } + params.push(limit); + + const items = await query( + ` + select cs.id, cs.settlement_no as "settlementNo", cs.referrer_user_id as "referrerUserId", + u.name as "referrerName", u.phone as "referrerPhone", cs.status, + cs.period_start as "periodStart", cs.period_end as "periodEnd", + cs.source_count as "sourceCount", cs.paid_user_count as "paidUserCount", + cs.gross_amount_cents as "grossAmountCents", + cs.commission_amount_cents as "commissionAmountCents", + cs.default_rate as "defaultRate", cs.effective_rate as "effectiveRate", + cs.reviewed_by as "reviewedBy", cs.reviewed_at as "reviewedAt", + cs.paid_by as "paidBy", cs.paid_at as "paidAt", + cs.payment_method as "paymentMethod", cs.payment_account as "paymentAccount", + cs.remark, cs.metadata, cs.created_at as "createdAt", cs.updated_at as "updatedAt" + from public.commission_settlements cs + join public.platform_users u on u.id = cs.referrer_user_id + where ${filters.join(' and ')} + order by cs.period_start desc, cs.created_at desc + limit $${params.length} + `, + params, + ); + + return { items }; +} + +export async function generateCommissionSettlementRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireCommissionWrite(auth); + const body = await readJsonBody(ctx); + const startDate = dateValue(nullableString(body.startDate) || '', ''); + const endDate = dateValue(nullableString(body.endDate) || '', ''); + const referrerUserId = requiredString(body, 'referrerUserId'); + if (startDate > endDate) throw new HttpError(400, 'startDate must be before or equal to endDate', 'INVALID_DATE_RANGE'); + + const item = await transaction(async client => { + const member = await client.query<{ userId: string }>( + ` + select user_id as "userId" + from public.tenant_memberships + where tenant_id = $1 + and user_id = $2 + and status = 'active' + and role in ('sales', 'agent', 'tenant_operator', 'teacher', 'tenant_admin', 'tenant_owner') + limit 1 + `, + [auth.tenantId, referrerUserId], + ); + if (!member.rows[0]) throw new HttpError(404, 'Referrer member not found', 'COMMISSION_MEMBER_NOT_FOUND'); + + const rows = await commissionRows(auth.tenantId, startDate, endDate, referrerUserId, null); + const unsettledRows = rows.filter(row => !row.settlementId); + const summary = summarizeCommissionRows(unsettledRows); + const settings = await commissionSettings(auth.tenantId); + if (summary.sourceCount <= 0) { + throw new HttpError(409, 'No unsettled commission sources found in this period', 'COMMISSION_NO_UNSETTLED_SOURCES'); + } + if (summary.commissionAmountCents < settings.minSettlementCents) { + throw new HttpError(409, 'Commission amount is below tenant settlement minimum', 'COMMISSION_BELOW_MINIMUM'); + } + + const settlementNo = `COMM-${startDate.replace(/-/g, '')}-${endDate.replace(/-/g, '')}-${referrerUserId.slice(0, 8)}`; + const referrerSummary = summary.byReferrer[0] || { + effectiveRate: settings.defaultRate, + rateSource: 'default', + }; + + const settlement = await client.query( + ` + insert into public.commission_settlements ( + tenant_id, settlement_no, referrer_user_id, status, period_start, period_end, + source_count, paid_user_count, gross_amount_cents, commission_amount_cents, + default_rate, effective_rate, generated_by, remark, metadata + ) + values ( + $1, $2, $3, $4, $5::date, $6::date, + $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb + ) + on conflict (tenant_id, referrer_user_id, period_start, period_end) + do update set status = case + when public.commission_settlements.status in ('paid', 'approved') + then public.commission_settlements.status + else excluded.status + end, + source_count = excluded.source_count, + paid_user_count = excluded.paid_user_count, + gross_amount_cents = excluded.gross_amount_cents, + commission_amount_cents = excluded.commission_amount_cents, + default_rate = excluded.default_rate, + effective_rate = excluded.effective_rate, + generated_by = excluded.generated_by, + remark = excluded.remark, + metadata = excluded.metadata, + updated_at = now() + returning id, settlement_no as "settlementNo", referrer_user_id as "referrerUserId", + status, period_start as "periodStart", period_end as "periodEnd", + source_count as "sourceCount", paid_user_count as "paidUserCount", + gross_amount_cents as "grossAmountCents", + commission_amount_cents as "commissionAmountCents", + default_rate as "defaultRate", effective_rate as "effectiveRate", + created_at as "createdAt", updated_at as "updatedAt" + `, + [ + auth.tenantId, + settlementNo, + referrerUserId, + optionalChoice(body.status, ['draft', 'pending_review'], 'pending_review'), + startDate, + endDate, + summary.sourceCount, + summary.paidUserCount, + summary.grossAmountCents, + summary.commissionAmountCents, + settings.defaultRate, + referrerSummary.effectiveRate, + auth.userId, + nullableString(body.remark), + JSON.stringify({ rateSource: referrerSummary.rateSource, generatedFrom: 'api' }), + ], + ); + + for (const row of unsettledRows) { + await client.query( + ` + insert into public.commission_settlement_items ( + tenant_id, settlement_id, referrer_user_id, student_user_id, + source_type, source_id, source_no, source_paid_at, + gross_amount_cents, commission_rate, commission_amount_cents, + rate_source, attribution_type, metadata + ) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb) + on conflict (tenant_id, source_type, source_id) + do update set settlement_id = excluded.settlement_id, + commission_rate = excluded.commission_rate, + commission_amount_cents = excluded.commission_amount_cents, + rate_source = excluded.rate_source, + metadata = excluded.metadata, + updated_at = now() + `, + [ + auth.tenantId, + settlement.rows[0].id, + row.referrerUserId, + row.studentUserId, + row.sourceType, + row.sourceId, + row.sourceNo, + row.sourcePaidAt, + intValue(row.grossAmountCents), + numberValue(row.commissionRate), + intValue(row.commissionAmountCents), + row.rateSource, + row.attributionType, + JSON.stringify(row.metadata || {}), + ], + ); + } + + await recordAudit(client, auth, 'commission.settlement.generated', 'commission_settlements', settlement.rows[0].id, { + referrerUserId, + startDate, + endDate, + sourceCount: summary.sourceCount, + commissionAmountCents: summary.commissionAmountCents, + }); + return { ...settlement.rows[0], items: unsettledRows.map(rowToCommissionItem) }; + }); + + return { item }; +} + +export async function updateCommissionSettlementStatusRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + if (!canReviewCommission(auth)) { + throw new HttpError(403, 'Commission review permission is required', 'TENANT_PERMISSION_REQUIRED'); + } + const body = await readJsonBody(ctx); + const settlementId = requiredString(body, 'settlementId'); + const status = optionalChoice(body.status, SETTLEMENT_STATUSES, 'approved'); + if (status === 'draft') { + throw new HttpError(400, 'Cannot move settlement back to draft through status API', 'INVALID_SETTLEMENT_STATUS'); + } + + const item = await transaction(async client => { + const current = await client.query<{ status: string }>( + ` + select status + from public.commission_settlements + where tenant_id = $1 and id = $2 + limit 1 + for update + `, + [auth.tenantId, settlementId], + ); + if (!current.rows[0]) throw new HttpError(404, 'Commission settlement not found', 'COMMISSION_SETTLEMENT_NOT_FOUND'); + assertSettlementTransition(current.rows[0].status, status); + + const result = await client.query( + ` + update public.commission_settlements + set status = $3, + reviewed_by = case when $3 in ('approved', 'rejected', 'cancelled') then $4 else reviewed_by end, + reviewed_at = case when $3 in ('approved', 'rejected', 'cancelled') then now() else reviewed_at end, + paid_by = case when $3 = 'paid' then $4 else paid_by end, + paid_at = case when $3 = 'paid' then now() else paid_at end, + payment_method = coalesce($5, payment_method), + payment_account = coalesce($6, payment_account), + remark = coalesce($7, remark), + metadata = metadata || $8::jsonb, + updated_at = now() + where tenant_id = $1 and id = $2 + returning id, settlement_no as "settlementNo", referrer_user_id as "referrerUserId", + status, period_start as "periodStart", period_end as "periodEnd", + source_count as "sourceCount", paid_user_count as "paidUserCount", + gross_amount_cents as "grossAmountCents", + commission_amount_cents as "commissionAmountCents", + reviewed_by as "reviewedBy", reviewed_at as "reviewedAt", + paid_by as "paidBy", paid_at as "paidAt", + payment_method as "paymentMethod", payment_account as "paymentAccount", + remark, metadata, updated_at as "updatedAt" + `, + [ + auth.tenantId, + settlementId, + status, + auth.userId, + nullableString(body.paymentMethod), + nullableString(body.paymentAccount), + nullableString(body.remark), + JSON.stringify(objectValue(body.metadata)), + ], + ); + if (!result.rows[0]) throw new HttpError(404, 'Commission settlement not found', 'COMMISSION_SETTLEMENT_NOT_FOUND'); + await recordAudit(client, auth, 'commission.settlement.status_updated', 'commission_settlements', settlementId, { + status, + }); + return result.rows[0]; + }); + + return { item }; +} diff --git a/apps/api/src/features/referral/index.ts b/apps/api/src/features/referral/index.ts index eac12eb8..a2b930df 100644 --- a/apps/api/src/features/referral/index.ts +++ b/apps/api/src/features/referral/index.ts @@ -1,4 +1,14 @@ import type { RouteDefinition } from '../../core/router.js'; +import { + commissionOrdersRoute, + commissionSettingsRoute, + commissionSettlementsRoute, + commissionSummaryRoute, + generateCommissionSettlementRoute, + updateCommissionSettingsRoute, + updateCommissionSettlementStatusRoute, + updateMemberCommissionRateRoute, +} from './commission.js'; import { crmConfigRoute, crmQueueRoute, @@ -31,4 +41,12 @@ export const referralRoutes: RouteDefinition[] = [ ['GET', '/api/crm/config', crmConfigRoute], ['PUT', '/api/crm/config', upsertCrmConfigRoute], ['GET', '/api/crm/queue', crmQueueRoute], + ['GET', '/api/commission/settings', commissionSettingsRoute], + ['PUT', '/api/commission/settings', updateCommissionSettingsRoute], + ['PUT', '/api/commission/member-rate', updateMemberCommissionRateRoute], + ['GET', '/api/commission/summary', commissionSummaryRoute], + ['GET', '/api/commission/orders', commissionOrdersRoute], + ['GET', '/api/commission/settlements', commissionSettlementsRoute], + ['POST', '/api/commission/settlements/generate', generateCommissionSettlementRoute], + ['POST', '/api/commission/settlements/status', updateCommissionSettlementStatusRoute], ]; diff --git a/apps/api/src/features/tenant-admin/auth.ts b/apps/api/src/features/tenant-admin/auth.ts index 2cc3226b..46aade46 100644 --- a/apps/api/src/features/tenant-admin/auth.ts +++ b/apps/api/src/features/tenant-admin/auth.ts @@ -13,10 +13,10 @@ const TENANT_ADMIN_ROLES = new Set([ const ROLE_PERMISSION_DEFAULTS: Record = { tenant_owner: ['*'], tenant_admin: ['*'], - tenant_operator: ['dashboard:read', 'content:*', 'marketing:*', 'codes:read', 'coupons:read', 'referral:read', 'crm:read'], + tenant_operator: ['dashboard:read', 'content:*', 'marketing:*', 'codes:read', 'coupons:read', 'referral:read', 'commission:read', 'crm:read'], teacher: ['content:*', 'classes:read', 'students:read', 'students:notes:*', 'students:followups:*'], - sales: ['codes:*', 'coupons:*', 'referral:*'], - agent: ['codes:read', 'coupons:read', 'referral:self'], + sales: ['codes:*', 'coupons:*', 'referral:*', 'commission:self'], + agent: ['codes:read', 'coupons:read', 'referral:self', 'commission:self'], student: [], }; @@ -101,6 +101,10 @@ export function tenantPermissionCatalog() { { key: 'referral:read', label: '客资全局查看' }, { key: 'referral:self', label: '本人客资查看' }, { key: 'referral:write', label: '客资归属管理' }, + { key: 'commission:self', label: '本人分佣查看' }, + { key: 'commission:read', label: '分佣结算查看' }, + { key: 'commission:write', label: '分佣结算生成' }, + { key: 'commission:review', label: '分佣结算审核/打款' }, { key: 'crm:read', label: 'CRM 队列查看' }, { key: 'crm:write', label: 'CRM 入队和重试' }, { key: 'feedback:read', label: '反馈查看' }, diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 6e70ecf3..05b79f91 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -141,7 +141,8 @@ | 销售统计/客户列表/团队 | 可联调 | `/api/referral/sales-*`、`team` | | CRM 配置/队列 | 可联调 | `/api/crm/config`、`/api/crm/queue` | | 真实 CRM webhook worker | 待补齐 | 钉钉/飞书/企微发送、签名、重试、死信 | -| 分佣结算 | 待补齐 | 缺佣金规则、结算单、审核、导出 | +| 分佣结算基础闭环 | 可联调 | `/api/commission/settings`、`member-rate`、`summary`、`orders`、`settlements`、`settlements/generate`、`settlements/status`;支持订单/激活码归因、批次/成员/默认比例优先级、北京时间账期、结算单生成、审核、打款状态、已打款锁定、销售/代理本人范围和租户隔离 | +| 分佣打款增强 | 待补齐 | 银行/微信/支付宝真实打款、结算导出、发票/凭证、财务复核和分佣看板 | ## 内容导入与迁移 diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index 7a8cd03f..cefc38d6 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -12,7 +12,7 @@ - `scoreline`:分数线字段、院校、专业、记录、趋势、年份。 - `video`:题目视频讲解、批量预加载、通用视频搜索。 - `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、权益查询。 - - `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列。 + - `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。 - `platform-admin`:平台方租户管理、SaaS 套餐、订阅、账单、服务费收款、使用量。 - `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码批次、优惠券、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。 - `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册 JSON 导入维护。 @@ -149,6 +149,14 @@ POST /api/referral/manual-bind GET /api/referral/team PUT /api/referral/team POST /api/referral/qrcode +GET /api/commission/settings +PUT /api/commission/settings +PUT /api/commission/member-rate +GET /api/commission/summary +GET /api/commission/orders +GET /api/commission/settlements +POST /api/commission/settlements/generate +POST /api/commission/settlements/status GET /api/crm/config PUT /api/crm/config GET /api/crm/queue diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index 676121cf..43edd803 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -45,7 +45,7 @@ - 真实短信、微信登录、QQ 登录、微信支付、支付宝支付 provider 还未正式接完。 - 对象存储已完成签名 provider,但 PDF 预览、防盗链、视频水印、上传后校验还要补。 - 大批量 Excel/CSV、分数线、视频导入和异步 worker 还未完成。 -- 数据看板、分佣结算、AI 择校、主题模板市场等仍是后续商用增强项。 +- 数据看板和分佣结算基础 API 已可联调;分佣真实打款/导出/凭证、AI 择校、主题模板市场等仍是后续商用增强项。 ## 前后端协作建议 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 114553cc..96633c93 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -43,7 +43,7 @@ | 旧功能/组件 | 新后端状态 | 待补齐 | | --- | --- | --- | | 用户管理 | 已覆盖 | 租户成员、学生列表、学生资料、批量学生 upsert、禁用/恢复、批量分班、学生备注、跟进任务已实现;批量 CRM 推送、补绑、学习督导自动化待补 | -| 销售/代理管理 | 部分覆盖 | referral/team/stats 有;缺分佣比例、结算单、审核、导出 | +| 销售/代理管理 | 部分覆盖 | referral/team/stats 已有;分佣设置、订单/激活码归因、结算单生成、审核、打款状态和本人/全局权限范围已覆盖;还缺真实打款、导出、发票/凭证和分佣看板 | | 班级/教师管理 | 已覆盖 | 班级、班级成员、教师/班主任/助教/学生范围权限已有;可视化 UI 和更细数据范围组合待补 | | 数据看板 | 部分覆盖 | 租户 dashboard 聚合 API 已覆盖收益、注册、答题、活跃、题型、科目、题量、套餐销量、运营动态、24h 活跃和激活码使用;后续补预聚合 worker、销售转化和分佣结算看板 | | 地区管理 | 部分覆盖 | 地区和内容入口已有;平台公共题库已可按 SaaS 套餐/租户授权并由租户采纳;还缺更完整的全国/单地区套餐 UI 和版本同步策略 | @@ -100,7 +100,7 @@ 3. 题库导出:PDF/Word/JSON 导出、水印、导出审计和权限控制。 4. 导入扩展:Excel/CSV、分数线、视频批量导入和大批量异步 worker。 5. 公共题库商业化:平台公共/地区题库授权和租户快照采纳已完成基础闭环;还需版本同步、租户自改冲突处理和运营后台 UI。 -6. CRM/销售结算:真实 CRM worker、轮询/定向分配、分佣规则、结算单、审核和导出。 +6. CRM/销售结算:分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺真实 CRM worker、轮询/定向分配、打款导出、凭证和销售结算看板。 7. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。 8. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 4c11a027..5300c93b 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -101,13 +101,19 @@ 9. 数据看板 - 已完成首版实时聚合接口,覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。 - 继续补日/周/月预聚合 worker、缓存策略、慢 SQL 监控和大租户性能压测。 - - 继续补销售/代理转化、分佣结算、客资跟进效果看板。 + - 分佣结算基础闭环已完成;继续补销售/代理转化、结算导出、真实打款、凭证和客资跟进效果看板。 -10. 学生运营管理 +10. 销售/代理分佣 + - 已完成租户默认分佣比例、成员分佣比例、激活码批次分佣比例。 + - 已完成订单和激活码两类来源的佣金归因,且只统计客资绑定后的成交。 + - 已完成结算单生成、重复结算拦截、审核、打款状态、已打款锁定、租户隔离和销售/代理本人范围权限。 + - 继续补结算导出、真实打款 provider、发票/凭证、财务复核流、销售团队分佣看板和异常调整单。 + +11. 学生运营管理 - 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注和跟进任务。 - 继续补批量 CRM 推送、学习督导自动化、跟进效果统计和前端 UI。 -11. AI 择校推荐 +12. AI 择校推荐 - 地区考试数据上下文。 - 学生输入 schema。 - AI 返回 JSON schema。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index e94b8803..eb0f89e2 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -162,6 +162,7 @@ tenant::theme | 学习排行榜 | `GET /api/learning/leaderboard?metric=questions&period=all` | | 题目视频 | `GET /api/questions/{questionId}/videos`、`POST /api/questions/videos/batch`、`POST /api/videos/play` | | 题目反馈 | `POST /api/profile/feedbacks`、`GET /api/profile/feedbacks` | +| 分佣结算 | `GET /api/commission/settings`、`PUT /api/commission/settings`、`PUT /api/commission/member-rate`、`GET /api/commission/summary`、`GET /api/commission/orders`、`GET /api/commission/settlements`、`POST /api/commission/settlements/generate`、`POST /api/commission/settlements/status` | | 背单词 | `/api/catalog/vocabulary-units`、`/api/catalog/vocabulary-words` | | 单词进度/计划 | `/api/learning/vocabulary/progress`、`/api/learning/vocabulary/stats`、`/api/learning/vocabulary/review-plan`、`POST /api/learning/vocabulary/review` | | 单词收藏 | `/api/learning/vocabulary/favorites` | @@ -395,6 +396,57 @@ GET /api/tenant-admin/dashboard?timeRange=30d®ionId=<可选地区ID>&limit=10 - `recentActivities.details` 只包含可展示的低敏汇总信息,不包含手机号、支付密钥、对象存储 key 等敏感字段。 - 大租户正式上线后会补预聚合 worker,前端不应依赖任何临时 SQL 口径或自己维护缓存口径。 +### 销售/代理分佣结算 + +分佣结算由后端统一计算,前端不要读取订单、激活码或客资后自行算佣金。当前后端已支持订单和激活码两类来源,并且只统计客资首绑保护后的成交,避免后绑抢单。 + +常用接口: + +| 页面/动作 | 接口 | 权限 | +| --- | --- | --- | +| 查看租户分佣设置 | `GET /api/commission/settings` | `commission:read` | +| 修改默认分佣设置 | `PUT /api/commission/settings` | `commission:write` | +| 设置销售/代理个人比例 | `PUT /api/commission/member-rate` | `commission:write` | +| 分佣汇总 | `GET /api/commission/summary?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD&referrerUserId=...` | `commission:read` 或 `commission:self` | +| 分佣来源明细 | `GET /api/commission/orders?...` | `commission:read` 或 `commission:self` | +| 结算单列表 | `GET /api/commission/settlements?...` | `commission:read` 或 `commission:self` | +| 生成结算单 | `POST /api/commission/settlements/generate` | `commission:write` | +| 审核/打款状态 | `POST /api/commission/settlements/status` | `commission:review` | + +金额字段统一为分: + +```text +grossAmountCents +commissionAmountCents +minSettlementCents +``` + +比例字段统一为 0 到 1 的数字: + +```text +defaultRate = 0.2 +commissionRate = 0.35 +``` + +结算状态: + +```text +draft -> pending_review -> approved -> paid +pending_review -> rejected/cancelled +approved -> cancelled +``` + +前端处理规则: + +- 销售/代理默认只有 `commission:self`,只能查看自己的分佣;租户运营/管理员拥有 `commission:read` 才能查看全局。 +- `startDate/endDate` 使用 `YYYY-MM-DD`,后端按 `Asia/Shanghai` 业务日计算账期。 +- 分佣比例优先级由后端处理:激活码批次比例 > 成员个人比例 > 租户默认比例。 +- `sourceType=order` 表示学生订单;`sourceType=activation_code` 表示激活码兑换。 +- 已进入结算单的来源会返回 `settlementId/settlementStatus`,前端不要重复发起生成。 +- 已打款结算单不可再修改状态;遇到 `COMMISSION_SETTLEMENT_LOCKED` 展示“已打款,不可变更”。 +- `COMMISSION_NO_UNSETTLED_SOURCES` 表示当前账期无未结算来源,不是系统异常。 +- 当前版本仅支持线下打款状态登记;真实银行/微信/支付宝打款、导出、发票/凭证和财务复核后续由 worker/provider 增强。 + ### 背单词计划与复习上报 背单词页面分三类数据:单元列表、每日计划、单词进度。前端不需要计算下次复习日期,只提交“认识/不认识”,由后端统一更新 `nextReviewDate`、连续正确、掌握状态和每日复习计划。 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index b5e87e43..678565ca 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -155,6 +155,18 @@ function getFreePort() { }); } +function shanghaiDateKey(date = new Date()) { + const parts = Object.fromEntries( + new Intl.DateTimeFormat('en-US', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(date).map(part => [part.type, part.value]), + ); + return `${parts.year}-${parts.month}-${parts.day}`; +} + async function waitForHealth(timeoutMs = 12_000) { const started = Date.now(); let lastError = null; @@ -3757,6 +3769,202 @@ async function testReferralAndCrmGrowth() { }); assert.ok(teamList.items?.some(item => item.memberUserId === TENANT_AGENT_USER_ID), 'sales should see own agent team'); + const commissionSettings = await request('/api/commission/settings', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + defaultRate: 0.2, + minSettlementCents: 0, + settlementCycle: 'monthly', + config: { source: 'integration-test' }, + }, + }); + assert.equal(Number(commissionSettings.item?.defaultRate), 0.2, 'tenant admin should configure default commission rate'); + + const agentRate = await request('/api/commission/member-rate', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + userId: TENANT_AGENT_USER_ID, + commissionRate: 0.35, + commissionConfig: { note: 'integration agent rate' }, + }, + }); + assert.equal(Number(agentRate.item?.commissionRate), 0.35, 'tenant admin should configure agent commission rate'); + + const commissionOrder = await request('/api/commerce/orders', { + method: 'POST', + body: { + planId: ids.plan, + payProvider: 'manual', + payMethod: 'manual', + regionId: ids.region, + }, + }); + assert.equal(commissionOrder.item?.status, 'pending', 'commission smoke order should start pending'); + + const commissionPaid = await request('/api/commerce/payments/manual-confirm', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + orderNo: commissionOrder.item.orderNo, + amountCents: commissionOrder.item.amountCents, + providerTradeNo: `commission-${commissionOrder.item.orderNo}`, + }, + }); + assert.equal(commissionPaid.item?.status, 'paid', 'commission smoke order should be paid'); + + const commissionBatch = await request('/api/tenant-admin/code-batches', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + name: '集成测试分佣批次', + saleType: 'agent', + defaultUnitPriceCents: 2000, + days: 30, + regionId: ids.region, + commissionRate: 0.45, + }, + }); + assert.equal(Number(commissionBatch.item?.commissionRate), 0.45, 'code batch should persist commission rate'); + + const commissionCode = await request('/api/tenant-admin/activation-codes', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + code: 'IT-COMMISSION-001', + days: 30, + batchId: commissionBatch.item.id, + unitPriceCents: 2000, + usedRegionId: ids.region, + remark: 'commission integration code', + }, + }); + assert.equal(commissionCode.item?.code, 'IT-COMMISSION-001', 'tenant admin should create commission activation code'); + + const commissionRedeemed = await request('/api/commerce/activation-codes/redeem', { + method: 'POST', + body: { code: 'IT-COMMISSION-001', regionId: ids.region }, + }); + assert.ok(commissionRedeemed.item?.entitlement?.id, 'commission activation code should be redeemable'); + + const today = shanghaiDateKey(); + const agentCommissionSummary = await request('/api/commission/summary', { + userId: TENANT_AGENT_USER_ID, + query: { startDate: today, endDate: today }, + }); + assert.equal(agentCommissionSummary.item?.referrerUserId, TENANT_AGENT_USER_ID, 'agent commission summary should be scoped to self'); + assert.equal(agentCommissionSummary.item?.sourceCount >= 2, true, 'agent commission summary should include order and activation code'); + assert.equal(agentCommissionSummary.item?.grossAmountCents >= 2990, true, 'agent commission summary should include gross amount'); + assert.equal(agentCommissionSummary.item?.commissionAmountCents >= 1217, true, 'agent commission should apply member and batch rates'); + + const salesOtherCommissionDenied = await request('/api/commission/summary', { + userId: TENANT_AGENT_USER_ID, + query: { startDate: today, endDate: today, referrerUserId: TENANT_SALES_USER_ID }, + expectStatus: 403, + }); + assert.equal(salesOtherCommissionDenied.code, 'COMMISSION_SCOPE_REQUIRED', 'agent should not read another referrer commission'); + + const salesAgentCommissionDenied = await request('/api/commission/summary', { + userId: TENANT_SALES_USER_ID, + query: { startDate: today, endDate: today, referrerUserId: TENANT_AGENT_USER_ID }, + expectStatus: 403, + }); + assert.equal(salesAgentCommissionDenied.code, 'COMMISSION_SCOPE_REQUIRED', 'sales should not read agent commission without commission:read'); + + const commissionOrders = await request('/api/commission/orders', { + userId: TENANT_ADMIN_USER_ID, + query: { startDate: today, endDate: today, referrerUserId: TENANT_AGENT_USER_ID, limit: 20 }, + }); + assert.ok(commissionOrders.items?.some(item => item.sourceType === 'order' && item.rateSource === 'member'), 'commission orders should include paid order with member rate'); + assert.ok(commissionOrders.items?.some(item => item.sourceType === 'activation_code' && item.rateSource === 'batch'), 'commission orders should include activation code with batch rate'); + assert.ok(!JSON.stringify(commissionOrders).includes('crm-secret-smoke'), 'commission order list must not leak CRM secret'); + + const generatedSettlement = await request('/api/commission/settlements/generate', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + referrerUserId: TENANT_AGENT_USER_ID, + startDate: today, + endDate: today, + status: 'pending_review', + remark: 'integration settlement', + }, + }); + assert.equal(generatedSettlement.item?.referrerUserId, TENANT_AGENT_USER_ID, 'tenant admin should generate agent settlement'); + assert.equal(generatedSettlement.item?.status, 'pending_review', 'generated settlement should enter review flow'); + assert.equal(generatedSettlement.item?.sourceCount >= 2, true, 'generated settlement should include unsettled sources'); + + const duplicateSettlement = await request('/api/commission/settlements/generate', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + referrerUserId: TENANT_AGENT_USER_ID, + startDate: today, + endDate: today, + }, + expectStatus: 409, + }); + assert.equal(duplicateSettlement.code, 'COMMISSION_NO_UNSETTLED_SOURCES', 'settlement generation should not duplicate settled sources'); + + const approvedSettlement = await request('/api/commission/settlements/status', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + settlementId: generatedSettlement.item.id, + status: 'approved', + remark: 'integration approved', + }, + }); + assert.equal(approvedSettlement.item?.status, 'approved', 'tenant admin should approve settlement'); + assert.equal(approvedSettlement.item?.reviewedBy, TENANT_ADMIN_USER_ID, 'settlement approval should record reviewer'); + + const paidSettlement = await request('/api/commission/settlements/status', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + settlementId: generatedSettlement.item.id, + status: 'paid', + paymentMethod: 'offline_bank', + paymentAccount: 'masked-bank-account', + metadata: { source: 'integration-test' }, + }, + }); + assert.equal(paidSettlement.item?.status, 'paid', 'tenant admin should mark settlement paid'); + assert.equal(paidSettlement.item?.paidBy, TENANT_ADMIN_USER_ID, 'settlement paid status should record operator'); + assert.equal(paidSettlement.item?.paymentMethod, 'offline_bank', 'settlement paid status should persist payment method'); + + const paidSettlementLocked = await request('/api/commission/settlements/status', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + settlementId: generatedSettlement.item.id, + status: 'rejected', + }, + expectStatus: 409, + }); + assert.equal(paidSettlementLocked.code, 'COMMISSION_SETTLEMENT_LOCKED', 'paid settlement should not be mutable'); + + const settlementList = await request('/api/commission/settlements', { + userId: TENANT_AGENT_USER_ID, + query: { startDate: today, endDate: today }, + }); + assert.ok(settlementList.items?.some(item => item.id === generatedSettlement.item.id && item.status === 'paid'), 'agent should see own paid settlement'); + + const studentCommissionDenied = await request('/api/commission/summary', { + query: { startDate: today, endDate: today }, + expectStatus: 403, + }); + assert.equal(studentCommissionDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access commission summary'); + + const partnerCommissionDenied = await request('/api/commission/summary', { + tenantId: PARTNER_TENANT_ID, + userId: TENANT_AGENT_USER_ID, + query: { startDate: today, endDate: today }, + expectStatus: 403, + }); + assert.equal(partnerCommissionDenied.code, 'TENANT_ADMIN_REQUIRED', 'commission APIs must be tenant isolated'); + const crmQueue = await request('/api/crm/queue', { userId: TENANT_ADMIN_USER_ID, query: { status: 'pending' }, diff --git a/scripts/smoke-seed.js b/scripts/smoke-seed.js index 8aec8513..64ab0c8b 100644 --- a/scripts/smoke-seed.js +++ b/scripts/smoke-seed.js @@ -208,7 +208,7 @@ async function main() { and user_id = $2::uuid and source_type in ('order', 'activation_code') and ( - legacy_source_id in ('SMOKE20260621', 'SMOKESELF20260621') + legacy_source_id in ('SMOKE20260621', 'SMOKESELF20260621', 'IT-COMMISSION-001') or source_id in ( select id from public.orders @@ -256,6 +256,53 @@ async function main() { [tenantId, checkoutCouponCodes], ); + await client.query( + ` + delete from public.commission_settlement_items + where tenant_id = $1 + and ( + referrer_user_id in ($2::uuid, $3::uuid, $4::uuid) + or student_user_id in ($5::uuid, $6::uuid) + ) + `, + [tenantId, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser, ids.user, ids.secondStudentUser], + ); + + await client.query( + ` + delete from public.commission_settlements + where tenant_id = $1 + and referrer_user_id in ($2::uuid, $3::uuid, $4::uuid) + `, + [tenantId, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser], + ); + + await client.query( + ` + delete from public.tenant_commission_settings + where tenant_id = $1 + `, + [tenantId], + ); + + await client.query( + ` + delete from public.activation_codes + where tenant_id = $1 + and code::text = 'IT-COMMISSION-001' + `, + [tenantId], + ); + + await client.query( + ` + delete from public.code_batches + where tenant_id = $1 + and name = '集成测试分佣批次' + `, + [tenantId], + ); + await client.query( ` delete from public.crm_webhook_queue diff --git a/supabase/migrations/202606290009_commission_settlements.sql b/supabase/migrations/202606290009_commission_settlements.sql new file mode 100644 index 00000000..6daa4080 --- /dev/null +++ b/supabase/migrations/202606290009_commission_settlements.sql @@ -0,0 +1,129 @@ +alter table public.tenant_memberships + add column if not exists commission_rate numeric(6,4), + add column if not exists commission_config jsonb not null default '{}'::jsonb; + +do $$ +begin + if not exists (select 1 from pg_constraint where conname = 'tenant_memberships_commission_rate_check') then + alter table public.tenant_memberships + add constraint tenant_memberships_commission_rate_check + check (commission_rate is null or (commission_rate >= 0 and commission_rate <= 1)); + end if; +end $$; + +do $$ +begin + if not exists (select 1 from pg_constraint where conname = 'code_batches_commission_rate_check') then + alter table public.code_batches + add constraint code_batches_commission_rate_check + check (commission_rate is null or (commission_rate >= 0 and commission_rate <= 1)); + end if; +end $$; + +create table if not exists public.tenant_commission_settings ( + tenant_id uuid primary key references public.tenants(id) on delete cascade, + default_rate numeric(6,4) not null default 0.2000 check (default_rate >= 0 and default_rate <= 1), + min_settlement_cents integer not null default 0 check (min_settlement_cents >= 0), + settlement_cycle text not null default 'monthly' check (settlement_cycle in ('manual', 'weekly', 'monthly')), + config jsonb not null default '{}'::jsonb, + updated_by uuid references public.platform_users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.commission_settlements ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + settlement_no text not null, + referrer_user_id uuid not null references public.platform_users(id) on delete cascade, + status text not null default 'draft' + check (status in ('draft', 'pending_review', 'approved', 'paid', 'rejected', 'cancelled')), + period_start date not null, + period_end date not null, + source_count integer not null default 0 check (source_count >= 0), + paid_user_count integer not null default 0 check (paid_user_count >= 0), + gross_amount_cents integer not null default 0 check (gross_amount_cents >= 0), + commission_amount_cents integer not null default 0 check (commission_amount_cents >= 0), + default_rate numeric(6,4) not null default 0 check (default_rate >= 0 and default_rate <= 1), + effective_rate numeric(6,4), + generated_by uuid references public.platform_users(id) on delete set null, + reviewed_by uuid references public.platform_users(id) on delete set null, + paid_by uuid references public.platform_users(id) on delete set null, + reviewed_at timestamptz, + paid_at timestamptz, + payment_method text, + payment_account text, + remark text, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (tenant_id, settlement_no), + unique (tenant_id, referrer_user_id, period_start, period_end) +); + +create table if not exists public.commission_settlement_items ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + settlement_id uuid references public.commission_settlements(id) on delete set null, + referrer_user_id uuid not null references public.platform_users(id) on delete cascade, + student_user_id uuid references public.platform_users(id) on delete set null, + source_type text not null check (source_type in ('order', 'activation_code')), + source_id uuid not null, + source_no text, + source_paid_at timestamptz, + gross_amount_cents integer not null default 0 check (gross_amount_cents >= 0), + commission_rate numeric(6,4) not null check (commission_rate >= 0 and commission_rate <= 1), + commission_amount_cents integer not null default 0 check (commission_amount_cents >= 0), + rate_source text not null default 'default' check (rate_source in ('batch', 'member', 'default')), + attribution_type text not null default 'protected_lead', + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (tenant_id, source_type, source_id) +); + +create index if not exists idx_commission_items_referrer + on public.commission_settlement_items(tenant_id, referrer_user_id, source_paid_at desc); + +create index if not exists idx_commission_items_settlement + on public.commission_settlement_items(tenant_id, settlement_id); + +create index if not exists idx_commission_settlements_referrer + on public.commission_settlements(tenant_id, referrer_user_id, period_start desc); + +alter table public.tenant_commission_settings enable row level security; +alter table public.commission_settlements enable row level security; +alter table public.commission_settlement_items enable row level security; + +drop policy if exists tenant_isolation on public.tenant_commission_settings; +create policy tenant_isolation on public.tenant_commission_settings + for all + using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) + with check (tenant_id = app.current_tenant_id() or app.is_platform_admin()); + +drop policy if exists tenant_isolation on public.commission_settlements; +create policy tenant_isolation on public.commission_settlements + for all + using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) + with check (tenant_id = app.current_tenant_id() or app.is_platform_admin()); + +drop policy if exists tenant_isolation on public.commission_settlement_items; +create policy tenant_isolation on public.commission_settlement_items + for all + using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) + with check (tenant_id = app.current_tenant_id() or app.is_platform_admin()); + +drop trigger if exists set_updated_at on public.tenant_commission_settings; +create trigger set_updated_at + before update on public.tenant_commission_settings + for each row execute function app.touch_updated_at(); + +drop trigger if exists set_updated_at on public.commission_settlements; +create trigger set_updated_at + before update on public.commission_settlements + for each row execute function app.touch_updated_at(); + +drop trigger if exists set_updated_at on public.commission_settlement_items; +create trigger set_updated_at + before update on public.commission_settlement_items + for each row execute function app.touch_updated_at();