143 lines
7.4 KiB
TypeScript
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'),
|
|
},
|
|
};
|
|
}
|