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,
paidAt: invoices.paidAt,
notes: invoices.notes,
stripePaymentIntentId: invoices.stripePaymentIntentId,
stripeRefundId: invoices.stripeRefundId,
createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt,
})
@@ -482,50 +480,40 @@ invoicesRouter.post(
// Payment stats for admin dashboard
invoicesRouter.get("/stats/summary", async (c) => {
try {
const db = getDb();
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const db = getDb();
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const [revenueResult] = await db
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
.from(invoices)
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`));
const [revenueResult] = await db
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
.from(invoices)
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`));
const [outstandingResult] = await db
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
.from(invoices)
.where(eq(invoices.status, "pending"));
const [outstandingResult] = await db
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
.from(invoices)
.where(eq(invoices.status, "pending"));
const [refundsResult] = await db
.select({ total: sql<number>`coalesce(sum(amount_cents), 0)` })
.from(refunds)
.where(sql`${refunds.createdAt} >= ${startOfMonth}`);
const [refundsResult] = await db
.select({ total: sql<number>`coalesce(sum(amount_cents), 0)` })
.from(refunds)
.where(sql`${refunds.createdAt} >= ${startOfMonth}`);
const methodBreakdown = await db
.select({
method: invoices.paymentMethod,
total: sql<number>`count(*)`,
})
.from(invoices)
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`))
.groupBy(invoices.paymentMethod);
const methodBreakdown = await db
.select({
method: invoices.paymentMethod,
total: sql<number>`count(*)`,
})
.from(invoices)
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`))
.groupBy(invoices.paymentMethod);
return c.json({
revenueThisMonth: revenueResult?.total ?? 0,
outstanding: outstandingResult?.total ?? 0,
refundsThisMonth: refundsResult?.total ?? 0,
methodBreakdown,
});
} catch (err) {
console.error("stats/summary error:", err);
return c.json({
revenueThisMonth: 0,
outstanding: 0,
refundsThisMonth: 0,
methodBreakdown: [],
});
}
return c.json({
revenueThisMonth: revenueResult?.total ?? 0,
outstanding: outstandingResult?.total ?? 0,
refundsThisMonth: refundsResult?.total ?? 0,
methodBreakdown,
});
});
// 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.
* Returns the image bytes with proper Content-Type.
*/
settingsRouter.get("/logo", async (c) => {
settingsRouter.get("/logo", requireSuperUser(), async (c) => {
const db = getDb();
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");
// null key = unassigned; staffId string = that groomer; undefined set = all visible
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);
useEffect(() => {
fetch("/api/invoices/stats/summary")
.then((r) => r.ok ? r.json() : null)
.then((data) => { if (data) setPaymentStats(data); })
.catch(() => {});
}, []);
const loadAppointments = useCallback(() => {
const from = weekStart.toISOString();
const to = addDays(weekStart, 7).toISOString();
@@ -322,24 +314,6 @@ export function AppointmentsPage() {
</button>
</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 ── */}
<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>
+96 -74
View File
@@ -173,21 +173,22 @@ function InvoiceDetailModal({
const [error, setError] = useState<string | null>(null);
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
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 [refundAmount, setRefundAmount] = useState("");
const [refundError, setRefundError] = useState<string | null>(null);
const [refunding, setRefunding] = useState(false);
const [partialAmount, setPartialAmount] = useState("");
const [stripeDetails, setStripeDetails] = useState<{ cardLast4: string | null; paymentStatus: string | null; stripeRefundId: string | null } | null>(null);
// Fetch current staff role to determine manager access
const [staffMe, setStaffMe] = useState<{ role: string; isSuperUser: boolean } | null>(null);
// Fetch Stripe details when modal opens for paid invoices with a payment intent
useEffect(() => {
fetch("/api/staff/me")
.then((r) => r.json())
.then((d) => setStaffMe(d))
.catch(() => setStaffMe(null));
}, []);
const isManager = staffMe && (staffMe.role === "manager" || staffMe.isSuperUser);
if (invoice.status === "paid" && invoice.stripePaymentIntentId) {
fetch(`/api/invoices/${invoice.id}/stripe-details`)
.then((r) => r.ok ? r.json() : null)
.then((data) => { if (data) setStripeDetails(data); })
.catch(() => {});
} else {
setStripeDetails(null);
}
}, [invoice.id, invoice.status, invoice.stripePaymentIntentId]);
// Tip split state: array of {staffId, staffName, pct}
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>;
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.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
{invoice.stripePaymentIntentId && (
{stripeDetails && (
<>
{invoice.cardLast4 && (
<SummaryRow label="Card" value={`•••• ${invoice.cardLast4}`} />
{stripeDetails.cardLast4 && (
<SummaryRow label="Card" value={`•••• ${stripeDetails.cardLast4}`} />
)}
{invoice.paymentStatus && (
<SummaryRow label="Stripe status" value={invoice.paymentStatus} />
{stripeDetails.paymentStatus && (
<SummaryRow label="Stripe status" value={stripeDetails.paymentStatus} />
)}
{invoice.stripeRefundId && (
{stripeDetails.stripeRefundId && (
<SummaryRow label="Refund" value="Refunded" />
)}
</>
@@ -480,85 +510,77 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
</div>
)}
{(invoice.status === "paid" || invoice.status === "void") && (
<div style={{ marginTop: "1rem", borderTop: "1px solid #e2e8f0", paddingTop: "1rem" }}>
{invoice.stripeRefundId && (
<div style={{ marginBottom: "0.75rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span style={{ background: "#fef3c7", color: "#92400e", padding: "0.2rem 0.6rem", borderRadius: 4, fontSize: 13, fontWeight: 600 }}>Refunded</span>
</div>
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
{invoice.status === "paid" && invoice.stripePaymentIntentId && (
<button
onClick={() => setShowRefundDialog(true)}
style={{ ...btnStyle, color: "#b45309", borderColor: "#b45309" }}
>
Refund
</button>
)}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end" }}>
{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>
<button onClick={onClose} style={btnStyle}>Close</button>
</div>
)}
{/* Refund Dialog */}
{showRefundDialog && (
<div style={{ marginTop: "1rem", border: "1px solid #e2e8f0", borderRadius: 8, padding: "1rem", background: "#f9fafb" }}>
<p style={{ fontWeight: 600, margin: "0 0 0.75rem" }}>Process Refund</p>
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}>
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
<input type="radio" checked={refundType === "full"} onChange={() => setRefundType("full")} />
<Modal onClose={() => setShowRefundDialog(false)}>
<h2 style={{ marginTop: 0 }}>Issue Refund</h2>
<p style={{ fontSize: 14, color: "#6b7280", marginBottom: "1rem" }}>
Invoice total: <strong>{fmtMoney(invoice.totalCents)}</strong>
</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
</label>
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
<input type="radio" checked={refundType === "partial"} onChange={() => setRefundType("partial")} />
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600 }}>
<input
type="radio"
name="refundType"
value="partial"
checked={refundType === "partial"}
onChange={() => setRefundType("partial")}
/>
Partial refund
</label>
</div>
{refundType === "partial" && (
<div style={{ marginBottom: "0.75rem" }}>
<div style={{ marginBottom: "1rem" }}>
<input
type="number"
min="0.01"
step="0.01"
placeholder="Amount ($)"
value={refundAmount}
onChange={(e) => setRefundAmount(e.target.value)}
style={{ ...inputStyle, width: 100 }}
placeholder="0.00"
value={partialAmount}
onChange={(e) => setPartialAmount(e.target.value)}
style={{ ...inputStyle, width: 120 }}
/>
</div>
)}
{refundError && <p style={{ color: "red", margin: "0 0 0.5rem", fontSize: 13 }}>{refundError}</p>}
<div style={{ display: "flex", gap: "0.5rem" }}>
{error && <p style={{ color: "red", margin: "0.5rem 0" }}>{error}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }}>
<button
onClick={async () => {
setRefunding(true);
setRefundError(null);
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" }}
onClick={issueRefund}
disabled={saving}
style={{ ...btnStyle, backgroundColor: "#b45309", color: "#fff", borderColor: "#b45309" }}
>
{refunding ? "Processing…" : "Process Refund"}
{saving ? "Processing…" : "Issue Refund"}
</button>
<button onClick={() => setShowRefundDialog(false)} style={btnStyle}>
Cancel
</button>
<button onClick={() => { setShowRefundDialog(false); setRefundError(null); }} style={btnStyle}>Cancel</button>
</div>
</div>
</Modal>
)}
</Modal>
</Modal>
);
}