diff --git a/feedgroup.js b/feedgroup.js
new file mode 100644
index 0000000..2413e7a
--- /dev/null
+++ b/feedgroup.js
@@ -0,0 +1,39 @@
+// One proof-feed message per purchase (Marty, 2026-09-25).
+//
+// The feed used to post each tier payout on its own line and, in payouts mode, never said who
+// bought what. Marty: "I see the level 1 payout, I see a level 2 payout, but I don't see who
+// purchased something and what they bought." So: the buyer and the package first, then the
+// level payouts under it, then any pass-ups, as ONE message. It composes from every event of
+// the transaction, which is why the caller triggers it on AdminPaid, the last event the contract
+// emits for a purchase: by then Purchase, TierPaid and PassedUp of that tx are all indexed.
+//
+// Pure: events in, HTML string out. Tested against real index rows before it shipped.
+'use strict';
+
+const PCT = { 1: 50, 2: 20, 3: 10 };
+
+function usd(cents) { return ('$' + (cents / 100).toFixed(2)).replace(/\.00$/, ''); }
+
+// txEvents: every indexed event with this tx. who(id) -> "#12 @name". weiToPol(wei) -> "1.23".
+// Returns null when there is no Purchase in the tx (not a package purchase) or when
+// `requirePayout` is set and no member was paid (a root or unsponsored buyer: nothing to prove).
+function compose(txEvents, who, weiToPol, opts) {
+ opts = opts || {};
+ const pur = txEvents.find(e => e.type === 'Purchase');
+ if (!pur) return null;
+ const tiers = txEvents.filter(e => e.type === 'TierPaid').sort((a, b) => (a.tier || 0) - (b.tier || 0) || (a.li || 0) - (b.li || 0));
+ if (opts.requirePayout && !tiers.length) return null;
+ const lines = ['\u{1F9FE} ' + who(pur.buyerId) + ' bought a ' + usd(pur.priceCents) + ' package'];
+ for (const t of tiers) {
+ const pct = PCT[t.tier];
+ const about = pct ? ' (' + usd(Math.round(pur.priceCents * pct / 100)) + ')' : '';
+ lines.push('\u{1F4B8} Level ' + t.tier + ': ' + weiToPol(t.amountWei) + ' POL' + about + ' → ' + who(t.recipientId)
+ + (t.hops > 0 ? ' (passed up ' + t.hops + ')' : ''));
+ }
+ if (!tiers.length) lines.push('No member payout on this one: the buyer has nobody above them to pay.');
+ const passed = txEvents.filter(e => e.type === 'PassedUp');
+ for (const p of passed) if (p.skippedId) lines.push('⏭ Level ' + (p.tier || '?') + ' skipped ' + who(p.skippedId) + ' (not yet qualified)');
+ return lines.join('\n');
+}
+
+module.exports = { compose };
diff --git a/server.js b/server.js
index b345b53..754424a 100644
--- a/server.js
+++ b/server.js
@@ -37,7 +37,8 @@ const releases = require('./releases');
const ledger = require('./ledger');
const videosweep = require('./videosweep');
const sbcheck = require('./sbcheck');
-const marks = require('./marks'); // one-time claims shared across running instances (deploy overlap) // Google Safe Browsing screen on advertiser destinations
+const marks = require('./marks');
+const feedgroup = require('./feedgroup'); // one proof-feed message per purchase: buyer, package, then the tier payouts // one-time claims shared across running instances (deploy overlap) // Google Safe Browsing screen on advertiser destinations
const updates = require('./updates');
const audit = require('./audit'); // counter audit: views vs delivery logs, charges vs shows (Marty, 2026-09-15) // member update emails from Admin > Releases (Marty, 2026-09-14)
const leaderboard = require('./leaderboard');
@@ -1072,11 +1073,19 @@ async function telegramOnEvent(ev) {
const who = id => '#' + id + (names[id] ? ' @' + names[id] : '');
const cc = chain.getConfig();
const tx = (cc.explorer ? cc.explorer.replace(/\/+$/, '') : 'https://polygonscan.com') + '/tx/' + ev.tx;
- const build = (mode) => {
+ const build = async (mode) => {
let line = null;
- if (ev.type === 'TierPaid') line = '\u{1F4B8} Level ' + ev.tier + ' payout: ' + weiToPol(ev.amountWei) + ' POL \u2192 ' + who(ev.recipientId);
+ // Tier payouts and the purchase itself are no longer posted one by one: they are composed
+ // into ONE message on AdminPaid, the last event of every purchase tx, so the feed reads
+ // "who bought what, then who was paid" (Marty, 2026-09-25). In payouts mode a purchase that
+ // paid no member (root or unsponsored buyer) stays silent, as before.
+ if (ev.type === 'AdminPaid') {
+ const txEvents = chain.recentEvents(1e9).filter(x => x.tx === ev.tx);
+ const ids = new Set(); txEvents.forEach(x => { for (const k of ['buyerId', 'recipientId', 'skippedId']) if (x[k]) ids.add(x[k]); });
+ const nm = await accounts.namesForMembers([...ids]).catch(() => ({}));
+ line = feedgroup.compose(txEvents, id => '#' + id + (nm[id] ? ' @' + nm[id] : ''), weiToPol, { requirePayout: mode === 'payouts' });
+ }
else if (ev.type === 'BuyerCounted' && mode !== 'payouts') line = '\u2B50 ' + who(ev.sponsorId) + ' now has ' + ev.newCount + ' qualifying buyer' + (ev.newCount === 1 ? '' : 's') + (ev.newCount === 2 ? ' \u00b7 level 2 open' : ev.newCount === 5 ? ' \u00b7 level 3 open' : '');
- else if (ev.type === 'Purchase' && mode !== 'payouts') line = '\u{1F9FE} ' + who(ev.buyerId) + ' bought a $' + Math.round(ev.priceCents / 100) + ' package';
else if (ev.type === 'AwardPaid') line = '\u{1F4B8} Award payout: ' + weiToPol(ev.amountWei) + ' POL \u2192 ' + who(ev.toId);
else if (ev.type === 'MemberActivated' && mode === 'all') line = '\u{1F91D} ' + who(ev.id) + ' switched on payouts';
if (!line) return null;
@@ -1112,8 +1121,10 @@ async function telegramOnEvent(ev) {
}
}
} catch (e) { console.error('missed-payout post', e.message); }
- if (sc.telegramChatId) { const t = build(String(sc.telegramEvents || 'payouts')); if (t) await telegramSend(sc.telegramChatId, t, sc.telegramTopicId); if (missed && String(sc.telegramEvents || 'payouts') !== 'none') await telegramSend(sc.telegramChatId, missed, sc.telegramTopicId).catch(() => {}); }
- if (sc.telegramEchoChatId) { const t = build(String(sc.telegramEchoEvents || 'payouts')); if (t) await telegramSend(sc.telegramEchoChatId, '\u{1F7E0} InstantAdPay \u00b7 ' + t, sc.telegramEchoTopicId); }
+ // the main group used to get bare payout lines with no product name: nobody knew what they were from
+ const brand = t => '\u{1F7E0} InstantAdPay\n' + t;
+ if (sc.telegramChatId) { const t = await build(String(sc.telegramEvents || 'payouts')); if (t) await telegramSend(sc.telegramChatId, brand(t), sc.telegramTopicId); if (missed && String(sc.telegramEvents || 'payouts') !== 'none') await telegramSend(sc.telegramChatId, missed, sc.telegramTopicId).catch(() => {}); }
+ if (sc.telegramEchoChatId) { const t = await build(String(sc.telegramEchoEvents || 'payouts')); if (t) await telegramSend(sc.telegramEchoChatId, brand(t), sc.telegramEchoTopicId); }
if (missed && sc.telegramEchoChatId && String(sc.telegramEchoEvents || 'payouts') !== 'none') await telegramSend(sc.telegramEchoChatId, '\u{1F7E0} InstantAdPay \u00b7 ' + missed, sc.telegramEchoTopicId).catch(() => {});
}
// holding-tank arrivals -> one digest line in the shared payments topic (Marty, 2026-09-12): who is