Files
AgendaPro/server/routes/cash.ts
T
AgendaPro Dev e8d2435cc2 AgendaPro v1.0 - multi-tenant SaaS, mobile calendar, public booking
Multi-tenant scheduling SaaS (AgendaPro-equivalent):
- Backend: Node + Express + node:sqlite, multi-tenant (admin/owner/employee),
  versioned migrations, 4 templates (estetica-spa, barberia, clinica, blank).
- Frontend: React 18 + TS + Vite + Tailwind, FullCalendar drag-and-drop,
  Recharts dashboard, mobile-first (day/list views on mobile).
- Features: calendar+services+employees+clients+tickets, dashboard with
  best employee/service, top tickets, top/frequent clients, commissions,
  cancellation policy + no-show tracking, public online booking (/b/:slug),
  cash register (cierre de caja), reminders/notifications center.
- Verification: 65/65 e2e API, 58/58 visual (0 overflow, 0 console errors),
  typecheck clean, vite build OK.
2026-07-25 13:45:53 -06:00

120 lines
5.4 KiB
TypeScript

import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err, ownerOnly } from "../lib/auth.ts";
export const cashRouter = Router();
cashRouter.use(ownerOnly);
function sessionStats(sessionId: number, businessId: number) {
const row = db
.prepare(
`SELECT
COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE 0 END),0) income,
COALESCE(SUM(CASE WHEN type='expense' THEN amount ELSE 0 END),0) expense,
COUNT(*) n
FROM cash_entries WHERE session_id = ? AND business_id = ?`
)
.get(sessionId, businessId) as { income: number; expense: number; n: number };
const tickets = db
.prepare(
`SELECT COALESCE(SUM(amount),0) s, COUNT(*) n FROM cash_entries WHERE session_id = ? AND business_id = ? AND ticket_id IS NOT NULL`
)
.get(sessionId, businessId) as { s: number; n: number };
return {
income: Math.round(row.income * 100) / 100,
expense: Math.round(row.expense * 100) / 100,
entries: row.n,
ticket_income: Math.round(tickets.s * 100) / 100,
ticket_count: tickets.n,
};
}
cashRouter.get("/session", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const s = db
.prepare(`SELECT * FROM cash_sessions WHERE business_id = ? AND status = 'open' ORDER BY opened_at DESC LIMIT 1`)
.get(bid) as any;
if (!s) return res.json({ session: null });
res.json({ session: s, stats: sessionStats(s.id, bid) });
});
cashRouter.get("/sessions", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const limit = Math.min(60, Number(req.query.limit) || 30);
const rows = db
.prepare(`SELECT * FROM cash_sessions WHERE business_id = ? ORDER BY opened_at DESC LIMIT ?`)
.all(bid, limit) as any[];
const out = rows.map((s) => ({ session: s, stats: sessionStats(s.id, bid) }));
res.json({ sessions: out });
});
cashRouter.post("/open", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const open = db.prepare(`SELECT id FROM cash_sessions WHERE business_id = ? AND status = 'open'`).get(bid);
if (open) return err(res, 400, "Ya hay una caja abierta — ciérrala primero");
const { opening_amount = 0, note } = req.body ?? {};
const s = db
.prepare(
`INSERT INTO cash_sessions (business_id, opened_by_user_id, opening_amount, note) VALUES (?, ?, ?, ?) RETURNING *`
)
.get(bid, req.user!.id, Number(opening_amount) || 0, note || null) as any;
res.status(201).json({ session: s, stats: sessionStats(s.id, bid) });
});
cashRouter.post("/close", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const s = db.prepare(`SELECT * FROM cash_sessions WHERE business_id = ? AND status = 'open'`).get(bid) as any;
if (!s) return err(res, 400, "No hay caja abierta");
const { closing_amount, note } = req.body ?? {};
const stats = sessionStats(s.id, bid);
const expected = Number(s.opening_amount) + stats.income - stats.expense;
db.prepare(`UPDATE cash_sessions SET status='closed', closed_at=datetime('now'), closing_amount=?, expected_amount=?, note=COALESCE(?,note) WHERE id=?`).run(
Number(closing_amount) || 0,
Math.round(expected * 100) / 100,
note || null,
s.id
);
const updated = db.prepare(`SELECT * FROM cash_sessions WHERE id=?`).get(s.id) as any;
res.json({ session: updated, stats, expected: Math.round(expected * 100) / 100 });
});
cashRouter.get("/entries", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const sessionId = req.query.session_id ? Number(req.query.session_id) : null;
let rows: any[];
if (sessionId) {
rows = db.prepare(`SELECT * FROM cash_entries WHERE business_id = ? AND session_id = ? ORDER BY created_at DESC`).all(bid, sessionId);
} else {
const s = db.prepare(`SELECT id FROM cash_sessions WHERE business_id = ? AND status='open'`).get(bid) as any;
rows = s
? db.prepare(`SELECT * FROM cash_entries WHERE business_id = ? AND session_id = ? ORDER BY created_at DESC`).all(bid, s.id)
: [];
}
res.json({ entries: rows });
});
cashRouter.post("/entries", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const s = db.prepare(`SELECT id FROM cash_sessions WHERE business_id = ? AND status='open'`).get(bid) as any;
if (!s) return err(res, 400, "No hay caja abierta");
const { type, amount, concept, payment_method } = req.body ?? {};
if (!["income", "expense"].includes(type)) return err(res, 400, "Tipo inválido (income/expense)");
if (!amount || !concept) return err(res, 400, "Monto y concepto son obligatorios");
const r = db
.prepare(
`INSERT INTO cash_entries (business_id, session_id, type, amount, concept, payment_method) VALUES (?, ?, ?, ?, ?, ?) RETURNING *`
)
.get(bid, s.id, type, Number(amount), concept, payment_method || null) as any;
res.status(201).json({ entry: r, stats: sessionStats(s.id, bid) });
});
cashRouter.delete("/entries/:id", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const id = Number(req.params.id);
const e = db.prepare(`SELECT session_id FROM cash_entries WHERE id = ? AND business_id = ?`).get(id, bid) as any;
if (!e) return err(res, 404, "Movimiento no encontrado");
db.prepare(`DELETE FROM cash_entries WHERE id = ?`).run(id);
res.json({ ok: true, stats: sessionStats(e.session_id, bid) });
});