Advertiser-funded missions: the accounting that keeps their money separate
First increment of paid missions. No advertiser can buy one yet; this is the
layer that has to be right before anyone can, because the faucet is a single
wallet holding house float and every advertiser's unspent reserve at once.
THE SOLVENCY RULE, in paid.canSell: faucet balance must cover the house float
plus every outstanding reserve before a sale is accepted. Break it and you have
sold delivery you cannot pay for, and a hunter finds a code only to watch the
drip fail. Checked before the sale, never after.
Three consequences of Marty's decision that paid missions sit ON TOP of the
daily cap rather than inside it:
- a paid completion is excluded from paidToday, so it cannot eat house budget
- each live paid mission the hunter has not done raises their allowance by one
- a paid mission pays exactly what the advertiser set, with no random draw, so
the reserve taken at purchase is exact and can never come up short
Advertisers set the hunter payout themselves, floored at 0.30, and may pay more
to be completed sooner. Company margin rides on top as a percentage.
Existing missions are untouched: nothing carries the paid flag, so house
behaviour is byte for byte what it was. 18 new tests, 79 across the suite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+96
@@ -0,0 +1,96 @@
|
||||
// Advertiser-funded missions (Marty, 2026-09-24).
|
||||
//
|
||||
// A member buys a mission: they pay POL into the faucet wallet, and hunters who complete it are
|
||||
// dripped out of what they paid. Two rules follow from that, and everything here exists to hold
|
||||
// them:
|
||||
//
|
||||
// 1. A paid completion is NOT house spend. It does not count against dailyCapPol, and it does
|
||||
// not use up one of the hunter's missionsPerDay. Each live paid mission raises that hunter's
|
||||
// allowance by one, because the company is not funding it. ("Each additional mission paid by
|
||||
// an advertiser should allow +1 above the cap.")
|
||||
//
|
||||
// 2. THE SOLVENCY RULE. The faucet is one wallet holding house float AND every advertiser's
|
||||
// unspent reserve. If reserves ever exceed the balance we have sold delivery we cannot pay
|
||||
// for, and a hunter completes a mission only to watch the drip fail. So:
|
||||
//
|
||||
// faucet balance >= house float + sum(outstanding reserves)
|
||||
//
|
||||
// checked before a sale is accepted, never after.
|
||||
//
|
||||
// Money in is split at purchase: the hunter payout is reserved, the rest is company margin the
|
||||
// P&L can recognise. The advertiser sets the hunter payout themselves (base 0.30, they may pay
|
||||
// more to get completed sooner), so the reserve is exact rather than estimated. No draw, no
|
||||
// variance, no guessing.
|
||||
'use strict';
|
||||
const store = require('./store');
|
||||
|
||||
const DEFAULTS = {
|
||||
paidEnabled: 1,
|
||||
paidBaseHunterPol: 0.30, // the floor an advertiser may set as the hunter's payout
|
||||
paidMarginPct: 100, // company margin on top of the hunter payout, as a percentage
|
||||
paidMinBlock: 100, // completions per block
|
||||
paidMaxConcurrent: 0, // 0 = no limit; supply is elastic now that paid missions add capacity
|
||||
paidHouseFloatPol: 25, // POL kept back for house missions, never counted as sellable
|
||||
};
|
||||
function cfg() { return Object.assign({}, DEFAULTS, store.read('settings', {})); }
|
||||
const r4 = n => Math.round(Number(n) * 10000) / 10000;
|
||||
|
||||
// ---- what a block costs -----------------------------------------------------------------
|
||||
// hunterPol is what each completing hunter receives; the advertiser pays that plus the margin.
|
||||
function quote(hunterPol, completions) {
|
||||
const c = cfg();
|
||||
const hp = r4(Math.max(Number(c.paidBaseHunterPol), Number(hunterPol) || 0));
|
||||
const n = Math.max(Number(c.paidMinBlock), Math.round(Number(completions) || 0));
|
||||
const reserve = r4(hp * n); // exact: no draw, so no buffer needed
|
||||
const margin = r4(reserve * (Number(c.paidMarginPct) / 100));
|
||||
return { hunterPol: hp, completions: n, reserve, margin, total: r4(reserve + margin) };
|
||||
}
|
||||
|
||||
// ---- reserves ---------------------------------------------------------------------------
|
||||
const paidMissions = () => store.read('missions', []).filter(m => m.paid);
|
||||
|
||||
// completions already granted against a mission (failed ones do not count, they never paid)
|
||||
function usedOf(missionId) {
|
||||
return store.read('payouts', []).filter(p => p.missionId === missionId && p.status !== 'failed' && !p.prize && !p.ref).length;
|
||||
}
|
||||
|
||||
// what every live paid mission still owes its hunters
|
||||
function outstanding() {
|
||||
let pol = 0, left = 0;
|
||||
for (const m of paidMissions()) {
|
||||
const remaining = Math.max(0, Number(m.budget || 0) - usedOf(m.id));
|
||||
left += remaining;
|
||||
pol += remaining * Number(m.hunterPol || 0);
|
||||
}
|
||||
return { reservePol: r4(pol), completionsLeft: left, missions: paidMissions().length };
|
||||
}
|
||||
|
||||
// THE guard. Given the faucet's real balance, can we take this sale?
|
||||
function canSell(balancePol, addReservePol) {
|
||||
const c = cfg();
|
||||
const o = outstanding();
|
||||
const float = Number(c.paidHouseFloatPol) || 0;
|
||||
const committed = r4(o.reservePol + float + Number(addReservePol || 0));
|
||||
const bal = r4(Number(balancePol) || 0);
|
||||
return {
|
||||
ok: bal >= committed,
|
||||
balance: bal, committed, houseFloat: float,
|
||||
alreadyReserved: o.reservePol, adding: r4(Number(addReservePol) || 0),
|
||||
shortBy: bal >= committed ? 0 : r4(committed - bal),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- the hunter's allowance ---------------------------------------------------------------
|
||||
// Base allowance is the company-funded missionsPerDay. Every live paid mission the hunter has
|
||||
// not yet done adds one, because that completion costs the company nothing.
|
||||
function extraAllowance(doneIds) {
|
||||
const done = doneIds instanceof Set ? doneIds : new Set(doneIds || []);
|
||||
return paidMissions().filter(m => m.active && !done.has(m.id) && Math.max(0, Number(m.budget || 0) - usedOf(m.id)) > 0).length;
|
||||
}
|
||||
|
||||
function isPaid(missionId) {
|
||||
const m = store.read('missions', []).find(x => x.id === missionId);
|
||||
return !!(m && m.paid);
|
||||
}
|
||||
|
||||
module.exports = { cfg, quote, outstanding, canSell, extraAllowance, isPaid, usedOf, paidMissions };
|
||||
+17
-4
@@ -35,9 +35,16 @@ function draw(min, max, skew) {
|
||||
// day defaults to today: called without it the comparison was against undefined and silently
|
||||
// returned 0 for everyone, which would read as "no finds yet" to any new caller
|
||||
function findsToday(memberId, day) { const d = day || ctDay(); return store.read('payouts', []).filter(p => p.memberId === Number(memberId) && p.day === d && p.status !== 'failed' && !p.prize && !p.ref).length; }
|
||||
function daily(memberId) { const s = settings(); const limit = Math.max(1, Number(s.missionsPerDay) || 3); const done = findsToday(memberId, ctDay()); return { limit, done, left: Math.max(0, limit - done) }; }
|
||||
function daily(memberId, doneIds) { const s = settings(); const base = Math.max(1, Number(s.missionsPerDay) || 3);
|
||||
// every live paid mission this hunter has not done yet adds one, because the company is not
|
||||
// funding that completion
|
||||
let extra = 0; try { extra = require('./paid').extraAllowance(doneIds); } catch (e) {}
|
||||
const limit = base + extra; const done = findsToday(memberId, ctDay()); return { limit, done, left: Math.max(0, limit - done) }; }
|
||||
// What the COMPANY has spent today. Advertiser-funded completions are excluded on purpose: the
|
||||
// hunter is paid out of what the advertiser already put in the faucet, so counting it here would
|
||||
// let a paid mission use up the house budget (Marty, 2026-09-24).
|
||||
function paidToday(day) {
|
||||
return store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && !p.ref).reduce((n, p) => n + p.pol, 0);
|
||||
return store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && !p.ref && !p.paid).reduce((n, p) => n + p.pol, 0);
|
||||
}
|
||||
|
||||
// has this member completed this mission TODAY (Central)? Missions reset at midnight Central with the codes and
|
||||
@@ -56,15 +63,21 @@ function doneToday(memberId, wallet) {
|
||||
// record a completion; decide paid-today vs queued
|
||||
function grant(member, mission) {
|
||||
const s = settings();
|
||||
const pol = draw(Number(s.minPol), Number(s.maxPol));
|
||||
// An advertiser-funded mission pays exactly what the advertiser set, every time. No draw, so
|
||||
// the reserve taken at purchase is exact and can never come up short.
|
||||
const isPaid = !!mission.paid;
|
||||
const pol = isPaid ? Math.round(Number(mission.hunterPol || 0) * 10000) / 10000
|
||||
: draw(Number(s.minPol), Number(s.maxPol));
|
||||
const day = ctDay();
|
||||
const budgetLeft = mission.budget ? mission.budget - store.read('payouts', []).filter(p => p.missionId === mission.id && p.status !== 'failed').length : Infinity;
|
||||
if (budgetLeft <= 0) return { error: 'This mission has paid out all it was funded for.' };
|
||||
const overCap = paidToday(day) + pol > Number(s.dailyCapPol);
|
||||
// the house cap governs house spend only; a paid completion is already funded
|
||||
const overCap = !isPaid && (paidToday(day) + pol > Number(s.dailyCapPol));
|
||||
const rec = {
|
||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
||||
memberId: Number(member.memberId), email: member.email, wallet: member.wallet, username: member.username || null,
|
||||
missionId: mission.id, site: mission.site, pol, day, at: Date.now(),
|
||||
paid: isPaid || undefined, // marks advertiser-funded spend so the house accounting skips it
|
||||
status: overCap ? 'queued' : 'due', tx: null, paidAt: null, error: null,
|
||||
};
|
||||
store.update('payouts', [], all => { all.push(rec); return all; });
|
||||
|
||||
@@ -223,7 +223,7 @@ const server = http.createServer(async (req, res) => {
|
||||
if (p === '/api/my/board') {
|
||||
referrals.seen(me); // hunters already signed in never pass through /auth again, so keep the directory fresh here too
|
||||
const done = rewards.doneToday(me.memberId, me.wallet); const wdone = done; // today's finds, by member id or wallet
|
||||
return json(res, 200, { me: { memberId: me.memberId, username: me.username, wallet: me.wallet }, missions: missions.forMember(me.memberId).map(m => Object.assign(pubMission(m), { done: done.has(m.id) || wdone.has(m.id) })), drips: rewards.mine(me.memberId).slice(0, 20), faucet: { on: faucetOn }, pool: rewards.pool(), daily: rewards.daily(me.memberId),
|
||||
return json(res, 200, { me: { memberId: me.memberId, username: me.username, wallet: me.wallet }, missions: missions.forMember(me.memberId).map(m => Object.assign(pubMission(m), { done: done.has(m.id) || wdone.has(m.id) })), drips: rewards.mine(me.memberId).slice(0, 20), faucet: { on: faucetOn }, pool: rewards.pool(), daily: rewards.daily(me.memberId, rewards.doneToday(me.memberId, me.wallet)),
|
||||
badges: social.badgesFor(me.memberId), badgeCards: (() => { const got = new Set(social.badgesFor(me.memberId).map(b => b.id)); const who = social.nameOf(me); return social.BADGES.map(b => ({ id: b.id, name: b.name, icon: b.icon, why: b.why, art: '/badges/badge-' + b.id + '.jpg', earned: got.has(b.id), page: got.has(b.id) ? SITE + '/b/' + who + '/' + b.id : null, image: got.has(b.id) ? SITE + '/badge-img/' + who + '/' + b.id + '.jpg' : null })); })(), rank: { week: social.rankOf(me.memberId, 'week'), month: social.rankOf(me.memberId, 'month'), all: social.rankOf(me.memberId, 'all') },
|
||||
share: { link: social.shareLink(SITE, me), joinUrl: 'https://instantadpay.com/join/' + encodeURIComponent(String(me.username || me.memberId)) + '?from=polhunter' },
|
||||
referral: referrals.state(me) });
|
||||
@@ -233,7 +233,7 @@ const server = http.createServer(async (req, res) => {
|
||||
if (!me.wallet) return json(res, 400, { error: 'Link a wallet on InstantAdPay first so the drip has somewhere to land, then open PolHunter again.' });
|
||||
if (rewards.completed(me.memberId, m.id, me.wallet)) return json(res, 400, { error: 'You already completed this one.' });
|
||||
{ const pool = rewards.pool(); if (pool.spent) return json(res, 400, { error: SPENT_MSG, spent: true, resetsAt: pool.resetsAt }); }
|
||||
{ const d = rewards.daily(me.memberId); if (!d.left) return json(res, 400, { error: dailyMsg(d), dailyDone: true, resetsAt: rewards.pool().resetsAt }); }
|
||||
{ const d = rewards.daily(me.memberId, rewards.doneToday(me.memberId, me.wallet)); if (!d.left) return json(res, 400, { error: dailyMsg(d), dailyDone: true, resetsAt: rewards.pool().resetsAt }); }
|
||||
if (limited('start:' + me.memberId, 20, 3600000)) return json(res, 429, { error: 'Easy. Twenty starts an hour is plenty.' });
|
||||
const t = missions.issue(me.memberId, m.id);
|
||||
// a mission URL may place the token itself with {token} (a Telegram Mini App takes it in
|
||||
@@ -249,7 +249,7 @@ const server = http.createServer(async (req, res) => {
|
||||
const m = missions.get(c.rec.missionId); if (!m) return json(res, 404, { error: 'That mission is gone.' });
|
||||
if (rewards.completed(me.memberId, m.id, me.wallet)) return json(res, 400, { error: 'You already completed this one.' });
|
||||
{ const pool = rewards.pool(); if (pool.spent) return json(res, 400, { error: SPENT_MSG, spent: true, resetsAt: pool.resetsAt }); }
|
||||
{ const d = rewards.daily(me.memberId); if (!d.left) return json(res, 400, { error: dailyMsg(d), dailyDone: true, resetsAt: rewards.pool().resetsAt }); }
|
||||
{ const d = rewards.daily(me.memberId, rewards.doneToday(me.memberId, me.wallet)); if (!d.left) return json(res, 400, { error: dailyMsg(d), dailyDone: true, resetsAt: rewards.pool().resetsAt }); }
|
||||
const before = new Set(social.badgesFor(me.memberId).map(b => b.id));
|
||||
const g = rewards.grant(me, m); if (g.error) return json(res, 400, g);
|
||||
// whoever brought this hunter gets paid for it: the bounty on their first find, a match while they are new
|
||||
|
||||
Reference in New Issue
Block a user