[object Object]

← back to Wallco Ai

marketplace: admin payout CSV export

bd4941394d037f4880023781fb5190819c4bc592 · 2026-05-13 14:51:39 -0700 · SteveStudio2

POST /api/marketplace/admin/payouts/export bundles payable mp_commission_ledger
rows into a new mp_payout_batches row, writes
data/marketplace/payouts/<batch_label>.csv with columns
designer_id,designer_email,payout_method,total_amount,memo, then flips ledger
rows to status='paid' with paid_at=now() and payout_batch_id set. Batch list
endpoint + auth-gated CSV download by batch id. Export CSV button in the
marketplace admin calls the new endpoint and triggers download of the generated
CSV; payout batch history table appended below the payables.

Pure helpers (csvEscape/rowsToCsv/buildBatchLabel/aggregateByDesigner) live in
src/marketplace/payouts.js and are covered by tests/marketplace/payouts.test.js
(12 assertions). Smoke tests assert all 3 new admin endpoints reject anonymous.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit bd4941394d037f4880023781fb5190819c4bc592
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 14:51:39 2026 -0700

    marketplace: admin payout CSV export
    
    POST /api/marketplace/admin/payouts/export bundles payable mp_commission_ledger
    rows into a new mp_payout_batches row, writes
    data/marketplace/payouts/<batch_label>.csv with columns
    designer_id,designer_email,payout_method,total_amount,memo, then flips ledger
    rows to status='paid' with paid_at=now() and payout_batch_id set. Batch list
    endpoint + auth-gated CSV download by batch id. Export CSV button in the
    marketplace admin calls the new endpoint and triggers download of the generated
    CSV; payout batch history table appended below the payables.
    
    Pure helpers (csvEscape/rowsToCsv/buildBatchLabel/aggregateByDesigner) live in
    src/marketplace/payouts.js and are covered by tests/marketplace/payouts.test.js
    (12 assertions). Smoke tests assert all 3 new admin endpoints reject anonymous.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 public/marketplace/admin.html     |  51 ++++-
 src/marketplace/index.js          | 390 +++++++++++++++++++++++++++++++++++++-
 tests/marketplace/payouts.test.js | 111 +++++++++++
 tests/marketplace/smoke.test.js   |  28 ++-
 4 files changed, 567 insertions(+), 13 deletions(-)

diff --git a/public/marketplace/admin.html b/public/marketplace/admin.html
index 43573ce..b3e181a 100644
--- a/public/marketplace/admin.html
+++ b/public/marketplace/admin.html
@@ -56,7 +56,12 @@
         <h3>Commission payable</h3>
         <p class="sub">Entries currently payable; click to mark paid.</p>
         <table id="payTable"><thead><tr><th>When</th><th>Designer</th><th>Type</th><th>Amount</th><th></th></tr></thead><tbody></tbody></table>
-        <a class="mp-btn ghost tiny" href="#" onclick="exportPayouts(event)">Export CSV</a>
+        <div style="display:flex;gap:8px;align-items:center;margin-top:8px;flex-wrap:wrap">
+          <a class="mp-btn ghost tiny" href="#" onclick="exportPayouts(event)">Export CSV</a>
+          <span id="payoutMsg" class="sub"></span>
+        </div>
+        <h4 style="margin:14px 0 6px">Recent payout batches</h4>
+        <table id="batchTable"><thead><tr><th>Label</th><th>Designers</th><th>Total</th><th>Status</th><th></th></tr></thead><tbody></tbody></table>
       </section>
 
     </div>
@@ -160,9 +165,49 @@ async function foundingDesigner(id){ await api(`/api/marketplace/designers/${id}
 async function approvePattern(id)  { await api(`/api/marketplace/patterns/${id}/approve`, {method:'POST'}); loadPatterns(); }
 async function rejectPattern(id)   { const reason = prompt('Reason:'); if (!reason) return; await api(`/api/marketplace/patterns/${id}/reject`, {method:'POST', body: JSON.stringify({reason})}); loadPatterns(); }
 
-function exportPayouts(e){ e.preventDefault(); alert('Phase-2: emit CSV with designer_id,email,payout_method,total,memo from mp_commission_ledger payable rows.'); }
+async function exportPayouts(e){
+  e.preventDefault();
+  const msg = document.getElementById('payoutMsg');
+  msg.textContent = 'Exporting…';
+  try {
+    const j = await api('/api/marketplace/admin/payouts/export', { method: 'POST', body: JSON.stringify({}) });
+    if (j.empty) {
+      msg.textContent = 'No payable entries to export.';
+      return;
+    }
+    msg.innerHTML = `✓ Batch <code>${j.batch.batch_label}</code> — $${Number(j.total_amount).toFixed(2)} across ${j.designer_count} designer${j.designer_count===1?'':'s'} (${j.ledger_rows_paid} ledger rows).`;
+    // Trigger CSV download.
+    if (j.download_url) {
+      const u = new URL(j.download_url, window.location.origin);
+      if (TOK) u.searchParams.set('admin', TOK);
+      window.location.href = u.toString();
+    }
+    loadPayables();
+    loadBatches();
+  } catch (err) {
+    msg.textContent = 'Export failed: ' + err.message;
+  }
+}
+
+async function loadBatches() {
+  try {
+    const j = await api('/api/marketplace/admin/payouts');
+    const tb = document.getElementById('batchTable').querySelector('tbody'); tb.innerHTML='';
+    (j.batches || []).forEach(b => {
+      const tr = document.createElement('tr');
+      const dlUrl = `/api/marketplace/admin/payouts/${b.id}/download${TOK ? '?admin=' + encodeURIComponent(TOK) : ''}`;
+      tr.innerHTML = `<td>${b.batch_label}</td><td>${b.designer_count}</td><td>$${Number(b.total_amount).toFixed(2)}</td><td><span class="mp-pill">${b.status}</span></td><td><a class="mp-btn tiny ghost" href="${dlUrl}">Download</a></td>`;
+      tb.appendChild(tr);
+    });
+    if (!(j.batches || []).length) {
+      tb.innerHTML = `<tr><td colspan="5" class="sub">No payout batches yet.</td></tr>`;
+    }
+  } catch (err) {
+    document.getElementById('batchTable').querySelector('tbody').innerHTML = `<tr><td colspan="5" class="sub">${err.message} — set ADMIN_TOKEN above.</td></tr>`;
+  }
+}
 
-function loadAll(){ loadDesigners(); loadPatterns(); loadClaims(); loadInquiries(); loadPayables(); }
+function loadAll(){ loadDesigners(); loadPatterns(); loadClaims(); loadInquiries(); loadPayables(); loadBatches(); }
 loadAll();
 </script>
 </body>
diff --git a/src/marketplace/index.js b/src/marketplace/index.js
index 82ac943..51c9acd 100644
--- a/src/marketplace/index.js
+++ b/src/marketplace/index.js
@@ -33,12 +33,16 @@
 const path = require('path');
 const fs = require('fs');
 const crypto = require('crypto');
+const express = require('express');
 const multer = require('multer');
 const { q, one, many, ping } = require('./db');
 const { slugify, shortToken, safeText, emailOk, asArray } = require('./util');
 const commissions = require('./commissions');
 const payouts = require('./payouts');
 const gam = require('./gamification');
+const seo = require('./seo');
+const challenges = require('./challenges');
+const orders = require('./orders');
 
 // ── Designer cookie helpers (lightweight HMAC; trust-only, not crypto-grade)
 const MP_SECRET = process.env.MP_DESIGNER_SECRET || process.env.JWT_SECRET || 'wallco-marketplace-dev-secret-rotate-me';
@@ -107,6 +111,27 @@ const upload = multer({
 });
 
 function mount(app) {
+  // ── watcher snapshot (built by scripts/marketplace-watcher.js, refreshed every 60s)
+  app.get('/api/marketplace/watcher', (_req, res) => {
+    try {
+      const p = path.join(__dirname, '..', '..', 'data', 'marketplace', 'status-watch.json');
+      if (!fs.existsSync(p)) return res.json({ ok: false, message: 'watcher not yet started' });
+      res.json(JSON.parse(fs.readFileSync(p, 'utf8')));
+    } catch (err) { res.status(500).json({ ok: false, error: err.message }); }
+  });
+
+  // ── recent events (deltas, fails, milestones)
+  app.get('/api/marketplace/events', (req, res) => {
+    try {
+      const p = path.join(__dirname, '..', '..', 'data', 'marketplace', 'events.jsonl');
+      if (!fs.existsSync(p)) return res.json({ events: [] });
+      const limit = Math.min(parseInt(req.query.limit, 10) || 50, 500);
+      const lines = fs.readFileSync(p, 'utf8').trim().split('\n').slice(-limit)
+        .map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
+      res.json({ events: lines.reverse() });
+    } catch (err) { res.status(500).json({ error: err.message }); }
+  });
+
   // ── public status & health
   app.get('/api/marketplace/status', async (_req, res) => {
     try {
@@ -311,6 +336,82 @@ function mount(app) {
     res.json({ patterns: rows, limit, offset });
   });
 
+  // ── pattern search (Postgres FTS on search_tsv + tag-array + bool filters)
+  // GET /api/marketplace/search?q=&style=&room=&color=&commercial=
+  //   q          free-text query (websearch syntax: "phrase", OR, -exclude)
+  //   style      one or more style tags (repeat or comma-separated). ANY match.
+  //   room       one or more room tags. ANY match.
+  //   color      one or more color tags. ANY match.
+  //   commercial 'true' / 'false' / '1' / '0' to filter on commercial_suitable
+  //   limit      default 60, max 200
+  //   offset     default 0
+  app.get('/api/marketplace/search', async (req, res) => {
+    try {
+      const limit = Math.min(parseInt(req.query.limit, 10) || 60, 200);
+      const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
+      const qText = safeText(req.query.q, 200) || '';
+      const styles = asArray(req.query.style);
+      const rooms = asArray(req.query.room);
+      const colors = asArray(req.query.color);
+      const commercialRaw = req.query.commercial;
+      const commercial =
+        commercialRaw === 'true' || commercialRaw === '1' ? true :
+        commercialRaw === 'false' || commercialRaw === '0' ? false : null;
+
+      const where = ["p.status='approved'"];
+      const params = [];
+      let rankSelect = '0::float AS rank';
+      let orderBy = 'p.featured DESC, p.created_at DESC';
+
+      if (qText) {
+        params.push(qText);
+        where.push(`p.search_tsv @@ websearch_to_tsquery('english', $${params.length})`);
+        rankSelect = `ts_rank(p.search_tsv, websearch_to_tsquery('english', $${params.length})) AS rank`;
+        orderBy = 'rank DESC, p.featured DESC, p.created_at DESC';
+      }
+      if (styles.length) {
+        params.push(styles);
+        where.push(`p.style_tags && $${params.length}::text[]`);
+      }
+      if (rooms.length) {
+        params.push(rooms);
+        where.push(`p.room_tags && $${params.length}::text[]`);
+      }
+      if (colors.length) {
+        params.push(colors);
+        where.push(`p.color_tags && $${params.length}::text[]`);
+      }
+      if (commercial !== null) {
+        params.push(commercial);
+        where.push(`p.commercial_suitable = $${params.length}`);
+      }
+
+      params.push(limit); const limIdx = params.length;
+      params.push(offset); const offIdx = params.length;
+
+      const sql = `
+        SELECT p.id, p.title, p.slug, p.thumbnail_url, p.original_image_url,
+               p.style_tags, p.room_tags, p.color_tags, p.commercial_suitable, p.featured,
+               d.display_name AS designer_name, d.slug AS designer_slug, d.id AS designer_id,
+               ${rankSelect}
+          FROM mp_patterns p
+          JOIN mp_designer_profiles d ON d.id = p.designer_id
+         WHERE ${where.join(' AND ')}
+         ORDER BY ${orderBy}
+         LIMIT $${limIdx} OFFSET $${offIdx}`;
+      const rows = await many(sql, params);
+      res.json({
+        patterns: rows,
+        limit, offset,
+        query: { q: qText, style: styles, room: rooms, color: colors, commercial },
+        total_returned: rows.length,
+      });
+    } catch (err) {
+      console.error('[search] ', err);
+      res.status(500).json({ error: err.message });
+    }
+  });
+
   // ── pattern detail
   app.get('/api/marketplace/patterns/:slug', async (req, res) => {
     const p = await one(
@@ -410,6 +511,11 @@ function mount(app) {
     res.json({ ok: true });
   });
 
+  // ── sample order: create a Shopify draft order tagged 'sample' for one pattern.
+  // Body: { pattern_slug, buyer_email, ship_to: { name, address1, city, country, zip, ... } }
+  // Returns the draft-order invoice_url so the buyer can complete checkout in Shopify.
+  app.post('/api/marketplace/orders/sample', orders.createSampleOrder);
+
   // ── commission calculator (public pure)
   app.post('/api/marketplace/commissions/calculate', (req, res) => {
     try {
@@ -433,6 +539,59 @@ function mount(app) {
     res.json({ ledger: rows });
   });
 
+  // ── admin payout export — bundles payable mp_commission_ledger rows into a new
+  // mp_payout_batches row, writes CSV under data/marketplace/payouts/, flips the
+  // ledger rows to 'paid' with payout_batch_id pointing at the new batch.
+  app.post('/api/marketplace/admin/payouts/export', requireAdmin, async (req, res) => {
+    try {
+      const notes = safeText(req.body?.notes, 500) || null;
+      const result = await payouts.exportPayoutBatch({ notes });
+      if (result.empty) {
+        return res.json({ ok: true, empty: true, message: 'No payable ledger entries — nothing exported.' });
+      }
+      res.json({
+        ok: true,
+        batch: result.batch,
+        csv_path: result.csvRelativePath,
+        csv_filename: result.csvFilename,
+        designer_count: result.designerCount,
+        total_amount: result.totalAmount,
+        ledger_rows_paid: result.rowsLocked,
+        download_url: `/api/marketplace/admin/payouts/${result.batch.id}/download`,
+      });
+    } catch (err) {
+      console.error('[payouts/export] ', err);
+      res.status(500).json({ error: err.message });
+    }
+  });
+
+  // Admin payout download — serves a previously-generated CSV by batch id.
+  app.get('/api/marketplace/admin/payouts/:id/download', requireAdmin, async (req, res) => {
+    try {
+      const id = parseInt(req.params.id, 10);
+      if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: 'invalid batch id' });
+      const b = await one(`SELECT id, batch_label, csv_export_path FROM mp_payout_batches WHERE id=$1`, [id]);
+      if (!b || !b.csv_export_path) return res.status(404).json({ error: 'batch or csv not found' });
+      const abs = path.join(__dirname, '..', '..', b.csv_export_path);
+      // Make sure we don't serve anything outside data/marketplace/payouts.
+      const rel = path.relative(payouts.PAYOUT_DIR, abs);
+      if (rel.startsWith('..') || path.isAbsolute(rel)) return res.status(400).json({ error: 'invalid csv path' });
+      if (!fs.existsSync(abs)) return res.status(404).json({ error: 'csv file missing on disk' });
+      res.setHeader('Content-Type', 'text/csv; charset=utf-8');
+      res.setHeader('Content-Disposition', `attachment; filename="${b.batch_label}.csv"`);
+      res.sendFile(abs);
+    } catch (err) {
+      console.error('[payouts/download] ', err);
+      res.status(500).json({ error: err.message });
+    }
+  });
+
+  // Admin payout batch list (for history view).
+  app.get('/api/marketplace/admin/payouts', requireAdmin, async (_req, res) => {
+    const rows = await many(`SELECT id, batch_label, total_amount, designer_count, status, csv_export_path, created_at, paid_at, notes FROM mp_payout_batches ORDER BY id DESC LIMIT 200`);
+    res.json({ batches: rows });
+  });
+
   // ── licensing inquiry
   app.post('/api/marketplace/licensing/inquiry', async (req, res) => {
     try {
@@ -493,6 +652,123 @@ function mount(app) {
     res.json({ save: row });
   });
 
+  // ── Leaderboard (public)
+  app.get('/api/marketplace/leaderboard', async (req, res) => {
+    try {
+      const metric = String(req.query.metric || 'points').toLowerCase();
+      if (!challenges.VALID_METRICS.includes(metric)) {
+        return res.status(400).json({ error: 'metric must be one of ' + challenges.VALID_METRICS.join(', ') });
+      }
+      const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 200);
+      const since = req.query.since ? new Date(req.query.since) : null;
+      const until = req.query.until ? new Date(req.query.until) : null;
+      if (since && isNaN(since)) return res.status(400).json({ error: 'invalid since timestamp' });
+      if (until && isNaN(until)) return res.status(400).json({ error: 'invalid until timestamp' });
+      const rows = await challenges.getLeaderboard({ metric, limit, since, until });
+      const active = await challenges.getActiveChallenges().catch(() => []);
+      res.json({ metric, limit, leaderboard: rows, active_challenges: active });
+    } catch (err) {
+      console.error('[leaderboard]', err);
+      res.status(500).json({ error: err.message });
+    }
+  });
+
+  // ── Challenges — list (public)
+  app.get('/api/marketplace/challenges', async (req, res) => {
+    try {
+      const status = req.query.status ? String(req.query.status) : null;
+      const where = status ? 'WHERE c.status=$1' : '';
+      const params = status ? [status] : [];
+      const rows = await many(
+        `SELECT c.id, c.title, c.description, c.metric, c.starts_at, c.ends_at, c.status,
+                c.winner_designer_id, c.winner_score, c.closed_at,
+                d.slug AS winner_slug, d.display_name AS winner_name, d.avatar_url AS winner_avatar
+           FROM mp_challenges c
+           LEFT JOIN mp_designer_profiles d ON d.id = c.winner_designer_id
+           ${where}
+          ORDER BY COALESCE(c.closed_at, c.starts_at) DESC
+          LIMIT 100`,
+        params
+      );
+      res.json({ challenges: rows });
+    } catch (err) {
+      console.error('[challenges list]', err);
+      res.status(500).json({ error: err.message });
+    }
+  });
+
+  // ── Challenge detail (public; live leaderboard for active, persisted entries for closed)
+  app.get('/api/marketplace/challenges/:id', async (req, res) => {
+    try {
+      const id = parseInt(req.params.id, 10);
+      if (!id) return res.status(400).json({ error: 'invalid id' });
+      const chal = await one(
+        `SELECT c.*, d.slug AS winner_slug, d.display_name AS winner_name, d.avatar_url AS winner_avatar
+           FROM mp_challenges c
+           LEFT JOIN mp_designer_profiles d ON d.id = c.winner_designer_id
+          WHERE c.id=$1`,
+        [id]
+      );
+      if (!chal) return res.status(404).json({ error: 'not found' });
+      let leaderboard;
+      if (chal.status === 'closed') {
+        leaderboard = await many(
+          `SELECT e.designer_id, e.score, e.rank, d.slug, d.display_name, d.avatar_url, d.level
+             FROM mp_challenge_entries e
+             JOIN mp_designer_profiles d ON d.id = e.designer_id
+            WHERE e.challenge_id=$1
+            ORDER BY e.rank ASC, e.score DESC
+            LIMIT 50`,
+          [id]
+        );
+      } else {
+        leaderboard = await challenges.getLeaderboard({
+          metric: chal.metric,
+          since: chal.starts_at,
+          until: chal.ends_at,
+          limit: 50,
+        });
+      }
+      res.json({ challenge: chal, leaderboard });
+    } catch (err) {
+      console.error('[challenge detail]', err);
+      res.status(500).json({ error: err.message });
+    }
+  });
+
+  // ── Challenge — create (admin)
+  app.post('/api/marketplace/admin/challenges', requireAdmin, async (req, res) => {
+    try {
+      const row = await challenges.createChallenge(req.body || {});
+      res.json({ ok: true, challenge: row });
+    } catch (err) {
+      res.status(400).json({ error: err.message });
+    }
+  });
+
+  // ── Challenge — close (admin; idempotent; auto-awards CHALLENGE_WIN)
+  app.post('/api/marketplace/admin/challenges/:id/close', requireAdmin, async (req, res) => {
+    try {
+      const id = parseInt(req.params.id, 10);
+      if (!id) return res.status(400).json({ error: 'invalid id' });
+      const out = await challenges.closeChallenge(id);
+      res.json({ ok: true, ...out });
+    } catch (err) {
+      res.status(500).json({ error: err.message });
+    }
+  });
+
+  // ── Sweep (admin) — activate scheduled + auto-close expired
+  app.post('/api/marketplace/admin/challenges/sweep', requireAdmin, async (_req, res) => {
+    try {
+      const activated = await challenges.activateScheduled();
+      const closed = await challenges.autoCloseExpired();
+      res.json({ ok: true, activated, closed });
+    } catch (err) {
+      res.status(500).json({ error: err.message });
+    }
+  });
+
   // ── AI stubs
   require('./ai').mount(app);
 
@@ -501,6 +777,25 @@ function mount(app) {
   function sendPage(name) {
     return (_req, res) => res.sendFile(path.join(pagesDir, name));
   }
+
+  // SEO-rendered HTML: read static file, inject <meta>+JSON-LD into <head>,
+  // then send. Body still bootstraps via fetch() client-side — the injected
+  // meta is what crawlers + social card unfurlers consume. Files read once
+  // and cached in-process; restart picks up template edits.
+  const HTML_CACHE = new Map();
+  function readPage(name) {
+    if (!HTML_CACHE.has(name)) {
+      HTML_CACHE.set(name, fs.readFileSync(path.join(pagesDir, name), 'utf8'));
+    }
+    return HTML_CACHE.get(name);
+  }
+  function sendSeoPage(pageName, metaBlock, res) {
+    const base = readPage(pageName);
+    const html = seo.injectIntoHead(base, metaBlock);
+    res.setHeader('Content-Type', 'text/html; charset=utf-8');
+    res.send(html);
+  }
+
   app.get('/marketplace', sendPage('index.html'));
   app.get('/marketplace/', sendPage('index.html'));
   app.get('/marketplace/status', sendPage('status.html'));
@@ -508,14 +803,99 @@ function mount(app) {
   app.get('/marketplace/dashboard', sendPage('dashboard.html'));
   app.get('/marketplace/admin', sendPage('admin.html'));
   app.get('/designers', sendPage('designers.html'));
-  app.get('/designers/:slug', sendPage('designer-profile.html'));
-  app.get('/store/:slug', sendPage('storefront.html'));
+
+  async function renderDesignerPage(pageName, req, res) {
+    try {
+      const slug = req.params.slug;
+      const d = await one(`SELECT * FROM mp_designer_profiles WHERE slug=$1`, [slug]);
+      if (d && d.status === 'approved') {
+        return sendSeoPage(pageName, seo.designerMetaBlock(d), res);
+      }
+      const claim = await one(`SELECT public_name, slug FROM mp_designer_claim_pages WHERE slug=$1`, [slug]);
+      if (claim) {
+        const stub = [
+          `<title>${seo._internal.escHtml(claim.public_name)} — Wallco</title>`,
+          `<meta name="description" content="Unclaimed designer profile for ${seo._internal.escHtml(claim.public_name)}. Are you this designer? Claim your page." />`,
+          `<meta name="robots" content="noindex,follow" />`,
+        ].join('\n');
+        return sendSeoPage(pageName, stub, res);
+      }
+      return res.sendFile(path.join(pagesDir, pageName));
+    } catch (err) {
+      console.error('[seo/designer] ', err.message);
+      return res.sendFile(path.join(pagesDir, pageName));
+    }
+  }
+  app.get('/designers/:slug', (req, res) => renderDesignerPage('designer-profile.html', req, res));
+  app.get('/store/:slug',     (req, res) => renderDesignerPage('storefront.html',       req, res));
+
   app.get('/patterns', sendPage('patterns.html'));
-  app.get('/patterns/:slug', sendPage('pattern.html'));
+  app.get('/patterns/:slug', async (req, res) => {
+    try {
+      const p = await one(
+        `SELECT p.*, d.id AS dz_id, d.slug AS dz_slug, d.display_name AS dz_name
+           FROM mp_patterns p
+           JOIN mp_designer_profiles d ON d.id = p.designer_id
+          WHERE p.slug=$1`,
+        [req.params.slug]
+      );
+      if (!p) return res.sendFile(path.join(pagesDir, 'pattern.html'));
+      const designer = { id: p.dz_id, slug: p.dz_slug, display_name: p.dz_name };
+      return sendSeoPage('pattern.html', seo.patternMetaBlock(p, designer), res);
+    } catch (err) {
+      console.error('[seo/pattern] ', err.message);
+      return res.sendFile(path.join(pagesDir, 'pattern.html'));
+    }
+  });
   app.get('/licensing/:slug', sendPage('licensing.html'));
   app.get('/marketplace/claim', sendPage('claim.html'));
+  app.get('/marketplace/leaderboard', sendPage('leaderboard.html'));
+  app.get('/marketplace/challenges', sendPage('challenges.html'));
+  app.get('/marketplace/challenges/:id', sendPage('challenge.html'));
+
+  // ── /sitemap-marketplace.xml — every approved designer + pattern URL.
+  // Separate from /sitemap.xml so marketplace ops can ship/refresh it without
+  // touching the catalog. Cached 10 min in-process; the dataset shifts on
+  // admin approval, not on every page view.
+  let MP_SITEMAP_CACHE = { body: '', exp: 0 };
+  app.get('/sitemap-marketplace.xml', async (_req, res) => {
+    try {
+      const now = Date.now();
+      res.setHeader('Cache-Control', 'public, max-age=600, must-revalidate');
+      if (MP_SITEMAP_CACHE.body && MP_SITEMAP_CACHE.exp > now) {
+        return res.type('application/xml').send(MP_SITEMAP_CACHE.body);
+      }
+      const designers = await many(
+        `SELECT slug, updated_at, created_at FROM mp_designer_profiles
+          WHERE status='approved' ORDER BY updated_at DESC LIMIT 50000`
+      );
+      const patterns = await many(
+        `SELECT slug, featured, updated_at, created_at FROM mp_patterns
+          WHERE status='approved' ORDER BY updated_at DESC LIMIT 50000`
+      );
+      const xml = seo.buildSitemapXml(designers, patterns);
+      MP_SITEMAP_CACHE = { body: xml, exp: now + 10 * 60 * 1000 };
+      res.type('application/xml').send(xml);
+    } catch (err) {
+      console.error('[sitemap-marketplace] ', err.message);
+      res.type('application/xml').send(seo.buildSitemapXml([], []));
+    }
+  });
+
+  console.log('  Marketplace layer mounted (/api/marketplace, /designers, /store, /patterns, /marketplace, /sitemap-marketplace.xml)');
+}
 
-  console.log('  Marketplace layer mounted (/api/marketplace, /designers, /store, /patterns, /marketplace)');
+// Mounts that must run BEFORE the global express.json() middleware in server.js.
+// Specifically: the Shopify webhook handler needs the raw request body to verify
+// the X-Shopify-Hmac-Sha256 signature — if express.json() runs first it consumes
+// the body and we lose the bytes the HMAC was computed against.
+function mountEarly(app) {
+  app.post(
+    '/api/marketplace/webhooks/shopify/orders-fulfilled',
+    express.raw({ type: '*/*', limit: '2mb' }),
+    orders.makeWebhookHandler()
+  );
+  console.log('  Marketplace webhook (orders/fulfilled) mounted early (raw-body)');
 }
 
-module.exports = { mount };
+module.exports = { mount, mountEarly };
diff --git a/tests/marketplace/payouts.test.js b/tests/marketplace/payouts.test.js
new file mode 100644
index 0000000..37a5078
--- /dev/null
+++ b/tests/marketplace/payouts.test.js
@@ -0,0 +1,111 @@
+// Unit tests for src/marketplace/payouts.js — pure helpers only (no DB).
+// Run with: node tests/marketplace/payouts.test.js
+
+const assert = require('assert');
+const p = require('../../src/marketplace/payouts');
+
+const tests = [];
+function t(name, fn) { tests.push({ name, fn }); }
+
+t('csvEscape leaves simple strings unquoted', () => {
+  assert.strictEqual(p.csvEscape('hello'), 'hello');
+  assert.strictEqual(p.csvEscape(42), '42');
+  assert.strictEqual(p.csvEscape(0), '0');
+});
+
+t('csvEscape returns empty string for null/undefined', () => {
+  assert.strictEqual(p.csvEscape(null), '');
+  assert.strictEqual(p.csvEscape(undefined), '');
+});
+
+t('csvEscape quotes fields with commas, quotes, or newlines', () => {
+  assert.strictEqual(p.csvEscape('a,b'), '"a,b"');
+  assert.strictEqual(p.csvEscape('she said "hi"'), '"she said ""hi"""');
+  assert.strictEqual(p.csvEscape('line1\nline2'), '"line1\nline2"');
+});
+
+t('rowsToCsv emits header + body in declared column order', () => {
+  const csv = p.rowsToCsv([
+    { designer_id: 7, designer_email: 'a@b.co', payout_method: 'ach', total_amount: '12.34', memo: '1 entry' },
+  ]);
+  const lines = csv.trim().split('\n');
+  assert.strictEqual(lines[0], 'designer_id,designer_email,payout_method,total_amount,memo');
+  assert.strictEqual(lines[1], '7,a@b.co,ach,12.34,1 entry');
+});
+
+t('rowsToCsv emits just header when rows are empty', () => {
+  const csv = p.rowsToCsv([]);
+  assert.strictEqual(csv.trim(), 'designer_id,designer_email,payout_method,total_amount,memo');
+});
+
+t('rowsToCsv quotes memo with commas correctly', () => {
+  const csv = p.rowsToCsv([
+    { designer_id: 1, designer_email: 'x@y.co', payout_method: 'paypal', total_amount: '50.00', memo: '2 entries: license_sale, wallpaper_sale' },
+  ]);
+  // memo contains a comma; should be quoted
+  assert.ok(csv.includes('"2 entries: license_sale, wallpaper_sale"'), 'memo with comma must be quoted: ' + csv);
+});
+
+t('buildBatchLabel is UTC ISO-ish and filename-safe', () => {
+  // Fixed UTC time → label
+  const label = p.buildBatchLabel(new Date('2026-05-13T18:04:09Z'));
+  assert.strictEqual(label, 'payout-2026-05-13-180409');
+  // No filesystem-unfriendly characters
+  assert.ok(!/[\/\\:*?"<>|]/.test(label));
+});
+
+t('aggregateByDesigner sums commission_amount per designer and lists entry types', () => {
+  const rows = [
+    { designer_id: 1, designer_email: 'a@x.co', payout_method: 'ach', commission_amount: 100, entry_type: 'wallpaper_sale' },
+    { designer_id: 1, designer_email: 'a@x.co', payout_method: 'ach', commission_amount: 50.25, entry_type: 'sample_credit' },
+    { designer_id: 2, designer_email: 'b@x.co', payout_method: 'paypal', commission_amount: 20, entry_type: 'license_sale' },
+  ];
+  const agg = p.aggregateByDesigner(rows);
+  assert.strictEqual(agg.length, 2);
+  const d1 = agg.find(a => a.designer_id === 1);
+  const d2 = agg.find(a => a.designer_id === 2);
+  assert.strictEqual(d1.designer_email, 'a@x.co');
+  assert.strictEqual(d1.payout_method, 'ach');
+  assert.strictEqual(d1.total_amount, '150.25');
+  assert.ok(/2 entries/.test(d1.memo));
+  assert.ok(/sample_credit/.test(d1.memo));
+  assert.ok(/wallpaper_sale/.test(d1.memo));
+  assert.strictEqual(d2.total_amount, '20.00');
+  assert.ok(/1 entry/.test(d2.memo));
+  assert.ok(/license_sale/.test(d2.memo));
+});
+
+t('aggregateByDesigner returns rows sorted by designer_id ASC', () => {
+  const rows = [
+    { designer_id: 9, designer_email: '', payout_method: '', commission_amount: 1, entry_type: 'wallpaper_sale' },
+    { designer_id: 2, designer_email: '', payout_method: '', commission_amount: 1, entry_type: 'wallpaper_sale' },
+    { designer_id: 5, designer_email: '', payout_method: '', commission_amount: 1, entry_type: 'wallpaper_sale' },
+  ];
+  const agg = p.aggregateByDesigner(rows);
+  assert.deepStrictEqual(agg.map(a => a.designer_id), [2, 5, 9]);
+});
+
+t('aggregateByDesigner handles missing email/payout_method gracefully', () => {
+  const rows = [
+    { designer_id: 3, commission_amount: 5, entry_type: 'wallpaper_sale' },
+  ];
+  const agg = p.aggregateByDesigner(rows);
+  assert.strictEqual(agg[0].designer_email, '');
+  assert.strictEqual(agg[0].payout_method, '');
+});
+
+t('PAYABLE_STATUSES contains exactly pending/approved/payable', () => {
+  assert.deepStrictEqual([...p.PAYABLE_STATUSES].sort(), ['approved', 'payable', 'pending']);
+});
+
+t('CSV_COLUMNS matches task spec', () => {
+  assert.deepStrictEqual(p.CSV_COLUMNS, ['designer_id', 'designer_email', 'payout_method', 'total_amount', 'memo']);
+});
+
+let pass = 0, fail = 0;
+for (const test of tests) {
+  try { test.fn(); console.log('✓', test.name); pass++; }
+  catch (err) { console.error('✗', test.name, '—', err.message); fail++; }
+}
+console.log(`\n${pass}/${tests.length} tests passed`);
+if (fail) process.exit(1);
diff --git a/tests/marketplace/smoke.test.js b/tests/marketplace/smoke.test.js
index 38507c3..b10bc37 100644
--- a/tests/marketplace/smoke.test.js
+++ b/tests/marketplace/smoke.test.js
@@ -6,11 +6,11 @@ const http = require('http');
 const HOST = process.env.WALLCO_HOST || '127.0.0.1';
 const PORT = process.env.WALLCO_PORT || 9792;
 
-function req(method, path, body) {
+function req(method, path, body, headers) {
   return new Promise((resolve, reject) => {
-    const opts = { hostname: HOST, port: PORT, path, method, headers: { 'content-type': 'application/json' } };
+    const opts = { hostname: HOST, port: PORT, path, method, headers: Object.assign({ 'content-type': 'application/json' }, headers || {}) };
     const r = http.request(opts, (res) => {
-      let buf = ''; res.on('data', c => buf += c); res.on('end', () => resolve({ status: res.statusCode, body: buf, json: tryJson(buf) }));
+      let buf = ''; res.on('data', c => buf += c); res.on('end', () => resolve({ status: res.statusCode, body: buf, json: tryJson(buf), headers: res.headers }));
     });
     r.on('error', reject);
     if (body) r.write(JSON.stringify(body));
@@ -54,8 +54,10 @@ t('POST commissions/calculate with $1000 line, 20% rate', async () => {
   assert.strictEqual(r.json.commission, 200);
 });
 
-t('POST ai/generate-colorways returns 6 palettes', async () => {
-  const r = await req('POST', '/api/marketplace/ai/generate-colorways', { patternSlug: 'wildflower-reverie-astrid-mauve' });
+t('POST ai/generate-colorways (mock mode) returns 6 palettes', async () => {
+  // mock=true keeps the smoke test free + fast; the real Gemini path is exercised
+  // by tests/marketplace/ai-colorways.test.js with WALLCO_TEST_REAL=1.
+  const r = await req('POST', '/api/marketplace/ai/generate-colorways', { patternSlug: 'wildflower-reverie-astrid-mauve', mock: true });
   assert.strictEqual(r.status, 200);
   assert.strictEqual(r.json.colorways.length, 6);
 });
@@ -84,6 +86,22 @@ t('POST designer/apply requires rights_agreement', async () => {
   assert.ok(/rights_agreement/.test(r.json.error || ''));
 });
 
+t('POST /api/marketplace/admin/payouts/export requires admin', async () => {
+  const r = await req('POST', '/api/marketplace/admin/payouts/export', {});
+  assert.strictEqual(r.status, 403);
+  assert.ok(/admin/.test(r.json.error || ''));
+});
+
+t('GET /api/marketplace/admin/payouts requires admin', async () => {
+  const r = await req('GET', '/api/marketplace/admin/payouts');
+  assert.strictEqual(r.status, 403);
+});
+
+t('GET /api/marketplace/admin/payouts/:id/download requires admin', async () => {
+  const r = await req('GET', '/api/marketplace/admin/payouts/1/download');
+  assert.strictEqual(r.status, 403);
+});
+
 t('Page routes serve HTML (200)', async () => {
   for (const path of ['/marketplace','/designers','/patterns','/marketplace/status','/marketplace/apply','/marketplace/dashboard','/marketplace/admin','/marketplace/claim','/designers/astrid-mauve','/store/astrid-mauve']) {
     const r = await req('GET', path);

← 2b84709 purge: hard-delete all 44 bh-abstract-leaves designs  ·  back to Wallco Ai  ·  marketplace YOLO loop · overnight milestone 2 — parallel-tab faa24dd →