import test from 'node:test'; import assert from 'node:assert/strict'; import { buildPaymentProfile, PAYMENT_PROFILE_RULE } from '../functions/paymentProfile.ts'; const DAY = 86400000; const T = Date.parse('2026-09-23T00:00:00+08:00'); function payment(amountFen, days, sku = 'coin_pack', extra = {}) { return { amountFen, paidAt: T - days * DAY + 3600000, sku, ...extra }; } function profile(orders, extra = {}) { return buildPaymentProfile({ cutoff: T, registerTime: T - 90 * DAY, payUser: true, missingOpenid: false, confirmedOrders: orders.length, futureOrders: 0, invalidOrders: 0, missingTimeOrders: 0, fallbackTimeOrders: 0, duplicateConflicts: 0, previousPaid: false, amount15d: orders.filter(o => o.paidAt >= T - 15 * DAY).reduce((sum, o) => sum + o.amountFen, 0), amount30d: orders.reduce((sum, o) => sum + o.amountFen, 0), lastPaidAt: orders.length ? Math.max(...orders.map(o => o.paidAt)) : null, recentOrders: orders, ...extra, }); } test('daily 6 yuan remains RFM VIP4, while its separate ticket reference stays 6 yuan', () => { const result = profile(Array.from({ length: 30 }, (_, i) => payment(600, i + 1))); assert.equal(result.vip_level, 4); assert.equal(result.rfm.score, 3.6); assert.equal(result.rfm.pay_days_30d, 30); assert.equal(result.spending.trend_factor, 1); assert.equal(result.spending.daily_reference_fen, 600); assert.equal(result.ticket_factors.general.anchor_fen, 600); assert.equal(result.net_payment_verified, false); assert.equal(result.amount_basis, 'confirmed_order_amount'); }); test('repeated 68/128 yuan purchases establish ticket evidence independently of VIP', () => { for (const [amount, days, anchor, vip] of [[6800, 4, 6800, 3], [12800, 5, 12800, 4], [6800, 1, 2150, 2]]) { const result = profile(Array.from({ length: days }, (_, i) => payment(amount, i + 1))); assert.equal(result.ticket_factors.general.anchor_fen, anchor); assert.equal(result.vip_level, vip); } }); test('same-day orders affect medians, not frequency confidence; two equal RFM users have different tickets', () => { const large = Array.from({ length: 5 }, (_, i) => payment(3000, i + 1)); const small = Array.from({ length: 25 }, (_, i) => payment(600, Math.floor(i / 5) + 1)); const a = profile(large), b = profile(small); assert.equal(a.rfm.score, b.rfm.score); assert.equal(a.rfm.pay_days_30d, b.rfm.pay_days_30d); assert.equal(a.ticket_factors.general.anchor_fen, 3000); assert.equal(b.ticket_factors.general.anchor_fen, 600); assert.equal(profile(Array.from({ length: 20 }, () => payment(6800, 1))).ticket_factors.general.confidence, 0.25); }); test('SKU weighting changes ticket evidence only; historical weighting ends at its configured boundary', () => { const old = Array.from({ length: 4 }, (_, i) => payment(3000, i + 2, 'unlimited_health_bundle_30')); const result = profile(old); assert.equal(result.rfm.m30_fen, 12000); assert.equal(result.ticket_factors.general.anchor_fen, 1320); const current = old.map(o => ({ ...o, paidAt: PAYMENT_PROFILE_RULE.legacyBundle.before + 3600000 })); assert.equal(profile(current).ticket_factors.general.adjusted_median_fen, 3000); const pass = profile(Array.from({ length: 4 }, (_, i) => payment(3000, i + 1, 'battlepass_30'))); assert.equal(pass.ticket_factors.general.anchor_fen, 1200); assert.equal(pass.rfm.m30_fen, 12000); }); test('new accounts use a minimum seven-day denominator; missing registration uses 30/15', () => { const result = profile([payment(7000, 1)], { registerTime: T - DAY }); assert.equal(result.spending.observed_days30, 7); assert.equal(result.spending.observed_days15, 7); assert.equal(result.spending.daily_reference_fen, 1000); const unknown = profile([payment(7000, 1)], { registerTime: null }); assert.equal(unknown.spending.observed_days30, 30); assert.equal(unknown.spending.observed_days15, 15); assert.ok(unknown.quality_warnings.includes('registration_time_missing')); }); test('recency uses all history and paid users never become VIP0 when the window expires', () => { const old = profile([], { confirmedOrders: 1, previousPaid: true, lastPaidAt: T - 40 * DAY }); assert.equal(old.vip_level, 1); assert.equal(old.rfm.r_score, 2); assert.equal(old.rfm.recency_days, 40); assert.equal(old.spending.daily_reference_fen, 0); assert.equal(profile([], { payUser: false }).vip_level, 0); assert.equal(profile([]).paid_status, 'unknown'); assert.equal(profile([]).vip_level, null); }); test('RFM uses half-up rounding and exact recency thresholds; trend is bounded', () => { // M2 F3 R5 = 2.5 -> VIP3, not floor or bankers rounding. assert.equal(profile(Array.from({ length: 5 }, (_, i) => payment(600, i + 1))).vip_level, 3); for (const [days, score] of [[6, 5], [7, 4], [15, 3], [30, 2], [60, 1]]) { assert.equal(profile([], { confirmedOrders: 1, lastPaidAt: T - days * DAY }).rfm.r_score, score); } assert.equal(profile([payment(600, 1)]).spending.trend_factor, 1.25); assert.equal(profile([payment(600, 20)]).spending.trend_factor, 0.75); }); test('invalid identity, malformed sources and missing history do not create new levels', () => { for (const input of [{ payUser: false }, { missingOpenid: true }, { missingTimeOrders: 1 }, { invalidOrders: 1 }, { duplicateConflicts: 1 }, { registerTime: T + DAY }]) { const result = profile([payment(10000, 1)], input); assert.equal(result.data_valid, false); assert.equal(result.vip_level, null); assert.equal(result.ticket_factors, null); } assert.equal(profile([], { payUser: false, previousPaid: true }).has_ever_paid, true); });