Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58232381c7 | |||
| 4fa4859eaf | |||
| ca88385b8d | |||
| 3f2769a43a | |||
| 0ed87f9ed8 | |||
| 648755eee5 | |||
| 77a6319459 | |||
| df07f2d6dc | |||
| dadabb0ea7 | |||
| d5a8b19322 | |||
| 4d1d94296f | |||
| c6800a6144 | |||
| 000e90a617 | |||
| 70e9465b68 | |||
| 8c3e0f9554 | |||
| f4f522d5e6 | |||
| e8455195ee |
@@ -7,3 +7,5 @@ apps/web/dist
|
||||
apps/api/dist
|
||||
packages/db/dist
|
||||
packages/types/dist
|
||||
.turbo
|
||||
screenshots/
|
||||
|
||||
@@ -20,6 +20,8 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.15.4'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -42,6 +44,8 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.15.4'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -62,6 +66,8 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.15.4'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -101,6 +107,8 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.15.4'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -238,7 +246,6 @@ jobs:
|
||||
echo "Deploying images tagged $TAG to groombook-dev..."
|
||||
|
||||
# Run migration with PR image
|
||||
kubectl delete job migrate-schema -n groombook-dev --ignore-not-found
|
||||
kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found
|
||||
cat <<EOF | kubectl apply -n groombook-dev -f -
|
||||
apiVersion: batch/v1
|
||||
@@ -303,6 +310,8 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.15.4'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -409,11 +418,17 @@ jobs:
|
||||
|
||||
git push -u origin "chore/update-image-tags-${TAG}"
|
||||
|
||||
# Create PR and merge immediately (no required checks on groombook/infra)
|
||||
PR_URL=$(gh pr create \
|
||||
--repo groombook/infra \
|
||||
--base main \
|
||||
--head "chore/update-image-tags-${TAG}" \
|
||||
--title "chore: deploy ${TAG} to dev" \
|
||||
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
|
||||
gh pr merge "$PR_URL" --merge
|
||||
# Check if PR already exists for this branch
|
||||
EXISTING_PR=$(gh pr list --repo groombook/infra --head "chore/update-image-tags-${TAG}" --state open --json number -q '.[0].number' || true)
|
||||
if [ -n "$EXISTING_PR" ]; then
|
||||
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR"
|
||||
gh pr merge "$EXISTING_PR" --repo groombook/infra --merge
|
||||
else
|
||||
PR_URL=$(gh pr create \
|
||||
--repo groombook/infra \
|
||||
--base main \
|
||||
--head "chore/update-image-tags-${TAG}" \
|
||||
--title "chore: deploy ${TAG} to dev" \
|
||||
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
|
||||
gh pr merge "$PR_URL" --merge
|
||||
fi
|
||||
|
||||
@@ -14,7 +14,29 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
steps:
|
||||
- name: Validate tag format
|
||||
run: |
|
||||
TAG="${{ inputs.tag }}"
|
||||
if ! echo "$TAG" | grep -qE '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}-[a-f0-9]{7}$'; then
|
||||
echo "::error::Invalid tag format: '$TAG'. Expected format: YYYY.MM.DD-sha7 (e.g. 2026.03.28-f1b85bf)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag format valid: $TAG"
|
||||
|
||||
- name: Verify image exists in GHCR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${{ inputs.tag }}"
|
||||
# Check that the API image exists — if API was pushed, web/migrate were too
|
||||
if ! gh api "/orgs/groombook/packages/container/api/versions" --jq ".[].metadata.container.tags[]" 2>/dev/null | grep -qF "$TAG"; then
|
||||
echo "::error::Image ghcr.io/groombook/api:$TAG not found in GHCR. Verify the tag was built and pushed."
|
||||
exit 1
|
||||
fi
|
||||
echo "Image verified: ghcr.io/groombook/api:$TAG exists"
|
||||
|
||||
- name: Generate infra repo token
|
||||
id: infra-token
|
||||
uses: tibdex/github-app-token@v2
|
||||
|
||||
+5
-1
@@ -12,6 +12,7 @@ RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build
|
||||
FROM deps AS builder
|
||||
RUN mkdir -p /home/node/.cache/node/corepack
|
||||
COPY packages/ packages/
|
||||
COPY apps/api/ apps/api/
|
||||
RUN pnpm --filter @groombook/types build && \
|
||||
@@ -34,6 +35,9 @@ COPY --from=builder /app/packages/types/dist packages/types/dist
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
EXPOSE 3000
|
||||
RUN apk add --no-cache curl
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/health || exit 1
|
||||
CMD ["node", "apps/api/dist/index.js"]
|
||||
|
||||
# Migrate stage — runs drizzle-kit migrate against the database
|
||||
@@ -46,4 +50,4 @@ CMD ["pnpm", "db:seed"]
|
||||
|
||||
# Reset stage — drops all tables, re-runs migrations, and re-seeds
|
||||
FROM builder AS reset
|
||||
CMD ["pnpm", "db:reset"]
|
||||
CMD ["pnpm", "db:reset"]
|
||||
@@ -41,10 +41,6 @@ const createAppointmentSchema = z.object({
|
||||
frequencyWeeks: z.number().int().min(1).max(52),
|
||||
count: z.number().int().min(2).max(52),
|
||||
})
|
||||
.refine(
|
||||
(r) => r.frequencyWeeks * r.count <= 52,
|
||||
{ message: "Recurrence series must not exceed 1 year" }
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -167,9 +163,8 @@ appointmentsRouter.post(
|
||||
}
|
||||
}
|
||||
|
||||
// Check batherStaffId conflicts if set
|
||||
if (apptFields.batherStaffId) {
|
||||
const conflicts = await tx
|
||||
const bathConflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
@@ -185,7 +180,7 @@ appointmentsRouter.post(
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
if (bathConflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
@@ -425,7 +420,8 @@ appointmentsRouter.patch(
|
||||
const needsConflictCheck =
|
||||
updateFields.startTime !== undefined ||
|
||||
updateFields.endTime !== undefined ||
|
||||
updateFields.staffId !== undefined;
|
||||
updateFields.staffId !== undefined ||
|
||||
updateFields.batherStaffId !== undefined;
|
||||
|
||||
const update: Record<string, unknown> = {
|
||||
...updateFields,
|
||||
@@ -461,6 +457,11 @@ appointmentsRouter.patch(
|
||||
updateFields.staffId !== undefined
|
||||
? updateFields.staffId
|
||||
: current.staffId;
|
||||
// Use provided batherStaffId (may be null to unassign); fall back to existing
|
||||
const batherStaffId =
|
||||
updateFields.batherStaffId !== undefined
|
||||
? updateFields.batherStaffId
|
||||
: current.batherStaffId;
|
||||
|
||||
if (end <= start) {
|
||||
throw Object.assign(new Error("end before start"), {
|
||||
@@ -488,13 +489,8 @@ appointmentsRouter.patch(
|
||||
}
|
||||
}
|
||||
|
||||
// Check batherStaffId conflicts if being updated or already set
|
||||
const batherStaffId =
|
||||
updateFields.batherStaffId !== undefined
|
||||
? updateFields.batherStaffId
|
||||
: current.batherStaffId;
|
||||
if (batherStaffId) {
|
||||
const conflicts = await tx
|
||||
const bathConflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
@@ -511,7 +507,7 @@ appointmentsRouter.patch(
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
if (bathConflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,10 +102,7 @@ bookRouter.get("/availability", async (c) => {
|
||||
|
||||
const bookingSchema = z.object({
|
||||
serviceId: z.string().uuid(),
|
||||
startTime: z.string().datetime().refine(
|
||||
(dt) => new Date(dt) > new Date(),
|
||||
{ message: "Appointment must be in the future" }
|
||||
),
|
||||
startTime: z.string().datetime(),
|
||||
clientName: z.string().min(1).max(200),
|
||||
clientEmail: z.string().email(),
|
||||
clientPhone: z.string().max(50).optional(),
|
||||
|
||||
+149
-53
@@ -4,6 +4,7 @@ import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
gte,
|
||||
getDb,
|
||||
invoices,
|
||||
invoiceLineItems,
|
||||
@@ -44,61 +45,53 @@ const updateInvoiceSchema = z.object({
|
||||
});
|
||||
|
||||
// List invoices
|
||||
const listInvoicesQuerySchema = z.object({
|
||||
clientId: z.string().uuid().optional(),
|
||||
appointmentId: z.string().uuid().optional(),
|
||||
status: z.enum(["draft", "pending", "paid", "void"]).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
invoicesRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.req.query("clientId");
|
||||
const appointmentId = c.req.query("appointmentId");
|
||||
const status = c.req.query("status");
|
||||
const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200);
|
||||
const offset = parseInt(c.req.query("offset") || "0", 10);
|
||||
|
||||
const conditions = [];
|
||||
if (clientId) conditions.push(eq(invoices.clientId, clientId));
|
||||
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
|
||||
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const [totalResult] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(invoices)
|
||||
.where(whereClause);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: invoices.id,
|
||||
appointmentId: invoices.appointmentId,
|
||||
clientId: invoices.clientId,
|
||||
clientName: clients.name,
|
||||
subtotalCents: invoices.subtotalCents,
|
||||
taxCents: invoices.taxCents,
|
||||
tipCents: invoices.tipCents,
|
||||
totalCents: invoices.totalCents,
|
||||
status: invoices.status,
|
||||
paymentMethod: invoices.paymentMethod,
|
||||
paidAt: invoices.paidAt,
|
||||
notes: invoices.notes,
|
||||
createdAt: invoices.createdAt,
|
||||
updatedAt: invoices.updatedAt,
|
||||
})
|
||||
.from(invoices)
|
||||
.leftJoin(clients, eq(invoices.clientId, clients.id))
|
||||
.where(whereClause)
|
||||
.orderBy(invoices.createdAt)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return c.json({ data: rows, total: totalResult?.count ?? 0 });
|
||||
});
|
||||
|
||||
invoicesRouter.get(
|
||||
"/",
|
||||
zValidator("query", listInvoicesQuerySchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const { clientId, appointmentId, status, limit, offset } = c.req.valid("query");
|
||||
|
||||
const conditions = [];
|
||||
if (clientId) conditions.push(eq(invoices.clientId, clientId));
|
||||
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
|
||||
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const [totalResult] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(invoices)
|
||||
.where(whereClause);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: invoices.id,
|
||||
appointmentId: invoices.appointmentId,
|
||||
clientId: invoices.clientId,
|
||||
clientName: clients.name,
|
||||
subtotalCents: invoices.subtotalCents,
|
||||
taxCents: invoices.taxCents,
|
||||
tipCents: invoices.tipCents,
|
||||
totalCents: invoices.totalCents,
|
||||
status: invoices.status,
|
||||
paymentMethod: invoices.paymentMethod,
|
||||
paidAt: invoices.paidAt,
|
||||
notes: invoices.notes,
|
||||
createdAt: invoices.createdAt,
|
||||
updatedAt: invoices.updatedAt,
|
||||
})
|
||||
.from(invoices)
|
||||
.leftJoin(clients, eq(invoices.clientId, clients.id))
|
||||
.where(whereClause)
|
||||
.orderBy(invoices.createdAt)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return c.json({ data: rows, total: totalResult?.count ?? 0 });
|
||||
}
|
||||
);
|
||||
|
||||
// Get single invoice with line items and tip splits
|
||||
invoicesRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
@@ -385,3 +378,106 @@ invoicesRouter.post(
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -286,6 +286,10 @@ reportsRouter.get("/clients", async (c) => {
|
||||
ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90);
|
||||
const ninetyDaysAgoISO = ninetyDaysAgo.toISOString();
|
||||
|
||||
const page = Math.max(1, parseInt(c.req.query("page") ?? "1", 10) || 1);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(c.req.query("limit") ?? "20", 10) || 20));
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const churnRisk = await db
|
||||
.select({
|
||||
clientId: clients.id,
|
||||
@@ -298,15 +302,34 @@ reportsRouter.get("/clients", async (c) => {
|
||||
.having(
|
||||
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
|
||||
)
|
||||
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`);
|
||||
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const [churnCountRow] = await db
|
||||
.select({ total: sql<number>`count(*)::int` })
|
||||
.from(
|
||||
db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.leftJoin(appointments, eq(appointments.clientId, clients.id))
|
||||
.groupBy(clients.id)
|
||||
.having(
|
||||
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
|
||||
)
|
||||
.as("churn_count")
|
||||
);
|
||||
const churnRiskTotal = churnCountRow?.total ?? 0;
|
||||
|
||||
return c.json({
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
newClients,
|
||||
activeInPeriodCount: activeInPeriod.length,
|
||||
churnRisk: churnRisk.slice(0, 20), // top 20 at-risk clients
|
||||
churnRiskTotal: churnRisk.length,
|
||||
churnRisk,
|
||||
churnRiskTotal,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ const createServiceSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
basePriceCents: z.number().int().positive(),
|
||||
durationMinutes: z.number().int().positive().max(480),
|
||||
durationMinutes: z.number().int().positive(),
|
||||
active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Hono } from "hono";
|
||||
import Stripe from "stripe";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, invoices } from "@groombook/db";
|
||||
import { getStripeClient } from "../services/payment.js";
|
||||
|
||||
@@ -45,13 +44,10 @@ webhooksRouter.post("/stripe", async (c) => {
|
||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||
for (const invoiceId of invoiceIds) {
|
||||
if (!invoiceId) continue;
|
||||
const parsed = z.string().uuid().safeParse(invoiceId.trim());
|
||||
if (!parsed.success) continue;
|
||||
const invoiceIdTrimmed = invoiceId.trim();
|
||||
const [inv] = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, invoiceIdTrimmed))
|
||||
.where(eq(invoices.id, invoiceId))
|
||||
.limit(1);
|
||||
if (!inv) continue;
|
||||
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
||||
@@ -64,7 +60,7 @@ webhooksRouter.post("/stripe", async (c) => {
|
||||
stripePaymentIntentId: pi.id,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, invoiceIdTrimmed));
|
||||
.where(eq(invoices.id, invoiceId));
|
||||
}
|
||||
}
|
||||
} else if (event.type === "payment_intent.payment_failed") {
|
||||
@@ -73,16 +69,13 @@ webhooksRouter.post("/stripe", async (c) => {
|
||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||
for (const invoiceId of invoiceIds) {
|
||||
if (!invoiceId) continue;
|
||||
const parsed = z.string().uuid().safeParse(invoiceId.trim());
|
||||
if (!parsed.success) continue;
|
||||
const invoiceIdTrimmed = invoiceId.trim();
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, invoiceIdTrimmed));
|
||||
.where(eq(invoices.id, invoiceId));
|
||||
}
|
||||
}
|
||||
} else if (event.type === "charge.refunded") {
|
||||
|
||||
@@ -20,3 +20,5 @@ FROM nginx:alpine AS runner
|
||||
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:80/ || exit 1
|
||||
|
||||
@@ -3,10 +3,22 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|svg|ico|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
}
|
||||
|
||||
# Proxy API calls to the API service
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types";
|
||||
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit, StripePaymentInfo, PaymentStats } from "@groombook/types";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -173,6 +173,23 @@ function InvoiceDetailModal({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
|
||||
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}
|
||||
const linkedAppt = invoice.appointmentId
|
||||
@@ -271,6 +288,31 @@ 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>;
|
||||
|
||||
const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0;
|
||||
@@ -330,6 +372,18 @@ function InvoiceDetailModal({
|
||||
/>
|
||||
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
|
||||
{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>
|
||||
|
||||
{/* ── Tip Distribution ── */}
|
||||
@@ -447,10 +501,101 @@ function InvoiceDetailModal({
|
||||
</div>
|
||||
)}
|
||||
{(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 && !invoice.stripeRefundId && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setRefundType("full");
|
||||
setRefundAmountStr("");
|
||||
setShowRefundDialog(true);
|
||||
}}
|
||||
style={{ ...btnStyle, color: "#dc2626", borderColor: "#dc2626" }}
|
||||
>
|
||||
Refund
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} style={btnStyle}>Close</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -492,6 +637,8 @@ export function InvoicesPage() {
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [stats, setStats] = useState<PaymentStats | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(true);
|
||||
|
||||
const LIMIT = 50;
|
||||
|
||||
@@ -513,6 +660,15 @@ export function InvoicesPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [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() {
|
||||
if (createData) return Promise.resolve();
|
||||
setCreateLoading(true);
|
||||
@@ -573,6 +729,36 @@ export function InvoicesPage() {
|
||||
</button>
|
||||
</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 ? (
|
||||
<p style={{ color: "#6b7280" }}>
|
||||
No invoices yet. Create one from a completed appointment.
|
||||
|
||||
@@ -567,7 +567,7 @@ async function seed() {
|
||||
|
||||
// ── Staff ──
|
||||
const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) =>
|
||||
({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: false })
|
||||
({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: profile === "uat" && i === 0 })
|
||||
);
|
||||
const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) =>
|
||||
({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false })
|
||||
|
||||
@@ -153,10 +153,38 @@ export interface Invoice {
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
stripePaymentIntentId?: string | null;
|
||||
stripeRefundId?: string | null;
|
||||
paymentFailureReason?: string | null;
|
||||
lineItems?: InvoiceLineItem[];
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
export type ImpersonationSessionStatus = "active" | "ended" | "expired";
|
||||
|
||||
Reference in New Issue
Block a user