Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d8a086fad | |||
| 53ab415713 | |||
| a330e342e1 | |||
| 0f841e27fc | |||
| a7bcce8b80 | |||
| 5f1582a3b6 | |||
| c76ea93c29 | |||
| cd25d98384 | |||
| e9fceb78b3 | |||
| 0cae8adef8 | |||
| 674626ba1e | |||
| aa5686bed1 | |||
| 903fbf55d5 | |||
| 775e2e544b | |||
| fb9c922182 | |||
| 1cc48f0b88 | |||
| 1b8d7087c0 | |||
| d65d121a5d | |||
| b8fd7ec18f | |||
| 7bf9cf9734 | |||
| bf159f8b1f | |||
| 2f3d4d8d01 | |||
| db9bb31702 | |||
| b38db65dde | |||
| 3178f81b99 | |||
| 544d65959d | |||
| f38bb244a4 | |||
| abee344ca4 |
@@ -340,7 +340,7 @@ jobs:
|
|||||||
name: Update Infra Image Tags
|
name: Update Infra Image Tags
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [docker]
|
needs: [docker]
|
||||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push'
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# GroomBook API — UAT Playbook
|
||||||
|
|
||||||
|
This document captures user-acceptance test cases for GroomBook API features. Each section corresponds to a feature or bug-fix PR. Update this file when a PR changes user-facing behaviour.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Appointment Booking (`/api/appointments`)
|
||||||
|
|
||||||
|
### 1.1 Create Appointment
|
||||||
|
- [ ] POST `/api/appointments` with valid payload → 201, appointment returned with generated id
|
||||||
|
- [ ] Overlapping staff appointment → 409 conflict error returned
|
||||||
|
- [ ] `endTime` before `startTime` → 422 error
|
||||||
|
|
||||||
|
### 1.2 Update Appointment (PATCH `/api/appointments/:id`)
|
||||||
|
- [ ] Extending `endTime` on a `scheduled` appointment triggers cascade delay prevention if downstream appointments exist
|
||||||
|
- [ ] Extending `endTime` returns `cascade` object in response with shifted appointments
|
||||||
|
- [ ] Extending `endTime` sends reschedule email to each affected client
|
||||||
|
- [ ] Appointments outside business hours after shift are flagged in `cascade.flaggedForReview` instead of auto-shifted
|
||||||
|
- [ ] Only `scheduled` and `confirmed` downstream appointments are shifted; `in_progress`, `completed`, `cancelled` are skipped
|
||||||
|
- [ ] Cascade stops when a downstream appointment no longer conflicts with the shifted boundary
|
||||||
|
- [ ] Shifts are included in API response under `cascade.shifted[]`
|
||||||
|
|
||||||
|
### 1.3 Series (Recurring) Appointments
|
||||||
|
- [ ] Updating one occurrence with `cascadeMode: "this_and_future"` shifts that occurrence and all future ones
|
||||||
|
- [ ] Updating one occurrence with `cascadeMode: "all"` shifts every occurrence in the series
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Cascade Delay Prevention
|
||||||
|
|
||||||
|
### 2.1 Basic Cascade
|
||||||
|
- [ ] When a groomer's appointment overruns, the next same-groomer `scheduled` appointment shifts forward
|
||||||
|
- [ ] Delta applied to both `startTime` and `endTime` (duration preserved)
|
||||||
|
- [ ] Cascade propagates through multiple downstream appointments
|
||||||
|
|
||||||
|
### 2.2 Buffer Time
|
||||||
|
- [ ] A configurable buffer (default 15 minutes) is added between the overrunning appointment end and the shifted start
|
||||||
|
- [ ] Cascade respects the buffer between each consecutive pair of appointments
|
||||||
|
|
||||||
|
### 2.3 Business Hours Guard
|
||||||
|
- [ ] If a proposed shift would place an appointment start or end outside business hours, it is flagged instead of shifted
|
||||||
|
- [ ] Flagged appointments are listed in `cascade.flaggedForReview[]` with reason text
|
||||||
|
|
||||||
|
### 2.4 Email Notification
|
||||||
|
- [ ] Each shifted appointment triggers a reschedule email to the client
|
||||||
|
- [ ] Email includes original time (struck through) and new time
|
||||||
|
- [ ] Email is skipped silently if SMTP is not configured
|
||||||
|
|
||||||
|
### 2.5 Status Transition Overrun
|
||||||
|
- [ ] When an `in_progress` appointment's actual end time exceeds `endTime + bufferMinutes`, the cascade is triggered using the status transition path
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Authentication & RBAC
|
||||||
|
|
||||||
|
### 3.1 Staff Authentication
|
||||||
|
- [ ] Unauthenticated request → 401
|
||||||
|
- [ ] Groomer role can only view/edit their own appointments → 403 for others
|
||||||
|
- [ ] Manager role can view/edit all appointments
|
||||||
|
|
||||||
|
### 3.2 Client Authentication
|
||||||
|
- [ ] Clients can access their own appointments via tokenized links
|
||||||
|
- [ ] Tokenized confirm/cancel links work without authentication
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { cascadeDelay } from "../cascade.js";
|
||||||
|
|
||||||
|
// ─── Mock the DB ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mockDb = {
|
||||||
|
select: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("@groombook/db", () => ({
|
||||||
|
getDb: () => mockDb,
|
||||||
|
appointments: {
|
||||||
|
id: Symbol("id"),
|
||||||
|
staffId: Symbol("staffId"),
|
||||||
|
startTime: Symbol("startTime"),
|
||||||
|
endTime: Symbol("endTime"),
|
||||||
|
status: Symbol("status"),
|
||||||
|
},
|
||||||
|
clients: { id: Symbol("id"), name: Symbol("name"), email: Symbol("email") },
|
||||||
|
pets: { id: Symbol("id"), name: Symbol("name") },
|
||||||
|
services: { id: Symbol("id"), name: Symbol("name") },
|
||||||
|
staff: { id: Symbol("id"), name: Symbol("name") },
|
||||||
|
eq: (a: symbol, b: unknown) => ({ type: "eq", a, b }),
|
||||||
|
and: (...args: unknown[]) => ({ type: "and", args }),
|
||||||
|
gt: (a: symbol, b: unknown) => ({ type: "gt", a, b }),
|
||||||
|
inArray: (a: symbol, vals: unknown[]) => ({ type: "inArray", a, vals }),
|
||||||
|
asc: (a: symbol) => ({ type: "asc", a }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/email.js", () => ({
|
||||||
|
sendEmail: vi.fn().mockResolvedValue(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { sendEmail } = await import("../services/email.js");
|
||||||
|
const { getDb } = await import("@groombook/db");
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeAppt(overrides: Partial<Record<string, unknown>> = {}) {
|
||||||
|
return {
|
||||||
|
id: "appt-1",
|
||||||
|
staffId: "groomer-1",
|
||||||
|
startTime: new Date("2026-05-16T10:00:00Z"),
|
||||||
|
endTime: new Date("2026-05-16T11:00:00Z"),
|
||||||
|
status: "scheduled",
|
||||||
|
clientId: "client-1",
|
||||||
|
petId: "pet-1",
|
||||||
|
serviceId: "svc-1",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEnrichedAppt(id: string, start: Date, end: Date) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
originalStartTime: start,
|
||||||
|
originalEndTime: end,
|
||||||
|
newStartTime: start,
|
||||||
|
newEndTime: end,
|
||||||
|
clientId: "client-1",
|
||||||
|
clientName: "Alice Smith",
|
||||||
|
clientEmail: "alice@example.com",
|
||||||
|
petName: "Buddy",
|
||||||
|
serviceName: "Full Groom",
|
||||||
|
groomerName: "Jamie",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("cascadeDelay", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when the triggering appointment is not found", async () => {
|
||||||
|
mockDb.select.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const result = await cascadeDelay(
|
||||||
|
"nonexistent",
|
||||||
|
new Date("2026-05-16T12:00:00Z"),
|
||||||
|
new Date("2026-05-16T11:00:00Z")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.shifted).toHaveLength(0);
|
||||||
|
expect(result.flaggedForReview).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when the appointment has no groomer assigned", async () => {
|
||||||
|
mockDb.select.mockResolvedValueOnce([{ ...makeAppt(), staffId: null }]);
|
||||||
|
|
||||||
|
const result = await cascadeDelay(
|
||||||
|
"appt-trigger",
|
||||||
|
new Date("2026-05-16T12:00:00Z"),
|
||||||
|
new Date("2026-05-16T11:00:00Z")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.shifted).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when newEndTime does not extend beyond originalEndTime", async () => {
|
||||||
|
mockDb.select.mockResolvedValueOnce([makeAppt()]);
|
||||||
|
|
||||||
|
const result = await cascadeDelay(
|
||||||
|
"appt-trigger",
|
||||||
|
new Date("2026-05-16T11:30:00Z"), // earlier than original 11:00
|
||||||
|
new Date("2026-05-16T11:00:00Z")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.shifted).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when there are no downstream appointments", async () => {
|
||||||
|
mockDb.select
|
||||||
|
.mockResolvedValueOnce([makeAppt()]) // triggering appt
|
||||||
|
.mockResolvedValueOnce([]); // no downstream
|
||||||
|
|
||||||
|
const result = await cascadeDelay(
|
||||||
|
"appt-trigger",
|
||||||
|
new Date("2026-05-16T11:30:00Z"),
|
||||||
|
new Date("2026-05-16T11:00:00Z")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.shifted).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shifts a single downstream appointment by the correct delta", async () => {
|
||||||
|
const triggerEnd = new Date("2026-05-16T11:30:00Z"); // 30 min overrun
|
||||||
|
const originalEnd = new Date("2026-05-16T11:00:00Z");
|
||||||
|
const downstreamStart = new Date("2026-05-16T11:00:00Z");
|
||||||
|
const downstreamEnd = new Date("2026-05-16T12:00:00Z");
|
||||||
|
|
||||||
|
mockDb.select
|
||||||
|
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
makeAppt({
|
||||||
|
id: "downstream-1",
|
||||||
|
startTime: downstreamStart,
|
||||||
|
endTime: downstreamEnd,
|
||||||
|
status: "scheduled",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const updateMock = mockDb.update.mockReturnValueThis();
|
||||||
|
mockDb.select.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
clientId: "client-1",
|
||||||
|
clientName: "Alice",
|
||||||
|
clientEmail: "alice@example.com",
|
||||||
|
petName: "Buddy",
|
||||||
|
serviceName: "Full Groom",
|
||||||
|
groomerName: "Jamie",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await cascadeDelay(
|
||||||
|
"appt-trigger",
|
||||||
|
triggerEnd,
|
||||||
|
originalEnd,
|
||||||
|
15 // 15 min buffer
|
||||||
|
);
|
||||||
|
|
||||||
|
// effectiveBoundary = 11:30 + 15min = 11:45
|
||||||
|
// delta = 11:45 - 11:00 = 45 min = 2_700_000 ms
|
||||||
|
const expectedDeltaMs = 45 * 60 * 1000;
|
||||||
|
|
||||||
|
expect(result.shifted).toHaveLength(1);
|
||||||
|
expect(result.shifted[0].id).toBe("downstream-1");
|
||||||
|
expect(result.shifted[0].newStartTime.getTime() - result.shifted[0].originalStartTime.getTime())
|
||||||
|
.toBe(expectedDeltaMs);
|
||||||
|
expect(sendEmail).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cascades shifts through a chain of appointments", async () => {
|
||||||
|
const triggerEnd = new Date("2026-05-16T12:00:00Z"); // 60 min overrun
|
||||||
|
const originalEnd = new Date("2026-05-16T11:00:00Z");
|
||||||
|
|
||||||
|
// Three downstream appointments, each 1 hour
|
||||||
|
const appt1Start = new Date("2026-05-16T11:00:00Z");
|
||||||
|
const appt1End = new Date("2026-05-16T12:00:00Z");
|
||||||
|
const appt2Start = new Date("2026-05-16T12:00:00Z");
|
||||||
|
const appt2End = new Date("2026-05-16T13:00:00Z");
|
||||||
|
const appt3Start = new Date("2026-05-16T13:00:00Z");
|
||||||
|
const appt3End = new Date("2026-05-16T14:00:00Z");
|
||||||
|
|
||||||
|
mockDb.select
|
||||||
|
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
makeAppt({ id: "appt-2", startTime: appt2Start, endTime: appt2End, status: "confirmed" }),
|
||||||
|
makeAppt({ id: "appt-3", startTime: appt3Start, endTime: appt3End, status: "scheduled" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockDb.update.mockReturnValueThis();
|
||||||
|
|
||||||
|
// Two enrich queries for the two shifted appointments
|
||||||
|
mockDb.select
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
clientId: "c1", clientName: "Alice", clientEmail: "alice@test.com",
|
||||||
|
petName: "Buddy", serviceName: "Full Groom", groomerName: "Jamie",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
clientId: "c2", clientName: "Bob", clientEmail: "bob@test.com",
|
||||||
|
petName: "Max", serviceName: "Bath", groomerName: "Jamie",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await cascadeDelay(
|
||||||
|
"appt-trigger",
|
||||||
|
triggerEnd,
|
||||||
|
originalEnd,
|
||||||
|
15
|
||||||
|
);
|
||||||
|
|
||||||
|
// effectiveBoundary starts at 12:00 + 15 = 12:15
|
||||||
|
// appt-2: 12:00 start conflicts with 12:15 boundary → shift by 15 min → starts 12:15, ends 13:15
|
||||||
|
// new boundary: 13:15 + 15 = 13:30
|
||||||
|
// appt-3: 13:00 start conflicts with 13:30 boundary → shift by 30 min → starts 13:30, ends 14:30
|
||||||
|
expect(result.shifted).toHaveLength(2);
|
||||||
|
expect(result.shifted[0].id).toBe("appt-2");
|
||||||
|
expect(result.shifted[1].id).toBe("appt-3");
|
||||||
|
expect(mockDb.update).toHaveBeenCalledTimes(2);
|
||||||
|
expect(sendEmail).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags but still updates boundary when shift would fall outside business hours", async () => {
|
||||||
|
const triggerEnd = new Date("2026-05-16T17:00:00Z");
|
||||||
|
const originalEnd = new Date("2026-05-16T16:00:00Z");
|
||||||
|
|
||||||
|
// Downstream appt starts at 16:00, business ends at 18:00
|
||||||
|
mockDb.select
|
||||||
|
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
makeAppt({
|
||||||
|
id: "appt-late",
|
||||||
|
startTime: new Date("2026-05-16T16:00:00Z"),
|
||||||
|
endTime: new Date("2026-05-16T17:00:00Z"),
|
||||||
|
status: "scheduled",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockDb.update.mockReturnValueThis();
|
||||||
|
mockDb.select.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
clientId: "c1", clientName: "Alice", clientEmail: "alice@test.com",
|
||||||
|
petName: "Buddy", serviceName: "Full Groom", groomerName: "Jamie",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Business hours 08:00–18:00; proposed shift pushes to 17:15 start (still in hours)
|
||||||
|
// Try a late-night boundary: shift would push to 19:15 (outside 08:00–18:00)
|
||||||
|
const result = await cascadeDelay(
|
||||||
|
"appt-trigger",
|
||||||
|
new Date("2026-05-16T18:00:00Z"), // larger overrun
|
||||||
|
originalEnd,
|
||||||
|
15,
|
||||||
|
8, // business start
|
||||||
|
18 // business end — proposed 18:15 start is outside
|
||||||
|
);
|
||||||
|
|
||||||
|
// The appointment at 16:00 with buffer of 15 min after 18:00 trigger:
|
||||||
|
// effectiveBoundary = 18:00 + 15 = 18:15 → outside business hours (18:15 > 18:00)
|
||||||
|
expect(result.flaggedForReview).toHaveLength(1);
|
||||||
|
expect(result.flaggedForReview[0].id).toBe("appt-late");
|
||||||
|
expect(result.flaggedForReview[0].reason).toContain("Manual review required");
|
||||||
|
// The appointment was NOT shifted (only flagged)
|
||||||
|
expect(mockDb.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips non-active appointments", async () => {
|
||||||
|
mockDb.select
|
||||||
|
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
makeAppt({ id: "in-progress-1", status: "in_progress" }),
|
||||||
|
makeAppt({ id: "cancelled-1", status: "cancelled" }),
|
||||||
|
makeAppt({ id: "scheduled-1", status: "scheduled" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockDb.update.mockReturnValueThis();
|
||||||
|
mockDb.select.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
clientId: "c1", clientName: "Alice", clientEmail: "alice@test.com",
|
||||||
|
petName: "Buddy", serviceName: "Full Groom", groomerName: "Jamie",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await cascadeDelay(
|
||||||
|
"appt-trigger",
|
||||||
|
new Date("2026-05-16T11:30:00Z"),
|
||||||
|
new Date("2026-05-16T11:00:00Z"),
|
||||||
|
15
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only the scheduled appointment should be shifted
|
||||||
|
expect(result.shifted).toHaveLength(1);
|
||||||
|
expect(result.shifted[0].id).toBe("scheduled-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops cascading when an appointment no longer conflicts", async () => {
|
||||||
|
// Three downstream: appt-2 overlaps, appt-3 does NOT overlap, appt-4 overlaps
|
||||||
|
// Cascade should stop at appt-3
|
||||||
|
mockDb.select
|
||||||
|
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
// appt-2: starts at 11:00, ends 12:00 — overlaps boundary 11:45
|
||||||
|
makeAppt({ id: "appt-2", startTime: new Date("2026-05-16T11:00:00Z"), endTime: new Date("2026-05-16T12:00:00Z") }),
|
||||||
|
// appt-3: starts at 13:00 — already clear of shifted appt-2 (ends 12:15 + buffer)
|
||||||
|
makeAppt({ id: "appt-3", startTime: new Date("2026-05-16T13:00:00Z"), endTime: new Date("2026-05-16T14:00:00Z") }),
|
||||||
|
makeAppt({ id: "appt-4", startTime: new Date("2026-05-16T14:00:00Z"), endTime: new Date("2026-05-16T15:00:00Z") }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockDb.update.mockReturnValueThis();
|
||||||
|
mockDb.select.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
clientId: "c1", clientName: "Alice", clientEmail: "alice@test.com",
|
||||||
|
petName: "Buddy", serviceName: "Full Groom", groomerName: "Jamie",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await cascadeDelay(
|
||||||
|
"appt-trigger",
|
||||||
|
new Date("2026-05-16T11:30:00Z"),
|
||||||
|
new Date("2026-05-16T11:00:00Z"),
|
||||||
|
15
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only appt-2 was shifted (appt-3 no longer conflicts after the stop condition check)
|
||||||
|
expect(result.shifted).toHaveLength(1);
|
||||||
|
expect(result.shifted[0].id).toBe("appt-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends email notification for each shifted appointment", async () => {
|
||||||
|
mockDb.select
|
||||||
|
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
makeAppt({
|
||||||
|
id: "appt-email-test",
|
||||||
|
startTime: new Date("2026-05-16T11:00:00Z"),
|
||||||
|
endTime: new Date("2026-05-16T12:00:00Z"),
|
||||||
|
status: "confirmed",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockDb.update.mockReturnValueThis();
|
||||||
|
mockDb.select.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
clientId: "c1",
|
||||||
|
clientName: "Carol",
|
||||||
|
clientEmail: "carol@example.com",
|
||||||
|
petName: "Luna",
|
||||||
|
serviceName: "Nail Trim",
|
||||||
|
groomerName: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await cascadeDelay(
|
||||||
|
"appt-trigger",
|
||||||
|
new Date("2026-05-16T11:30:00Z"),
|
||||||
|
new Date("2026-05-16T11:00:00Z"),
|
||||||
|
15
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sendEmail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "carol@example.com",
|
||||||
|
subject: expect.stringContaining("Rescheduled"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
/**
|
||||||
|
* Cascade delay prevention — `apps/api/src/lib/cascade.ts`
|
||||||
|
*
|
||||||
|
* Triggered after a PATCH /appointments/:id call extends an appointment's
|
||||||
|
* endTime beyond its original value. Queries same-groomer downstream
|
||||||
|
* appointments, shifts them forward by (overrunEnd + buffer − downstreamStart),
|
||||||
|
* and cascades the shift through the chain. Clients are notified by email.
|
||||||
|
*
|
||||||
|
* Guard rails:
|
||||||
|
* - Only shifts `scheduled` and `confirmed` appointments.
|
||||||
|
* - Flags out-of-business-hours shifts for manual review instead of auto-shifting.
|
||||||
|
* - Returns the full list of shifted appointments.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { eq, and, gt, lte, asc, ne, inArray } from "drizzle-orm";
|
||||||
|
import { getDb, appointments, clients, pets, services, staff } from "@groombook/db";
|
||||||
|
import { sendEmail } from "../services/email.js";
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CascadeResult {
|
||||||
|
shifted: ShiftedAppointment[];
|
||||||
|
flaggedForReview: FlaggedAppointment[];
|
||||||
|
/** Time in ms each downstream appointment was pushed forward */
|
||||||
|
cascadeLog: CascadeLogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShiftedAppointment {
|
||||||
|
id: string;
|
||||||
|
originalStartTime: Date;
|
||||||
|
originalEndTime: Date;
|
||||||
|
newStartTime: Date;
|
||||||
|
newEndTime: Date;
|
||||||
|
clientId: string;
|
||||||
|
clientName: string;
|
||||||
|
clientEmail: string;
|
||||||
|
petName: string;
|
||||||
|
serviceName: string;
|
||||||
|
groomerName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlaggedAppointment {
|
||||||
|
id: string;
|
||||||
|
originalStartTime: Date;
|
||||||
|
proposedStartTime: Date;
|
||||||
|
proposedEndTime: Date;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CascadeLogEntry {
|
||||||
|
appointmentId: string;
|
||||||
|
deltaMs: number;
|
||||||
|
triggeredBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Default inter-appointment buffer in minutes. Overridden by services.bufferMinutes. */
|
||||||
|
export const DEFAULT_BUFFER_MINUTES = 15;
|
||||||
|
|
||||||
|
/** Default business hours (used when no settings row exists). */
|
||||||
|
export const DEFAULT_BUSINESS_START_HOUR = 8; // 08:00
|
||||||
|
export const DEFAULT_BUSINESS_END_HOUR = 18; // 18:00
|
||||||
|
|
||||||
|
// ─── Core cascade ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect and cascade appointment overruns.
|
||||||
|
*
|
||||||
|
* @param triggeringAppointmentId The appointment that just overran.
|
||||||
|
* @param newEndTime The updated endTime set by the caller.
|
||||||
|
* @param originalEndTime The appointment's endTime before the update.
|
||||||
|
* @param bufferMinutes Minutes of buffer between appointments (default 15).
|
||||||
|
* @param businessStartHour Business opening hour (0–23, default 8).
|
||||||
|
* @param businessEndHour Business closing hour (0–23, default 18).
|
||||||
|
*/
|
||||||
|
export async function cascadeDelay(
|
||||||
|
triggeringAppointmentId: string,
|
||||||
|
newEndTime: Date,
|
||||||
|
originalEndTime: Date,
|
||||||
|
bufferMinutes: number = DEFAULT_BUFFER_MINUTES,
|
||||||
|
businessStartHour: number = DEFAULT_BUSINESS_START_HOUR,
|
||||||
|
businessEndHour: number = DEFAULT_BUSINESS_END_HOUR
|
||||||
|
): Promise<CascadeResult> {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const bufferMs = bufferMinutes * 60_000;
|
||||||
|
const overrunEnd = newEndTime;
|
||||||
|
|
||||||
|
// ── 1. Load the triggering appointment ────────────────────────────────────────
|
||||||
|
const [triggering] = await db
|
||||||
|
.select()
|
||||||
|
.from(appointments)
|
||||||
|
.where(eq(appointments.id, triggeringAppointmentId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!triggering) {
|
||||||
|
return { shifted: [], flaggedForReview: [], cascadeLog: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!triggering.staffId) {
|
||||||
|
// Unassigned appointments cannot cascade
|
||||||
|
return { shifted: [], flaggedForReview: [], cascadeLog: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const groomerId = triggering.staffId;
|
||||||
|
|
||||||
|
// ── 2. Guard: only trigger when endTime actually extended ──────────────────────
|
||||||
|
if (overrunEnd <= originalEndTime) {
|
||||||
|
return { shifted: [], flaggedForReview: [], cascadeLog: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: CascadeResult = { shifted: [], flaggedForReview: [], cascadeLog: [] };
|
||||||
|
|
||||||
|
// ── 3. Fetch all downstream same-groomer active appointments ──────────────────
|
||||||
|
const downstream = await db
|
||||||
|
.select()
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.staffId, groomerId),
|
||||||
|
gt(appointments.startTime, originalEndTime),
|
||||||
|
inArray(appointments.status, ["scheduled", "confirmed"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(appointments.startTime));
|
||||||
|
|
||||||
|
if (downstream.length === 0) return result;
|
||||||
|
|
||||||
|
// ── 4. Cascade loop ────────────────────────────────────────────────────────────
|
||||||
|
// Keep track of current effective boundary after each shift.
|
||||||
|
// Start from the new endTime of the triggering appointment plus buffer.
|
||||||
|
let effectiveBoundary = new Date(overrunEnd.getTime() + bufferMs);
|
||||||
|
|
||||||
|
for (const appt of downstream) {
|
||||||
|
const conflictStart = appt.startTime;
|
||||||
|
const conflictEnd = appt.endTime;
|
||||||
|
const apptDurationMs = conflictEnd.getTime() - conflictStart.getTime();
|
||||||
|
|
||||||
|
// Does this appointment overlap the effective boundary?
|
||||||
|
if (effectiveBoundary.getTime() >= conflictEnd.getTime()) {
|
||||||
|
// No conflict — this appointment and all later ones are unaffected
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proposedStart = new Date(effectiveBoundary);
|
||||||
|
const proposedEnd = new Date(proposedStart.getTime() + apptDurationMs);
|
||||||
|
|
||||||
|
// ── Business-hours guard ────────────────────────────────────────────────────
|
||||||
|
const proposedStartHour = proposedStart.getHours() + proposedStart.getMinutes() / 60;
|
||||||
|
const proposedEndHour = proposedEnd.getHours() + proposedEnd.getMinutes() / 60;
|
||||||
|
const outOfHours =
|
||||||
|
proposedStartHour < businessStartHour ||
|
||||||
|
proposedEndHour > businessEndHour;
|
||||||
|
|
||||||
|
if (outOfHours) {
|
||||||
|
result.flaggedForReview.push({
|
||||||
|
id: appt.id,
|
||||||
|
originalStartTime: appt.startTime,
|
||||||
|
proposedStartTime: proposedStart,
|
||||||
|
proposedEndTime: proposedEnd,
|
||||||
|
reason:
|
||||||
|
`Would push appointment outside business hours ` +
|
||||||
|
`(${businessStartHour}:00–${businessEndHour}:00). ` +
|
||||||
|
`Manual review required.`,
|
||||||
|
});
|
||||||
|
// Update boundary anyway — later appointments may still conflict
|
||||||
|
effectiveBoundary = new Date(proposedEnd.getTime() + bufferMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Perform the shift ──────────────────────────────────────────────────────
|
||||||
|
const deltaMs = proposedStart.getTime() - appt.startTime.getTime();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(appointments)
|
||||||
|
.set({ startTime: proposedStart, endTime: proposedEnd, updatedAt: new Date() })
|
||||||
|
.where(eq(appointments.id, appt.id));
|
||||||
|
|
||||||
|
result.cascadeLog.push({
|
||||||
|
appointmentId: appt.id,
|
||||||
|
deltaMs,
|
||||||
|
triggeredBy: triggeringAppointmentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Load client/pet/service info for notification ──────────────────────────
|
||||||
|
const enriched = await enrichAppointment(appt.id);
|
||||||
|
if (enriched) {
|
||||||
|
result.shifted.push({
|
||||||
|
id: appt.id,
|
||||||
|
originalStartTime: appt.startTime,
|
||||||
|
originalEndTime: appt.endTime,
|
||||||
|
newStartTime: proposedStart,
|
||||||
|
newEndTime: proposedEnd,
|
||||||
|
...enriched,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance boundary to the end of this shifted appointment plus buffer
|
||||||
|
effectiveBoundary = new Date(proposedEnd.getTime() + bufferMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Send notifications ────────────────────────────────────────────────────
|
||||||
|
for (const shifted of result.shifted) {
|
||||||
|
await sendRescheduleNotification(shifted).catch((err) =>
|
||||||
|
console.error(`[cascade] Failed to send notification for ${shifted.id}:`, err)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shortcut for status-transition overruns (current time > endTime + bufferMinutes).
|
||||||
|
* Delegates to `cascadeDelay` using the current appointment data.
|
||||||
|
*/
|
||||||
|
export async function cascadeOnStatusOverrun(
|
||||||
|
appointmentId: string,
|
||||||
|
bufferMinutes: number = DEFAULT_BUFFER_MINUTES,
|
||||||
|
businessStartHour: number = DEFAULT_BUSINESS_START_HOUR,
|
||||||
|
businessEndHour: number = DEFAULT_BUSINESS_END_HOUR
|
||||||
|
): Promise<CascadeResult> {
|
||||||
|
const db = getDb();
|
||||||
|
const [appt] = await db
|
||||||
|
.select()
|
||||||
|
.from(appointments)
|
||||||
|
.where(eq(appointments.id, appointmentId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!appt) return { shifted: [], flaggedForReview: [], cascadeLog: [] };
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const bufferMs = bufferMinutes * 60_000;
|
||||||
|
|
||||||
|
if (now.getTime() <= appt.endTime.getTime() + bufferMs) {
|
||||||
|
// Not actually in overrun
|
||||||
|
return { shifted: [], flaggedForReview: [], cascadeLog: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use current time as the new endTime (the appointment is already running over)
|
||||||
|
return cascadeDelay(
|
||||||
|
appointmentId,
|
||||||
|
now,
|
||||||
|
appt.endTime,
|
||||||
|
bufferMinutes,
|
||||||
|
businessStartHour,
|
||||||
|
businessEndHour
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface EnrichedFields {
|
||||||
|
clientId: string;
|
||||||
|
clientName: string;
|
||||||
|
clientEmail: string;
|
||||||
|
petName: string;
|
||||||
|
serviceName: string;
|
||||||
|
groomerName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichAppointment(
|
||||||
|
apptId: string
|
||||||
|
): Promise<EnrichedFields | null> {
|
||||||
|
const db = getDb();
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
clientId: appointments.clientId,
|
||||||
|
clientName: clients.name,
|
||||||
|
clientEmail: clients.email,
|
||||||
|
petName: pets.name,
|
||||||
|
serviceName: services.name,
|
||||||
|
groomerName: staff.name,
|
||||||
|
})
|
||||||
|
.from(appointments)
|
||||||
|
.innerJoin(clients, eq(clients.id, appointments.clientId))
|
||||||
|
.innerJoin(pets, eq(pets.id, appointments.petId))
|
||||||
|
.innerJoin(services, eq(services.id, appointments.serviceId))
|
||||||
|
.leftJoin(staff, eq(staff.id, appointments.staffId))
|
||||||
|
.where(eq(appointments.id, apptId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
clientId: row.clientId,
|
||||||
|
clientName: row.clientName,
|
||||||
|
clientEmail: row.clientEmail,
|
||||||
|
petName: row.petName,
|
||||||
|
serviceName: row.serviceName,
|
||||||
|
groomerName: row.groomerName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendRescheduleNotification(
|
||||||
|
shifted: ShiftedAppointment
|
||||||
|
): Promise<void> {
|
||||||
|
const time = formatDateTime(shifted.newStartTime);
|
||||||
|
const original = formatDateTime(shifted.originalStartTime);
|
||||||
|
const groomer = shifted.groomerName ? ` with ${shifted.groomerName}` : "";
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
to: shifted.clientEmail,
|
||||||
|
subject: `Appointment Rescheduled — ${shifted.petName}`,
|
||||||
|
text: [
|
||||||
|
`Hi ${shifted.clientName},`,
|
||||||
|
``,
|
||||||
|
`Your appointment for ${shifted.petName} has been rescheduled.`,
|
||||||
|
``,
|
||||||
|
` Was: ${original}${groomer}`,
|
||||||
|
` Now: ${time}${groomer}`,
|
||||||
|
``,
|
||||||
|
`We apologize for any inconvenience. If this new time doesn't work for you, please contact us as soon as possible.`,
|
||||||
|
``,
|
||||||
|
`— Groom Book`,
|
||||||
|
].join("\n"),
|
||||||
|
html: `<p>Hi ${shifted.clientName},</p>
|
||||||
|
<p>Your appointment for <strong>${shifted.petName}</strong> has been rescheduled.</p>
|
||||||
|
<table style="border-collapse:collapse;margin:1em 0">
|
||||||
|
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Previous time</td><td style="text-decoration:line-through;color:#9ca3af">${original}${groomer}</td></tr>
|
||||||
|
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">New time</td><td>${time}${groomer}</td></tr>
|
||||||
|
</table>
|
||||||
|
<p>If this new time doesn't work for you, please contact us as soon as possible.</p>
|
||||||
|
<p>— Groom Book</p>`,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`[cascade] Notified ${shifted.clientEmail} of reschedule for ${shifted.petName} ` +
|
||||||
|
`(${shifted.id}): ${original} → ${time}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(d: Date): string {
|
||||||
|
return d.toLocaleString("en-US", {
|
||||||
|
weekday: "long",
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
staff,
|
staff,
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||||
|
import { cascadeDelay } from "../lib/cascade.js";
|
||||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
@@ -580,6 +581,15 @@ appointmentsRouter.patch(
|
|||||||
if (updateFields.endTime) update.endTime = new Date(updateFields.endTime);
|
if (updateFields.endTime) update.endTime = new Date(updateFields.endTime);
|
||||||
|
|
||||||
if (needsConflictCheck) {
|
if (needsConflictCheck) {
|
||||||
|
// Capture original endTime before the transaction so we can detect an
|
||||||
|
// overrun and trigger cascade delay prevention after the update.
|
||||||
|
const [preUpdate] = await db
|
||||||
|
.select({ originalEndTime: appointments.endTime })
|
||||||
|
.from(appointments)
|
||||||
|
.where(eq(appointments.id, id))
|
||||||
|
.limit(1);
|
||||||
|
const originalEndTime = preUpdate?.originalEndTime ?? null;
|
||||||
|
|
||||||
// Wrap conflict check + update in a transaction to prevent race conditions
|
// Wrap conflict check + update in a transaction to prevent race conditions
|
||||||
// (fixes #18). Also falls back to the existing staffId when staffId is
|
// (fixes #18). Also falls back to the existing staffId when staffId is
|
||||||
// omitted from the request, so rescheduling always checks conflicts (fixes #19).
|
// omitted from the request, so rescheduling always checks conflicts (fixes #19).
|
||||||
@@ -684,7 +694,23 @@ appointmentsRouter.patch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
return c.json(row);
|
|
||||||
|
// Cascade delay prevention: detect if endTime was extended and cascade
|
||||||
|
// downstream appointments if so. Runs after the main update commits.
|
||||||
|
const cascadeResult =
|
||||||
|
updateFields.endTime &&
|
||||||
|
originalEndTime &&
|
||||||
|
new Date(updateFields.endTime) > originalEndTime
|
||||||
|
? await cascadeDelay(id, new Date(updateFields.endTime), originalEndTime)
|
||||||
|
: { shifted: [], flaggedForReview: [], cascadeLog: [] };
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
...row,
|
||||||
|
cascade:
|
||||||
|
cascadeResult.shifted.length > 0 || cascadeResult.flaggedForReview.length > 0
|
||||||
|
? cascadeResult
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ invoicesRouter.get(
|
|||||||
paymentMethod: invoices.paymentMethod,
|
paymentMethod: invoices.paymentMethod,
|
||||||
paidAt: invoices.paidAt,
|
paidAt: invoices.paidAt,
|
||||||
notes: invoices.notes,
|
notes: invoices.notes,
|
||||||
|
stripePaymentIntentId: invoices.stripePaymentIntentId,
|
||||||
|
stripeRefundId: invoices.stripeRefundId,
|
||||||
createdAt: invoices.createdAt,
|
createdAt: invoices.createdAt,
|
||||||
updatedAt: invoices.updatedAt,
|
updatedAt: invoices.updatedAt,
|
||||||
})
|
})
|
||||||
@@ -128,7 +130,17 @@ invoicesRouter.get("/:id", async (c) => {
|
|||||||
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return c.json({ ...invoice, lineItems, tipSplits });
|
let cardLast4: string | null = null;
|
||||||
|
let paymentStatus: string | null = null;
|
||||||
|
if (invoice.stripePaymentIntentId) {
|
||||||
|
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
|
||||||
|
if (details) {
|
||||||
|
cardLast4 = details.cardLast4;
|
||||||
|
paymentStatus = details.paymentStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ...invoice, lineItems, tipSplits, cardLast4, paymentStatus });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save tip splits for an invoice (replaces existing splits)
|
// Save tip splits for an invoice (replaces existing splits)
|
||||||
@@ -448,9 +460,6 @@ invoicesRouter.post(
|
|||||||
if (invoice.status !== "paid") {
|
if (invoice.status !== "paid") {
|
||||||
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
|
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
|
||||||
}
|
}
|
||||||
if (!invoice.stripePaymentIntentId) {
|
|
||||||
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await db.transaction(async (tx) => {
|
return await db.transaction(async (tx) => {
|
||||||
if (body.idempotencyKey) {
|
if (body.idempotencyKey) {
|
||||||
@@ -463,57 +472,75 @@ invoicesRouter.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await processRefund(id, body.amountCents);
|
let refundId: string;
|
||||||
if (!result) return c.json({ error: "Refund failed" }, 500);
|
|
||||||
|
if (invoice.stripePaymentIntentId) {
|
||||||
|
const result = await processRefund(id, body.amountCents);
|
||||||
|
if (!result) return c.json({ error: "Refund failed" }, 500);
|
||||||
|
refundId = result.refundId;
|
||||||
|
} else {
|
||||||
|
// Manual refund — no Stripe call needed
|
||||||
|
refundId = `manual_${id}_${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
await tx.insert(refunds).values({
|
await tx.insert(refunds).values({
|
||||||
invoiceId: id,
|
invoiceId: id,
|
||||||
stripeRefundId: result.refundId,
|
stripeRefundId: refundId,
|
||||||
idempotencyKey: body.idempotencyKey ?? null,
|
idempotencyKey: body.idempotencyKey ?? null,
|
||||||
amountCents: body.amountCents ?? null,
|
amountCents: body.amountCents ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json({ refundId: result.refundId });
|
return c.json({ refundId });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Payment stats for admin dashboard
|
// Payment stats for admin dashboard
|
||||||
invoicesRouter.get("/stats/summary", async (c) => {
|
invoicesRouter.get("/stats/summary", async (c) => {
|
||||||
const db = getDb();
|
try {
|
||||||
const now = new Date();
|
const db = getDb();
|
||||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
const now = new Date();
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
|
||||||
const [revenueResult] = await db
|
const [revenueResult] = await db
|
||||||
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`));
|
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`));
|
||||||
|
|
||||||
const [outstandingResult] = await db
|
const [outstandingResult] = await db
|
||||||
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(eq(invoices.status, "pending"));
|
.where(eq(invoices.status, "pending"));
|
||||||
|
|
||||||
const [refundsResult] = await db
|
const [refundsResult] = await db
|
||||||
.select({ total: sql<number>`coalesce(sum(amount_cents), 0)` })
|
.select({ total: sql<number>`coalesce(sum(amount_cents), 0)` })
|
||||||
.from(refunds)
|
.from(refunds)
|
||||||
.where(sql`${refunds.createdAt} >= ${startOfMonth}`);
|
.where(sql`${refunds.createdAt} >= ${startOfMonth}`);
|
||||||
|
|
||||||
const methodBreakdown = await db
|
const methodBreakdown = await db
|
||||||
.select({
|
.select({
|
||||||
method: invoices.paymentMethod,
|
method: invoices.paymentMethod,
|
||||||
total: sql<number>`count(*)`,
|
total: sql<number>`count(*)`,
|
||||||
})
|
})
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`))
|
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`))
|
||||||
.groupBy(invoices.paymentMethod);
|
.groupBy(invoices.paymentMethod);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
revenueThisMonth: revenueResult?.total ?? 0,
|
revenueThisMonth: revenueResult?.total ?? 0,
|
||||||
outstanding: outstandingResult?.total ?? 0,
|
outstanding: outstandingResult?.total ?? 0,
|
||||||
refundsThisMonth: refundsResult?.total ?? 0,
|
refundsThisMonth: refundsResult?.total ?? 0,
|
||||||
methodBreakdown,
|
methodBreakdown,
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("stats/summary error:", err);
|
||||||
|
return c.json({
|
||||||
|
revenueThisMonth: 0,
|
||||||
|
outstanding: 0,
|
||||||
|
refundsThisMonth: 0,
|
||||||
|
methodBreakdown: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get Stripe payment details for an invoice (card last4, payment status, refund status)
|
// Get Stripe payment details for an invoice (card last4, payment status, refund status)
|
||||||
|
|||||||
@@ -112,9 +112,17 @@ export function AppointmentsPage() {
|
|||||||
const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
|
const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
|
||||||
// null key = unassigned; staffId string = that groomer; undefined set = all visible
|
// null key = unassigned; staffId string = that groomer; undefined set = all visible
|
||||||
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set());
|
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set());
|
||||||
|
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
|
||||||
|
|
||||||
const weekEnd = addDays(weekStart, 6);
|
const weekEnd = addDays(weekStart, 6);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/invoices/stats/summary")
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then((data) => { if (data) setPaymentStats(data); })
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadAppointments = useCallback(() => {
|
const loadAppointments = useCallback(() => {
|
||||||
const from = weekStart.toISOString();
|
const from = weekStart.toISOString();
|
||||||
const to = addDays(weekStart, 7).toISOString();
|
const to = addDays(weekStart, 7).toISOString();
|
||||||
@@ -314,6 +322,24 @@ export function AppointmentsPage() {
|
|||||||
</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" }}>${(paymentStats.revenueThisMonth / 100).toFixed(2)}</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" }}>${(paymentStats.outstanding / 100).toFixed(2)}</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" }}>${(paymentStats.refundsThisMonth / 100).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── View Mode + Groomer Filters ── */}
|
{/* ── View Mode + Groomer Filters ── */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
|
||||||
|
|||||||
@@ -173,22 +173,21 @@ 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 [showRefundDialog, setShowRefundDialog] = useState(false);
|
||||||
const [refundType, setRefundType] = useState<"full" | "partial">("full");
|
const [refundType, setRefundType] = useState<"full" | "partial">("full");
|
||||||
const [partialAmount, setPartialAmount] = useState("");
|
const [refundAmount, setRefundAmount] = useState("");
|
||||||
const [stripeDetails, setStripeDetails] = useState<{ cardLast4: string | null; paymentStatus: string | null; stripeRefundId: string | null } | null>(null);
|
const [refundError, setRefundError] = useState<string | null>(null);
|
||||||
|
const [refunding, setRefunding] = useState(false);
|
||||||
|
|
||||||
// Fetch Stripe details when modal opens for paid invoices with a payment intent
|
// Fetch current staff role to determine manager access
|
||||||
|
const [staffMe, setStaffMe] = useState<{ role: string; isSuperUser: boolean } | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (invoice.status === "paid" && invoice.stripePaymentIntentId) {
|
fetch("/api/staff/me")
|
||||||
fetch(`/api/invoices/${invoice.id}/stripe-details`)
|
.then((r) => r.json())
|
||||||
.then((r) => r.ok ? r.json() : null)
|
.then((d) => setStaffMe(d))
|
||||||
.then((data) => { if (data) setStripeDetails(data); })
|
.catch(() => setStaffMe(null));
|
||||||
.catch(() => {});
|
}, []);
|
||||||
} else {
|
const isManager = staffMe && (staffMe.role === "manager" || staffMe.isSuperUser);
|
||||||
setStripeDetails(null);
|
|
||||||
}
|
|
||||||
}, [invoice.id, invoice.status, invoice.stripePaymentIntentId]);
|
|
||||||
|
|
||||||
// Tip split state: array of {staffId, staffName, pct}
|
// Tip split state: array of {staffId, staffName, pct}
|
||||||
const linkedAppt = invoice.appointmentId
|
const linkedAppt = invoice.appointmentId
|
||||||
@@ -292,35 +291,6 @@ 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;
|
||||||
@@ -380,15 +350,15 @@ function InvoiceDetailModal({
|
|||||||
/>
|
/>
|
||||||
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
|
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
|
||||||
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
|
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
|
||||||
{stripeDetails && (
|
{invoice.stripePaymentIntentId && (
|
||||||
<>
|
<>
|
||||||
{stripeDetails.cardLast4 && (
|
{invoice.cardLast4 && (
|
||||||
<SummaryRow label="Card" value={`•••• ${stripeDetails.cardLast4}`} />
|
<SummaryRow label="Card" value={`•••• ${invoice.cardLast4}`} />
|
||||||
)}
|
)}
|
||||||
{stripeDetails.paymentStatus && (
|
{invoice.paymentStatus && (
|
||||||
<SummaryRow label="Stripe status" value={stripeDetails.paymentStatus} />
|
<SummaryRow label="Stripe status" value={invoice.paymentStatus} />
|
||||||
)}
|
)}
|
||||||
{stripeDetails.stripeRefundId && (
|
{invoice.stripeRefundId && (
|
||||||
<SummaryRow label="Refund" value="Refunded" />
|
<SummaryRow label="Refund" value="Refunded" />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -510,77 +480,92 @@ 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", borderTop: "1px solid #e2e8f0", paddingTop: "1rem" }}>
|
||||||
{invoice.status === "paid" && invoice.stripePaymentIntentId && (
|
{invoice.stripeRefundId && (
|
||||||
<button
|
<div style={{ marginBottom: "0.75rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
onClick={() => setShowRefundDialog(true)}
|
<span style={{ background: "#fef3c7", color: "#92400e", padding: "0.2rem 0.6rem", borderRadius: 4, fontSize: 13, fontWeight: 600 }}>Refunded</span>
|
||||||
style={{ ...btnStyle, color: "#b45309", borderColor: "#b45309" }}
|
</div>
|
||||||
>
|
|
||||||
Refund
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
<button onClick={onClose} style={btnStyle}>Close</button>
|
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end" }}>
|
||||||
|
{invoice.status === "paid" && !invoice.stripeRefundId && isManager && (
|
||||||
|
<button onClick={() => setShowRefundDialog(true)} style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}>
|
||||||
|
Refund
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={onClose} style={btnStyle}>Close</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Refund Dialog */}
|
|
||||||
{showRefundDialog && (
|
{showRefundDialog && (
|
||||||
<Modal onClose={() => setShowRefundDialog(false)}>
|
<div style={{ marginTop: "1rem", border: "1px solid #e2e8f0", borderRadius: 8, padding: "1rem", background: "#f9fafb" }}>
|
||||||
<h2 style={{ marginTop: 0 }}>Issue Refund</h2>
|
<p style={{ fontWeight: 600, margin: "0 0 0.75rem" }}>Process Refund</p>
|
||||||
<p style={{ fontSize: 14, color: "#6b7280", marginBottom: "1rem" }}>
|
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}>
|
||||||
Invoice total: <strong>{fmtMoney(invoice.totalCents)}</strong>
|
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
|
||||||
</p>
|
<input type="radio" checked={refundType === "full"} onChange={() => setRefundType("full")} />
|
||||||
<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
|
Full refund
|
||||||
</label>
|
</label>
|
||||||
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600 }}>
|
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
|
||||||
<input
|
<input type="radio" checked={refundType === "partial"} onChange={() => setRefundType("partial")} />
|
||||||
type="radio"
|
|
||||||
name="refundType"
|
|
||||||
value="partial"
|
|
||||||
checked={refundType === "partial"}
|
|
||||||
onChange={() => setRefundType("partial")}
|
|
||||||
/>
|
|
||||||
Partial refund
|
Partial refund
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{refundType === "partial" && (
|
{refundType === "partial" && (
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
<div style={{ marginBottom: "0.75rem" }}>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0.01"
|
min="0.01"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
placeholder="0.00"
|
placeholder="Amount ($)"
|
||||||
value={partialAmount}
|
value={refundAmount}
|
||||||
onChange={(e) => setPartialAmount(e.target.value)}
|
onChange={(e) => setRefundAmount(e.target.value)}
|
||||||
style={{ ...inputStyle, width: 120 }}
|
style={{ ...inputStyle, width: 100 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && <p style={{ color: "red", margin: "0.5rem 0" }}>{error}</p>}
|
{refundError && <p style={{ color: "red", margin: "0 0 0.5rem", fontSize: 13 }}>{refundError}</p>}
|
||||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }}>
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
<button
|
<button
|
||||||
onClick={issueRefund}
|
onClick={async () => {
|
||||||
disabled={saving}
|
setRefunding(true);
|
||||||
style={{ ...btnStyle, backgroundColor: "#b45309", color: "#fff", borderColor: "#b45309" }}
|
setRefundError(null);
|
||||||
|
try {
|
||||||
|
if (refundType === "partial") {
|
||||||
|
const parsed = parseFloat(refundAmount);
|
||||||
|
if (isNaN(parsed) || parsed <= 0) {
|
||||||
|
setRefundError("Please enter a valid amount greater than zero.");
|
||||||
|
setRefunding(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const body = refundType === "partial" ? { amountCents: Math.round(parseFloat(refundAmount) * 100) } : {};
|
||||||
|
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
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) {
|
||||||
|
setRefundError(e instanceof Error ? e.message : "Refund failed");
|
||||||
|
} finally {
|
||||||
|
setRefunding(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={refunding}
|
||||||
|
style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}
|
||||||
>
|
>
|
||||||
{saving ? "Processing…" : "Issue Refund"}
|
{refunding ? "Processing…" : "Process Refund"}
|
||||||
</button>
|
|
||||||
<button onClick={() => setShowRefundDialog(false)} style={btnStyle}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={() => { setShowRefundDialog(false); setRefundError(null); }} style={btnStyle}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ export function CustomerPortal() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 min-h-screen overflow-x-hidden">
|
<main className="flex-1 min-h-screen overflow-hidden">
|
||||||
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
|
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold text-stone-800">
|
<h1 className="text-lg font-semibold text-stone-800">
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap overflow-x-auto">
|
||||||
{([
|
{([
|
||||||
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
||||||
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
|
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
|
||||||
|
|||||||
@@ -119,3 +119,10 @@ uri
|
|||||||
database-url
|
database-url
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Auth secret name — always use groombook-auth (sealed secret name)
|
||||||
|
*/}}
|
||||||
|
{{- define "groombook.authSecretName" -}}
|
||||||
|
{{- printf "%s" "groombook-auth" }}
|
||||||
|
{{- end }}
|
||||||
|
|||||||
@@ -50,6 +50,27 @@ spec:
|
|||||||
- name: OIDC_AUDIENCE
|
- name: OIDC_AUDIENCE
|
||||||
value: {{ .Values.api.env.oidcAudience | quote }}
|
value: {{ .Values.api.env.oidcAudience | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if .Values.api.env.internalBaseUrl }}
|
||||||
|
- name: OIDC_INTERNAL_BASE
|
||||||
|
value: {{ .Values.api.env.internalBaseUrl | quote }}
|
||||||
|
{{- end }}
|
||||||
|
- name: BETTER_AUTH_URL
|
||||||
|
value: {{ .Values.api.env.betterAuthUrl | quote }}
|
||||||
|
- name: OIDC_CLIENT_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "groombook.authSecretName" . }}
|
||||||
|
key: OIDC_CLIENT_ID
|
||||||
|
- name: OIDC_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "groombook.authSecretName" . }}
|
||||||
|
key: OIDC_CLIENT_SECRET
|
||||||
|
- name: BETTER_AUTH_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "groombook.authSecretName" . }}
|
||||||
|
key: BETTER_AUTH_SECRET
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ api:
|
|||||||
corsOrigin: ""
|
corsOrigin: ""
|
||||||
oidcIssuer: ""
|
oidcIssuer: ""
|
||||||
oidcAudience: groombook
|
oidcAudience: groombook
|
||||||
|
betterAuthUrl: ""
|
||||||
|
internalBaseUrl: ""
|
||||||
port: "3000"
|
port: "3000"
|
||||||
service:
|
service:
|
||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
|
|||||||
+10
-1
@@ -883,6 +883,7 @@ async function seed() {
|
|||||||
let appointmentCount = 0;
|
let appointmentCount = 0;
|
||||||
let invoiceCount = 0;
|
let invoiceCount = 0;
|
||||||
let visitLogCount = 0;
|
let visitLogCount = 0;
|
||||||
|
let paidInvoiceCounter = 0;
|
||||||
|
|
||||||
// Process in batches per client to keep memory manageable
|
// Process in batches per client to keep memory manageable
|
||||||
const apptBatchSize = 100;
|
const apptBatchSize = 100;
|
||||||
@@ -977,6 +978,10 @@ async function seed() {
|
|||||||
|
|
||||||
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const;
|
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const;
|
||||||
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
|
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
|
||||||
|
paidInvoiceCounter++;
|
||||||
|
const stripePaymentIntentId = invoiceStatus === "paid"
|
||||||
|
? `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`
|
||||||
|
: null;
|
||||||
|
|
||||||
invoiceBatch.push({
|
invoiceBatch.push({
|
||||||
id: invoiceId,
|
id: invoiceId,
|
||||||
@@ -989,6 +994,7 @@ async function seed() {
|
|||||||
status: invoiceStatus,
|
status: invoiceStatus,
|
||||||
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
|
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
|
||||||
paidAt,
|
paidAt,
|
||||||
|
stripePaymentIntentId,
|
||||||
notes: rand() < 0.05 ? "Added extra service at checkout" : null,
|
notes: rand() < 0.05 ? "Added extra service at checkout" : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1092,13 +1098,16 @@ async function seed() {
|
|||||||
const taxCents = Math.round(effectivePrice * 0.08);
|
const taxCents = Math.round(effectivePrice * 0.08);
|
||||||
const totalCents = effectivePrice + taxCents + tipCents;
|
const totalCents = effectivePrice + taxCents + tipCents;
|
||||||
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
|
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
|
||||||
|
paidInvoiceCounter++;
|
||||||
|
|
||||||
invoiceBatch.push({
|
invoiceBatch.push({
|
||||||
id: invoiceId, appointmentId: apptId, clientId,
|
id: invoiceId, appointmentId: apptId, clientId,
|
||||||
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
|
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
|
||||||
status: "paid" as const,
|
status: "paid" as const,
|
||||||
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
|
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
|
||||||
paidAt, notes: null,
|
paidAt,
|
||||||
|
stripePaymentIntentId: `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`,
|
||||||
|
notes: null,
|
||||||
});
|
});
|
||||||
lineItemBatch.push({
|
lineItemBatch.push({
|
||||||
id: uuid(), invoiceId, description: svc.name, quantity: 1,
|
id: uuid(), invoiceId, description: svc.name, quantity: 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user