Includes: API (Express/TypeScript), Astro site, Python workers, document generators, FCC compliance tools, Canada CRTC formation, Ansible infrastructure, and deployment scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
/**
|
|
* Refund management routes.
|
|
*
|
|
* Handles refunds for formation orders and compliance service orders
|
|
* when the failure was not the customer's fault.
|
|
*
|
|
* Refund flow:
|
|
* 1. System or admin initiates refund (POST /api/v1/admin/refunds)
|
|
* 2. Admin reviews + approves (PATCH /api/v1/admin/refunds/:id)
|
|
* 3. Admin sends refund via Relay dashboard (manual ACH)
|
|
* 4. Admin marks as sent/confirmed
|
|
* 5. Customer receives email notification at each step
|
|
*
|
|
* Auto-refund triggers (created by the formation worker):
|
|
* - State portal charged the card but rejected the filing
|
|
* - Payment went through but automation crashed before filing completed
|
|
* - Name collision discovered after payment was processed
|
|
*/
|
|
|
|
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();
|
|
|
|
// =====================================================================
|
|
// Admin: Create a refund
|
|
// =====================================================================
|
|
|
|
router.post("/api/v1/admin/refunds", requireAdmin, async (req, res) => {
|
|
try {
|
|
const {
|
|
order_type, order_id, order_number,
|
|
customer_name, customer_email,
|
|
original_amount_cents, refund_amount_cents, refund_type,
|
|
reason_category, reason_detail,
|
|
state_fee_recoverable, refund_method,
|
|
admin_notes,
|
|
} = req.body ?? {};
|
|
|
|
if (!order_number || !customer_email || !refund_amount_cents || !reason_category) {
|
|
res.status(400).json({ error: "Missing required fields: order_number, customer_email, refund_amount_cents, reason_category" });
|
|
return;
|
|
}
|
|
|
|
const year = new Date().getFullYear();
|
|
const short = uuidv4().split("-")[0]!.toUpperCase();
|
|
const refundNumber = `REF-${year}-${short}`;
|
|
|
|
const result = await pool.query(
|
|
`INSERT INTO refunds (
|
|
refund_number, order_type, order_id, order_number,
|
|
customer_name, customer_email,
|
|
original_amount_cents, refund_amount_cents, refund_type,
|
|
reason_category, reason_detail,
|
|
state_fee_recoverable, refund_method,
|
|
admin_notes, requested_by, status
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,'pending')
|
|
RETURNING id, refund_number`,
|
|
[
|
|
refundNumber,
|
|
order_type || "formation",
|
|
order_id || null,
|
|
order_number,
|
|
customer_name || "",
|
|
customer_email,
|
|
original_amount_cents || 0,
|
|
refund_amount_cents,
|
|
refund_type || "full",
|
|
reason_category,
|
|
reason_detail || null,
|
|
state_fee_recoverable || false,
|
|
refund_method || "relay_ach",
|
|
admin_notes || null,
|
|
`admin:${req.admin!.username}`,
|
|
],
|
|
);
|
|
|
|
const refund = result.rows[0];
|
|
|
|
// Link refund to the order
|
|
if (order_id) {
|
|
const table = order_type === "formation" ? "formation_orders" : "orders";
|
|
await pool.query(
|
|
`UPDATE ${table} SET refund_id = $1, refunded = FALSE WHERE id = $2`,
|
|
[refund.id, order_id],
|
|
);
|
|
}
|
|
|
|
// Log to audit
|
|
if (order_id) {
|
|
await pool.query(
|
|
`INSERT INTO order_audit_log (order_type, order_id, order_number, action, actor_type, actor_id, actor_name, note)
|
|
VALUES ($1, $2, $3, 'refund_initiated', 'admin', $4, $5, $6)`,
|
|
[order_type || "formation", order_id, order_number, req.admin!.id, req.admin!.username,
|
|
`Refund ${refundNumber}: $${(refund_amount_cents / 100).toFixed(2)} — ${reason_category}`],
|
|
);
|
|
}
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
refund_number: refund.refund_number,
|
|
refund_id: refund.id,
|
|
message: "Refund created. Review and approve to process.",
|
|
});
|
|
} catch (err) {
|
|
console.error("[refunds] Create error:", err);
|
|
res.status(500).json({ error: "Could not create refund." });
|
|
}
|
|
});
|
|
|
|
// =====================================================================
|
|
// Admin: List refunds
|
|
// =====================================================================
|
|
|
|
router.get("/api/v1/admin/refunds", requireAdmin, async (req, res) => {
|
|
try {
|
|
const status = req.query.status 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 status = $${idx++}`; params.push(status); }
|
|
|
|
const countResult = await pool.query(`SELECT COUNT(*) as total FROM refunds ${where}`, params);
|
|
|
|
params.push(limit, offset);
|
|
const result = await pool.query(
|
|
`SELECT * FROM refunds ${where} ORDER BY created_at DESC LIMIT $${idx++} OFFSET $${idx++}`,
|
|
params,
|
|
);
|
|
|
|
res.json({
|
|
refunds: result.rows,
|
|
total: parseInt(countResult.rows[0].total, 10),
|
|
limit, offset,
|
|
});
|
|
} catch (err) {
|
|
console.error("[refunds] List error:", err);
|
|
res.status(500).json({ error: "Could not load refunds." });
|
|
}
|
|
});
|
|
|
|
// =====================================================================
|
|
// Admin: Get single refund
|
|
// =====================================================================
|
|
|
|
router.get("/api/v1/admin/refunds/:id", requireAdmin, async (req, res) => {
|
|
try {
|
|
const id = parseInt(req.params.id, 10);
|
|
const result = await pool.query("SELECT * FROM refunds WHERE id = $1", [id]);
|
|
if (result.rows.length === 0) {
|
|
res.status(404).json({ error: "Refund not found." });
|
|
return;
|
|
}
|
|
res.json({ refund: result.rows[0] });
|
|
} catch (err) {
|
|
console.error("[refunds] Get error:", err);
|
|
res.status(500).json({ error: "Could not load refund." });
|
|
}
|
|
});
|
|
|
|
// =====================================================================
|
|
// Admin: Update refund status (approve, send, confirm, deny)
|
|
// =====================================================================
|
|
|
|
router.patch("/api/v1/admin/refunds/:id", requireAdmin, async (req, res) => {
|
|
try {
|
|
const id = parseInt(req.params.id, 10);
|
|
const { status, admin_notes, relay_transaction_id, denied_reason, refund_method } = req.body ?? {};
|
|
|
|
const current = await pool.query("SELECT * FROM refunds WHERE id = $1", [id]);
|
|
if (current.rows.length === 0) {
|
|
res.status(404).json({ error: "Refund not found." });
|
|
return;
|
|
}
|
|
const refund = current.rows[0];
|
|
|
|
const updates: string[] = [];
|
|
const params: any[] = [];
|
|
let pIdx = 1;
|
|
|
|
if (status) {
|
|
updates.push(`status = $${pIdx++}`); params.push(status);
|
|
|
|
if (status === "approved") {
|
|
updates.push(`approved_by = $${pIdx++}`); params.push(req.admin!.id);
|
|
updates.push("approved_at = now()");
|
|
}
|
|
if (status === "sent") {
|
|
updates.push("sent_at = now()");
|
|
}
|
|
if (status === "confirmed") {
|
|
updates.push("confirmed_at = now()");
|
|
// Mark the order as refunded
|
|
if (refund.order_id) {
|
|
const table = refund.order_type === "formation" ? "formation_orders" : "orders";
|
|
await pool.query(`UPDATE ${table} SET refunded = TRUE WHERE id = $1`, [refund.order_id]);
|
|
}
|
|
}
|
|
if (status === "denied") {
|
|
updates.push(`denied_reason = $${pIdx++}`); params.push(denied_reason || "Refund denied");
|
|
}
|
|
}
|
|
|
|
if (admin_notes !== undefined) { updates.push(`admin_notes = $${pIdx++}`); params.push(admin_notes); }
|
|
if (relay_transaction_id) { updates.push(`relay_transaction_id = $${pIdx++}`); params.push(relay_transaction_id); }
|
|
if (refund_method) { updates.push(`refund_method = $${pIdx++}`); params.push(refund_method); }
|
|
|
|
if (updates.length > 0) {
|
|
updates.push("updated_at = now()");
|
|
params.push(id);
|
|
await pool.query(
|
|
`UPDATE refunds SET ${updates.join(", ")} WHERE id = $${pIdx}`,
|
|
params,
|
|
);
|
|
}
|
|
|
|
// Audit log
|
|
if (status && refund.order_id) {
|
|
await pool.query(
|
|
`INSERT INTO order_audit_log (order_type, order_id, order_number, action, from_status, to_status, actor_type, actor_id, actor_name, note)
|
|
VALUES ($1, $2, $3, $4, $5, $6, 'admin', $7, $8, $9)`,
|
|
[refund.order_type, refund.order_id, refund.order_number,
|
|
`refund_${status}`, refund.status, status,
|
|
req.admin!.id, req.admin!.username,
|
|
`Refund ${refund.refund_number}: status → ${status}${admin_notes ? '. ' + admin_notes : ''}`],
|
|
);
|
|
}
|
|
|
|
res.json({ success: true, message: `Refund status updated to: ${status || 'updated'}` });
|
|
} catch (err) {
|
|
console.error("[refunds] Update error:", err);
|
|
res.status(500).json({ error: "Could not update refund." });
|
|
}
|
|
});
|
|
|
|
// =====================================================================
|
|
// Admin: Dashboard stats for refunds
|
|
// =====================================================================
|
|
|
|
router.get("/api/v1/admin/refunds/stats", requireAdmin, async (req, res) => {
|
|
try {
|
|
const result = await pool.query(`
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE status = 'pending') as pending,
|
|
COUNT(*) FILTER (WHERE status = 'approved') as approved,
|
|
COUNT(*) FILTER (WHERE status = 'processing') as processing,
|
|
COUNT(*) FILTER (WHERE status = 'sent') as sent,
|
|
COUNT(*) FILTER (WHERE status = 'confirmed') as confirmed,
|
|
COUNT(*) FILTER (WHERE status = 'denied') as denied,
|
|
COUNT(*) as total,
|
|
COALESCE(SUM(refund_amount_cents) FILTER (WHERE status IN ('sent','confirmed')), 0) as total_refunded_cents,
|
|
COALESCE(SUM(refund_amount_cents) FILTER (WHERE status = 'pending'), 0) as pending_refund_cents
|
|
FROM refunds
|
|
`);
|
|
res.json({ stats: result.rows[0] });
|
|
} catch (err) {
|
|
console.error("[refunds] Stats error:", err);
|
|
res.status(500).json({ error: "Could not load refund stats." });
|
|
}
|
|
});
|
|
|
|
// =====================================================================
|
|
// System: Auto-create refund (called by formation worker on failure)
|
|
// =====================================================================
|
|
|
|
router.post("/api/v1/internal/refunds/auto", async (req, res) => {
|
|
// This endpoint is called internally by the worker when a state charges
|
|
// the card but the filing fails. No admin auth — uses internal secret.
|
|
const secret = req.headers["x-internal-secret"];
|
|
if (!secret || secret !== process.env.WEBHOOK_SECRET) {
|
|
res.status(401).json({ error: "Unauthorized" });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const {
|
|
order_type, order_id, order_number,
|
|
customer_name, customer_email,
|
|
amount_cents, reason_category, reason_detail,
|
|
} = req.body ?? {};
|
|
|
|
const year = new Date().getFullYear();
|
|
const short = uuidv4().split("-")[0]!.toUpperCase();
|
|
const refundNumber = `REF-${year}-${short}`;
|
|
|
|
const result = await pool.query(
|
|
`INSERT INTO refunds (
|
|
refund_number, order_type, order_id, order_number,
|
|
customer_name, customer_email,
|
|
original_amount_cents, refund_amount_cents, refund_type,
|
|
reason_category, reason_detail,
|
|
requested_by, status
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$7,'full',$8,$9,'system','pending')
|
|
RETURNING id, refund_number`,
|
|
[refundNumber, order_type || "formation", order_id, order_number,
|
|
customer_name, customer_email, amount_cents,
|
|
reason_category, reason_detail],
|
|
);
|
|
|
|
// Create ERPNext issue to alert admin
|
|
console.log(`[refunds] Auto-refund created: ${result.rows[0].refund_number} for ${order_number} — $${(amount_cents / 100).toFixed(2)}`);
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
refund_number: result.rows[0].refund_number,
|
|
});
|
|
} catch (err) {
|
|
console.error("[refunds] Auto-create error:", err);
|
|
res.status(500).json({ error: "Could not create auto-refund." });
|
|
}
|
|
});
|
|
|
|
export default router;
|