From 4487a90d16d8feb9cc4240b6dab7c0952fd0ea1c Mon Sep 17 00:00:00 2001 From: martbost Date: Thu, 10 Sep 2026 11:16:40 -0500 Subject: [PATCH] Balances: one reservation rule (earned first) + purchased-grade credited pool - balances(): live campaign budgets are set aside up front, earned pool first then purchased, so dashboard numbers never move per impression; new 'in campaigns' line - earned pool gains a purchase_grade portion for refunds/comps: shows under Purchased as 'credited to you' and funds login ads (daily login charge draws on it first) - viewing credits always spend before credited purchased money - dashboard/donut/spend banner/earn pane/positions show purchased, credited, earned, in campaigns - chatbot canned answer + AI prompt: the balance rule Co-Authored-By: Claude Fable 5.1 --- ads.js | Bin 70846 -> 76731 bytes chatbot.js | 3 +++ db.js | 1 + public/assets/my.js | 33 ++++++++++++++++++++------------- public/my.html | 1 + server.js | 32 ++++++++++++++++++++------------ 6 files changed, 45 insertions(+), 25 deletions(-) diff --git a/ads.js b/ads.js index 4653de4a16d76297b803e6c65f2a9f5b221b8a0c..849beac9688096c5464e949ab29e5e62983ded77 100644 GIT binary patch delta 5764 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 delta 989 zcmZ`%T}V_x6lTQ|Ei0v!%*>NmyEix8D9~KYN)0OdAtL%^o4qsZUb=hdHZ!~1&F$wQ zdI+NEfJD?oFNHxSED;n~QN4swMAkzOJ@r^mSp-7O?yCLI(_zk;?|eUV|842WVCnFa zo*d~Nsl~A?RlY;Ga-SaqWIlc)RWmCfe{gp+I#q6sHIyq(iFeS5RG`@3h1uDaTY6cYsiQnD*nNfvtG9zYRCLOMPK< zT?3b77vhoZGQ6Bsx$fi7g81xuc0mV_46zh2A{<6H5SutA3o-+5U-IX!-Z;~MC8O1| zi7B|0z|u^32%}3+LNG}z2bwEdTfw%R!;FH!FOn@Z|qugOYz$8hRSba`&{(91Ty$FJg z=+UVz#I(f{;({kN9uAGJX`f~nkPs`1G%ASn922IEb2+ye?G}t$N(L<|4d}=BFPGus z=f*l+7xJ^20+J?{4V@5Krzu=auJ`hrne* z`&@(XUN>Rs*oG=MCA281-RLaFg5RIk;is|Xz5fb{XB;LB=*aWXwiOf9Lb=_cHA<#) zVG!pcNt6Wc*dCsEvo~z&gmm2I)QUfnFM>wEifdC%SPOWeaXwDG*-_a7dwEJg0Gjpd zT>sl$^=-fM&BwTjLf7Pf`pbl3zd#pz#)Aw0uL#rQM|0I*Zmz2Q?Ni>JFnkYuKLFS8 BY^VSL diff --git a/chatbot.js b/chatbot.js index c998dfd..b62c2a7 100644 --- a/chatbot.js +++ b/chatbot.js @@ -27,6 +27,8 @@ const CANNED = [ a: 'There are no withdrawals here, ever. The contract pays your share straight to your own wallet in the same transaction as the purchase. Nothing to request, nothing to wait on.' }, { re: /(how (much|do i) earn|commission|percent|split)/i, a: 'Every package splits the same way: 50 percent to the direct sponsor, 20 percent to level 2, 10 percent to level 3, 20 percent to the platform. Those numbers are constants in the contract and cannot be changed. Play with scenarios on the home page calculator. No income is promised; nobody earns unless real ad buying happens.' }, + { re: /(balance (went|going|keeps going) down|credits? (disappear|missing|gone|dropping|went down|ticking)|why (is|did) my (balance|credits)|in campaigns|set aside|reserved credits|available to spend)/i, + a: 'One rule: your balance is what is NOT committed to a live campaign. When you start a campaign its whole budget is set aside right then (earned credits first, then purchased), so your Available number drops once and then sits still while the ad runs. The budget spends down inside that campaign in Members > Campaigns, where you can see delivered views, network views and what is left. The dashboard shows Purchased available, Earned available and In live campaigns as three separate numbers; a paused campaign keeps its unspent budget set aside so it can resume. Nothing is lost when a balance reads low while a campaign is live. Purchased credits live on the blockchain; if we ever refund or credit purchased money it shows under Purchased as "credited to you" and spends like a purchase, login ads included. Earned credits fund every format except login ads.' }, { re: /(qualif|unlock level|level 2|level 3|pass.?up)/i, a: 'Level 1 is open to every member. Bring 2 buyers of $20 or more and level 2 unlocks; 5 unlock level 3. When a level is not qualified, its share climbs the sponsor line, checking up to 25 positions, and pays the first qualified person. Qualification never expires and cannot be bought. Qualified Start: you may link extra wallets of your own as positions under your account (Buy packages > Qualified Start); each one that buys a $20+ package counts as a qualifying buyer, its credits pool with yours, and 50% of its purchase comes back to your main wallet. Your own money, your own wallets, a faster start, never an income promise.' }, { re: /(need (a )?wallet|crypto experience|metamask|how (do i|to) join|sign ?up|register)/i, @@ -68,6 +70,7 @@ FACTS: - LINE BANNER (free, set in Profile): every member can set a destination URL (must allow framing) plus an optional banner image. It is shown to their next THREE levels of new members during welcome tours (position 1 for directs, 2, 3 below), and on their public BANNER WALL at /wall/ — a shareable page showing their line ladder with their join link. Free viral traffic that compounds as the team grows; no credits spent. - 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. +- 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. - Members EARN credits by attention: in the Earn credits section of Members, each ad in the daily set opens FULL SCREEN in its own tab, showing the advertiser's real website. A countdown runs while you watch (it pauses if you leave the tab), then a quick human check (click the named icon) must be passed before the view counts. Finish the daily set, claim a small daily credit batch. Earned credits spend on banner and text campaigns; attention earns advertising, referrals earn money, and viewer rewards are never cash. Advertisers get real, verified visits to their site. - Onsite SOLO ADS are live: a solo ad is a full message (subject + up to 2000 characters of formatted text + your link) delivered into members' on-site Inbox (Members > Inbox). The composer in Campaigns > Solo ad has a rich-text editor (bold, headings, lists, links), lets you ATTACH one image (PNG/JPG/WebP/GIF, up to 3MB) or one video (MP4/WebM, up to 25MB), and adds a call-to-action button with a custom label that opens the target URL. Cost 5 credits per guaranteed delivery, minimum 10 deliveries (50 credits). Each member receives a given solo at most once, and never the sender's own. Readers earn 2 credits per real read (10-second dwell on the open message, up to 5 rewarded reads/day) — claimed right from the message. Compose one in Campaigns > Solo ad. - Campaign target URLs are checked the moment they are submitted: the page must be reachable and must ALLOW framing (no X-Frame-Options deny/sameorigin, no blocking CSP frame-ancestors), because surf views show the real site full screen. Frame-blocking or dead URLs are rejected with the exact reason; the fix is a landing page that allows framing. Login-ad targets skip the frame check (they are click-through only). diff --git a/db.js b/db.js index 14b3279..939daec 100644 --- a/db.js +++ b/db.js @@ -72,6 +72,7 @@ async function bootstrap() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); const alterSafe0 = async sql => { try { await q(sql); } catch (e) { if (!['ER_DUP_FIELDNAME', 'ER_DUP_KEYNAME'].includes(e.code)) throw e; } }; await alterSafe0('ALTER TABLE earned_credits ADD COLUMN milestones VARCHAR(255) NULL'); // comma-joined granted milestone keys + await alterSafe0('ALTER TABLE earned_credits ADD COLUMN purchase_grade INT NOT NULL DEFAULT 0'); // part of the pool that is refunded/credited PURCHASED money: shows as purchased, funds login ads await alterSafe0('ALTER TABLE earned_credits ADD COLUMN login_day CHAR(10) NULL'); // last daily-login-bonus day await alterSafe0('ALTER TABLE earned_credits ADD COLUMN login_streak INT NOT NULL DEFAULT 0'); // consecutive-day streak // additive columns (MySQL 8 has no IF NOT EXISTS for columns) diff --git a/public/assets/my.js b/public/assets/my.js index dbd4c0c..a9090d9 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -192,16 +192,19 @@ try { const st = await (await fetch('/api/my/earn')).json(); if (!st.error) { - const purchased = d.credits || 0, earned = st.earned || 0, wc = Math.min(d.welcomeCredits || 0, earned); + // one rule: a balance is what is NOT committed to a live campaign. Budgets are + // set aside when a campaign starts and spend down inside Campaigns, so these + // numbers only move when a campaign is created, topped up or paused + const purchased = d.credits || 0, earned = d.earnedCredits != null ? d.earnedCredits : (st.earnedAvailable != null ? st.earnedAvailable : (st.earned || 0)), inC = d.inCampaigns || 0; $('dbCredits').textContent = (purchased + earned).toLocaleString(); - $('dbCreditsSub').textContent = purchased.toLocaleString() + ' purchased · ' + earned.toLocaleString() + ' earned'; + $('dbCreditsSub').textContent = purchased.toLocaleString() + ' purchased' + (d.creditedCredits ? ' (' + d.creditedCredits.toLocaleString() + ' credited to you)' : '') + ' · ' + earned.toLocaleString() + ' earned' + (inC ? ' · ' + inC.toLocaleString() + ' in campaigns' : ''); donut($('chDonut'), - [{ v: purchased, color: CH.mint }, { v: wc, color: CH.amber }, { v: earned - wc, color: CH.cyan }], - { big: (purchased + earned).toLocaleString(), small: 'spendable' }); + [{ v: purchased, color: CH.mint }, { v: earned, color: CH.cyan }, { v: inC, color: CH.amber }], + { big: (purchased + earned).toLocaleString(), small: 'available' }); legend($('chDonutLegend'), [ - { color: CH.mint, label: 'Purchased', v: purchased.toLocaleString() }, - { color: CH.amber, label: 'Welcome', v: wc.toLocaleString() }, - { color: CH.cyan, label: 'Earned and credited', v: Math.max(0, earned - wc).toLocaleString() }]); + { color: CH.mint, label: 'Purchased, available', v: purchased.toLocaleString() }, + { color: CH.cyan, label: 'Earned, available', v: earned.toLocaleString() }, + { color: CH.amber, label: 'In live campaigns', v: inC.toLocaleString() }]); const done = Math.min(st.views || 0, st.target || 5), left = Math.max(0, (st.target || 5) - done); donut($('chRing'), [{ v: done, color: st.claimed ? CH.mint : CH.cyan }, { v: left, color: 'none' }], { big: done + '/' + (st.target || 5), small: st.claimed ? 'claimed' : 'ads viewed' }); @@ -357,9 +360,9 @@ } }).catch(() => {}); } - const wc = d.welcomeCredits || 0; - $('dbCredits').textContent = ((d.credits || 0) + wc).toLocaleString(); - $('dbCreditsSub').textContent = wc ? (d.credits || 0).toLocaleString() + ' purchased · ' + wc + ' welcome' : ''; + const earnedAv = d.earnedCredits || 0, inCamp = d.inCampaigns || 0; + $('dbCredits').textContent = ((d.credits || 0) + earnedAv).toLocaleString(); + $('dbCreditsSub').textContent = (d.credits || 0).toLocaleString() + ' purchased' + (d.creditedCredits ? ' (' + d.creditedCredits.toLocaleString() + ' credited to you)' : '') + ' · ' + earnedAv.toLocaleString() + ' earned' + (inCamp ? ' · ' + inCamp.toLocaleString() + ' in campaigns' : ''); $('dbEarned').textContent = IAP.fmtPol(d.earnedWei || '0'); $('dbBuyers').textContent = d.buyerCount || 0; $('dbTeam').textContent = (d.referrals || []).length; @@ -591,11 +594,15 @@ if ($('spendBanner')) { $('spendBanner').hidden = false; $('spendBig').textContent = r.availableCredits.toLocaleString(); - $('spendSub').textContent = '= $' + (r.availableCredits / 100).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' of ad delivery' + (r.earnedCredits ? ' · ' + r.purchasedCredits.toLocaleString() + ' purchased + ' + r.earnedCredits.toLocaleString() + ' earned by viewing' : '') + (r.positionCount > 1 ? ' · pooled across ' + r.positionCount + ' positions (largest single position ' + (r.largestPosition || 0).toLocaleString() + ')' : ''); + $('spendSub').textContent = '= $' + (r.availableCredits / 100).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' of ad delivery · ' + r.purchasedCredits.toLocaleString() + ' purchased' + (r.creditedCredits ? ' (' + r.creditedCredits.toLocaleString() + ' of it credited to you, spends on anything)' : '') + ' + ' + (r.earnedCredits || 0).toLocaleString() + ' earned' + (r.positionCount > 1 ? ' · pooled across ' + r.positionCount + ' positions (largest single position ' + (r.largestPosition || 0).toLocaleString() + ')' : ''); + if ($('spendNote')) $('spendNote').textContent = r.inCampaigns + ? 'Not counting ' + r.inCampaigns.toLocaleString() + ' credits already set aside for your live campaigns. That budget spends down inside each campaign below. This number only moves when you start, top up or pause a campaign.' + : 'This is what is not committed to a campaign. When you start one, its budget moves out of here and spends down inside the campaign.'; $('spendRates').innerHTML = [['Banner', r.rates.bannerCreditsPerBatch + ' cr / ' + r.rates.bannerBatch + ' views'], ['Text', r.rates.textCreditsPerBatch + ' cr / ' + r.rates.textBatch + ' views'], ['Login', r.rates.loginCreditsPerDay + ' cr / day'], ['Solo', r.rates.soloCostPerRecipient + ' cr / delivery'], ['Featured', (r.rates.featuredPerDay || 40) + ' cr / day'], ['Visit', (r.rates.visitCostPerVisit || 3) + ' cr / visit']].map(x => '' + x[0] + ' ' + x[1] + '').join(''); } $('rateLine').textContent = 'Available to spend: ' + r.availableCredits.toLocaleString() + (r.earnedCredits ? ' (' + r.purchasedCredits.toLocaleString() + ' purchased + ' + r.earnedCredits + ' earned)' : '') + + (r.inCampaigns ? ' · ' + r.inCampaigns.toLocaleString() + ' set aside in live campaigns' : '') + ' credits · rates: banner ' + r.rates.bannerCreditsPerBatch + 'cr/' + r.rates.bannerBatch + ' views, text ' + r.rates.textCreditsPerBatch + 'cr/' + r.rates.textBatch + ' views, login ' + r.rates.loginCreditsPerDay + 'cr/day, solo ' @@ -1361,7 +1368,7 @@ + '' + (r.main.memberId ? '#' + r.main.memberId : 'payouts not on yet') + '' + '' + (r.main.buyerCount || 0) + ' qualifying buyer(s)' + '' + (r.main.credits || 0).toLocaleString() + ' credits' + (r.main.balanceWei != null ? ' · ' + (Number(BigInt(r.main.balanceWei) / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' POL' : '') + '' : ''; - const html = mainRow + rows + (list.length ? '

Pooled credits: ' + (r.totalCredits || 0).toLocaleString() + '. A campaign budget spends from one position at a time.

' : ''); + const html = mainRow + rows + (list.length ? '

Pooled credits: ' + (r.totalCredits || 0).toLocaleString() + '' + (r.credited ? ' plus ' + r.credited.toLocaleString() + ' credited to your account (spends from any position)' : '') + '. A campaign budget spends from one position at a time.

' : ''); if ($('qsList')) $('qsList').innerHTML = html; // Wallet tab: live POL balance of the linked wallet, and which package it covers const wb = $('walletBal'); @@ -1500,7 +1507,7 @@ const st = await (await fetch('/api/my/earn')).json(); if (st.error) return null; $('earnProgress').textContent = 'today: ' + st.views + ' / ' + st.target + ' ads viewed'; - $('earnBalance').textContent = st.earned + ' earned credits'; + $('earnBalance').textContent = (st.earnedAvailable != null ? st.earnedAvailable : st.earned) + ' earned credits available' + (st.reserved ? ' · ' + st.reserved + ' in live campaigns' : ''); const done = st.views >= st.target; $('earnClaimBtn').hidden = !(done && !st.claimed); if (st.claimed) { diff --git a/public/my.html b/public/my.html index 1bad24d..6dd2353 100644 --- a/public/my.html +++ b/public/my.html @@ -399,6 +399,7 @@
Available to spend
0 credits
+
diff --git a/server.js b/server.js index faa5bde..11d22d7 100644 --- a/server.js +++ b/server.js @@ -782,7 +782,8 @@ const server = http.createServer(async (req, res) => { const mm = await chain.member(memberId); out.buyerCount = mm.buyerCount; out.onchainSponsorId = mm.sponsorId; - out.credits = (await ads.pooledCredits((await myMemberIds(s)).ids)).total; + const bal = await ads.balances((await myMemberIds(s)).ids, s.email); + out.credits = bal.total; out.creditedCredits = bal.credited; out.earnedCredits = bal.earned; out.inCampaigns = bal.inCampaigns; out.availableCredits = bal.available; } catch (e) { out.chainReadError = true; } } return json(res, 200, out); @@ -831,7 +832,8 @@ const server = http.createServer(async (req, res) => { try { const mm = await chain.member(memberId); out.buyerCount = mm.buyerCount; - out.credits = (await ads.pooledCredits((await myMemberIds(s)).ids)).total; + const bal = await ads.balances((await myMemberIds(s)).ids, s.email); + out.credits = bal.total; out.creditedCredits = bal.credited; out.earnedCredits = bal.earned; out.inCampaigns = bal.inCampaigns; out.availableCredits = bal.available; } catch (e) { out.chainReadError = true; } let earned = 0n, n = 0; for (const ev of chain.recentEvents(600)) { @@ -878,19 +880,21 @@ const server = http.createServer(async (req, res) => { const mainId = await auth.refreshMemberId(s); const list = await accounts.positions(s.email); const out = []; + const posIds = [mainId, ...list.map(p => p.memberId)].filter(Boolean); + let balPer = {}, credited = 0; try { const bb = await ads.balances(posIds, s.email); credited = bb.credited; for (const p of bb.per) balPer[p.memberId] = p.avail; } catch (e) {} for (const pos of list) { let id = pos.memberId; if (!id) { try { id = await chain.memberIdByAccount(pos.address); if (id) await accounts.setPositionMember(pos.address, id); } catch (e) {} } const row = { address: pos.address, memberId: id || 0, buyerCount: 0, counted: false, credits: 0, created: pos.created }; if (id) { try { const mm = await chain.member(id); row.buyerCount = mm.buyerCount; row.counted = mm.countedAsBuyer; row.sponsorId = mm.sponsorId; } catch (e) {} - try { row.credits = await ads.availableCredits(id); } catch (e) {} + row.credits = balPer[id] != null ? balPer[id] : 0; } out.push(row); } const main = { address: (acct && acct.address) || null, memberId: mainId, credits: 0, buyerCount: 0 }; if (mainId) { - try { main.credits = await ads.availableCredits(mainId); } catch (e) {} + main.credits = balPer[mainId] != null ? balPer[mainId] : 0; try { main.buyerCount = (await chain.member(mainId)).buyerCount; } catch (e) {} } // live POL balances (the link signature proved the wallet is theirs) + a POL/USD rate from the catalog @@ -898,7 +902,7 @@ const server = http.createServer(async (req, res) => { if (main.address) main.balanceWei = await bal(main.address); for (const row of out) row.balanceWei = await bal(row.address); let polUsd = 0; try { const cat = await chain.catalog(); const pk = cat.find(x => x.costWei); if (pk) polUsd = (pk.priceCents / 100) / (Number(BigInt(pk.costWei)) / 1e18); } catch (e) {} - return json(res, 200, { main, positions: out, polUsd, totalCredits: main.credits + out.reduce((n, r) => n + r.credits, 0) }); + return json(res, 200, { main, positions: out, polUsd, totalCredits: main.credits + out.reduce((n, r) => n + r.credits, 0), credited }); } if (p === '/api/my/positions/remove' && req.method === 'POST') { const s = await auth.fromRequest(req); @@ -1554,12 +1558,15 @@ const server = http.createServer(async (req, res) => { const memberId = await auth.refreshMemberId(s); const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates(), bannerSizes: ads.bannerSizes() }; out.clickSources = await coach.clickSources(out.campaigns.map(c => c.id)); - const pool = await ads.pooledCredits((await myMemberIds(s)).ids); + const pool = await ads.balances((await myMemberIds(s)).ids, s.email); out.purchasedCredits = pool.total; out.largestPosition = pool.best.avail; // a single campaign budget has to fit one position out.positionCount = pool.per.length; - out.earnedCredits = await ads.earnedBalance(s.email); - out.availableCredits = out.purchasedCredits + out.earnedCredits; + out.creditedCredits = pool.credited; // refunded/credited purchased money, free (counts inside purchasedCredits) + out.earnedCredits = pool.earned; // earned pool minus what live campaigns already hold + out.earnedReserved = pool.earnedReserved; + out.inCampaigns = pool.inCampaigns; // budget still to deliver across live campaigns + out.availableCredits = pool.available; return json(res, 200, out); } if (p === '/api/my/campaigns' && req.method === 'POST') { @@ -1573,13 +1580,14 @@ const server = http.createServer(async (req, res) => { } // charge the best-funded of the member's positions (main + Qualified Start // wallets). A campaign burns from one member id, so the budget must fit inside it. - const pool = await ads.pooledCredits((await myMemberIds(s)).ids); + const ids = (await myMemberIds(s)).ids; + const pool = await ads.balances(ids, s.email); const fundId = pool.best.memberId || memberId; - const earnedNow = String(b.type) !== 'login' ? await ads.earnedBalance(s.email) : 0; + const earnedNow = String(b.type) !== 'login' ? pool.earned : 0; const budget = Math.floor(Number(b.budget) || 0); - if (pool.per.length > 1 && budget > pool.best.avail + earnedNow && budget <= pool.total + earnedNow) + if (pool.per.length > 1 && budget > pool.best.avail + pool.credited + earnedNow && budget <= pool.total + earnedNow) return json(res, 400, { error: 'Your credits are spread across ' + pool.per.length + ' positions and one campaign spends from one of them. The largest single position holds ' + pool.best.avail + ' credits: set the budget to that or less, or run two campaigns.' }); - const r = await ads.createCampaign(s.email, fundId, b); + const r = await ads.createCampaign(s.email, fundId, b, ids); return json(res, r.error ? 400 : 200, r); } m = /^\/api\/my\/campaigns\/(\d+)\/topup$/.exec(p);