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.
353 lines
16 KiB
TypeScript
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;
|