← 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
M public/marketplace/admin.htmlM src/marketplace/index.jsA tests/marketplace/payouts.test.jsM tests/marketplace/smoke.test.js
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 →