Referral rewards: pay a hunter for bringing someone who actually hunts
PolHunter retains well and recruits badly. On 2026-09-22 it had its best day, 105 finds from 37 hunters, and had sent three people to InstantAdPay in its whole life, all three on one member's link. Earning and sharing were separate actions and only one of them paid. Bringing someone is now the best-paid thing on the board, and the card sits first in the mission list rather than on a promo page nobody opens. A flat 3 POL the first time someone they brought finds a code, then 20% on top of that person's finds for their first 30 days, paid from the pool and never taken out of the newcomer's drip. Who counts as "brought by me" is the sponsor they actually joined InstantAdPay under, handed over in the sign-in payload, so the bounty and the commission always land on the same person. The bounty only pays for a member who is genuinely new, once per person, and referral drips have their own daily ceiling so a good referral day can never starve the finds. Also: findsToday defaulted its day argument to undefined and silently returned zero for every caller that omitted it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
// Referral rewards (Marty, 2026-09-23).
|
||||
//
|
||||
// PolHunter retains well and recruits badly: on 2026-09-22 it had its best day ever, 105 finds
|
||||
// from 37 hunters, and had sent three people to InstantAdPay in its whole life, all three on one
|
||||
// member's link. Earning and sharing were separate actions and only one of them paid. This makes
|
||||
// bringing someone the best-paid thing on the board.
|
||||
//
|
||||
// Two payments, both to the REFERRER, both out of a pool of their own so a good referral day can
|
||||
// never starve the finds:
|
||||
// bounty a flat drip the first time someone they brought completes a find, paid once per person
|
||||
// match a slice of every find that person makes while they are still new
|
||||
//
|
||||
// Who counts as "brought by me" is not a cookie PolHunter guessed at. It is the sponsor the member
|
||||
// actually joined InstantAdPay under, handed over in the sign-in payload as sponsorRef, so the
|
||||
// bounty and the InstantAdPay commission always land on the same person.
|
||||
'use strict';
|
||||
const store = require('./store');
|
||||
const rewards = require('./rewards');
|
||||
const { ctDay } = require('./missions');
|
||||
|
||||
const DEFAULTS = {
|
||||
refEnabled: 1,
|
||||
refBountyPol: 3, // flat, not a draw: a referral is work someone did, not a lottery
|
||||
refMatchPct: 20, // paid ON TOP of the newcomer's find, never taken out of it
|
||||
refMatchDays: 30, // how long the match runs, from the newcomer's InstantAdPay join date
|
||||
refNewDays: 30, // the bounty only pays for a genuinely new member, not an old one who
|
||||
// happens to start hunting years after their sponsor signed them up
|
||||
refCapPol: 20, // referral pool per Central day, separate from the find pool
|
||||
refMatchMinPol: 0.005,// do not write a transaction for dust
|
||||
};
|
||||
function cfg() { return Object.assign({}, DEFAULTS, store.read('settings', {})); }
|
||||
const round4 = n => Math.round(Number(n) * 10000) / 10000;
|
||||
const norm = s => String(s || '').trim().toLowerCase();
|
||||
|
||||
// ---- the hunter directory -------------------------------------------------------------------
|
||||
// Every sign-in upserts the member here, so the referral graph survives session expiry and we
|
||||
// always have a wallet to pay the referrer at.
|
||||
function seen(claims) {
|
||||
const id = Number(claims.memberId);
|
||||
if (!id) return null;
|
||||
const all = store.update('hunters', {}, h => {
|
||||
const cur = h[id] || { memberId: id, firstSeen: Date.now() };
|
||||
if (claims.email) cur.email = norm(claims.email);
|
||||
if (claims.username) cur.username = String(claims.username);
|
||||
if (claims.wallet) cur.wallet = norm(claims.wallet);
|
||||
if (claims.sponsorRef) cur.sponsorRef = norm(claims.sponsorRef);
|
||||
if (claims.joinedAt) cur.joinedAt = Number(claims.joinedAt);
|
||||
cur.lastSeen = Date.now();
|
||||
h[id] = cur;
|
||||
return h;
|
||||
});
|
||||
return all[id];
|
||||
}
|
||||
function hunter(memberId) { return store.read('hunters', {})[Number(memberId)] || null; }
|
||||
function keysOf(h) { return [norm(h.username), String(h.memberId)].filter(Boolean); }
|
||||
|
||||
// the referrer has to be a hunter themselves: we pay into a wallet we have seen, and nobody
|
||||
// earns from a board they have never opened
|
||||
function referrerOf(memberId) {
|
||||
const me = hunter(memberId);
|
||||
if (!me || !me.sponsorRef) return null;
|
||||
const all = store.read('hunters', {});
|
||||
for (const k of Object.keys(all)) {
|
||||
const x = all[k];
|
||||
if (Number(x.memberId) === Number(memberId)) continue;
|
||||
if (keysOf(x).includes(me.sponsorRef)) return x;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function broughtBy(memberId) {
|
||||
const me = hunter(memberId);
|
||||
if (!me) return [];
|
||||
const keys = keysOf(me);
|
||||
return Object.values(store.read('hunters', {}))
|
||||
.filter(x => Number(x.memberId) !== Number(memberId) && x.sponsorRef && keys.includes(x.sponsorRef))
|
||||
.sort((a, b) => (a.firstSeen || 0) - (b.firstSeen || 0));
|
||||
}
|
||||
|
||||
// ---- the pool -------------------------------------------------------------------------------
|
||||
const refRows = () => store.read('payouts', []).filter(p => p.ref && p.status !== 'failed');
|
||||
function paidRefToday(day) { return refRows().filter(p => p.day === (day || ctDay())).reduce((n, p) => n + p.pol, 0); }
|
||||
function refPool() {
|
||||
const c = cfg();
|
||||
const cap = Number(c.refCapPol), today = round4(paidRefToday());
|
||||
return { capPol: cap, todayPol: today, left: round4(Math.max(0, cap - today)), resetsAt: rewards.nextResetAt() };
|
||||
}
|
||||
|
||||
function write(referrer, pol, ref) {
|
||||
const rec = {
|
||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
||||
memberId: Number(referrer.memberId), email: referrer.email || null, wallet: referrer.wallet, username: referrer.username || null,
|
||||
missionId: 'ref:' + ref.kind, site: ref.kind === 'bounty' ? 'Referral bounty' : 'Referral match',
|
||||
pol: round4(pol), day: ctDay(), at: Date.now(),
|
||||
status: 'due', tx: null, paidAt: null, error: null, ref,
|
||||
};
|
||||
store.update('payouts', [], all => { all.push(rec); return all; });
|
||||
return rec;
|
||||
}
|
||||
const bountyPaidFor = id => refRows().some(p => p.ref.kind === 'bounty' && Number(p.ref.forMemberId) === Number(id));
|
||||
|
||||
// ---- the hook: called right after a find is recorded ------------------------------------------
|
||||
// Returns the drips it created, so the caller can post the bounty to Telegram.
|
||||
function onFind(member, find) {
|
||||
const c = cfg();
|
||||
if (String(c.refEnabled) !== '1') return [];
|
||||
const id = Number(member.memberId);
|
||||
const me = hunter(id) || {};
|
||||
const referrer = referrerOf(id);
|
||||
if (!referrer || !referrer.wallet || Number(referrer.memberId) === id) return [];
|
||||
|
||||
const out = [];
|
||||
let room = Number(c.refCapPol) - paidRefToday();
|
||||
const ageDays = me.joinedAt ? (Date.now() - Number(me.joinedAt)) / 86400000 : 0;
|
||||
const who = { forMemberId: id, forName: me.username || ('#' + id) };
|
||||
|
||||
// the bounty, once, on their first find, and only while they are genuinely a new member
|
||||
const finds = store.read('payouts', []).filter(p => Number(p.memberId) === id && !p.ref && !p.prize && p.status !== 'failed');
|
||||
if (finds.length <= 1 && ageDays <= Number(c.refNewDays) && !bountyPaidFor(id)) {
|
||||
const pol = Number(c.refBountyPol);
|
||||
if (pol <= room) { out.push(write(referrer, pol, Object.assign({ kind: 'bounty' }, who))); room -= pol; }
|
||||
}
|
||||
// the match, on every find inside the window, paid on top and never deducted from the newcomer
|
||||
if (ageDays <= Number(c.refMatchDays)) {
|
||||
const pol = round4(Number(find.pol) * Number(c.refMatchPct) / 100);
|
||||
if (pol >= Number(c.refMatchMinPol) && pol <= room) out.push(write(referrer, pol, Object.assign({ kind: 'match', findId: find.id, findPol: find.pol }, who)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- what the board shows ---------------------------------------------------------------------
|
||||
function state(member) {
|
||||
const c = cfg();
|
||||
const id = Number(member.memberId);
|
||||
const me = hunter(id) || { memberId: id };
|
||||
const rows = refRows().filter(p => Number(p.memberId) === id);
|
||||
const today = ctDay();
|
||||
const mine = broughtBy(id).map(x => {
|
||||
const theirFinds = store.read('payouts', []).filter(p => Number(p.memberId) === Number(x.memberId) && !p.ref && !p.prize && p.status !== 'failed');
|
||||
const paid = bountyPaidFor(x.memberId);
|
||||
return {
|
||||
name: x.username || ('#' + x.memberId),
|
||||
joinedAt: x.firstSeen || null,
|
||||
finds: theirFinds.length,
|
||||
state: paid ? 'earning' : theirFinds.length ? 'hunting' : 'signed up, no find yet',
|
||||
stillNew: x.joinedAt ? (Date.now() - Number(x.joinedAt)) / 86400000 <= Number(c.refMatchDays) : false,
|
||||
};
|
||||
});
|
||||
return {
|
||||
on: String(c.refEnabled) === '1',
|
||||
bountyPol: Number(c.refBountyPol),
|
||||
matchPct: Number(c.refMatchPct),
|
||||
matchDays: Number(c.refMatchDays),
|
||||
brought: mine.length,
|
||||
broughtToday: mine.filter(x => x.joinedAt && ctDay(x.joinedAt) === today).length,
|
||||
earnedPol: round4(rows.reduce((n, p) => n + p.pol, 0)),
|
||||
earnedTodayPol: round4(rows.filter(p => p.day === today).reduce((n, p) => n + p.pol, 0)),
|
||||
people: mine.slice(0, 25),
|
||||
pool: refPool(),
|
||||
};
|
||||
}
|
||||
|
||||
function totals() {
|
||||
const rows = refRows();
|
||||
const paid = rows.filter(p => p.status === 'paid');
|
||||
return {
|
||||
bounties: rows.filter(p => p.ref.kind === 'bounty').length,
|
||||
matches: rows.filter(p => p.ref.kind === 'match').length,
|
||||
pol: round4(rows.reduce((n, p) => n + p.pol, 0)),
|
||||
paidPol: round4(paid.reduce((n, p) => n + p.pol, 0)),
|
||||
referrers: new Set(rows.map(p => p.memberId)).size,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { seen, hunter, referrerOf, broughtBy, onFind, state, totals, refPool, cfg, paidRefToday };
|
||||
Reference in New Issue
Block a user