From 9709ce782d64e470d7811843475e4ec078d177eb Mon Sep 17 00:00:00 2001 From: martbost Date: Fri, 11 Sep 2026 07:57:44 -0500 Subject: [PATCH] Holding tank: unsponsored free members, first-come adoption (own $20 buy required, 2 open, 7-day window, twice max), release to tank, admin view; PIF wallet-to-wallet POL gift with logging Co-Authored-By: Claude Fable 5.1 --- chatbot.js | 2 + coach.js | 2 + db.js | 7 ++ public/admin.html | 8 +- public/assets/admin.js | 9 ++ public/assets/my.js | 57 ++++++++++++- public/assets/wallet.js | 18 +++- public/my.html | 11 ++- server.js | 34 ++++++++ tank.js | 177 ++++++++++++++++++++++++++++++++++++++++ 10 files changed, 320 insertions(+), 5 deletions(-) create mode 100644 tank.js diff --git a/chatbot.js b/chatbot.js index 7d757b9..463565b 100644 --- a/chatbot.js +++ b/chatbot.js @@ -77,6 +77,8 @@ FACTS: - Ad packages: Micro $5/500 credits, Activation $20/2,000, Builder $50/5,500, Growth $100/12,000, Leader $250/32,500. Dollar-priced, settled in POL (Polygon) at the live Chainlink rate. 1 credit = 1 cent of ad delivery. - Live formats: display banners (per impression), text ads (per impression), full-screen LOGIN ADS (per day: right after a member signs in they land on a sponsor interstitial — they click "Open Ad", the advertiser's page opens in a NEW tab, a countdown runs on the interstitial, and at zero a "Go to dashboard" button appears. Just a CTA link is enough; an optional banner image can be the clickable creative. No framing requirement since it opens in its own tab), WATCH-TO-EARN VIDEO ADS (advertiser uploads an MP4/WebM or gives a direct https .mp4/.webm link and picks a required watch length — 10s/30s/60s — which sets the per-view price; viewers watch in an escape-proof player under Earn credits > Watch videos, the watch time is enforced on the server clock, and they earn credits per completed watch; you never see your own videos), and solo ads. Banner ads also require a size (standard IAB sizes like 728x90, 300x250). Coming: featured rotation with disclosed rotation size, verified-visit packs. - WALLETS + BUYING POL (Training > Wallets and buying POL, /wallets, members only): preferred MetaMask (recommended; extra accounts for Qualified Start), Phantom, SafePal, Coinbase Wallet; Trust works but blocks buys spending most of its POL (keep ~2x). MoonPay flow: connect wallet, Buy packages > "Buy POL with a card" opens MoonPay with POL on Polygon + the member's address prefilled; card/Apple Pay/Google Pay; first-time ID check; minimum order ~$30; buy package cost + 2-3 POL for fees; POL arrives in minutes; then buy. Exchanges: withdraw POL on the Polygon network. Never MATIC on Ethereum, never share the recovery phrase. +- HOLDING TANK (Members > My line > Holding tank card): free members who joined with no sponsor wait there; a member who has switched on payouts AND bought their own $20+ package can Adopt one (first come, max 2 open adoptions, 7-day window; if the person never links a wallet or buys, they fall back into the tank; a person can be adopted twice at most). Adopting sets the sponsor, opens a chat and emails the member; their first purchase then binds to the adopter on-chain. Members can also "Release to tank" one of their own free referrals (pay it forward). Admin sees the tank under Members. +- PIF (pay it forward) button: on a free direct or an adopted member who has linked a wallet, the sponsor taps PIF, enters an amount (suggested: the $20 package plus fees), and their OWN wallet app opens with the member's address prefilled; the POL goes wallet to wallet. The site never touches the funds; it only logs the transaction and tells the recipient with a Polygonscan link. The gift is theirs; nothing forces a purchase. - FOUNDING WEEK / PRE-LAUNCH (Training > Founding week checklist, /launch, members only): eight items read live from the account: username, wallet linked, payouts on, level 2 qualified (2 buyers of $20+, or Qualified Start with 2 linked positions), the leader play = level 3 (5 qualifying buyers, up to 5 linked positions; then buy from the main wallet), line banner, links + play chosen (self-marked), first two placed. Reason: unqualified levels pass up, so leaders qualify BEFORE their teams' teams buy. Countdown shows when admin sets launchAt. Never call the site 'pre-launch' publicly: it is live and paying. - SCHEDULING (2026-09-11): any campaign except featured can take an optional start and end time (local time) in the New campaign form; solo ads label it "Send from" (inbox deliveries begin then). A scheduled campaign shows "scheduled" until it starts; at the end it shows "ended" and the unspent budget returns to Available. Banner/text scheduled campaigns join the partner network at their start time. There is NO dayparting (hours-of-day targeting) by design; the daily cap paces budgets. Each campaign row shows a small views-by-hour chart (on-site views, viewer's local time, last 7 days). - BALANCE RULE (members ask this a lot): a balance is what is NOT committed to a live campaign. Starting a campaign sets aside its whole budget at once (earned pool first, then purchased), so Available drops once and stays still while ads serve; the budget spends down inside Members > Campaigns. Dashboard shows Purchased available, Earned available, In live campaigns. A paused campaign keeps its unspent budget set aside so it can resume. A low balance with a live campaign is not lost credits. Refunds/comps of purchased money are credited off-chain as purchased-grade credits: shown under Purchased as "credited to you", fund anything incl. login ads. Viewing-earned credits never fund login ads. diff --git a/coach.js b/coach.js index 775db69..f8aedde 100644 --- a/coach.js +++ b/coach.js @@ -76,6 +76,8 @@ async function coachView(email) { // bound: the contract pays whoever the member registered under. A direct who activated with // no sponsor (or a different one) is in this line on the site but pays this member nothing. c.bound = c.onchainSponsorId == null ? null : (myId > 0 && c.onchainSponsorId === myId); + c.free = !d.memberId; // still free: can be released to the holding tank (pay it forward) + c.address = (!d.memberId && d.address) ? d.address : null; // linked wallet of a free direct: the PIF gift target out.push(Object.assign({ email: d.email, name: d.username ? '@' + d.username : (d.memberId ? 'member #' + d.memberId : d.email.replace(/^(.).*(@.*)$/, '$1***$2')), memberId: d.memberId || 0 }, c)); } // most actionable first: stalled lowest rung, then quiet days diff --git a/db.js b/db.js index 2a6b5c4..42c0833 100644 --- a/db.js +++ b/db.js @@ -139,6 +139,13 @@ async function bootstrap() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); await q(`CREATE TABLE IF NOT EXISTS nudges (email VARCHAR(190) PRIMARY KEY, rung INT NOT NULL, ts BIGINT NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); await q(`CREATE TABLE IF NOT EXISTS digests (email VARCHAR(190) PRIMARY KEY, ts BIGINT NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await q(`CREATE TABLE IF NOT EXISTS adoptions ( + id INT AUTO_INCREMENT PRIMARY KEY, + adoptee VARCHAR(190) NOT NULL, adopter VARCHAR(190) NOT NULL, + ts BIGINT NOT NULL, expires BIGINT NOT NULL, status VARCHAR(12) NOT NULL DEFAULT 'open', + note VARCHAR(600) NULL, closed BIGINT NULL, + INDEX (adoptee), INDEX (adopter), INDEX (status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // holding-tank adoptions await q(`CREATE TABLE IF NOT EXISTS prospects ( id INT AUTO_INCREMENT PRIMARY KEY, owner_email VARCHAR(190) NOT NULL, diff --git a/public/admin.html b/public/admin.html index 71da24f..5bbf109 100644 --- a/public/admin.html +++ b/public/admin.html @@ -226,6 +226,12 @@ ').join(''); + + '' + + (x.free && x.address ? ' ' : '') + + (x.free ? ' ' : '') + '').join(''); + el.querySelectorAll('[data-pif]').forEach(b => b.addEventListener('click', () => pif(b.dataset.pif, b.dataset.pname, b.dataset.paddr))); el.querySelectorAll('[data-nudge]').forEach(b => b.addEventListener('click', async () => { await openConvo(b.dataset.nudge, b.dataset.nname); const inp = $('chatInput'); if (inp) { inp.value = b.dataset.say.replace(/\{\{name\}\}/g, b.dataset.nname.replace(/^@/, '')); inp.focus(); } })); + el.querySelectorAll('[data-release]').forEach(b => b.addEventListener('click', async () => { + if (!confirm('Release ' + b.dataset.rname + ' to the holding tank? You stop being their sponsor and another member can adopt them.')) return; + try { await api('/api/my/tank/release', { email: b.dataset.release }); IAP.status(b.dataset.rname + ' is in the holding tank.', 'ok'); loadCoach(); } + catch (e) { IAP.status(e.message, 'bad'); } + })); } catch (e) {} } // schedule chips on the campaign table (local time) + by-hour view bars diff --git a/public/assets/wallet.js b/public/assets/wallet.js index b4fb3f6..147e553 100644 --- a/public/assets/wallet.js +++ b/public/assets/wallet.js @@ -226,6 +226,22 @@ window.IAPWallet = (function () { } } + // pay it forward: send POL straight from the sponsor's wallet to a downline member's + // linked address. A native transfer, no contract, no site custody: the wallet app + // shows the prefilled recipient and amount and the sponsor confirms there. + async function sendPol(toAddress, valueWei) { + if (!/^0x[0-9a-fA-F]{40}$/.test(String(toAddress || ''))) throw new Error('That member has no wallet address on file yet.'); + const c = await IAP.getConfig(); + const addr = await connect(); + const wantNum = chainNum(c.chainId); + let cur; try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) {} + if (!isNaN(chainNum(cur)) && chainNum(cur) !== wantNum) { await ensureChain(c); } + const tx = { from: await activeAddress(addr), to: toAddress, value: '0x' + BigInt(valueWei).toString(16) }; + try { const g = await (await fetch('/api/gas')).json(); if (g && g.maxPriorityFeePerGas && g.maxFeePerGas) { tx.maxPriorityFeePerGas = g.maxPriorityFeePerGas; tx.maxFeePerGas = g.maxFeePerGas; } } catch (e) {} + try { return await eth().request({ method: 'eth_sendTransaction', params: [tx] }); } + catch (e) { if (!isDead(e)) throw e; tx.from = await freshConnect(c); return eth().request({ method: 'eth_sendTransaction', params: [tx] }); } + } + async function waitTx(hash) { for (let i = 0; i < 90; i++) { try { @@ -264,5 +280,5 @@ window.IAPWallet = (function () { return BigInt(h); } function walletName() { try { const w = modal && modal.getWalletInfo && modal.getWalletInfo(); return (w && w.name) || ''; } catch (e) { return ''; } } - return { connect, signIn, buy, activate, waitTx, disconnect, balance, address: currentAddress, activeAddress, pickAccount, walletName }; + return { connect, signIn, buy, activate, sendPol, waitTx, disconnect, balance, address: currentAddress, activeAddress, pickAccount, walletName }; })(); diff --git a/public/my.html b/public/my.html index 5e35221..94c23f7 100644 --- a/public/my.html +++ b/public/my.html @@ -293,6 +293,13 @@ first package of $20 or more, they count toward your qualification.

Nobody yet. Your link is ready above; share it and this list starts filling.

+
+

Holding tank

members who arrived with no sponsor
+

People who joined without a sponsor wait here. Adopt one and you become their sponsor: a chat opens, they get an email with your message, and their first purchase binds to you on the contract. First come. At most 2 open adoptions at a time, and an adoption goes back to the tank after 7 days if they have not linked a wallet or bought.

+ +
+

Loading…

+

Coach your directs

Where each person you referred sits on the ladder, who has gone quiet, and what to say next. Nudge opens a chat with the message already written; edit it if you like, then send. Quiet members also get one automatic reminder email per step from InstantAdPay, so your nudge is the personal one.

@@ -890,9 +897,9 @@
- + - + diff --git a/server.js b/server.js index 3a411d1..a9b0746 100644 --- a/server.js +++ b/server.js @@ -24,6 +24,7 @@ const spaces = require('./spaces'); // DO Spaces video storage (inert unless DO_ let QR = null; try { QR = require('qrcode'); } catch (e) { /* optional */ } const chatbot = require('./chatbot'); const coach = require('./coach'); // coaching view, nudges, digest, prospects, link stats +const tank = require('./tank'); // holding tank: unsponsored free members, adoptions, pay-it-forward const burner = require('./burner'); // automatic on-chain credit burns (inert without ENGINE_KEY) const PORT = Number(process.env.PORT || 3000); @@ -301,6 +302,8 @@ async function boot() { setInterval(() => ads.scheduleSweep().catch(e => console.error('schedule sweep', e.message)), 5 * 60 * 1000); // scheduled starts/ends // follow-up email sequence: send whatever came due (every 10 min, first pass shortly after boot) coach.init({ dataDir: DATA_DIR, chain, accounts, mailer }); + tank.init({ dataDir: DATA_DIR, chain, accounts, messages, mailer, site: 'https://instantadpay.com' }); + setInterval(() => tank.sweep().catch(e => console.error('tank sweep', e.message)), 60 * 60 * 1000); // adoptions past their 7-day window burner.init({ chain, ads }); setTimeout(() => coach.nudgeTick().catch(e => console.error('coach', e.message)), 90 * 1000); setInterval(() => coach.nudgeTick().catch(e => console.error('coach', e.message)), 60 * 60 * 1000); @@ -980,6 +983,37 @@ const server = http.createServer(async (req, res) => { return json(res, r.error ? 400 : 200, r); } // -- coaching: every direct's ladder rung, stalled flag, and what to say + // -- holding tank: waiting members, my adoptions, adopt, release (pay it forward) + if (p === '/api/my/tank' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + return json(res, 200, await tank.view(s.email)); + } + if (p === '/api/my/tank/adopt' && req.method === 'POST') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const b = await readBody(req); + const r = await tank.adopt(s.email, b.who, b.note); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/my/tank/release' && req.method === 'POST') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const b = await readBody(req); + const r = await tank.release(s.email, b.email); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/my/gift' && req.method === 'POST') { // PIF: log a wallet-to-wallet POL gift and tell the recipient + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const b = await readBody(req); + const r = await tank.recordGift(s.email, b.email, b.tx, b.pol); + return json(res, r.error ? 400 : 200, r); + } + if (p === '/api/admin/tank' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + return json(res, 200, await tank.adminView()); + } if (p === '/api/my/coach' && req.method === 'GET') { const s = await auth.fromRequest(req); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); diff --git a/tank.js b/tank.js new file mode 100644 index 0000000..0d8b778 --- /dev/null +++ b/tank.js @@ -0,0 +1,177 @@ +'use strict'; +// Holding tank (Marty, 2026-09-11): free members who arrived with no sponsor wait +// here, and a member who has bought their own $20+ package can adopt one: +// first come, at most two open adoptions at a time, an adoption falls back into +// the tank after 7 days if the person never linked a wallet or bought, and a +// person can be adopted twice at most before they stay wherever they are. +// Members can also release one of their own free referrals into the tank +// (pay it forward). The contract binds sponsor at first purchase, so every +// hand-off here is a site record until then. +const fs = require('fs'); +const path = require('path'); +const db = require('./db'); + +const CAP_OPEN = 2; // open adoptions per adopter +const TTL_MS = 7 * 86400000; // an adoption's window to convert +const MAX_ADOPTIONS = 2; // per adoptee, lifetime +const MIN_OWN_BUY_CENTS = 2000; // adopter must have bought a $20+ package themselves + +let DATA_DIR = '.', accounts, chain, messages, mailer, siteUrl = 'https://instantadpay.com'; + +const J = { + db: { v: 1, nextId: 1, adoptions: [] }, + FILE: () => path.join(DATA_DIR, 'tank.json'), + load() { try { this.db = Object.assign(this.db, JSON.parse(fs.readFileSync(this.FILE(), 'utf8'))); } catch (e) {} }, + save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }, + async add(a) { const row = Object.assign({ id: this.db.nextId++ }, a); this.db.adoptions.push(row); this.save(); return row; }, + async open(adopter) { return this.db.adoptions.filter(x => x.status === 'open' && (!adopter || x.adopter === adopter)); }, + async countFor(adoptee) { return this.db.adoptions.filter(x => x.adoptee === adoptee && x.status !== 'released').length; }, + async setStatus(id, status) { const x = this.db.adoptions.find(r => r.id === id); if (x) { x.status = status; x.closed = Date.now(); this.save(); } }, + async recent(n) { return this.db.adoptions.slice(-(n || 100)).reverse(); } +}; +const D = { + async add(a) { + const r = await db.q('INSERT INTO adoptions (adoptee,adopter,ts,expires,status,note) VALUES (?,?,?,?,?,?)', [a.adoptee, a.adopter, a.ts, a.expires, a.status, a.note || null]); + return Object.assign({ id: r.insertId }, a); + }, + async open(adopter) { + const rows = adopter ? await db.q("SELECT * FROM adoptions WHERE status='open' AND adopter=?", [adopter]) : await db.q("SELECT * FROM adoptions WHERE status='open'"); + return rows.map(rowA); + }, + async countFor(adoptee) { const r = await db.q("SELECT COUNT(*) n FROM adoptions WHERE adoptee=? AND status<>'released'", [adoptee]); return Number(r[0].n); }, + async setStatus(id, status) { await db.q('UPDATE adoptions SET status=?, closed=? WHERE id=?', [status, Date.now(), Number(id)]); }, + async recent(n) { return (await db.q('SELECT * FROM adoptions ORDER BY id DESC LIMIT ?', [Number(n) || 100])).map(rowA); } +}; +const rowA = r => ({ id: r.id, adoptee: r.adoptee, adopter: r.adopter, ts: Number(r.ts), expires: Number(r.expires), status: r.status, note: r.note || null, closed: r.closed ? Number(r.closed) : null }); +const impl = () => db.enabled() ? D : J; + +function init(opts) { DATA_DIR = opts.dataDir; accounts = opts.accounts; chain = opts.chain; messages = opts.messages; mailer = opts.mailer; if (opts.site) siteUrl = opts.site; J.load(); } + +const mask = e => String(e || '').replace(/^(.).*(@.*)$/, '$1***$2'); +const nameOf = a => a.username ? '@' + a.username : (a.memberId ? 'member #' + a.memberId : mask(a.email)); +const inTank = a => !a.sponsorRef && !a.memberId; // arrived with no sponsor (or fell back), still free + +// waiting list: newest sign-in first so a live one is easy to spot +async function waiting() { + const all = await accounts.listAll(2000); + return all.filter(inTank).map(a => ({ email: a.email, name: nameOf(a), username: a.username || null, joined: a.created, lastSeen: a.lastSeen || 0, wallet: false })) + .sort((a, b) => (b.lastSeen || b.joined) - (a.lastSeen || a.joined)); +} + +// has this member bought a $20+ package themselves? (the qualifying-for-yourself buy) +function hasOwnBuy(memberId) { + if (!memberId) return false; + for (const ev of chain.recentEvents(200000)) if (ev.type === 'Purchase' && ev.buyerId === memberId && Number(ev.priceCents) >= MIN_OWN_BUY_CENTS) return true; + return false; +} +async function eligibility(email) { + const a = await accounts.byEmail(email); + if (!a) return { ok: false, reason: 'Sign in first.' }; + if (!a.memberId) return { ok: false, reason: 'Switch on payouts and buy your first $20 package to adopt from the tank.' }; + if (!hasOwnBuy(a.memberId)) return { ok: false, reason: 'Buy your own $20 or more package first. Adopting is for members who have made that move themselves.' }; + const open = await impl().open(a.email); + if (open.length >= CAP_OPEN) return { ok: false, reason: 'You have ' + CAP_OPEN + ' open adoptions. Help one of them link a wallet or buy, and a slot frees up.', full: true }; + return { ok: true, account: a, open }; +} + +async function view(email) { + const e = String(email || '').toLowerCase(); + const el = await eligibility(e); + const mine = []; + for (const ad of await impl().open(e)) { + const a = await accounts.byEmail(ad.adoptee); + mine.push({ id: ad.id, name: a ? nameOf(a) : mask(ad.adoptee), email: ad.adoptee, ts: ad.ts, expires: ad.expires, lastSeen: a ? (a.lastSeen || 0) : 0, wallet: !!(a && a.address), address: (a && a.address) || null, bought: !!(a && a.memberId) }); + } + return { eligible: el.ok, reason: el.ok ? '' : el.reason, cap: CAP_OPEN, ttlDays: TTL_MS / 86400000, waiting: await waiting(), mine }; +} + +async function adopt(adopterEmail, who, note) { + const e = String(adopterEmail || '').toLowerCase(); + const el = await eligibility(e); + if (!el.ok) return { error: el.reason }; + const me = el.account; + const key = String(who || '').trim().toLowerCase().replace(/^@/, ''); + const list = await waiting(); + const target = list.find(w => (w.username && w.username.toLowerCase() === key) || w.email === key); + if (!target) return { error: 'That member is no longer in the tank.' }; + if (target.email === e) return { error: 'That is you.' }; + if (await impl().countFor(target.email) >= MAX_ADOPTIONS) return { error: 'That member has been adopted twice already and stays where they are.' }; + const token = me.username || me.code || String(me.memberId); + const r = await accounts.setSponsorRef(target.email, token); + if (r.error) return r; + const now = Date.now(); + const text = String(note || '').trim().slice(0, 600) || ('Hi, I am ' + nameOf(me) + '. You joined InstantAdPay without a sponsor, so I picked you up from the holding tank. I will walk you through the first three steps whenever you are ready. Reply here.'); + const ad = await impl().add({ adoptee: target.email, adopter: e, ts: now, expires: now + TTL_MS, status: 'open', note: text }); + try { await messages.sendChat(me.memberId || 0, e, target.email, text); } catch (err) {} + if (mailer && mailer.hasKey()) { + try { + await mailer.send(target.email, nameOf(me) + ' is now your sponsor on InstantAdPay', + 'You joined InstantAdPay without a sponsor. ' + nameOf(me) + ' has picked you up from the holding tank and is your sponsor now, which means a real person to walk you through the first steps.\n\nTheir message:\n\n' + text + '\n\nReply in your member area: ' + siteUrl + '/my#messages\n\nInstantAdPay'); + } catch (err) {} + } + return { ok: true, adoption: ad, name: target.name }; +} + +// pay it forward: give one of your own free referrals to the tank +async function release(ownerEmail, directEmail) { + const o = String(ownerEmail || '').toLowerCase(), d = String(directEmail || '').toLowerCase(); + const owner = await accounts.byEmail(o), direct = await accounts.byEmail(d); + if (!owner || !direct) return { error: 'No such member.' }; + const toks = [owner.code, owner.username, owner.memberId ? String(owner.memberId) : null].filter(Boolean).map(String); + if (!toks.includes(String(direct.sponsorRef || ''))) return { error: 'That member is not in your line.' }; + if (direct.memberId) return { error: 'That member has already bought; on-chain sponsorship cannot move.' }; + const r = await accounts.setSponsorRef(d, ''); + if (r.error) return r; + // an open adoption of this person closes as released (it no longer counts toward their two) + let closed = 0; + for (const ad of await impl().open(null)) if (ad.adoptee === d) { await impl().setStatus(ad.id, 'released'); closed++; } + if (!closed) await impl().add({ adoptee: d, adopter: o, ts: Date.now(), expires: Date.now(), status: 'released', note: 'released to the tank' }); + return { ok: true }; +} + +// open adoptions past their window: converted ones close as done; the rest fall +// back into the tank unless the person has been adopted twice already +async function sweep() { + const now = Date.now(); + let done = 0, back = 0, kept = 0; + for (const ad of await impl().open(null)) { + if (ad.expires > now) continue; + const a = await accounts.byEmail(ad.adoptee); + if (!a) { await impl().setStatus(ad.id, 'expired'); continue; } + if (a.address || a.memberId) { await impl().setStatus(ad.id, 'done'); done++; continue; } + if (await impl().countFor(ad.adoptee) >= MAX_ADOPTIONS) { await impl().setStatus(ad.id, 'expired'); kept++; continue; } + await accounts.setSponsorRef(ad.adoptee, ''); + await impl().setStatus(ad.id, 'expired'); back++; + } + return { done, back, kept }; +} + +// pay-it-forward gift record: the sponsor already sent POL wallet-to-wallet; we +// only log the hash and tell the recipient. Recipient must be in the giver's line. +async function recordGift(fromEmail, toEmail, tx, pol) { + const f = String(fromEmail || '').toLowerCase(), t = String(toEmail || '').toLowerCase(); + const giver = await accounts.byEmail(f), to = await accounts.byEmail(t); + if (!giver || !to) return { error: 'No such member.' }; + const toks = [giver.code, giver.username, giver.memberId ? String(giver.memberId) : null].filter(Boolean).map(String); + const adopted = (await impl().open(f)).some(ad => ad.adoptee === t); + if (!toks.includes(String(to.sponsorRef || '')) && !adopted) return { error: 'That member is not in your line.' }; + if (!/^0x[0-9a-fA-F]{64}$/.test(String(tx || ''))) return { error: 'Transaction hash missing.' }; + const amount = Number(pol) || 0; + const row = { adoptee: t, adopter: f, ts: Date.now(), expires: Date.now(), status: 'gift', note: 'PIF ' + amount + ' POL ' + tx }; + await impl().add(row); + const cc = chain.getConfig ? chain.getConfig() : {}; + const link = ((cc.explorer || 'https://polygonscan.com').replace(/\/+$/, '')) + '/tx/' + tx; + const text = nameOf(giver) + ' just sent ' + amount + ' POL to your wallet so you can buy your first package. It is already there: open Buy packages when you are ready. Proof: ' + link; + try { await messages.sendChat(giver.memberId || 0, f, t, text); } catch (e) {} + if (mailer && mailer.hasKey()) { try { await mailer.send(t, nameOf(giver) + ' sent you POL for your first InstantAdPay package', text + '\n\n' + siteUrl + '/my#buy'); } catch (e) {} } + return { ok: true }; +} + +async function adminView() { + const recent = await impl().recent(200); + const names = {}; + for (const ad of recent) for (const em of [ad.adoptee, ad.adopter]) if (!(em in names)) { const a = await accounts.byEmail(em); names[em] = a ? nameOf(a) : em; } + return { waiting: await waiting(), adoptions: recent.map(ad => Object.assign({}, ad, { adopteeName: names[ad.adoptee], adopterName: names[ad.adopter] })), cap: CAP_OPEN, ttlDays: TTL_MS / 86400000 }; +} + +module.exports = { init, view, adopt, release, sweep, adminView, waiting, hasOwnBuy, recordGift, CAP_OPEN, TTL_MS };