Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fed390848b | |||
| 67e2157975 | |||
| 46e2af446f |
+16
-1
@@ -187,9 +187,24 @@ api.route("/search", searchRouter);
|
|||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
await initAuth();
|
await initAuth();
|
||||||
console.log(`API server listening on port ${port}`);
|
console.log(`API server listening on port ${port}`);
|
||||||
serve({ fetch: app.fetch, port });
|
const server = serve({ fetch: app.fetch, port });
|
||||||
|
|
||||||
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
|
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
|
||||||
startReminderScheduler();
|
startReminderScheduler();
|
||||||
|
|
||||||
|
function shutdown() {
|
||||||
|
console.log("Shutting down gracefully...");
|
||||||
|
server.close(() => {
|
||||||
|
console.log("HTTP server closed");
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
console.error("Forced shutdown after timeout");
|
||||||
|
process.exit(1);
|
||||||
|
}, 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { z } from "zod/v3";
|
|||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
eq,
|
eq,
|
||||||
gte,
|
|
||||||
getDb,
|
getDb,
|
||||||
invoices,
|
invoices,
|
||||||
invoiceLineItems,
|
invoiceLineItems,
|
||||||
@@ -378,106 +377,3 @@ invoicesRouter.post(
|
|||||||
return c.json({ refundId: result.refundId });
|
return c.json({ refundId: result.refundId });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Stripe Payment Info ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import { getStripeClient } from "../services/payment.js";
|
|
||||||
|
|
||||||
invoicesRouter.get("/:id/stripe-payment", 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);
|
|
||||||
|
|
||||||
if (!invoice.stripePaymentIntentId) {
|
|
||||||
return c.json({ error: "No Stripe payment found for this invoice" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
|
||||||
if (!stripe) return c.json({ error: "Stripe not configured" }, 503);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const paymentIntent = await stripe.paymentIntents.retrieve(invoice.stripePaymentIntentId);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const cardDetails = (paymentIntent as any).payment_details?.card;
|
|
||||||
const refundStatus = invoice.stripeRefundId
|
|
||||||
? await stripe.refunds.retrieve(invoice.stripeRefundId).then((r) => r.status).catch(() => null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return c.json({
|
|
||||||
paymentIntentId: invoice.stripePaymentIntentId,
|
|
||||||
amountPaidCents: paymentIntent.amount_received,
|
|
||||||
status: paymentIntent.status,
|
|
||||||
cardLast4: cardDetails?.last4 ?? null,
|
|
||||||
cardBrand: cardDetails?.brand ?? null,
|
|
||||||
refundId: invoice.stripeRefundId,
|
|
||||||
refundStatus,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return c.json({ error: "Failed to retrieve Stripe payment info" }, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Payment Stats ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
invoicesRouter.get("/stats", async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
const now = new Date();
|
|
||||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
||||||
|
|
||||||
const thisMonthInvoices = await db
|
|
||||||
.select()
|
|
||||||
.from(invoices)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
gte(invoices.createdAt, startOfMonth),
|
|
||||||
eq(invoices.status, "paid")
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const revenueCents = thisMonthInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
|
|
||||||
|
|
||||||
const pendingInvoices = await db
|
|
||||||
.select({ totalCents: invoices.totalCents })
|
|
||||||
.from(invoices)
|
|
||||||
.where(eq(invoices.status, "pending"));
|
|
||||||
|
|
||||||
const outstandingCents = pendingInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
|
|
||||||
|
|
||||||
const refundedInvoices = await db
|
|
||||||
.select()
|
|
||||||
.from(invoices)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
gte(invoices.createdAt, startOfMonth),
|
|
||||||
sql`${invoices.stripeRefundId} IS NOT NULL`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const refundsCents = refundedInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
|
|
||||||
|
|
||||||
const paymentMethodBreakdown = await db
|
|
||||||
.select({
|
|
||||||
paymentMethod: invoices.paymentMethod,
|
|
||||||
count: sql<number>`count(*)`,
|
|
||||||
totalCents: sql<number>`sum(${invoices.totalCents})`,
|
|
||||||
})
|
|
||||||
.from(invoices)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
gte(invoices.createdAt, startOfMonth),
|
|
||||||
sql`${invoices.paymentMethod} IS NOT NULL`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.groupBy(invoices.paymentMethod);
|
|
||||||
|
|
||||||
return c.json({
|
|
||||||
revenueCents,
|
|
||||||
outstandingCents,
|
|
||||||
refundsCents,
|
|
||||||
revenueCount: thisMonthInvoices.length,
|
|
||||||
refundCount: refundedInvoices.length,
|
|
||||||
paymentMethodBreakdown,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit, StripePaymentInfo, PaymentStats } from "@groombook/types";
|
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -173,23 +173,6 @@ 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 [stripeInfo, setStripeInfo] = useState<StripePaymentInfo | null>(null);
|
|
||||||
const [stripeLoading, setStripeLoading] = useState(false);
|
|
||||||
const [showRefundDialog, setShowRefundDialog] = useState(false);
|
|
||||||
const [refundType, setRefundType] = useState<"full" | "partial">("full");
|
|
||||||
const [refundAmountStr, setRefundAmountStr] = useState("");
|
|
||||||
const [refunding, setRefunding] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (invoice.status === "paid" && invoice.stripePaymentIntentId) {
|
|
||||||
setStripeLoading(true);
|
|
||||||
fetch(`/api/invoices/${invoice.id}/stripe-payment`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data: StripePaymentInfo) => setStripeInfo(data))
|
|
||||||
.catch(() => { /* non-blocking */ })
|
|
||||||
.finally(() => setStripeLoading(false));
|
|
||||||
}
|
|
||||||
}, [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
|
||||||
@@ -288,31 +271,6 @@ function InvoiceDetailModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitRefund() {
|
|
||||||
setRefunding(true);
|
|
||||||
setError(null);
|
|
||||||
const amountCents = refundType === "partial"
|
|
||||||
? Math.round(parseFloat(refundAmountStr) * 100)
|
|
||||||
: undefined;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ 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 : "Refund failed");
|
|
||||||
} finally {
|
|
||||||
setRefunding(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;
|
||||||
@@ -372,18 +330,6 @@ 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} />}
|
||||||
{stripeLoading && <SummaryRow label="Stripe" value="Loading…" />}
|
|
||||||
{stripeInfo && (
|
|
||||||
<>
|
|
||||||
{stripeInfo.cardLast4 && (
|
|
||||||
<SummaryRow label="Card" value={`${stripeInfo.cardBrand ?? "Card"} •••• ${stripeInfo.cardLast4}`} />
|
|
||||||
)}
|
|
||||||
<SummaryRow label="Stripe status" value={stripeInfo.status} />
|
|
||||||
{invoice.stripeRefundId && stripeInfo.refundStatus && (
|
|
||||||
<SummaryRow label="Refund status" value={stripeInfo.refundStatus === "succeeded" ? "Refunded" : stripeInfo.refundStatus} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Tip Distribution ── */}
|
{/* ── Tip Distribution ── */}
|
||||||
@@ -501,101 +447,10 @@ function InvoiceDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(invoice.status === "paid" || invoice.status === "void") && (
|
{(invoice.status === "paid" || invoice.status === "void") && (
|
||||||
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
|
||||||
{invoice.status === "paid" && invoice.stripePaymentIntentId && !invoice.stripeRefundId && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setRefundType("full");
|
|
||||||
setRefundAmountStr("");
|
|
||||||
setShowRefundDialog(true);
|
|
||||||
}}
|
|
||||||
style={{ ...btnStyle, color: "#dc2626", borderColor: "#dc2626" }}
|
|
||||||
>
|
|
||||||
Refund
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button onClick={onClose} style={btnStyle}>Close</button>
|
<button onClick={onClose} style={btnStyle}>Close</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showRefundDialog && (
|
|
||||||
<div style={{
|
|
||||||
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 110,
|
|
||||||
}}
|
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) setShowRefundDialog(false); }}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
background: "#fff", borderRadius: 8, padding: "1.5rem",
|
|
||||||
maxWidth: 400, width: "calc(100% - 2rem)",
|
|
||||||
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
|
||||||
}}>
|
|
||||||
<h3 style={{ margin: "0 0 1rem" }}>Process Refund</h3>
|
|
||||||
<p style={{ fontSize: 14, color: "#6b7280", marginBottom: "1rem" }}>
|
|
||||||
Invoice total: {fmtMoney(invoice.totalCents)}
|
|
||||||
</p>
|
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
|
||||||
<label style={{ display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13 }}>
|
|
||||||
Refund type
|
|
||||||
</label>
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
<button
|
|
||||||
onClick={() => setRefundType("full")}
|
|
||||||
style={{
|
|
||||||
...btnStyle,
|
|
||||||
backgroundColor: refundType === "full" ? "var(--color-primary)" : "#fff",
|
|
||||||
color: refundType === "full" ? "#fff" : "#374151",
|
|
||||||
borderColor: refundType === "full" ? "var(--color-primary)" : "#d1d5db",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Full refund
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { setRefundType("partial"); setRefundAmountStr((invoice.totalCents / 100).toFixed(2)); }}
|
|
||||||
style={{
|
|
||||||
...btnStyle,
|
|
||||||
backgroundColor: refundType === "partial" ? "var(--color-primary)" : "#fff",
|
|
||||||
color: refundType === "partial" ? "#fff" : "#374151",
|
|
||||||
borderColor: refundType === "partial" ? "var(--color-primary)" : "#d1d5db",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Partial refund
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{refundType === "partial" && (
|
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
|
||||||
<label style={{ display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13 }}>
|
|
||||||
Refund amount
|
|
||||||
</label>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
||||||
<span style={{ color: "#6b7280" }}>$</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0.01"
|
|
||||||
max={(invoice.totalCents / 100).toFixed(2)}
|
|
||||||
step="0.01"
|
|
||||||
value={refundAmountStr}
|
|
||||||
onChange={(e) => setRefundAmountStr(e.target.value)}
|
|
||||||
style={{ ...inputStyle, width: 100 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && <p style={{ color: "red", margin: "0.5rem 0" }}>{error}</p>}
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end" }}>
|
|
||||||
<button onClick={() => setShowRefundDialog(false)} style={btnStyle}>Cancel</button>
|
|
||||||
<button
|
|
||||||
onClick={submitRefund}
|
|
||||||
disabled={refunding || (refundType === "partial" && (!refundAmountStr || parseFloat(refundAmountStr) <= 0))}
|
|
||||||
style={{ ...btnStyle, backgroundColor: "#dc2626", color: "#fff", borderColor: "#dc2626" }}
|
|
||||||
>
|
|
||||||
{refunding ? "Refunding…" : "Refund"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -637,8 +492,6 @@ export function InvoicesPage() {
|
|||||||
const [createLoading, setCreateLoading] = useState(false);
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
|
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [stats, setStats] = useState<PaymentStats | null>(null);
|
|
||||||
const [statsLoading, setStatsLoading] = useState(true);
|
|
||||||
|
|
||||||
const LIMIT = 50;
|
const LIMIT = 50;
|
||||||
|
|
||||||
@@ -660,15 +513,6 @@ export function InvoicesPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [statusFilter]);
|
}, [statusFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setStatsLoading(true);
|
|
||||||
fetch("/api/invoices/stats")
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data: PaymentStats) => setStats(data))
|
|
||||||
.catch(() => { /* non-blocking */ })
|
|
||||||
.finally(() => setStatsLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function loadCreateData() {
|
function loadCreateData() {
|
||||||
if (createData) return Promise.resolve();
|
if (createData) return Promise.resolve();
|
||||||
setCreateLoading(true);
|
setCreateLoading(true);
|
||||||
@@ -729,36 +573,6 @@ export function InvoicesPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!statsLoading && stats && (
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1rem" }}>
|
|
||||||
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", padding: "0.875rem 1rem" }}>
|
|
||||||
<div style={{ fontSize: 12, color: "#6b7280", fontWeight: 500, marginBottom: "0.25rem" }}>Revenue this month</div>
|
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#065f46" }}>{fmtMoney(stats.revenueCents)}</div>
|
|
||||||
<div style={{ fontSize: 12, color: "#9ca3af" }}>{stats.revenueCount} paid</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", padding: "0.875rem 1rem" }}>
|
|
||||||
<div style={{ fontSize: 12, color: "#6b7280", fontWeight: 500, marginBottom: "0.25rem" }}>Outstanding</div>
|
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#92400e" }}>{fmtMoney(stats.outstandingCents)}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", padding: "0.875rem 1rem" }}>
|
|
||||||
<div style={{ fontSize: 12, color: "#6b7280", fontWeight: 500, marginBottom: "0.25rem" }}>Refunds this month</div>
|
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#991b1b" }}>{fmtMoney(stats.refundsCents)}</div>
|
|
||||||
<div style={{ fontSize: 12, color: "#9ca3af" }}>{stats.refundCount} refunds</div>
|
|
||||||
</div>
|
|
||||||
{stats.paymentMethodBreakdown.length > 0 && (
|
|
||||||
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", padding: "0.875rem 1rem" }}>
|
|
||||||
<div style={{ fontSize: 12, color: "#6b7280", fontWeight: 500, marginBottom: "0.25rem" }}>By payment method</div>
|
|
||||||
{stats.paymentMethodBreakdown.map((b) => (
|
|
||||||
<div key={b.paymentMethod} style={{ fontSize: 13, display: "flex", justifyContent: "space-between", marginTop: "0.2rem" }}>
|
|
||||||
<span style={{ textTransform: "capitalize" }}>{b.paymentMethod}</span>
|
|
||||||
<span style={{ fontWeight: 600 }}>{fmtMoney(b.totalCents)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{invoiceList.length === 0 ? (
|
{invoiceList.length === 0 ? (
|
||||||
<p style={{ color: "#6b7280" }}>
|
<p style={{ color: "#6b7280" }}>
|
||||||
No invoices yet. Create one from a completed appointment.
|
No invoices yet. Create one from a completed appointment.
|
||||||
|
|||||||
@@ -153,38 +153,10 @@ export interface Invoice {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
stripePaymentIntentId?: string | null;
|
|
||||||
stripeRefundId?: string | null;
|
|
||||||
paymentFailureReason?: string | null;
|
|
||||||
lineItems?: InvoiceLineItem[];
|
lineItems?: InvoiceLineItem[];
|
||||||
tipSplits?: InvoiceTipSplit[];
|
tipSplits?: InvoiceTipSplit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StripePaymentInfo {
|
|
||||||
paymentIntentId: string;
|
|
||||||
amountPaidCents: number;
|
|
||||||
status: string;
|
|
||||||
cardLast4: string | null;
|
|
||||||
cardBrand: string | null;
|
|
||||||
refundId: string | null;
|
|
||||||
refundStatus: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaymentMethodBreakdown {
|
|
||||||
paymentMethod: PaymentMethod;
|
|
||||||
count: number;
|
|
||||||
totalCents: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaymentStats {
|
|
||||||
revenueCents: number;
|
|
||||||
outstandingCents: number;
|
|
||||||
refundsCents: number;
|
|
||||||
revenueCount: number;
|
|
||||||
refundCount: number;
|
|
||||||
paymentMethodBreakdown: PaymentMethodBreakdown[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Impersonation ──────────────────────────────────────────────────────────
|
// ─── Impersonation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type ImpersonationSessionStatus = "active" | "ended" | "expired";
|
export type ImpersonationSessionStatus = "active" | "ended" | "expired";
|
||||||
|
|||||||
Reference in New Issue
Block a user