1b4a6d5398
skycash's match on toplinzy started paying at 11:31 this morning, about 100 minutes before the origin gate went live. Marty: "it would be wrong to take it back." So the match finishes its 30 days. Done as a named list of member ids rather than by stamping toplinzy's joinedRef as polhunter.com. That shortcut would have written a false origin into the record, and the board now shows that field to members, so it would have been a lie in two places at once. The exemption forgives the ORIGIN CHECK AND NOTHING ELSE, and it is match-only: it can never open a new bounty, or an exemption becomes the same hole again wearing a config field. The new-member floor still applies. An unlisted member is unaffected. An empty list behaves exactly as no list. state() honours it too, so the card says "earning" for somebody who is in fact earning, rather than telling them they earn nothing while paying them. 9 tests on the exemption, 90 across the suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
249 lines
14 KiB
JavaScript
249 lines
14 KiB
JavaScript
// 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
|
|
// THE gate, and it has to be an absolute date, not a rolling window. It was refNewDays: 30 on a
|
|
// site whose oldest account is 19 days old, so it excluded nobody and 8.67 POL went to sponsors
|
|
// for existing members hunting — zero acquisitions (Marty, 2026-09-23: "I need people that never
|
|
// existed before. That's why we're doing it."). Anyone whose account predates this moment was
|
|
// already a member, by definition, and can never earn their sponsor a bounty or a match.
|
|
refNewAfter: '2026-09-23T10:00:00Z',
|
|
refRequireOrigin: 1, // only pay for members PolHunter actually sent (joinedRef)
|
|
// Member ids forgiven the origin check, comma separated. For people whose match had ALREADY
|
|
// started paying before the rule existed: stopping it mid-stream would be taking back something
|
|
// they were told they had earned (Marty, 2026-09-24, on skycash and toplinzy). It forgives ONLY
|
|
// the origin test. The new-member floor still applies, and bountyPaidFor still stops a second
|
|
// bounty, so an exemption can pay the running match and nothing else.
|
|
refOriginExempt: '',
|
|
|
|
refNewDays: 30, // secondary: a newcomer only earns their sponsor a bounty in their first
|
|
// month, so a dormant signup cannot trigger one a year later
|
|
refCapPol: 20, // referral pool per Central day, separate from the find pool
|
|
refMatchMinPol: 0.005,// do not write a transaction for dust
|
|
// Marty's launch bonus (2026-09-23): an extra drip to the FIRST hunter whose referral gets all
|
|
// the way through. Once ever, never again. Set to 0 to retire it.
|
|
refFirstBonusPol: 5,
|
|
};
|
|
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();
|
|
// forgiven the origin check by name, because their match was already running when the rule landed
|
|
function exempt(c, memberId) {
|
|
return String(c.refOriginExempt || '').split(/[,\s]+/).map(Number).filter(n => n > 0).includes(Number(memberId));
|
|
}
|
|
|
|
// ---- 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);
|
|
if (claims.joinedRef) cur.joinedRef = String(claims.joinedRef).toLowerCase();
|
|
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' : ref.kind === 'first-bonus' ? 'First referral bonus' : '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;
|
|
// the floor: did this person exist before the mission did?
|
|
// This gate FAILS CLOSED and must stay that way. No floor configured, a blank or mistyped
|
|
// date, or a member with no InstantAdPay join date on file: nobody gets paid. The last version
|
|
// of this line paid out whenever the setting did not exclude anyone, which is how 8.67 POL went
|
|
// to sponsors of members who already existed. A referral only counts if the person did not
|
|
// exist in InstantAdPay before the mission did.
|
|
const floor = Date.parse(String(c.refNewAfter || ''));
|
|
if (!Number.isFinite(floor) || floor <= 0) return [];
|
|
if (!me.joinedAt || Number(me.joinedAt) < floor) return [];
|
|
// AND PolHunter has to have actually sent them. The account being new is not enough: somebody who
|
|
// joined InstantAdPay off an email, a banner or another site and only later wandered over here was
|
|
// never a PolHunter acquisition, and paying a bounty for them defeats the point (Marty,
|
|
// 2026-09-24). The proof is InstantAdPay's first-touch source, handed over signed in the sign-in
|
|
// payload; a share link stamps it polhunter.com from the URL itself, so a stripped referrer does
|
|
// not lose a genuine referral. Fails closed: no origin on file pays nobody.
|
|
// only an explicit '0' turns this off: a typo or a stray value must leave the gate standing,
|
|
// the same way the floor does
|
|
const viaHunt = /polhunter/i.test(String(me.joinedRef || ''));
|
|
const forgiven = !viaHunt && exempt(c, id); // origin forgiven, but MATCH ONLY, see below
|
|
if (String(c.refRequireOrigin) !== '0' && !viaHunt && !forgiven) return [];
|
|
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');
|
|
// `forgiven` covers a match that was already running when the origin rule landed. It must NEVER
|
|
// open a new bounty, or an exemption becomes the same hole again wearing a config field.
|
|
if (!forgiven && finds.length <= 1 && ageDays <= Number(c.refNewDays) && !bountyPaidFor(id)) {
|
|
const pol = Number(c.refBountyPol);
|
|
if (pol <= room) {
|
|
const firstEver = !refRows().some(p => p.ref.kind === 'bounty');
|
|
out.push(write(referrer, pol, Object.assign({ kind: 'bounty' }, who))); room -= pol;
|
|
// the launch bonus rides along with the very first bounty anyone earns, once ever. It is
|
|
// deliberately outside the daily referral ceiling: it is a single payment, not a rate.
|
|
const bonus = Number(c.refFirstBonusPol) || 0;
|
|
if (firstEver && bonus > 0 && !refRows().some(p => p.ref.kind === 'first-bonus')) {
|
|
out.push(write(referrer, bonus, Object.assign({ kind: 'first-bonus' }, who)));
|
|
}
|
|
}
|
|
}
|
|
// 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();
|
|
// the same floor onFind pays by, so the card can never promise an earning it will not make
|
|
const floor = Date.parse(String(c.refNewAfter || ''));
|
|
const gateOk = Number.isFinite(floor) && floor > 0;
|
|
const needOrigin = String(c.refRequireOrigin) !== '0';
|
|
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);
|
|
const viaHunt = /polhunter/i.test(String(x.joinedRef || '')) || exempt(c, x.memberId);
|
|
// qualifying means BOTH: new to InstantAdPay, and sent here by PolHunter
|
|
const isNew = !!(gateOk && x.joinedAt && Number(x.joinedAt) >= floor && (!needOrigin || viaHunt));
|
|
return {
|
|
name: x.username || ('#' + x.memberId),
|
|
// when they actually joined InstantAdPay, not when this directory first noticed them:
|
|
// the backfill stamped firstSeen at seeding time and made everyone look brought-in today
|
|
joinedAt: x.joinedAt || x.firstSeen || null,
|
|
finds: theirFinds.length,
|
|
// an account that predates the mission was already a member: say so plainly rather than
|
|
// show them as 'hunting' next to a bounty that will never arrive
|
|
isNew, viaHunt,
|
|
// say exactly which condition they miss, so nobody waits on a bounty that will not come
|
|
state: !isNew
|
|
? (gateOk && x.joinedAt && Number(x.joinedAt) < floor
|
|
? 'already a member before the mission, earns nothing'
|
|
: 'joined InstantAdPay some other way, not through your link')
|
|
: paid ? 'earning' : theirFinds.length ? 'hunting' : 'signed up, no find yet',
|
|
stillNew: isNew && (Date.now() - Number(x.joinedAt)) / 86400000 <= Number(c.refMatchDays),
|
|
};
|
|
});
|
|
return {
|
|
on: String(c.refEnabled) === '1',
|
|
bountyPol: Number(c.refBountyPol),
|
|
firstBonusPol: refRows().some(p => p.ref.kind === 'first-bonus') ? 0 : Number(c.refFirstBonusPol) || 0,
|
|
matchPct: Number(c.refMatchPct),
|
|
matchDays: Number(c.refMatchDays),
|
|
brought: mine.length,
|
|
broughtNew: mine.filter(x => x.isNew).length, // the only ones that can ever pay
|
|
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,
|
|
firstBonus: (() => { const b = rows.find(p => p.ref.kind === 'first-bonus'); return b ? { who: b.username || ('#' + b.memberId), pol: b.pol, at: b.at, status: b.status } : null; })(),
|
|
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 };
|