Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f362aa61b4 | |||
| 4e9abd793d | |||
| 0019511061 | |||
| 9a0a63d1df | |||
| 24a032dd9d | |||
| 13f2550ee2 | |||
| f29ac2e40d | |||
| 25dae6af58 | |||
| 4737fc9dd8 | |||
| bdcad0d9dc | |||
| 6da19d51fc | |||
| c919632aea | |||
| b1124e6a6c | |||
| 90794e4e14 | |||
| 522e5dbf63 | |||
| 39f603589b | |||
| 2ab06853e6 | |||
| e7fd820b31 | |||
| fafb717e5a |
@@ -117,7 +117,7 @@ api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"
|
|||||||
api.use("/admin/*", requireRoleOrSuperUser("manager"));
|
api.use("/admin/*", requireRoleOrSuperUser("manager"));
|
||||||
api.use("/admin/settings/*", requireSuperUser());
|
api.use("/admin/settings/*", requireSuperUser());
|
||||||
api.use("/reports/*", requireRole("manager"));
|
api.use("/reports/*", requireRole("manager"));
|
||||||
api.use("/invoices/*", requireRole("manager"));
|
api.use("/invoices/*", requireRole("manager", "groomer"));
|
||||||
api.use("/impersonation/*", requireRole("manager"));
|
api.use("/impersonation/*", requireRole("manager"));
|
||||||
|
|
||||||
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
|
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { genericOAuth } from "better-auth/plugins";
|
import { genericOAuth } from "better-auth/plugins";
|
||||||
import { google, github } from "better-auth/social-providers";
|
|
||||||
import { getDb, authProviderConfig, eq } from "@groombook/db";
|
import { getDb, authProviderConfig, eq } from "@groombook/db";
|
||||||
import { decryptSecret } from "@groombook/db";
|
import { decryptSecret } from "@groombook/db";
|
||||||
|
|
||||||
@@ -171,19 +170,7 @@ export async function initAuth(): Promise<void> {
|
|||||||
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||||
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
||||||
|
|
||||||
const socialPlugins = [];
|
const callbackBase = `${BETTER_AUTH_URL}/api/auth/callback`;
|
||||||
if (hasGoogle) {
|
|
||||||
socialPlugins.push(google({
|
|
||||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
||||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (hasGitHub) {
|
|
||||||
socialPlugins.push(github({
|
|
||||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
|
||||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build Better-Auth instance using resolved config
|
// Build Better-Auth instance using resolved config
|
||||||
authInstance = betterAuth({
|
authInstance = betterAuth({
|
||||||
@@ -212,8 +199,23 @@ export async function initAuth(): Promise<void> {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
...socialPlugins,
|
|
||||||
],
|
],
|
||||||
|
socialProviders: {
|
||||||
|
...(hasGoogle ? {
|
||||||
|
google: {
|
||||||
|
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||||
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||||
|
redirectURI: `${callbackBase}/google`,
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
|
...(hasGitHub ? {
|
||||||
|
github: {
|
||||||
|
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||||
|
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||||
|
redirectURI: `${callbackBase}/github`,
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
|
},
|
||||||
session: {
|
session: {
|
||||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||||
updateAge: 60 * 60 * 24, // 1 day
|
updateAge: 60 * 60 * 24, // 1 day
|
||||||
|
|||||||
@@ -338,3 +338,69 @@ invoicesRouter.patch(
|
|||||||
return c.json({ ...updated, lineItems });
|
return c.json({ ...updated, lineItems });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Issue a refund on a paid invoice (Stripe integration placeholder)
|
||||||
|
const refundSchema = z.object({
|
||||||
|
amountCents: z.number().int().positive().optional(), // omitting = full refund
|
||||||
|
});
|
||||||
|
|
||||||
|
invoicesRouter.post(
|
||||||
|
"/:id/refund",
|
||||||
|
zValidator("json", refundSchema),
|
||||||
|
async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
|
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||||
|
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||||
|
if (invoice.status !== "paid") return c.json({ error: "Can only refund paid invoices" }, 422);
|
||||||
|
|
||||||
|
const refundAmount = body.amountCents ?? invoice.totalCents;
|
||||||
|
|
||||||
|
// TODO: Integrate Stripe here
|
||||||
|
// const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
||||||
|
// await stripe.refunds.create({ payment_intent: invoice.stripePaymentIntentId, amount: refundAmount });
|
||||||
|
|
||||||
|
// For now, log and mark as refunded in a future version
|
||||||
|
return c.json({ message: "Refund endpoint ready — Stripe integration pending", refundAmount, status: "pending" });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Payment stats for admin dashboard
|
||||||
|
invoicesRouter.get("/stats/summary", async (c) => {
|
||||||
|
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(eq(invoices.status, "paid"));
|
||||||
|
|
||||||
|
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(tip_cents), 0)` })
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.status, "paid"));
|
||||||
|
|
||||||
|
const methodBreakdown = await db
|
||||||
|
.select({
|
||||||
|
method: invoices.paymentMethod,
|
||||||
|
total: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.status, "paid"))
|
||||||
|
.groupBy(invoices.paymentMethod);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
revenueThisMonth: revenueResult?.total ?? 0,
|
||||||
|
outstanding: outstandingResult?.total ?? 0,
|
||||||
|
refundsThisMonth: refundsResult?.total ?? 0,
|
||||||
|
methodBreakdown,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ test.beforeEach(async ({ page }) => {
|
|||||||
json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 },
|
json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Appointments, clients, services, staff, invoices, book, etc.
|
if (url.includes("/api/invoices")) {
|
||||||
|
return route.fulfill({ json: { data: [], total: 0 } });
|
||||||
|
}
|
||||||
|
// Appointments, clients, services, staff, book, etc.
|
||||||
return route.fulfill({ json: [] });
|
return route.fulfill({ json: [] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -82,6 +85,7 @@ test("admin staff page loads", async ({ page }) => {
|
|||||||
|
|
||||||
test("admin invoices page loads", async ({ page }) => {
|
test("admin invoices page loads", async ({ page }) => {
|
||||||
await page.goto("/admin/invoices");
|
await page.goto("/admin/invoices");
|
||||||
|
await page.waitForLoadState("domcontentloaded");
|
||||||
await expect(page.getByText("GroomBook")).toBeVisible();
|
await expect(page.getByText("GroomBook")).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "Invoices" })).toBeVisible();
|
await expect(page.getByRole("link", { name: "Invoices" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
|
After Width: | Height: | Size: 184 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Error>
|
||||||
|
<Code>AccessDenied</Code>
|
||||||
|
<Message>You have no right to access this object because of bucket acl.</Message>
|
||||||
|
<RequestId>69D96C853FAECD363909C4A0</RequestId>
|
||||||
|
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
|
||||||
|
<EC>0003-00000001</EC>
|
||||||
|
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
|
||||||
|
</Error>
|
||||||
|
After Width: | Height: | Size: 227 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Error>
|
||||||
|
<Code>AccessDenied</Code>
|
||||||
|
<Message>You have no right to access this object because of bucket acl.</Message>
|
||||||
|
<RequestId>69D96CFC84D7A9333708F278</RequestId>
|
||||||
|
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
|
||||||
|
<EC>0003-00000001</EC>
|
||||||
|
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
|
||||||
|
</Error>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Error>
|
||||||
|
<Code>AccessDenied</Code>
|
||||||
|
<Message>You have no right to access this object because of bucket acl.</Message>
|
||||||
|
<RequestId>69D96D48D7892E37386B9ACB</RequestId>
|
||||||
|
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
|
||||||
|
<EC>0003-00000001</EC>
|
||||||
|
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
|
||||||
|
</Error>
|
||||||
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 196 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Error>
|
||||||
|
<Code>AccessDenied</Code>
|
||||||
|
<Message>You have no right to access this object because of bucket acl.</Message>
|
||||||
|
<RequestId>69D96C25663D703833F23607</RequestId>
|
||||||
|
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
|
||||||
|
<EC>0003-00000001</EC>
|
||||||
|
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
|
||||||
|
</Error>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Error>
|
||||||
|
<Code>AccessDenied</Code>
|
||||||
|
<Message>You have no right to access this object because of bucket acl.</Message>
|
||||||
|
<RequestId>69D96D89851C843332073968</RequestId>
|
||||||
|
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
|
||||||
|
<EC>0003-00000001</EC>
|
||||||
|
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
|
||||||
|
</Error>
|
||||||
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 205 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Error>
|
||||||
|
<Code>AccessDenied</Code>
|
||||||
|
<Message>You have no right to access this object because of bucket acl.</Message>
|
||||||
|
<RequestId>69D96C9C5A03D33730C61AD8</RequestId>
|
||||||
|
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
|
||||||
|
<EC>0003-00000001</EC>
|
||||||
|
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
|
||||||
|
</Error>
|
||||||
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 307 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 252 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Error>
|
||||||
|
<Code>AccessDenied</Code>
|
||||||
|
<Message>You have no right to access this object because of bucket acl.</Message>
|
||||||
|
<RequestId>69D96BEB91911B30317E3BE8</RequestId>
|
||||||
|
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
|
||||||
|
<EC>0003-00000001</EC>
|
||||||
|
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
|
||||||
|
</Error>
|
||||||
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 199 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Error>
|
||||||
|
<Code>AccessDenied</Code>
|
||||||
|
<Message>You have no right to access this object because of bucket acl.</Message>
|
||||||
|
<RequestId>69D96BFB7B92D33535D6D90D</RequestId>
|
||||||
|
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
|
||||||
|
<EC>0003-00000001</EC>
|
||||||
|
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
|
||||||
|
</Error>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Error>
|
||||||
|
<Code>AccessDenied</Code>
|
||||||
|
<Message>You have no right to access this object because of bucket acl.</Message>
|
||||||
|
<RequestId>69D96B8BDF4B473630A2E120</RequestId>
|
||||||
|
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
|
||||||
|
<EC>0003-00000001</EC>
|
||||||
|
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
|
||||||
|
</Error>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Error>
|
||||||
|
<Code>AccessDenied</Code>
|
||||||
|
<Message>You have no right to access this object because of bucket acl.</Message>
|
||||||
|
<RequestId>69D96D78BFFCAD343037C27C</RequestId>
|
||||||
|
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
|
||||||
|
<EC>0003-00000001</EC>
|
||||||
|
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
|
||||||
|
</Error>
|
||||||
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 279 KiB |
@@ -173,6 +173,9 @@ 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 [showRefundDialog, setShowRefundDialog] = useState(false);
|
||||||
|
const [refundType, setRefundType] = useState<"full" | "partial">("full");
|
||||||
|
const [partialAmount, setPartialAmount] = useState("");
|
||||||
|
|
||||||
// Tip split state: array of {staffId, staffName, pct}
|
// Tip split state: array of {staffId, staffName, pct}
|
||||||
const linkedAppt = invoice.appointmentId
|
const linkedAppt = invoice.appointmentId
|
||||||
@@ -271,6 +274,35 @@ 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 <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;
|
||||||
@@ -447,10 +479,76 @@ 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" }}>
|
<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>
|
||||||
|
)}
|
||||||
<button onClick={onClose} style={btnStyle}>Close</button>
|
<button onClick={onClose} style={btnStyle}>Close</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Refund Dialog */}
|
||||||
|
{showRefundDialog && (
|
||||||
|
<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.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: "1rem" }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={partialAmount}
|
||||||
|
onChange={(e) => setPartialAmount(e.target.value)}
|
||||||
|
style={{ ...inputStyle, width: 120 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <p style={{ color: "red", margin: "0.5rem 0" }}>{error}</p>}
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }}>
|
||||||
|
<button
|
||||||
|
onClick={issueRefund}
|
||||||
|
disabled={saving}
|
||||||
|
style={{ ...btnStyle, backgroundColor: "#b45309", color: "#fff", borderColor: "#b45309" }}
|
||||||
|
>
|
||||||
|
{saving ? "Processing…" : "Issue Refund"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowRefundDialog(false)} style={btnStyle}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -492,9 +590,17 @@ 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 [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
|
||||||
|
|
||||||
const LIMIT = 50;
|
const LIMIT = 50;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/invoices/stats/summary")
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then((data) => { if (data) setPaymentStats(data); })
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function loadInvoices(newOffset: number) {
|
async function loadInvoices(newOffset: number) {
|
||||||
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
|
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
|
||||||
if (statusFilter) params.set("status", statusFilter);
|
if (statusFilter) params.set("status", statusFilter);
|
||||||
@@ -573,6 +679,34 @@ export function InvoicesPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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" }}>{fmtMoney(paymentStats.revenueThisMonth)}</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" }}>{fmtMoney(paymentStats.outstanding)}</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" }}>{fmtMoney(paymentStats.refundsThisMonth)}</div>
|
||||||
|
</div>
|
||||||
|
{paymentStats.methodBreakdown.length > 0 && (
|
||||||
|
<div style={{ background: "#f8fafc", border: "1px solid #e2e8f0", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#475569", fontWeight: 600, marginBottom: "0.25rem" }}>By method</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#64748b" }}>
|
||||||
|
{paymentStats.methodBreakdown.map((b) => (
|
||||||
|
<div key={b.method ?? "unknown"}>{b.method ?? "other"}: {b.total}</div>
|
||||||
|
))}
|
||||||
|
</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.
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import base64
|
||||||
|
import requests
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
api_key = os.environ.get("MINIMAX_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError("MINIMAX_API_KEY environment variable not set")
|
||||||
|
|
||||||
|
url = "https://api.minimax.io/v1/image_generation"
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}"}
|
||||||
|
|
||||||
|
os.makedirs("minimax-output", exist_ok=True)
|
||||||
|
|
||||||
|
# Comprehensive list of dog breeds and variations for diverse demo data
|
||||||
|
dog_prompts = [
|
||||||
|
# Large breeds
|
||||||
|
("german-shepherd-alert", "German Shepherd dog with alert expression, standing confidently, professional pet photography, studio lighting, photorealistic"),
|
||||||
|
("golden-retriever-happy", "Golden Retriever with joyful expression, sitting, golden coat, natural daylight, professional pet photography, photorealistic"),
|
||||||
|
("labrador-running", "Black Labrador Retriever running towards camera, outdoor park setting, dynamic pose, professional pet photography, photorealistic"),
|
||||||
|
("german-shepherd-sitting", "German Shepherd sitting in front of studio backdrop, professional portrait, studio lighting, photorealistic"),
|
||||||
|
("golden-retriever-lying", "Golden Retriever lying down on grass, peaceful expression, outdoor natural lighting, professional pet photography, photorealistic"),
|
||||||
|
|
||||||
|
# Medium breeds
|
||||||
|
("beagle-curious", "Beagle with curious expression, sitting, outdoor garden setting, professional pet photography, photorealistic"),
|
||||||
|
("cocker-spaniel-groomed", "Cocker Spaniel freshly groomed with fluffy coat, happy expression, professional grooming studio, photorealistic"),
|
||||||
|
("english-springer-spaniel", "English Springer Spaniel in natural outdoor setting, alert pose, professional pet photography, photorealistic"),
|
||||||
|
("boxer-playful", "Boxer dog with playful expression, standing, muscular build, professional studio lighting, photorealistic"),
|
||||||
|
("bulldog-gentle", "English Bulldog with gentle expression, sitting, studio backdrop, professional pet photography, photorealistic"),
|
||||||
|
|
||||||
|
# Small breeds
|
||||||
|
("maltese-fluffy", "Maltese dog with white fluffy coat, sitting, groomed appearance, professional pet photography, studio lighting, photorealistic"),
|
||||||
|
("shih-tzu-groomed", "Shih Tzu with long groomed coat, sitting pretty, professional grooming studio, photorealistic"),
|
||||||
|
("pomeranian-alert", "Pomeranian with alert expression, standing, fluffy coat, professional pet photography, photorealistic"),
|
||||||
|
("yorkshire-terrier", "Yorkshire Terrier with silky coat, sitting, professional grooming environment, photorealistic"),
|
||||||
|
("pug-curious", "Pug with curious expression, sitting, studio lighting, professional pet photography, photorealistic"),
|
||||||
|
|
||||||
|
# Specialty breeds
|
||||||
|
("poodle-standard-groomed", "Standard Poodle with professionally groomed coat, standing in show stance, professional grooming studio, photorealistic"),
|
||||||
|
("dachshund-long", "Long-haired Dachshund, lying down, relaxed pose, professional pet photography, photorealistic"),
|
||||||
|
("corgi-happy", "Welsh Corgi with happy expression, standing, professional outdoor setting, photorealistic"),
|
||||||
|
("husky-alert", "Siberian Husky with alert expression, sitting, professional pet photography, studio lighting, photorealistic"),
|
||||||
|
("german-shepherd-lying", "German Shepherd lying down in relaxed pose, indoor setting, professional pet photography, photorealistic"),
|
||||||
|
|
||||||
|
# Mixed/rescue variations
|
||||||
|
("mixed-breed-brown", "Brown and white mixed breed dog, friendly expression, sitting, professional pet photography, photorealistic"),
|
||||||
|
("mixed-breed-black", "Black mixed breed dog with gentle eyes, standing, outdoor natural lighting, photorealistic"),
|
||||||
|
("mixed-breed-spotted", "Spotted mixed breed dog, playful pose, outdoor park setting, professional pet photography, photorealistic"),
|
||||||
|
("terrier-mix-sitting", "Terrier mix dog, alert expression, sitting, professional studio backdrop, photorealistic"),
|
||||||
|
("spaniel-mix-outdoor", "Spaniel mix dog in outdoor garden, relaxed pose, natural daylight, professional pet photography, photorealistic"),
|
||||||
|
|
||||||
|
# Additional variations
|
||||||
|
("labrador-golden", "Golden Labrador Retriever, calm expression, standing in professional pose, studio lighting, photorealistic"),
|
||||||
|
("labrador-black-sitting", "Black Labrador Retriever sitting, gentle expression, professional pet photography, photorealistic"),
|
||||||
|
("rottweiler-calm", "Rottweiler with calm expression, sitting, professional studio, photorealistic"),
|
||||||
|
("doberman-alert", "Doberman Pinscher with alert expression, standing, professional pet photography, photorealistic"),
|
||||||
|
("german-shepherd-side", "German Shepherd in side profile, standing, professional outdoor setting, photorealistic"),
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"Generating {len(dog_prompts)} unique dog images...")
|
||||||
|
print(f"Start time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
print("")
|
||||||
|
|
||||||
|
generated = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for i, (filename_base, prompt) in enumerate(dog_prompts, 1):
|
||||||
|
filename = f"dog-{filename_base}.png"
|
||||||
|
filepath = f"minimax-output/{filename}"
|
||||||
|
|
||||||
|
# Check if already exists
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
size = os.path.getsize(filepath)
|
||||||
|
print(f"[{i:2d}/{len(dog_prompts)}] ✓ {filename} (already exists, {size} bytes)")
|
||||||
|
generated += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"[{i:2d}/{len(dog_prompts)}] Generating {filename}...", end=" ", flush=True)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": "image-01",
|
||||||
|
"prompt": prompt,
|
||||||
|
"aspect_ratio": "1:1",
|
||||||
|
"response_format": "base64",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, headers=headers, json=payload, timeout=120)
|
||||||
|
|
||||||
|
# Check for quota errors
|
||||||
|
if response.status_code == 429:
|
||||||
|
print(f"✗ QUOTA EXCEEDED")
|
||||||
|
print(f"\nQuota limit reached after {generated} successful generations")
|
||||||
|
break
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
if "data" in data and "image_base64" in data["data"]:
|
||||||
|
images = data["data"]["image_base64"]
|
||||||
|
|
||||||
|
with open(filepath, "wb") as f:
|
||||||
|
f.write(base64.b64decode(images[0]))
|
||||||
|
|
||||||
|
file_size = os.path.getsize(filepath)
|
||||||
|
print(f"✓ ({file_size} bytes)")
|
||||||
|
generated += 1
|
||||||
|
else:
|
||||||
|
print(f"✗ Unexpected response format")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
print(f"✗ Timeout")
|
||||||
|
failed += 1
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
if "429" in str(e) or "quota" in str(e).lower():
|
||||||
|
print(f"✗ QUOTA EXCEEDED")
|
||||||
|
print(f"\nQuota limit reached after {generated} successful generations")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print(f"✗ {type(e).__name__}")
|
||||||
|
failed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ {type(e).__name__}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
time.sleep(0.5) # Small delay between requests
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print(f"End time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
print(f"✓ Successfully generated: {generated}")
|
||||||
|
print(f"✗ Failed: {failed}")
|
||||||
|
print(f"\nCopying images to demo-pets directory...")
|
||||||
|
|
||||||
|
# Copy all generated images to demo-pets
|
||||||
|
import subprocess
|
||||||
|
result = subprocess.run(
|
||||||
|
["cp", "-v", "minimax-output/dog-*.png", "apps/web/public/demo-pets/"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Count files in demo-pets
|
||||||
|
import glob
|
||||||
|
demo_pets = glob.glob("apps/web/public/demo-pets/dog-*.png")
|
||||||
|
print(f"✓ Copied to demo-pets. Total dog images: {len(demo_pets)}")
|
||||||
|
else:
|
||||||
|
print(f"Note: Copy result - {result.stderr}")
|
||||||
@@ -251,6 +251,7 @@ export const invoices = pgTable(
|
|||||||
status: invoiceStatusEnum("status").notNull().default("draft"),
|
status: invoiceStatusEnum("status").notNull().default("draft"),
|
||||||
paymentMethod: paymentMethodEnum("payment_method"),
|
paymentMethod: paymentMethodEnum("payment_method"),
|
||||||
paidAt: timestamp("paid_at"),
|
paidAt: timestamp("paid_at"),
|
||||||
|
stripePaymentIntentId: text("stripe_payment_intent_id"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ const dogBreeds = [
|
|||||||
"Brittany", "English Springer Spaniel", "Maltese", "Bichon Frise",
|
"Brittany", "English Springer Spaniel", "Maltese", "Bichon Frise",
|
||||||
"West Highland White Terrier", "Vizsla", "Chihuahua", "Collie",
|
"West Highland White Terrier", "Vizsla", "Chihuahua", "Collie",
|
||||||
"Basset Hound", "Newfoundland", "Samoyed", "Australian Shepherd",
|
"Basset Hound", "Newfoundland", "Samoyed", "Australian Shepherd",
|
||||||
"Pembroke Welsh Corgi", "French Bulldog", "Weimaraner",
|
"Pembroke Welsh Corgi", "French Bulldog", "Weimaraner", "Puggle",
|
||||||
"Mixed Breed", "Mixed Breed", "Mixed Breed",
|
"Mixed Breed", "Mixed Breed", "Mixed Breed",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -281,6 +281,44 @@ const productsUsed = [
|
|||||||
"Coconut oil shampoo, leave-in conditioner, cologne",
|
"Coconut oil shampoo, leave-in conditioner, cologne",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const demoPetImages = [
|
||||||
|
"/demo-pets/dog-golden-after.png",
|
||||||
|
"/demo-pets/dog-poodle-groomed.png",
|
||||||
|
"/demo-pets/dog-black-lab.png",
|
||||||
|
"/demo-pets/dog-shih-tzu.png",
|
||||||
|
"/demo-pets/dog-cocker-spaniel.png",
|
||||||
|
"/demo-pets/dog-schnauzer.png",
|
||||||
|
"/demo-pets/dog-maltese.png",
|
||||||
|
"/demo-pets/dog-dachshund.png",
|
||||||
|
"/demo-pets/dog-pomeranian.png",
|
||||||
|
"/demo-pets/dog-bichon-frise.png",
|
||||||
|
"/demo-pets/dog-golden-retriever.png",
|
||||||
|
"/demo-pets/dog-labrador.png",
|
||||||
|
"/demo-pets/dog-mixed-breed.png",
|
||||||
|
"/demo-pets/dog-poodle.png",
|
||||||
|
"/demo-pets/dog-terrier.png",
|
||||||
|
"/demo-pets/dog-afghan-hound.png",
|
||||||
|
"/demo-pets/dog-basset-brown-white.png",
|
||||||
|
"/demo-pets/dog-bichon-white-groomed.png",
|
||||||
|
"/demo-pets/dog-boxer-fawn-athletic.png",
|
||||||
|
"/demo-pets/dog-cavalier-cream-gentle.png",
|
||||||
|
"/demo-pets/dog-cocker-buff-friendly.png",
|
||||||
|
"/demo-pets/dog-corgi.png",
|
||||||
|
"/demo-pets/dog-dachshund-black-tan.png",
|
||||||
|
"/demo-pets/dog-golden-before.png",
|
||||||
|
"/demo-pets/dog-pomeranian-white-studio.png",
|
||||||
|
"/demo-pets/dog-schnauzer-black-groomed.png",
|
||||||
|
"/demo-pets/dog-setter-red-sunlit.png",
|
||||||
|
"/demo-pets/dog-sheepdog-merle-running.png",
|
||||||
|
];
|
||||||
|
|
||||||
|
const puggleImages = [
|
||||||
|
"/demo-pets/dog-puggle-fawn-playful.png",
|
||||||
|
"/demo-pets/dog-puggle-black-sitting.png",
|
||||||
|
"/demo-pets/dog-puggle-cream-groomed.png",
|
||||||
|
"/demo-pets/dog-puggle-fawn-grooming.png",
|
||||||
|
];
|
||||||
|
|
||||||
// ── Service definitions ──────────────────────────────────────────────────────
|
// ── Service definitions ──────────────────────────────────────────────────────
|
||||||
// Deterministic service IDs + UNIQUE(name) constraint make seed fully idempotent:
|
// Deterministic service IDs + UNIQUE(name) constraint make seed fully idempotent:
|
||||||
// first run inserts, subsequent runs update existing rows via ON CONFLICT (name).
|
// first run inserts, subsequent runs update existing rows via ON CONFLICT (name).
|
||||||
@@ -621,6 +659,7 @@ async function seed() {
|
|||||||
const clientRecords: ClientRecord[] = [];
|
const clientRecords: ClientRecord[] = [];
|
||||||
const petRecords: PetRecord[] = [];
|
const petRecords: PetRecord[] = [];
|
||||||
|
|
||||||
|
let petIndex = 0; // Track pet count to assign Puggle images to first 250 pets
|
||||||
const clientBatchSize = 50;
|
const clientBatchSize = 50;
|
||||||
for (let batch = 0; batch < Math.ceil(cfg.clientCount / clientBatchSize); batch++) {
|
for (let batch = 0; batch < Math.ceil(cfg.clientCount / clientBatchSize); batch++) {
|
||||||
const clientBatch: (typeof schema.clients.$inferInsert)[] = [];
|
const clientBatch: (typeof schema.clients.$inferInsert)[] = [];
|
||||||
@@ -652,7 +691,7 @@ async function seed() {
|
|||||||
const petCount = rand() < 0.5 ? 1 : rand() < 0.7 ? 2 : 3;
|
const petCount = rand() < 0.5 ? 1 : rand() < 0.7 ? 2 : 3;
|
||||||
for (let p = 0; p < petCount; p++) {
|
for (let p = 0; p < petCount; p++) {
|
||||||
const petId = uuid();
|
const petId = uuid();
|
||||||
const breed = pick(dogBreeds);
|
const breed = petIndex < 250 ? "Puggle" : pick(dogBreeds);
|
||||||
const dob = new Date(now);
|
const dob = new Date(now);
|
||||||
dob.setFullYear(dob.getFullYear() - randInt(1, 14));
|
dob.setFullYear(dob.getFullYear() - randInt(1, 14));
|
||||||
dob.setMonth(randInt(0, 11));
|
dob.setMonth(randInt(0, 11));
|
||||||
@@ -671,9 +710,11 @@ async function seed() {
|
|||||||
shampooPreference: pick(shampoos),
|
shampooPreference: pick(shampoos),
|
||||||
specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null,
|
specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null,
|
||||||
customFields: {},
|
customFields: {},
|
||||||
|
image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages),
|
||||||
});
|
});
|
||||||
|
|
||||||
petRecords.push({ id: petId, clientId });
|
petRecords.push({ id: petId, clientId });
|
||||||
|
petIndex++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,6 +745,7 @@ async function seed() {
|
|||||||
shampooPreference: pet.shampooPreference,
|
shampooPreference: pet.shampooPreference,
|
||||||
specialCareNotes: pet.specialCareNotes,
|
specialCareNotes: pet.specialCareNotes,
|
||||||
customFields: pet.customFields,
|
customFields: pet.customFields,
|
||||||
|
image: pet.image,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -738,8 +780,8 @@ async function seed() {
|
|||||||
.values({ id: uc.id, name: uc.name, email: uc.email, phone: uc.phone, address: uc.address })
|
.values({ id: uc.id, name: uc.name, email: uc.email, phone: uc.phone, address: uc.address })
|
||||||
.onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } });
|
.onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } });
|
||||||
await db.insert(schema.pets)
|
await db.insert(schema.pets)
|
||||||
.values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z") })
|
.values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) })
|
||||||
.onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z") } });
|
.onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) } });
|
||||||
// Create one completed appointment for this client
|
// Create one completed appointment for this client
|
||||||
const apptId = uuid();
|
const apptId = uuid();
|
||||||
const svcIdx = 0;
|
const svcIdx = 0;
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ export interface Invoice {
|
|||||||
status: InvoiceStatus;
|
status: InvoiceStatus;
|
||||||
paymentMethod: PaymentMethod | null;
|
paymentMethod: PaymentMethod | null;
|
||||||
paidAt: string | null;
|
paidAt: string | null;
|
||||||
|
stripePaymentIntentId: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||