feat(gro-609): add Stripe details to invoice modal and fix stats date filter
- Add GET /api/invoices/:id/stripe-details endpoint to fetch card last4 and payment status from Stripe - Add getPaymentIntentDetails() to payment service - Fix stats summary query to filter by startOfMonth - Add cardLast4, paymentStatus, stripeRefundId transient fields to Invoice type - Display Stripe details (card last4, payment status, refund status) in modal - Add stripeRefundId and paymentFailureReason to Invoice schema (was missing in dev types) Ref: GRO-609 Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -422,7 +422,7 @@ invoicesRouter.patch(
|
|||||||
|
|
||||||
// ─── Refund ───────────────────────────────────────────────────────────────────
|
// ─── Refund ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import { processRefund } from "../services/payment.js";
|
import { processRefund, getPaymentIntentDetails } from "../services/payment.js";
|
||||||
|
|
||||||
const refundSchema = z.object({
|
const refundSchema = z.object({
|
||||||
amountCents: z.number().int().nonnegative().optional(),
|
amountCents: z.number().int().nonnegative().optional(),
|
||||||
@@ -515,3 +515,30 @@ invoicesRouter.get("/stats/summary", async (c) => {
|
|||||||
methodBreakdown,
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -162,3 +162,19 @@ export async function createSetupIntent(customerId: string): Promise<{ clientSec
|
|||||||
|
|
||||||
return { clientSecret: setupIntent.client_secret! };
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -176,6 +176,19 @@ function InvoiceDetailModal({
|
|||||||
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 [partialAmount, setPartialAmount] = useState("");
|
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}
|
// Tip split state: array of {staffId, staffName, pct}
|
||||||
const linkedAppt = invoice.appointmentId
|
const linkedAppt = invoice.appointmentId
|
||||||
@@ -367,6 +380,19 @@ function InvoiceDetailModal({
|
|||||||
/>
|
/>
|
||||||
{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} />}
|
||||||
|
{stripeDetails && (
|
||||||
|
<>
|
||||||
|
{stripeDetails.cardLast4 && (
|
||||||
|
<SummaryRow label="Card" value={`•••• ${stripeDetails.cardLast4}`} />
|
||||||
|
)}
|
||||||
|
{stripeDetails.paymentStatus && (
|
||||||
|
<SummaryRow label="Stripe status" value={stripeDetails.paymentStatus} />
|
||||||
|
)}
|
||||||
|
{stripeDetails.stripeRefundId && (
|
||||||
|
<SummaryRow label="Refund" value="Refunded" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Tip Distribution ── */}
|
{/* ── Tip Distribution ── */}
|
||||||
|
|||||||
@@ -159,6 +159,9 @@ export interface Invoice {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lineItems?: InvoiceLineItem[];
|
lineItems?: InvoiceLineItem[];
|
||||||
|
// Transient fields populated from Stripe API (not stored in DB)
|
||||||
|
cardLast4?: string | null;
|
||||||
|
paymentStatus?: string | null;
|
||||||
tipSplits?: InvoiceTipSplit[];
|
tipSplits?: InvoiceTipSplit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user