new-site/api/src/routes/agents.ts
justin 345979ed00 Allow multiple referral codes per sales agent
Drop the UNIQUE constraint on sales_agents.email (migration 084) so a single
agent (person/company) can hold several referral codes, each with its own
client discount and commission split. All commission lookups already key on
the unique agent_code, so no lookup logic changes.

Agent-creation endpoint now:
- accepts repeat emails (creates an additional code instead of 409)
- accepts client_discount_value, commission_type, commission_pct per code
- reports existing codes for the email in the response

Both Jay Kordic codes (REF-JKORDIC 7%/12%, REF-JAYK05 5%/15%) now share his
real email jay_kordic@thehorizongroup.biz.
2026-06-02 14:44:22 -05:00

353 lines
16 KiB
TypeScript

import { Router } from "express";
import { pool } from "../db.js";
import { requireAdmin } from "../middleware/admin-auth.js";
import { v4 as uuidv4 } from "uuid";
const router = Router();
// Helper: generate a REF-XXXXX agent code
function generateAgentCode(): string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I/O/1/0 to avoid confusion
let code = "REF-";
for (let i = 0; i < 5; i++) code += chars[Math.floor(Math.random() * chars.length)];
return code;
}
// Helper: create commission record when an order uses an agent's code
// This is exported so other route files can call it
export async function createCommission(params: {
agentCode: string;
orderType: string;
orderId: number;
orderNumber: string;
serviceSlug?: string;
customerName: string;
customerEmail: string;
orderAmountCents: number;
discountCents: number;
}): Promise<void> {
// Look up the agent
const agentResult = await pool.query(
"SELECT id, agent_code, commission_default_cents, commission_pct, commission_overrides, commission_type FROM sales_agents WHERE agent_code = $1 AND active = TRUE",
[params.agentCode],
);
if (agentResult.rows.length === 0) return;
const agent = agentResult.rows[0];
// Calculate commission amount
let commissionCents = agent.commission_default_cents || 30000; // $300 default
const overrides = agent.commission_overrides || {};
// Precedence:
// 1. Explicit per-service override (always wins, flat cents)
// 2. Percent-based agents earn commission_pct of the order on EVERY order type
// 3. Otherwise fall back to per-type flat defaults
if (params.serviceSlug && overrides[params.serviceSlug]) {
commissionCents = overrides[params.serviceSlug];
} else if (agent.commission_type === "percent") {
// Percent agents (e.g. referral partners on a flat % deal) get the same
// percentage regardless of order type. order_amount_cents is the total paid.
commissionCents = Math.round((params.orderAmountCents * (agent.commission_pct || 10)) / 100);
} else if (params.orderType === "canada_crtc") {
commissionCents = overrides["canada-crtc"] || 30000;
} else if (params.orderType === "formation") {
commissionCents = overrides["formation"] || 5000;
} else if (params.orderType === "bundle") {
commissionCents = overrides["bundle"] || 10000;
}
await pool.query(
`INSERT INTO commission_ledger (agent_id, agent_code, order_type, order_id, order_number, service_slug, customer_name, customer_email, order_amount_cents, discount_cents, commission_cents, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'pending')`,
[agent.id, agent.agent_code, params.orderType, params.orderId, params.orderNumber,
params.serviceSlug || null, params.customerName, params.customerEmail,
params.orderAmountCents, params.discountCents, commissionCents],
);
// Update agent stats
await pool.query(
"UPDATE sales_agents SET total_referrals = total_referrals + 1, total_pending_cents = total_pending_cents + $1, updated_at = now() WHERE id = $2",
[commissionCents, agent.id],
);
}
// =====================================================================
// Admin: Create a new sales agent
// =====================================================================
router.post("/api/v1/admin/agents", requireAdmin, async (req, res) => {
try {
const {
name, email, phone, company,
commission_type, commission_default_cents, commission_pct, commission_overrides,
client_discount_value, notes,
} = req.body ?? {};
if (!name || !email) {
res.status(400).json({ error: "Name and email are required." });
return;
}
// A single agent (person/company) may hold MULTIPLE referral codes, each with
// its own client discount + commission split. We therefore allow repeat emails;
// each call creates a new code. We surface any existing codes for the same email
// in the response so the caller knows it's an additional code, not a mistake.
const existing = await pool.query(
"SELECT agent_code, commission_pct FROM sales_agents WHERE email = $1 ORDER BY created_at",
[email.toLowerCase().trim()],
);
const existingCodes = existing.rows.map((r) => r.agent_code);
// Per-code config (defaults preserve prior behaviour: 5% client discount, 10% commission)
const clientDiscount = client_discount_value !== undefined ? client_discount_value : 5;
const commType = commission_type === "flat" ? "flat" : "percent";
const commPct = commission_pct !== undefined ? commission_pct : 10;
// Generate unique agent code
let agentCode = generateAgentCode();
let attempts = 0;
while (attempts < 10) {
const dup = await pool.query("SELECT id FROM sales_agents WHERE agent_code = $1", [agentCode]);
if (dup.rows.length === 0) break;
agentCode = generateAgentCode();
attempts++;
}
// Create the linked discount code (client discount off service fees;
// referral_pct mirrors the agent commission for reporting).
const dcResult = await pool.query(
`INSERT INTO discount_codes (code, description, discount_type, discount_value, referral_partner, referral_email, referral_pct, active)
VALUES ($1, $2, 'percent', $3, $4, $5, $6, TRUE)
RETURNING id`,
[agentCode, `Sales agent: ${name}`, clientDiscount, name, email.toLowerCase().trim(), commType === "percent" ? commPct : 0],
);
const discountCodeId = dcResult.rows[0].id;
// Create the agent (one row per referral code)
const result = await pool.query(
`INSERT INTO sales_agents (agent_code, discount_code_id, name, email, phone, company, commission_type, commission_default_cents, commission_pct, commission_overrides, notes, onboarded_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, now())
RETURNING id, agent_code`,
[agentCode, discountCodeId, name, email.toLowerCase().trim(), phone || null, company || null,
commType, commission_default_cents || 30000, commPct,
JSON.stringify(commission_overrides || {}), notes || null],
);
const agent = result.rows[0];
res.status(201).json({
success: true,
agent_id: agent.id,
agent_code: agent.agent_code,
client_discount_value: clientDiscount,
commission_type: commType,
commission_pct: commType === "percent" ? commPct : undefined,
existing_codes_for_email: existingCodes,
referral_url: `https://performancewest.net/order/canada-crtc?code=${agent.agent_code}`,
message: existingCodes.length > 0
? `Additional referral code created for ${email}. New code: ${agent.agent_code} (${clientDiscount}% client discount${commType === "percent" ? `, ${commPct}% commission` : ""}). This agent now has ${existingCodes.length + 1} codes.`
: `Agent created. Referral code: ${agent.agent_code}. Client gets ${clientDiscount}% off, agent earns commission per sale.`,
});
} catch (err) {
console.error("[agents] Create error:", err);
res.status(500).json({ error: "Could not create agent." });
}
});
// =====================================================================
// Admin: List all agents
// =====================================================================
router.get("/api/v1/admin/agents", requireAdmin, async (req, res) => {
try {
const active = req.query.active !== "false";
const result = await pool.query(
`SELECT id, agent_code, name, email, phone, company, active,
commission_default_cents, commission_pct,
total_referrals, total_earned_cents, total_paid_cents, total_pending_cents,
onboarded_at, created_at
FROM sales_agents WHERE ($1::boolean IS NULL OR active = $1) ORDER BY created_at DESC`,
[active],
);
res.json({ agents: result.rows });
} catch (err) {
console.error("[agents] List error:", err);
res.status(500).json({ error: "Could not load agents." });
}
});
// =====================================================================
// Admin: Get single agent with commission history
// =====================================================================
router.get("/api/v1/admin/agents/:id", requireAdmin, async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const agent = await pool.query("SELECT * FROM sales_agents WHERE id = $1", [id]);
if (agent.rows.length === 0) { res.status(404).json({ error: "Agent not found." }); return; }
const commissions = await pool.query(
"SELECT * FROM commission_ledger WHERE agent_id = $1 ORDER BY created_at DESC LIMIT 50",
[id],
);
res.json({ agent: agent.rows[0], commissions: commissions.rows });
} catch (err) {
console.error("[agents] Get error:", err);
res.status(500).json({ error: "Could not load agent." });
}
});
// =====================================================================
// Admin: Update agent
// =====================================================================
router.patch("/api/v1/admin/agents/:id", requireAdmin, async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const { name, phone, company, commission_default_cents, commission_pct, commission_overrides, active, notes } = req.body ?? {};
const updates: string[] = [];
const params: any[] = [];
let idx = 1;
if (name !== undefined) { updates.push(`name = $${idx++}`); params.push(name); }
if (phone !== undefined) { updates.push(`phone = $${idx++}`); params.push(phone); }
if (company !== undefined) { updates.push(`company = $${idx++}`); params.push(company); }
if (commission_default_cents !== undefined) { updates.push(`commission_default_cents = $${idx++}`); params.push(commission_default_cents); }
if (commission_pct !== undefined) { updates.push(`commission_pct = $${idx++}`); params.push(commission_pct); }
if (commission_overrides !== undefined) { updates.push(`commission_overrides = $${idx++}`); params.push(JSON.stringify(commission_overrides)); }
if (active !== undefined) { updates.push(`active = $${idx++}`); params.push(active); }
if (notes !== undefined) { updates.push(`notes = $${idx++}`); params.push(notes); }
if (updates.length > 0) {
updates.push("updated_at = now()");
params.push(id);
await pool.query(`UPDATE sales_agents SET ${updates.join(", ")} WHERE id = $${idx}`, params);
}
res.json({ success: true, message: "Agent updated." });
} catch (err) {
console.error("[agents] Update error:", err);
res.status(500).json({ error: "Could not update agent." });
}
});
// =====================================================================
// Admin: List commissions (with filters)
// =====================================================================
router.get("/api/v1/admin/commissions", requireAdmin, async (req, res) => {
try {
const status = req.query.status as string || "";
const agentId = req.query.agent_id as string || "";
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 200);
const offset = parseInt(req.query.offset as string, 10) || 0;
let where = "WHERE 1=1";
const params: any[] = [];
let idx = 1;
if (status) { where += ` AND c.status = $${idx++}`; params.push(status); }
if (agentId) { where += ` AND c.agent_id = $${idx++}`; params.push(parseInt(agentId, 10)); }
const countResult = await pool.query(`SELECT COUNT(*) as total FROM commission_ledger c ${where}`, params);
params.push(limit, offset);
const result = await pool.query(
`SELECT c.*, a.name as agent_name, a.email as agent_email
FROM commission_ledger c
JOIN sales_agents a ON c.agent_id = a.id
${where}
ORDER BY c.created_at DESC
LIMIT $${idx++} OFFSET $${idx++}`,
params,
);
res.json({
commissions: result.rows,
total: parseInt(countResult.rows[0].total, 10),
limit, offset,
});
} catch (err) {
console.error("[commissions] List error:", err);
res.status(500).json({ error: "Could not load commissions." });
}
});
// =====================================================================
// Admin: Update commission status (approve, pay, cancel)
// =====================================================================
router.patch("/api/v1/admin/commissions/:id", requireAdmin, async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const { status, payment_method, payment_reference, notes } = req.body ?? {};
const current = await pool.query("SELECT * FROM commission_ledger WHERE id = $1", [id]);
if (current.rows.length === 0) { res.status(404).json({ error: "Commission not found." }); return; }
const comm = current.rows[0];
const updates: string[] = [];
const params: any[] = [];
let idx = 1;
if (status) {
updates.push(`status = $${idx++}`); params.push(status);
if (status === "approved") {
updates.push(`approved_by = $${idx++}`); params.push(req.admin!.id);
updates.push("approved_at = now()");
}
if (status === "paid") {
updates.push("paid_at = now()");
if (payment_method) { updates.push(`payment_method = $${idx++}`); params.push(payment_method); }
if (payment_reference) { updates.push(`payment_reference = $${idx++}`); params.push(payment_reference); }
// Update agent totals
await pool.query(
"UPDATE sales_agents SET total_paid_cents = total_paid_cents + $1, total_pending_cents = total_pending_cents - $1, total_earned_cents = total_earned_cents + $1, updated_at = now() WHERE id = $2",
[comm.commission_cents, comm.agent_id],
);
}
if (status === "cancelled") {
await pool.query(
"UPDATE sales_agents SET total_pending_cents = total_pending_cents - $1, updated_at = now() WHERE id = $2",
[comm.commission_cents, comm.agent_id],
);
}
}
if (notes !== undefined) { updates.push(`notes = $${idx++}`); params.push(notes); }
if (updates.length > 0) {
updates.push("updated_at = now()");
params.push(id);
await pool.query(`UPDATE commission_ledger SET ${updates.join(", ")} WHERE id = $${idx}`, params);
}
res.json({ success: true, message: `Commission status updated to: ${status || "updated"}` });
} catch (err) {
console.error("[commissions] Update error:", err);
res.status(500).json({ error: "Could not update commission." });
}
});
// =====================================================================
// Admin: Commission stats dashboard
// =====================================================================
router.get("/api/v1/admin/commissions/stats", requireAdmin, async (req, res) => {
try {
const result = await pool.query(`
SELECT
COUNT(*) FILTER (WHERE status = 'pending') as pending_count,
COUNT(*) FILTER (WHERE status = 'eligible') as eligible_count,
COUNT(*) FILTER (WHERE status = 'approved') as approved_count,
COUNT(*) FILTER (WHERE status = 'paid') as paid_count,
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled_count,
COALESCE(SUM(commission_cents) FILTER (WHERE status = 'pending'), 0) as pending_cents,
COALESCE(SUM(commission_cents) FILTER (WHERE status = 'eligible'), 0) as eligible_cents,
COALESCE(SUM(commission_cents) FILTER (WHERE status IN ('approved','processing')), 0) as approved_cents,
COALESCE(SUM(commission_cents) FILTER (WHERE status = 'paid'), 0) as paid_cents,
COUNT(DISTINCT agent_id) as active_agents
FROM commission_ledger
`);
res.json({ stats: result.rows[0] });
} catch (err) {
res.status(500).json({ error: "Could not load commission stats." });
}
});
export default router;