new-site/api/src/routes/admin-crypto.ts
justin f8cd37ac8c Initial commit — Performance West telecom compliance platform
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>
2026-04-27 06:54:22 -05:00

337 lines
12 KiB
TypeScript

/**
* Admin crypto treasury endpoints.
*
* All endpoints require the admin token (X-Admin-Token header) — same
* gate used by admin-filings and reseller-certs. Read-only endpoints
* return 200; mutating endpoints audit to order_audit_log.
*/
import crypto from "node:crypto";
import { Router } from "express";
import type { Request, Response } from "express";
import { pool } from "../db.js";
const router = Router();
// ── Auth middleware ─────────────────────────────────────────────────────
function requireAdminToken(req: Request, res: Response): boolean {
const expected = process.env.ADMIN_API_TOKEN || "";
const supplied = (req.headers["x-admin-token"] || "").toString().trim();
if (!expected) {
// If token not configured, reject — fail-closed.
res.status(503).json({ error: "ADMIN_API_TOKEN not set" });
return false;
}
// Timing-safe compare. timingSafeEqual throws on length mismatch, so
// guard with a length check first (a cheap length-disclosure trade-off
// that's negligible compared to the attack surface of naïve ==).
const sb = Buffer.from(supplied);
const eb = Buffer.from(expected);
const ok = sb.length === eb.length && crypto.timingSafeEqual(sb, eb);
if (!ok) {
res.status(403).json({ error: "forbidden" });
return false;
}
return true;
}
async function auditLog(
actor: string, action: string, target: string, details?: unknown,
) {
// order_audit_log schema requires: order_type IN ('formation','service','quote'),
// order_id (integer, NOT NULL), action, actor_type IN
// ('system','admin','worker','customer') + optional order_number,
// actor_name, metadata. We use order_type='service' (closest match —
// crypto treasury is an internal service action) and store the real
// crypto-treasury target (order_number or 'sweep:N') in order_number.
try {
await pool.query(
`INSERT INTO order_audit_log
(order_type, order_id, order_number, action, actor_type, actor_name, metadata)
VALUES ('service', 0, $1, $2, 'admin', $3, $4::jsonb)`,
[target, action, actor, JSON.stringify(details ?? {})],
);
} catch (err) {
console.error("[admin-crypto] audit log failed:", err);
// non-fatal
}
}
// ── GET /api/v1/admin/crypto-payments ───────────────────────────────────
router.get(
"/api/v1/admin/crypto-payments",
async (req: Request, res: Response) => {
if (!requireAdminToken(req, res)) return;
const stateFilter = typeof req.query.state === "string" ? req.query.state : "";
const params: (string | number)[] = [];
const where: string[] = [];
if (stateFilter) {
where.push(`j.state = $${params.length + 1}`);
params.push(stateFilter);
}
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const r = await pool.query(
`
SELECT j.order_id, j.order_type, j.state, j.coin,
j.amount_coin, j.amount_usd_cents, j.needed_usd_cents,
j.offramp_provider, j.offramp_ref, j.relay_deposit_id,
j.target_card_id, j.last_error, j.attempt_count,
j.next_retry_at, j.received_at, j.funds_at_relay_at, j.settled_at,
j.created_at, j.updated_at,
(
SELECT COALESCE(SUM(amount_usd_cents), 0)
FROM vendor_obligations
WHERE order_id = j.order_id
) AS total_obligations_cents,
(
SELECT COUNT(*)
FROM vendor_obligations
WHERE order_id = j.order_id AND status = 'paid'
) AS obligations_paid
FROM crypto_payment_jobs j
${whereSql}
ORDER BY j.created_at DESC
LIMIT 200
`,
params,
);
res.json({ jobs: r.rows, count: r.rows.length });
},
);
// NOTE: specific paths (sweeps, tax-export) are registered BELOW before
// the /:order_id param route to avoid Express pattern-match conflicts
// (order_id='sweeps' would otherwise match this handler).
// ── GET /api/v1/admin/crypto-payments/:order_id ────────────────────────
// Registered AFTER the specific sub-paths below. Express matches in
// registration order; putting this last ensures /sweeps and
// /tax-export aren't interpreted as order_ids.
// ── Detail view (must come AFTER specific sub-paths below) ────────────
//
// We re-register this at the end of the file so Express pattern-matches
// /sweeps and /tax-export first.
// ── POST /api/v1/admin/crypto-payments/:order_id/retry-offramp ─────────
router.post(
"/api/v1/admin/crypto-payments/:order_id/retry-offramp",
async (req: Request, res: Response) => {
if (!requireAdminToken(req, res)) return;
const orderId = req.params.order_id;
const r = await pool.query(
`UPDATE crypto_payment_jobs
SET state = 'sizing',
last_error = NULL,
next_retry_at = NULL,
updated_at = NOW()
WHERE order_id = $1
AND state IN ('manual','failed','offramping')
RETURNING state, attempt_count`,
[orderId],
);
if (r.rows.length === 0) {
res.status(409).json({
error: "job not in a retry-able state (must be manual / failed / offramping)",
});
return;
}
await auditLog(
req.headers["x-admin-user"]?.toString() || "admin",
"crypto_retry_offramp", orderId, { new_state: "sizing" },
);
res.json({ ok: true, state: r.rows[0].state });
},
);
// ── POST /api/v1/admin/crypto-payments/:order_id/mark-settled ──────────
router.post(
"/api/v1/admin/crypto-payments/:order_id/mark-settled",
async (req: Request, res: Response) => {
if (!requireAdminToken(req, res)) return;
const orderId = req.params.order_id;
const { note } = req.body ?? {};
const r = await pool.query(
`UPDATE crypto_payment_jobs
SET state = 'settled',
settled_at = NOW(),
last_error = NULL,
updated_at = NOW()
WHERE order_id = $1
RETURNING state`,
[orderId],
);
if (r.rows.length === 0) {
res.status(404).json({ error: "job not found" }); return;
}
await auditLog(
req.headers["x-admin-user"]?.toString() || "admin",
"crypto_manual_settle", orderId, { note },
);
res.json({ ok: true });
},
);
// ── GET /api/v1/admin/crypto-payments/sweeps ───────────────────────────
router.get(
"/api/v1/admin/crypto-payments/sweeps",
async (req: Request, res: Response) => {
if (!requireAdminToken(req, res)) return;
const r = await pool.query(
`SELECT * FROM cold_wallet_sweeps
WHERE status IN ('pending','approved','broadcast')
ORDER BY created_at DESC
LIMIT 100`,
);
res.json({ sweeps: r.rows });
},
);
// ── POST /api/v1/admin/crypto-payments/sweeps/:id/approve ──────────────
router.post(
"/api/v1/admin/crypto-payments/sweeps/:id/approve",
async (req: Request, res: Response) => {
if (!requireAdminToken(req, res)) return;
const sweepId = Number(req.params.id);
if (!Number.isFinite(sweepId)) {
res.status(400).json({ error: "bad sweep id" }); return;
}
const actor = req.headers["x-admin-user"]?.toString() || "admin";
const r = await pool.query(
`UPDATE cold_wallet_sweeps
SET status = 'approved',
approved_by = $2,
approved_at = NOW(),
updated_at = NOW()
WHERE id = $1
AND status = 'pending'
RETURNING coin, amount_coin`,
[sweepId, actor],
);
if (r.rows.length === 0) {
res.status(409).json({ error: "sweep not in pending state" }); return;
}
await auditLog(actor, "crypto_sweep_approve", `sweep:${sweepId}`,
{ coin: r.rows[0].coin, amount_coin: r.rows[0].amount_coin });
res.json({ ok: true });
},
);
// ── GET /api/v1/admin/crypto-payments/tax-export?year=YYYY ─────────────
//
// IRS Form 8949 columns: description, date_acquired, date_sold,
// proceeds, cost_basis, gain_loss. Covers all offramp/disposal rows
// whose disposed_at is in the given tax year.
router.get(
"/api/v1/admin/crypto-payments/tax-export",
async (req: Request, res: Response) => {
if (!requireAdminToken(req, res)) return;
const year = Number(req.query.year) || new Date().getUTCFullYear() - 1;
const r = await pool.query(
`
SELECT order_id, coin,
amount_coin, fx_rate_usd,
acquired_at, disposed_at,
basis_usd_cents, proceeds_usd_cents,
provider, provider_ref
FROM crypto_payment_ledger
WHERE movement_type = 'offramp'
AND state IN ('pending','confirmed')
AND disposed_at >= make_timestamptz($1, 1, 1, 0, 0, 0, 'UTC')
AND disposed_at < make_timestamptz($1 + 1, 1, 1, 0, 0, 0, 'UTC')
ORDER BY disposed_at ASC, id ASC
`,
[year],
);
// Build the CSV
const header = [
"Description", // e.g., "0.00873 BTC — Order CO-SMOKE02"
"Date Acquired", // MM/DD/YYYY
"Date Sold",
"Proceeds (USD)",
"Cost Basis (USD)",
"Gain/(Loss) (USD)",
"Provider Reference",
];
const lines: string[] = [header.join(",")];
const fmt = (d: Date) =>
`${String(d.getUTCMonth() + 1).padStart(2, "0")}/${String(d.getUTCDate()).padStart(2, "0")}/${d.getUTCFullYear()}`;
for (const row of r.rows) {
const amount = Number(row.amount_coin);
const proceeds = Number(row.proceeds_usd_cents || 0) / 100;
const basis = Number(row.basis_usd_cents || 0) / 100;
const gain = proceeds - basis;
const acquired = row.acquired_at ? fmt(new Date(row.acquired_at)) : "";
const sold = row.disposed_at ? fmt(new Date(row.disposed_at)) : "";
const desc = `${Math.abs(amount).toFixed(8)} ${row.coin} — Order ${row.order_id}`;
lines.push([
`"${desc}"`,
`"${acquired}"`,
`"${sold}"`,
proceeds.toFixed(2),
basis.toFixed(2),
gain.toFixed(2),
`"${row.provider_ref || ""}"`,
].join(","));
}
res.setHeader("Content-Type", "text/csv");
res.setHeader(
"Content-Disposition",
`attachment; filename="crypto-disposals-${year}.csv"`,
);
res.send(lines.join("\n"));
},
);
// ── GET /api/v1/admin/crypto-payments/:order_id (must be LAST) ─────────
// Registered last so specific paths above match first.
router.get(
"/api/v1/admin/crypto-payments/:order_id",
async (req: Request, res: Response) => {
if (!requireAdminToken(req, res)) return;
const orderId = req.params.order_id;
const job = await pool.query(
"SELECT * FROM crypto_payment_jobs WHERE order_id = $1",
[orderId],
);
if (job.rows.length === 0) {
res.status(404).json({ error: "job not found" }); return;
}
const [ledger, obligations, deposit] = await Promise.all([
pool.query(
`SELECT * FROM crypto_payment_ledger WHERE order_id = $1 ORDER BY created_at ASC`,
[orderId],
),
pool.query(
`SELECT * FROM vendor_obligations WHERE order_id = $1 ORDER BY obligation_kind, id`,
[orderId],
),
job.rows[0].relay_deposit_id
? pool.query("SELECT * FROM relay_deposits WHERE id = $1", [job.rows[0].relay_deposit_id])
: Promise.resolve({ rows: [] }),
]);
res.json({
job: job.rows[0],
ledger: ledger.rows,
obligations: obligations.rows,
relay_deposit: deposit.rows[0] || null,
});
},
);
export default router;