Compare commits

..

1 Commits

Author SHA1 Message Date
Flea Flicker 3d45582609 fix(GRO-874): add requireSuperUser() to GET /api/admin/settings/logo
The logo proxy route was missing auth middleware, allowing any
unauthenticated caller to receive the presigned S3 URL and exposing
the internal Ceph RGW hostname. Matches auth pattern used by all
other /api/admin/* routes in this file.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-22 03:42:29 +00:00
4 changed files with 126 additions and 142 deletions
+29 -41
View File
@@ -101,8 +101,6 @@ invoicesRouter.get(
paymentMethod: invoices.paymentMethod, paymentMethod: invoices.paymentMethod,
paidAt: invoices.paidAt, paidAt: invoices.paidAt,
notes: invoices.notes, notes: invoices.notes,
stripePaymentIntentId: invoices.stripePaymentIntentId,
stripeRefundId: invoices.stripeRefundId,
createdAt: invoices.createdAt, createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt, updatedAt: invoices.updatedAt,
}) })
@@ -482,50 +480,40 @@ invoicesRouter.post(
// Payment stats for admin dashboard // Payment stats for admin dashboard
invoicesRouter.get("/stats/summary", async (c) => { invoicesRouter.get("/stats/summary", async (c) => {
try { const db = getDb();
const db = getDb(); const now = new Date();
const now = new Date(); const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const [revenueResult] = await db const [revenueResult] = await db
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` }) .select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
.from(invoices) .from(invoices)
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`)); .where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`));
const [outstandingResult] = await db const [outstandingResult] = await db
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` }) .select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
.from(invoices) .from(invoices)
.where(eq(invoices.status, "pending")); .where(eq(invoices.status, "pending"));
const [refundsResult] = await db const [refundsResult] = await db
.select({ total: sql<number>`coalesce(sum(amount_cents), 0)` }) .select({ total: sql<number>`coalesce(sum(amount_cents), 0)` })
.from(refunds) .from(refunds)
.where(sql`${refunds.createdAt} >= ${startOfMonth}`); .where(sql`${refunds.createdAt} >= ${startOfMonth}`);
const methodBreakdown = await db const methodBreakdown = await db
.select({ .select({
method: invoices.paymentMethod, method: invoices.paymentMethod,
total: sql<number>`count(*)`, total: sql<number>`count(*)`,
}) })
.from(invoices) .from(invoices)
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`)) .where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`))
.groupBy(invoices.paymentMethod); .groupBy(invoices.paymentMethod);
return c.json({ return c.json({
revenueThisMonth: revenueResult?.total ?? 0, revenueThisMonth: revenueResult?.total ?? 0,
outstanding: outstandingResult?.total ?? 0, outstanding: outstandingResult?.total ?? 0,
refundsThisMonth: refundsResult?.total ?? 0, refundsThisMonth: refundsResult?.total ?? 0,
methodBreakdown, methodBreakdown,
}); });
} catch (err) {
console.error("stats/summary error:", err);
return c.json({
revenueThisMonth: 0,
outstanding: 0,
refundsThisMonth: 0,
methodBreakdown: [],
});
}
}); });
// Get Stripe payment details for an invoice (card last4, payment status, refund status) // Get Stripe payment details for an invoice (card last4, payment status, refund status)
+1 -1
View File
@@ -218,7 +218,7 @@ settingsRouter.post(
* Proxies the logo from S3 so the browser never sees an S3 URL. * Proxies the logo from S3 so the browser never sees an S3 URL.
* Returns the image bytes with proper Content-Type. * Returns the image bytes with proper Content-Type.
*/ */
settingsRouter.get("/logo", async (c) => { settingsRouter.get("/logo", requireSuperUser(), async (c) => {
const db = getDb(); const db = getDb();
const [row] = await db.select().from(businessSettings).limit(1); const [row] = await db.select().from(businessSettings).limit(1);
-26
View File
@@ -112,17 +112,9 @@ export function AppointmentsPage() {
const [viewMode, setViewMode] = useState<"status" | "groomer">("status"); const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
// null key = unassigned; staffId string = that groomer; undefined set = all visible // null key = unassigned; staffId string = that groomer; undefined set = all visible
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set()); const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set());
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
const weekEnd = addDays(weekStart, 6); const weekEnd = addDays(weekStart, 6);
useEffect(() => {
fetch("/api/invoices/stats/summary")
.then((r) => r.ok ? r.json() : null)
.then((data) => { if (data) setPaymentStats(data); })
.catch(() => {});
}, []);
const loadAppointments = useCallback(() => { const loadAppointments = useCallback(() => {
const from = weekStart.toISOString(); const from = weekStart.toISOString();
const to = addDays(weekStart, 7).toISOString(); const to = addDays(weekStart, 7).toISOString();
@@ -322,24 +314,6 @@ export function AppointmentsPage() {
</button> </button>
</div> </div>
{/* Payment Stats Summary */}
{paymentStats && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1.25rem" }}>
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#166534", fontWeight: 600, marginBottom: "0.25rem" }}>Revenue (paid)</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#15803d" }}>${(paymentStats.revenueThisMonth / 100).toFixed(2)}</div>
</div>
<div style={{ background: "#fefce8", border: "1px solid #fde047", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#854d0e", fontWeight: 600, marginBottom: "0.25rem" }}>Outstanding</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#a16207" }}>${(paymentStats.outstanding / 100).toFixed(2)}</div>
</div>
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#991b1b", fontWeight: 600, marginBottom: "0.25rem" }}>Refunds (this mo.)</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#dc2626" }}>${(paymentStats.refundsThisMonth / 100).toFixed(2)}</div>
</div>
</div>
)}
{/* ── View Mode + Groomer Filters ── */} {/* ── View Mode + Groomer Filters ── */}
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}> <div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span> <span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
+96 -74
View File
@@ -173,21 +173,22 @@ function InvoiceDetailModal({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2)); const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash"); const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
const [showRefundDialog, setShowRefundDialog] = useState(false); const [showRefundDialog, setShowRefundDialog] = useState(false);
const [refundType, setRefundType] = useState<"full" | "partial">("full"); const [refundType, setRefundType] = useState<"full" | "partial">("full");
const [refundAmount, setRefundAmount] = useState(""); const [partialAmount, setPartialAmount] = useState("");
const [refundError, setRefundError] = useState<string | null>(null); const [stripeDetails, setStripeDetails] = useState<{ cardLast4: string | null; paymentStatus: string | null; stripeRefundId: string | null } | null>(null);
const [refunding, setRefunding] = useState(false);
// Fetch current staff role to determine manager access // Fetch Stripe details when modal opens for paid invoices with a payment intent
const [staffMe, setStaffMe] = useState<{ role: string; isSuperUser: boolean } | null>(null);
useEffect(() => { useEffect(() => {
fetch("/api/staff/me") if (invoice.status === "paid" && invoice.stripePaymentIntentId) {
.then((r) => r.json()) fetch(`/api/invoices/${invoice.id}/stripe-details`)
.then((d) => setStaffMe(d)) .then((r) => r.ok ? r.json() : null)
.catch(() => setStaffMe(null)); .then((data) => { if (data) setStripeDetails(data); })
}, []); .catch(() => {});
const isManager = staffMe && (staffMe.role === "manager" || staffMe.isSuperUser); } else {
setStripeDetails(null);
}
}, [invoice.id, invoice.status, invoice.stripePaymentIntentId]);
// Tip split state: array of {staffId, staffName, pct} // Tip split state: array of {staffId, staffName, pct}
const linkedAppt = invoice.appointmentId const linkedAppt = invoice.appointmentId
@@ -291,6 +292,35 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
} }
} }
async function issueRefund() {
const amountCents = refundType === "partial"
? Math.round(parseFloat(partialAmount) * 100)
: undefined;
if (refundType === "partial" && (!amountCents || amountCents <= 0)) {
setError("Enter a valid refund amount");
return;
}
setSaving(true);
setError(null);
try {
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(amountCents ? { amountCents } : {}),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
setShowRefundDialog(false);
onUpdated();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to issue refund");
} finally {
setSaving(false);
}
}
if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading</p></Modal>; if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading</p></Modal>;
const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0; const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0;
@@ -350,15 +380,15 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
/> />
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />} {invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />} {invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
{invoice.stripePaymentIntentId && ( {stripeDetails && (
<> <>
{invoice.cardLast4 && ( {stripeDetails.cardLast4 && (
<SummaryRow label="Card" value={`•••• ${invoice.cardLast4}`} /> <SummaryRow label="Card" value={`•••• ${stripeDetails.cardLast4}`} />
)} )}
{invoice.paymentStatus && ( {stripeDetails.paymentStatus && (
<SummaryRow label="Stripe status" value={invoice.paymentStatus} /> <SummaryRow label="Stripe status" value={stripeDetails.paymentStatus} />
)} )}
{invoice.stripeRefundId && ( {stripeDetails.stripeRefundId && (
<SummaryRow label="Refund" value="Refunded" /> <SummaryRow label="Refund" value="Refunded" />
)} )}
</> </>
@@ -480,85 +510,77 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
</div> </div>
)} )}
{(invoice.status === "paid" || invoice.status === "void") && ( {(invoice.status === "paid" || invoice.status === "void") && (
<div style={{ marginTop: "1rem", borderTop: "1px solid #e2e8f0", paddingTop: "1rem" }}> <div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
{invoice.stripeRefundId && ( {invoice.status === "paid" && invoice.stripePaymentIntentId && (
<div style={{ marginBottom: "0.75rem", display: "flex", alignItems: "center", gap: "0.5rem" }}> <button
<span style={{ background: "#fef3c7", color: "#92400e", padding: "0.2rem 0.6rem", borderRadius: 4, fontSize: 13, fontWeight: 600 }}>Refunded</span> onClick={() => setShowRefundDialog(true)}
</div> style={{ ...btnStyle, color: "#b45309", borderColor: "#b45309" }}
>
Refund
</button>
)} )}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end" }}> <button onClick={onClose} style={btnStyle}>Close</button>
{invoice.status === "paid" && invoice.stripePaymentIntentId && !invoice.stripeRefundId && isManager && (
<button onClick={() => setShowRefundDialog(true)} style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}>
Refund
</button>
)}
<button onClick={onClose} style={btnStyle}>Close</button>
</div>
</div> </div>
)} )}
{/* Refund Dialog */}
{showRefundDialog && ( {showRefundDialog && (
<div style={{ marginTop: "1rem", border: "1px solid #e2e8f0", borderRadius: 8, padding: "1rem", background: "#f9fafb" }}> <Modal onClose={() => setShowRefundDialog(false)}>
<p style={{ fontWeight: 600, margin: "0 0 0.75rem" }}>Process Refund</p> <h2 style={{ marginTop: 0 }}>Issue Refund</h2>
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}> <p style={{ fontSize: 14, color: "#6b7280", marginBottom: "1rem" }}>
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}> Invoice total: <strong>{fmtMoney(invoice.totalCents)}</strong>
<input type="radio" checked={refundType === "full"} onChange={() => setRefundType("full")} /> </p>
<div style={{ marginBottom: "0.75rem" }}>
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600, marginBottom: "0.5rem" }}>
<input
type="radio"
name="refundType"
value="full"
checked={refundType === "full"}
onChange={() => setRefundType("full")}
/>
Full refund Full refund
</label> </label>
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}> <label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600 }}>
<input type="radio" checked={refundType === "partial"} onChange={() => setRefundType("partial")} /> <input
type="radio"
name="refundType"
value="partial"
checked={refundType === "partial"}
onChange={() => setRefundType("partial")}
/>
Partial refund Partial refund
</label> </label>
</div> </div>
{refundType === "partial" && ( {refundType === "partial" && (
<div style={{ marginBottom: "0.75rem" }}> <div style={{ marginBottom: "1rem" }}>
<input <input
type="number" type="number"
min="0.01" min="0.01"
step="0.01" step="0.01"
placeholder="Amount ($)" placeholder="0.00"
value={refundAmount} value={partialAmount}
onChange={(e) => setRefundAmount(e.target.value)} onChange={(e) => setPartialAmount(e.target.value)}
style={{ ...inputStyle, width: 100 }} style={{ ...inputStyle, width: 120 }}
/> />
</div> </div>
)} )}
{refundError && <p style={{ color: "red", margin: "0 0 0.5rem", fontSize: 13 }}>{refundError}</p>} {error && <p style={{ color: "red", margin: "0.5rem 0" }}>{error}</p>}
<div style={{ display: "flex", gap: "0.5rem" }}> <div style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }}>
<button <button
onClick={async () => { onClick={issueRefund}
setRefunding(true); disabled={saving}
setRefundError(null); style={{ ...btnStyle, backgroundColor: "#b45309", color: "#fff", borderColor: "#b45309" }}
try {
const body = refundType === "partial" ? { amountCents: Math.round(parseFloat(refundAmount) * 100) } : {};
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
setShowRefundDialog(false);
onUpdated();
} catch (e: unknown) {
setRefundError(e instanceof Error ? e.message : "Refund failed");
} finally {
setRefunding(false);
}
}}
disabled={refunding}
style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}
> >
{refunding ? "Processing…" : "Process Refund"} {saving ? "Processing…" : "Issue Refund"}
</button>
<button onClick={() => setShowRefundDialog(false)} style={btnStyle}>
Cancel
</button> </button>
<button onClick={() => { setShowRefundDialog(false); setRefundError(null); }} style={btnStyle}>Cancel</button>
</div> </div>
</div> </Modal>
)} )}
</Modal>
</Modal>
); );
} }