diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 9bb8790..91ac4ee 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -102,6 +102,7 @@ invoicesRouter.get( paidAt: invoices.paidAt, notes: invoices.notes, stripePaymentIntentId: invoices.stripePaymentIntentId, + stripeRefundId: invoices.stripeRefundId, createdAt: invoices.createdAt, updatedAt: invoices.updatedAt, }) @@ -129,7 +130,17 @@ invoicesRouter.get("/:id", async (c) => { db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)), ]); - return c.json({ ...invoice, lineItems, tipSplits }); + let cardLast4: string | null = null; + let paymentStatus: string | null = null; + if (invoice.stripePaymentIntentId) { + const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId); + if (details) { + cardLast4 = details.cardLast4; + paymentStatus = details.paymentStatus; + } + } + + return c.json({ ...invoice, lineItems, tipSplits, cardLast4, paymentStatus }); }); // Save tip splits for an invoice (replaces existing splits) @@ -449,9 +460,6 @@ invoicesRouter.post( if (invoice.status !== "paid") { return c.json({ error: "Refund only allowed on paid invoices" }, 422); } - if (!invoice.stripePaymentIntentId) { - return c.json({ error: "No Stripe payment intent found for this invoice" }, 422); - } return await db.transaction(async (tx) => { if (body.idempotencyKey) { @@ -464,17 +472,25 @@ invoicesRouter.post( } } - const result = await processRefund(id, body.amountCents); - if (!result) return c.json({ error: "Refund failed" }, 500); + let refundId: string; + + if (invoice.stripePaymentIntentId) { + const result = await processRefund(id, body.amountCents); + if (!result) return c.json({ error: "Refund failed" }, 500); + refundId = result.refundId; + } else { + // Manual refund — no Stripe call needed + refundId = `manual_${id}_${Date.now()}`; + } await tx.insert(refunds).values({ invoiceId: id, - stripeRefundId: result.refundId, + stripeRefundId: refundId, idempotencyKey: body.idempotencyKey ?? null, amountCents: body.amountCents ?? null, }); - return c.json({ refundId: result.refundId }); + return c.json({ refundId }); }); } ); diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx index 76dcbfd..0eceb4c 100644 --- a/apps/web/src/pages/Invoices.tsx +++ b/apps/web/src/pages/Invoices.tsx @@ -173,22 +173,21 @@ function InvoiceDetailModal({ const [error, setError] = useState(null); const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2)); const [paymentMethod, setPaymentMethod] = useState(invoice.paymentMethod ?? "cash"); - const [showRefundDialog, setShowRefundDialog] = useState(false); +const [showRefundDialog, setShowRefundDialog] = useState(false); const [refundType, setRefundType] = useState<"full" | "partial">("full"); - const [partialAmount, setPartialAmount] = useState(""); - const [stripeDetails, setStripeDetails] = useState<{ cardLast4: string | null; paymentStatus: string | null; stripeRefundId: string | null } | null>(null); + const [refundAmount, setRefundAmount] = useState(""); + const [refundError, setRefundError] = useState(null); + const [refunding, setRefunding] = useState(false); - // Fetch Stripe details when modal opens for paid invoices with a payment intent + // Fetch current staff role to determine manager access + const [staffMe, setStaffMe] = useState<{ role: string; isSuperUser: boolean } | null>(null); useEffect(() => { - 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]); + fetch("/api/staff/me") + .then((r) => r.json()) + .then((d) => setStaffMe(d)) + .catch(() => setStaffMe(null)); + }, []); + const isManager = staffMe && (staffMe.role === "manager" || staffMe.isSuperUser); // Tip split state: array of {staffId, staffName, pct} const linkedAppt = invoice.appointmentId @@ -292,35 +291,6 @@ function InvoiceDetailModal({ } } - 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

Loading…

; const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0; @@ -380,15 +350,15 @@ function InvoiceDetailModal({ /> {invoice.paidAt && } {invoice.paymentMethod && } - {stripeDetails && ( + {invoice.stripePaymentIntentId && ( <> - {stripeDetails.cardLast4 && ( - + {invoice.cardLast4 && ( + )} - {stripeDetails.paymentStatus && ( - + {invoice.paymentStatus && ( + )} - {stripeDetails.stripeRefundId && ( + {invoice.stripeRefundId && ( )} @@ -510,77 +480,92 @@ function InvoiceDetailModal({ )} {(invoice.status === "paid" || invoice.status === "void") && ( -
- {invoice.status === "paid" && invoice.stripePaymentIntentId && ( - +
+ {invoice.stripeRefundId && ( +
+ Refunded +
)} - +
+ {invoice.status === "paid" && !invoice.stripeRefundId && isManager && ( + + )} + +
)} - {/* Refund Dialog */} {showRefundDialog && ( - setShowRefundDialog(false)}> -

Issue Refund

-

- Invoice total: {fmtMoney(invoice.totalCents)} -

-
-