server/laf-cloud/functions/paymentProfile.ts
2026-09-24 17:59:52 +08:00

143 lines
7.4 KiB
TypeScript

// Pure daily user features. Activity eligibility, offer selection and prices do not belong here.
export const PAYMENT_PROFILE_RULE = {
version: 'payment_profile_v1_20260923',
moneyThresholdsFen: [3000, 10000, 30000, 100000],
frequencyDays: [2, 5, 10, 16],
recencyDays: [7, 15, 30, 60],
priorFen: 600,
confidenceDays: 4,
minimumObservationDays: 7,
// Only the historical SKU/version window identified in the reference export.
legacyBundle: { sku: 'unlimited_health_bundle_30', before: Date.parse('2026-09-22T00:00:00+08:00') },
// Populate only after reward versions have been checked for comparability.
comparableActivityVersions: { coin: [] as string[], lucky: [] as string[] },
};
const DAY = 86400000;
const OFFSET = 8 * 3600000;
export type ProfileOrder = { amountFen: number; paidAt: number; sku: string; rewardVersion?: string; activity?: string };
export type ProfileInput = {
cutoff: number; registerTime: number | null; payUser: any; missingOpenid: boolean;
confirmedOrders: number; futureOrders: number; invalidOrders: number; missingTimeOrders: number;
fallbackTimeOrders: number; duplicateConflicts: number; previousPaid: boolean;
amount15d: number; amount30d: number; lastPaidAt: number | null; recentOrders: ProfileOrder[];
};
function median(values: number[]) {
if (!values.length) return PAYMENT_PROFILE_RULE.priorFen;
const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
}
function evidenceWeight(order: ProfileOrder, context: 'general' | 'coin' | 'lucky') {
const sku = order.sku.toLowerCase();
const rule = PAYMENT_PROFILE_RULE;
if (order.sku === rule.legacyBundle.sku && order.paidAt < rule.legacyBundle.before) return 0.3;
// First-purchase/subscription evidence must not be promoted by activity matching.
if (sku === 'starter_pack' || sku === 'month_card' || /^battlepass(?:_|$)/.test(sku)) return 0.25;
if (context !== 'general' && order.activity === context && order.rewardVersion
&& rule.comparableActivityVersions[context].includes(order.rewardVersion)) return 1;
if (sku === 'reborn_gift' || sku.startsWith('jungle_treasure_') || sku.startsWith('gold_miner')) return 0.5;
return 1;
}
function ticketFactors(orders: ProfileOrder[], payDays: number, context: 'general' | 'coin' | 'lucky') {
const prior = PAYMENT_PROFILE_RULE.priorFen;
const daily = new Map<number, number>();
const adjusted: number[] = [];
for (const order of orders) {
const amount = prior + evidenceWeight(order, context) * (order.amountFen - prior);
adjusted.push(amount);
const day = Math.floor((order.paidAt + OFFSET) / DAY);
daily.set(day, Math.max(daily.get(day) ?? 0, amount));
}
const p50 = median(adjusted);
const maxima = [...daily.values()].sort((a, b) => b - a);
const repeated = maxima.length >= 2 ? maxima[1] : p50;
const confidence = Math.min(1, payDays / PAYMENT_PROFILE_RULE.confidenceDays);
return {
confidence,
adjusted_median_fen: p50,
repeat_ticket_fen: repeated,
anchor_fen: prior + confidence * (Math.max(prior, p50, repeated) - prior),
};
}
// Shared by calculation and publication from a committed/retained RFM tuple.
export function paymentVipLevel(hasPaid: boolean, score: number) {
return hasPaid ? Math.max(1, Math.min(5, Math.floor(score + 0.5))) : 0;
}
export function buildPaymentProfile(input: ProfileInput) {
const errors: string[] = [];
const warnings = ['channel_actual_payment_unverified', 'refunds_not_synced'];
const confirmedPaid = input.confirmedOrders > 0 || input.futureOrders > 0 || input.previousPaid;
const conflict = confirmedPaid && input.payUser === false;
const paidStatus = conflict ? 'unknown' : confirmedPaid ? 'paid' : input.payUser === false ? 'never' : 'unknown';
if (input.missingOpenid) errors.push('missing_openid');
if (paidStatus === 'unknown') errors.push('unknown_paid_status');
if ((confirmedPaid || input.payUser === true) && input.confirmedOrders === 0) errors.push('paid_history_missing_or_after_cutoff');
if (conflict) errors.push('paid_status_conflict');
if (input.invalidOrders) errors.push('invalid_orders');
if (input.missingTimeOrders) errors.push('missing_payment_time');
if (input.duplicateConflicts) errors.push('conflicting_duplicate_orders');
if (input.fallbackTimeOrders) warnings.push('creation_time_used_as_payment_time');
if (input.registerTime === null) warnings.push('registration_time_missing');
else if (input.registerTime > input.cutoff) errors.push('registered_after_cutoff');
if (input.recentOrders.some(order => !order.sku)) warnings.push('missing_sku_uses_normal_weight');
if (input.recentOrders.some(order => order.sku === PAYMENT_PROFILE_RULE.legacyBundle.sku
&& order.paidAt >= PAYMENT_PROFILE_RULE.legacyBundle.before)) warnings.push('legacy_bundle_version_unverified');
const metadata = {
profile_schema_version: 1,
attempted_profile_rule_version: PAYMENT_PROFILE_RULE.version,
paid_status: paidStatus,
has_ever_paid: confirmedPaid ? true : paidStatus === 'never' ? false : null,
data_valid: errors.length === 0,
invalid_reasons: errors,
quality_warnings: warnings,
amount_basis: 'confirmed_order_amount',
net_payment_verified: false,
};
if (errors.length) return {
...metadata, vip_level: null, profile_rule_version: null, profile_calculated_as_of: null,
rfm: null, spending: null, ticket_factors: null,
};
const payDays = new Set(input.recentOrders.map(order => Math.floor((order.paidAt + OFFSET) / DAY))).size;
const recency = input.lastPaidAt === null ? null : Math.floor((input.cutoff - input.lastPaidAt) / DAY);
const m = 1 + PAYMENT_PROFILE_RULE.moneyThresholdsFen.filter(value => input.amount30d >= value).length;
const f = 1 + PAYMENT_PROFILE_RULE.frequencyDays.filter(value => payDays >= value).length;
const r = recency === null ? 1 : 5 - PAYMENT_PROFILE_RULE.recencyDays.filter(value => recency >= value).length;
const scoreTenths = 7 * m + 2 * f + r;
const age = input.registerTime === null ? 30 : Math.max(1, (input.cutoff - input.registerTime) / DAY);
const l30 = Math.max(PAYMENT_PROFILE_RULE.minimumObservationDays, Math.min(30, age));
const l15 = Math.max(PAYMENT_PROFILE_RULE.minimumObservationDays, Math.min(15, age));
const d30 = input.amount30d / l30;
const d15 = input.amount15d / l15;
const trend = d30 > 0 ? Math.max(0.75, Math.min(1.25, 1 + 0.3 * (d15 / d30 - 1))) : 1;
return {
...metadata,
vip_level: paymentVipLevel(paidStatus !== 'never', scoreTenths / 10),
profile_rule_version: PAYMENT_PROFILE_RULE.version,
profile_calculated_as_of: input.cutoff,
rfm: {
m30_fen: input.amount30d, pay_days_30d: payDays, orders30: input.recentOrders.length,
last_effective_paid_at: input.lastPaidAt, recency_days: recency,
m_score: m, f_score: f, r_score: r, score: scoreTenths / 10,
},
spending: {
m15_fen: input.amount15d, m30_fen: input.amount30d, observed_days30: l30, observed_days15: l15,
daily30_fen: d30, daily15_fen: d15, trend_factor: trend,
daily_reference_fen: d30 * trend, weekly_reference_fen: 7 * d30 * trend,
two_day_reference_fen: 2 * d30 * trend,
},
ticket_factors: {
general: ticketFactors(input.recentOrders, payDays, 'general'),
coin: ticketFactors(input.recentOrders, payDays, 'coin'),
lucky: ticketFactors(input.recentOrders, payDays, 'lucky'),
},
};
}