diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts
index 5a730b9..0d2565a 100644
--- a/apps/api/src/routes/invoices.ts
+++ b/apps/api/src/routes/invoices.ts
@@ -422,7 +422,7 @@ invoicesRouter.patch(
// ─── Refund ───────────────────────────────────────────────────────────────────
-import { processRefund } from "../services/payment.js";
+import { processRefund, getPaymentIntentDetails } from "../services/payment.js";
const refundSchema = z.object({
amountCents: z.number().int().nonnegative().optional(),
@@ -515,3 +515,30 @@ invoicesRouter.get("/stats/summary", async (c) => {
methodBreakdown,
});
});
+
+// Get Stripe payment details for an invoice (card last4, payment status, refund status)
+invoicesRouter.get("/:id/stripe-details", async (c) => {
+ const db = getDb();
+ const id = c.req.param("id");
+
+ const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
+ if (!invoice) return c.json({ error: "Not found" }, 404);
+
+ 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({
+ stripePaymentIntentId: invoice.stripePaymentIntentId,
+ stripeRefundId: invoice.stripeRefundId,
+ cardLast4,
+ paymentStatus,
+ });
+});
diff --git a/apps/api/src/services/payment.ts b/apps/api/src/services/payment.ts
index d09d1db..cad0894 100644
--- a/apps/api/src/services/payment.ts
+++ b/apps/api/src/services/payment.ts
@@ -162,3 +162,19 @@ export async function createSetupIntent(customerId: string): Promise<{ clientSec
return { clientSecret: setupIntent.client_secret! };
}
+
+export async function getPaymentIntentDetails(
+ paymentIntentId: string
+): Promise<{ cardLast4: string | null; paymentStatus: string | null } | null> {
+ const stripe = getStripeClient();
+ if (!stripe) return null;
+
+ const pi = await stripe.paymentIntents.retrieve(paymentIntentId);
+ const cardLast4 = pi.payment_method
+ ? (pi.payment_method as Stripe.PaymentMethod).card?.last4 ?? null
+ : null;
+ return {
+ cardLast4,
+ paymentStatus: pi.status ?? null,
+ };
+}
diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx
index 5058442..76dcbfd 100644
--- a/apps/web/src/pages/Invoices.tsx
+++ b/apps/web/src/pages/Invoices.tsx
@@ -176,6 +176,19 @@ function InvoiceDetailModal({
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);
+
+ // Fetch Stripe details when modal opens for paid invoices with a payment intent
+ 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]);
// Tip split state: array of {staffId, staffName, pct}
const linkedAppt = invoice.appointmentId
@@ -367,6 +380,19 @@ function InvoiceDetailModal({
/>
{invoice.paidAt && }
{invoice.paymentMethod && }
+ {stripeDetails && (
+ <>
+ {stripeDetails.cardLast4 && (
+
+ )}
+ {stripeDetails.paymentStatus && (
+
+ )}
+ {stripeDetails.stripeRefundId && (
+
+ )}
+ >
+ )}
{/* ── Tip Distribution ── */}
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 918bfc2..90ef116 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -159,6 +159,9 @@ export interface Invoice {
createdAt: string;
updatedAt: string;
lineItems?: InvoiceLineItem[];
+ // Transient fields populated from Stripe API (not stored in DB)
+ cardLast4?: string | null;
+ paymentStatus?: string | null;
tipSplits?: InvoiceTipSplit[];
}