feat: deterministic seed, impersonation migration, test factories (GRO-110) #92
@@ -2,22 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { JwtPayload } from "../middleware/auth.js";
|
||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||
import { buildStaff } from "@groombook/db/factories";
|
||||
|
||||
// ─── Mock data ───────────────────────────────────────────────────────────────
|
||||
// ─── Mock data (built with factories for schema-safe defaults) ────────────────
|
||||
|
||||
const MANAGER_STAFF = {
|
||||
id: "staff-manager-id",
|
||||
oidcSub: "oidc-manager-sub",
|
||||
role: "manager",
|
||||
name: "Manager",
|
||||
};
|
||||
|
||||
const GROOMER_STAFF = {
|
||||
id: "staff-groomer-id",
|
||||
oidcSub: "oidc-groomer-sub",
|
||||
role: "groomer",
|
||||
name: "Groomer",
|
||||
};
|
||||
const MANAGER_STAFF = buildStaff({ id: "staff-manager-id", oidcSub: "oidc-manager-sub", role: "manager", name: "Manager" });
|
||||
const GROOMER_STAFF = buildStaff({ id: "staff-groomer-id", oidcSub: "oidc-groomer-sub", role: "groomer", name: "Groomer" });
|
||||
|
||||
const CLIENT = { id: "aabbccdd-1111-2222-3333-444444444444", name: "Fido Owner" };
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "path";
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@groombook/db/factories": path.resolve(__dirname, "../../packages/db/src/factories.ts"),
|
||||
"@groombook/db": path.resolve(__dirname, "../../packages/db/src/index.ts"),
|
||||
},
|
||||
},
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@
|
||||
"typecheck": "pnpm -r typecheck",
|
||||
"test": "pnpm -r --filter='!@groombook/e2e' test",
|
||||
"db:migrate": "pnpm --filter @groombook/db migrate",
|
||||
"db:seed": "pnpm --filter @groombook/db seed"
|
||||
"db:seed": "pnpm --filter @groombook/db seed",
|
||||
"db:reset": "pnpm --filter @groombook/db reset"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Create impersonation_session_status enum and tables
|
||||
CREATE TYPE "impersonation_session_status" AS ENUM ('active', 'ended', 'expired');
|
||||
|
||||
CREATE TABLE "impersonation_sessions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"staff_id" uuid NOT NULL,
|
||||
"client_id" uuid NOT NULL,
|
||||
"reason" text,
|
||||
"status" "impersonation_session_status" DEFAULT 'active' NOT NULL,
|
||||
"started_at" timestamp DEFAULT now() NOT NULL,
|
||||
"ended_at" timestamp,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "impersonation_sessions_staff_id_staff_id_fk" FOREIGN KEY ("staff_id") REFERENCES "staff"("id") ON DELETE restrict,
|
||||
CONSTRAINT "impersonation_sessions_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE restrict
|
||||
);
|
||||
|
||||
CREATE TABLE "impersonation_audit_logs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"session_id" uuid NOT NULL,
|
||||
"action" text NOT NULL,
|
||||
"page_visited" text,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "impersonation_audit_logs_session_id_impersonation_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "impersonation_sessions"("id") ON DELETE cascade
|
||||
);
|
||||
@@ -71,6 +71,13 @@
|
||||
"when": 1773993600000,
|
||||
"tag": "0009_client_soft_delete",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1742500800000,
|
||||
"tag": "0010_impersonation_sessions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,6 +9,10 @@
|
||||
".": {
|
||||
"default": "./dist/index.js",
|
||||
"types": "./src/index.ts"
|
||||
},
|
||||
"./factories": {
|
||||
"default": "./src/factories.ts",
|
||||
"types": "./src/factories.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -16,6 +20,7 @@
|
||||
"generate": "drizzle-kit generate",
|
||||
"migrate": "drizzle-kit migrate",
|
||||
"seed": "tsx src/seed.ts",
|
||||
"reset": "tsx src/reset.ts && drizzle-kit migrate && tsx src/seed.ts",
|
||||
"studio": "drizzle-kit studio",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Test factories — build typed in-memory entities for unit tests.
|
||||
*
|
||||
* Each factory returns a fully-populated object with valid defaults.
|
||||
* Pass an overrides object to customise specific fields.
|
||||
*
|
||||
* IDs are generated with a deterministic counter so tests produce stable,
|
||||
* readable values (e.g. "staff-1", "client-2") without needing crypto.
|
||||
*
|
||||
* Usage:
|
||||
* import { buildStaff, buildClient, buildPet } from "@groombook/db/factories";
|
||||
*
|
||||
* const manager = buildStaff({ role: "manager" });
|
||||
* const client = buildClient({ name: "Alice Smith" });
|
||||
* const pet = buildPet({ clientId: client.id });
|
||||
*/
|
||||
|
||||
import type { staff, clients, pets, services, appointments } from "./schema.js";
|
||||
|
||||
// ── Counter-based ID factory ─────────────────────────────────────────────────
|
||||
|
||||
const counters: Record<string, number> = {};
|
||||
|
||||
function nextId(prefix: string): string {
|
||||
counters[prefix] = (counters[prefix] ?? 0) + 1;
|
||||
return `${prefix}-${counters[prefix]}`;
|
||||
}
|
||||
|
||||
/** Reset all counters. Call in beforeEach() to keep tests independent. */
|
||||
export function resetFactoryCounters(): void {
|
||||
for (const key of Object.keys(counters)) {
|
||||
delete counters[key];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Type aliases ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type StaffRow = typeof staff.$inferSelect;
|
||||
export type ClientRow = typeof clients.$inferSelect;
|
||||
export type PetRow = typeof pets.$inferSelect;
|
||||
export type ServiceRow = typeof services.$inferSelect;
|
||||
export type AppointmentRow = typeof appointments.$inferSelect;
|
||||
|
||||
// ── Factories ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildStaff(overrides: Partial<StaffRow> = {}): StaffRow {
|
||||
const id = nextId("staff");
|
||||
return {
|
||||
id,
|
||||
name: `Staff Member ${id}`,
|
||||
email: `${id}@groombook.test`,
|
||||
oidcSub: `oidc-${id}`,
|
||||
role: "groomer",
|
||||
active: true,
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
|
||||
const id = nextId("client");
|
||||
return {
|
||||
id,
|
||||
name: `Client ${id}`,
|
||||
email: `${id}@example.com`,
|
||||
phone: "555-0100",
|
||||
address: "1 Main St, Springfield, CA 90000",
|
||||
notes: null,
|
||||
emailOptOut: false,
|
||||
status: "active",
|
||||
disabledAt: null,
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPet(overrides: Partial<PetRow> & { clientId: string }): PetRow {
|
||||
const id = nextId("pet");
|
||||
const defaults: PetRow = {
|
||||
id,
|
||||
clientId: overrides.clientId,
|
||||
name: `Pet ${id}`,
|
||||
species: "Dog",
|
||||
breed: "Mixed Breed",
|
||||
weightKg: "15.00",
|
||||
dateOfBirth: new Date("2020-06-15T00:00:00Z"),
|
||||
healthAlerts: null,
|
||||
groomingNotes: null,
|
||||
cutStyle: null,
|
||||
shampooPreference: null,
|
||||
specialCareNotes: null,
|
||||
customFields: {},
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||
};
|
||||
return { ...defaults, ...overrides };
|
||||
}
|
||||
|
||||
export function buildService(overrides: Partial<ServiceRow> = {}): ServiceRow {
|
||||
const id = nextId("service");
|
||||
return {
|
||||
id,
|
||||
name: `Service ${id}`,
|
||||
description: "A grooming service",
|
||||
basePriceCents: 6500,
|
||||
durationMinutes: 60,
|
||||
active: true,
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAppointment(
|
||||
overrides: Partial<AppointmentRow> & { clientId: string; petId: string; serviceId: string; staffId: string }
|
||||
): AppointmentRow {
|
||||
const id = nextId("appointment");
|
||||
const startTime = new Date("2025-06-01T10:00:00Z");
|
||||
const endTime = new Date("2025-06-01T11:00:00Z");
|
||||
const defaults: AppointmentRow = {
|
||||
id,
|
||||
clientId: overrides.clientId,
|
||||
petId: overrides.petId,
|
||||
serviceId: overrides.serviceId,
|
||||
staffId: overrides.staffId,
|
||||
batherStaffId: null,
|
||||
seriesId: null,
|
||||
seriesIndex: null,
|
||||
groupId: null,
|
||||
status: "scheduled",
|
||||
startTime,
|
||||
endTime,
|
||||
notes: null,
|
||||
priceCents: null,
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||
};
|
||||
return { ...defaults, ...overrides };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* reset.ts — Drop all application tables and re-run migrations + seed.
|
||||
*
|
||||
* Intended for local development only. Never run against production.
|
||||
*
|
||||
* Usage:
|
||||
* DATABASE_URL=postgres://... npx tsx packages/db/src/reset.ts
|
||||
*/
|
||||
|
||||
import postgres from "postgres";
|
||||
|
||||
async function reset() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("DATABASE_URL is not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
console.error("[FATAL] db:reset must not be run in production.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = postgres(url, { max: 1 });
|
||||
|
||||
console.log("Dropping all application tables...\n");
|
||||
|
||||
// Drop in dependency order (children before parents)
|
||||
await client`
|
||||
DO $$ DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (
|
||||
SELECT tablename FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
) LOOP
|
||||
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
`;
|
||||
|
||||
// Drop custom enums
|
||||
await client`
|
||||
DO $$ DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (
|
||||
SELECT typname FROM pg_type
|
||||
WHERE typtype = 'e' AND typnamespace = (
|
||||
SELECT oid FROM pg_namespace WHERE nspname = 'public'
|
||||
)
|
||||
) LOOP
|
||||
EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
`;
|
||||
|
||||
// Drop the drizzle migrations tracking table
|
||||
await client`DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE`;
|
||||
await client`DROP SCHEMA IF EXISTS drizzle CASCADE`;
|
||||
|
||||
console.log("✓ All tables and enums dropped\n");
|
||||
|
||||
await client.end();
|
||||
}
|
||||
|
||||
reset().catch((err) => {
|
||||
console.error("Reset failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
+71
-27
@@ -1,14 +1,17 @@
|
||||
/**
|
||||
* Seed script — generates realistic test data for Groom Book development.
|
||||
* Seed script — generates deterministic, PII-free test data for Groom Book.
|
||||
*
|
||||
* Creates:
|
||||
* - 3 groomers + 3 bathers (staff)
|
||||
* - 1 manager + 1 receptionist + 3 groomers + 3 bathers (8 staff total)
|
||||
* - 10 services
|
||||
* - 500 clients, each with 1-3 dogs
|
||||
* - ~2 500 appointments spread across the past 12 months
|
||||
* - Invoices for completed appointments with line items and tip splits
|
||||
* - Grooming visit logs for completed appointments
|
||||
*
|
||||
* Output is fully deterministic: the same seed value always produces the
|
||||
* same rows with the same IDs.
|
||||
*
|
||||
* Usage:
|
||||
* DATABASE_URL=postgres://... npx tsx packages/db/src/seed.ts
|
||||
*/
|
||||
@@ -17,29 +20,61 @@ import postgres from "postgres";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
// ── Deterministic PRNG (Mulberry32) ──────────────────────────────────────────
|
||||
|
||||
function pick<T>(arr: T[]): T {
|
||||
return arr[Math.floor(Math.random() * arr.length)]!;
|
||||
/**
|
||||
* Returns a seeded pseudo-random number generator.
|
||||
* Same seed → identical sequence of numbers every run.
|
||||
*/
|
||||
function createPrng(seed: number): () => number {
|
||||
let s = seed | 0;
|
||||
return function (): number {
|
||||
s = (s + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const rand = createPrng(42);
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Return a random element from an array using the seeded PRNG. */
|
||||
function pick<T>(arr: T[]): T {
|
||||
return arr[Math.floor(rand() * arr.length)]!;
|
||||
}
|
||||
|
||||
/** Return n distinct random elements from an array. */
|
||||
function pickN<T>(arr: T[], n: number): T[] {
|
||||
const shuffled = [...arr].sort(() => Math.random() - 0.5);
|
||||
const shuffled = [...arr].sort(() => rand() - 0.5);
|
||||
return shuffled.slice(0, n);
|
||||
}
|
||||
|
||||
function randInt(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
return Math.floor(rand() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function randDate(start: Date, end: Date): Date {
|
||||
return new Date(
|
||||
start.getTime() + Math.random() * (end.getTime() - start.getTime())
|
||||
);
|
||||
return new Date(start.getTime() + rand() * (end.getTime() - start.getTime()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a deterministic UUID v4 from the seeded PRNG.
|
||||
* Conforms to RFC 4122 §4.4 (variant bits set correctly).
|
||||
*/
|
||||
function uuid(): string {
|
||||
return crypto.randomUUID();
|
||||
const hex = (n: number) => n.toString(16).padStart(2, "0");
|
||||
const bytes = Array.from({ length: 16 }, () => Math.floor(rand() * 256));
|
||||
bytes[6] = ((bytes[6]! & 0x0f) | 0x40); // version 4
|
||||
bytes[8] = ((bytes[8]! & 0x3f) | 0x80); // variant bits
|
||||
return [
|
||||
bytes.slice(0, 4).map(hex).join(""),
|
||||
bytes.slice(4, 6).map(hex).join(""),
|
||||
bytes.slice(6, 8).map(hex).join(""),
|
||||
bytes.slice(8, 10).map(hex).join(""),
|
||||
bytes.slice(10, 16).map(hex).join(""),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
// ── Data pools ───────────────────────────────────────────────────────────────
|
||||
@@ -227,6 +262,15 @@ async function seed() {
|
||||
console.log("Seeding Groom Book database...\n");
|
||||
|
||||
// ── Staff ──
|
||||
// Deterministic staff IDs so they can be referenced in scripts/tests
|
||||
const managerStaff = [
|
||||
{ id: uuid(), name: "Jordan Lee", email: "jordan@groombook.dev", role: "manager" as const },
|
||||
];
|
||||
|
||||
const receptionistStaff = [
|
||||
{ id: uuid(), name: "Sam Rivera", email: "sam@groombook.dev", role: "receptionist" as const },
|
||||
];
|
||||
|
||||
const groomers = [
|
||||
{ id: uuid(), name: "Sarah Mitchell", email: "sarah@groombook.dev", role: "groomer" as const },
|
||||
{ id: uuid(), name: "James Park", email: "james@groombook.dev", role: "groomer" as const },
|
||||
@@ -240,7 +284,7 @@ async function seed() {
|
||||
{ id: uuid(), name: "Devon Williams", email: "devon@groombook.dev", role: "groomer" as const },
|
||||
];
|
||||
|
||||
const allStaff = [...groomers, ...bathers];
|
||||
const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers];
|
||||
for (const s of allStaff) {
|
||||
await db.insert(schema.staff).values({
|
||||
id: s.id,
|
||||
@@ -250,7 +294,7 @@ async function seed() {
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
console.log(`✓ Created ${allStaff.length} staff (3 groomers, 3 bathers)`);
|
||||
console.log(`✓ Created ${allStaff.length} staff (1 manager, 1 receptionist, 3 groomers, 3 bathers)`);
|
||||
|
||||
// ── Services ──
|
||||
const serviceIds: string[] = [];
|
||||
@@ -274,7 +318,7 @@ async function seed() {
|
||||
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
||||
|
||||
interface ClientRecord { id: string; name: string }
|
||||
interface PetRecord { id: string; clientId: string; name: string }
|
||||
interface PetRecord { id: string; clientId: string }
|
||||
|
||||
const clientRecords: ClientRecord[] = [];
|
||||
const petRecords: PetRecord[] = [];
|
||||
@@ -301,14 +345,14 @@ async function seed() {
|
||||
email,
|
||||
phone,
|
||||
address: addr,
|
||||
notes: Math.random() < 0.2 ? pick(["Prefers morning appointments", "Always pays cash", "VIP client", "Referred by a friend", "Has multiple pets — check all in"]) : null,
|
||||
emailOptOut: Math.random() < 0.1,
|
||||
notes: rand() < 0.2 ? pick(["Prefers morning appointments", "Always pays cash", "VIP client", "Referred by a friend", "Has multiple pets — check all in"]) : null,
|
||||
emailOptOut: rand() < 0.1,
|
||||
});
|
||||
|
||||
clientRecords.push({ id: clientId, name });
|
||||
|
||||
// 1-3 pets per client
|
||||
const petCount = Math.random() < 0.5 ? 1 : Math.random() < 0.7 ? 2 : 3;
|
||||
const petCount = rand() < 0.5 ? 1 : rand() < 0.7 ? 2 : 3;
|
||||
for (let p = 0; p < petCount; p++) {
|
||||
const petId = uuid();
|
||||
const breed = pick(dogBreeds);
|
||||
@@ -322,17 +366,17 @@ async function seed() {
|
||||
name: pick(dogNames),
|
||||
species: "Dog",
|
||||
breed,
|
||||
weightKg: String(randInt(3, 60) + Math.random().toFixed(1).slice(1)),
|
||||
weightKg: String(randInt(3, 60) + rand().toFixed(1).slice(1)),
|
||||
dateOfBirth: dob,
|
||||
healthAlerts: pick(healthAlerts),
|
||||
groomingNotes: pick(groomingNotes),
|
||||
cutStyle: pick(cutStyles),
|
||||
shampooPreference: pick(shampoos),
|
||||
specialCareNotes: Math.random() < 0.1 ? "Vet clearance required before grooming" : null,
|
||||
specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null,
|
||||
customFields: {},
|
||||
});
|
||||
|
||||
petRecords.push({ id: petId, clientId, name: "" });
|
||||
petRecords.push({ id: petId, clientId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,13 +431,13 @@ async function seed() {
|
||||
// Group pets by client for efficient appointment generation
|
||||
const petsByClient = new Map<string, string[]>();
|
||||
for (const pet of petRecords) {
|
||||
const arr = petsByClient.get(pet.clientId) || [];
|
||||
const arr = petsByClient.get(pet.clientId) ?? [];
|
||||
arr.push(pet.id);
|
||||
petsByClient.set(pet.clientId, arr);
|
||||
}
|
||||
|
||||
for (const client of clientRecords) {
|
||||
const pets = petsByClient.get(client.id) || [];
|
||||
const pets = petsByClient.get(client.id) ?? [];
|
||||
// Each client visits ~3-8 times over the year
|
||||
const visitCount = randInt(3, 8);
|
||||
|
||||
@@ -404,7 +448,7 @@ async function seed() {
|
||||
const serviceId = serviceIds[serviceIdx]!;
|
||||
const svc = servicesDef[serviceIdx]!;
|
||||
const groomer = pick(groomers);
|
||||
const bather = Math.random() < 0.6 ? pick(bathers) : null;
|
||||
const bather = rand() < 0.6 ? pick(bathers) : null;
|
||||
const status = pick(statuses);
|
||||
|
||||
// Schedule within the past year, or next 2 weeks for upcoming
|
||||
@@ -419,7 +463,7 @@ async function seed() {
|
||||
const endTime = new Date(startTime.getTime() + svc.dur * 60 * 1000);
|
||||
|
||||
const apptId = uuid();
|
||||
const priceCents = Math.random() < 0.2 ? svc.price + randInt(-500, 1000) : null;
|
||||
const priceCents = rand() < 0.2 ? svc.price + randInt(-500, 1000) : null;
|
||||
const effectivePrice = priceCents ?? svc.price;
|
||||
|
||||
apptBatch.push({
|
||||
@@ -440,11 +484,11 @@ async function seed() {
|
||||
// Create invoice for completed appointments
|
||||
if (status === "completed") {
|
||||
const invoiceId = uuid();
|
||||
const tipCents = Math.random() < 0.7 ? randInt(200, 3000) : 0;
|
||||
const tipCents = rand() < 0.7 ? randInt(200, 3000) : 0;
|
||||
const taxCents = Math.round(effectivePrice * 0.08);
|
||||
const totalCents = effectivePrice + taxCents + tipCents;
|
||||
|
||||
const invoiceStatus = Math.random() < 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;
|
||||
|
||||
invoiceBatch.push({
|
||||
@@ -458,7 +502,7 @@ async function seed() {
|
||||
status: invoiceStatus,
|
||||
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
|
||||
paidAt,
|
||||
notes: Math.random() < 0.05 ? "Added extra service at checkout" : null,
|
||||
notes: rand() < 0.05 ? "Added extra service at checkout" : null,
|
||||
});
|
||||
|
||||
// Line item
|
||||
|
||||
Reference in New Issue
Block a user