Compare commits

...

12 Commits

Author SHA1 Message Date
Paperclip 0d73532054 GRO-607: Replace mock payment flow with real Stripe Elements
- Install @stripe/stripe-js and @stripe/react-stripe-js
- Replace BillingPayments mock delay with real Stripe Elements:
  - Fetch publishableKey from GET /api/portal/config
  - Lazy load Stripe via loadStripe()
  - Wrap payment modal in <Elements> with PaymentElement
  - Use stripe.confirmPayment() with clientSecret from pay/pay-multiple endpoints
  - Support multi-invoice selection and single invoice payment
- Add "Save card for future payments" checkbox (setup_future_usage)
- Add payment method management: list saved cards, delete via DELETE endpoint
- Proper error handling for payment failures
- Autopay toggle (UI-only, Phase 2 backend pending)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 23:48:23 +00:00
Paperclip dcf5740489 GRO-606: Add payment API endpoints (pay invoice, payment methods, refunds)
Portal routes (client-facing):
- POST /api/portal/invoices/:id/pay - create PaymentIntent for single invoice
- POST /api/portal/invoices/pay-multiple - create PaymentIntent for multiple invoices
- GET /api/portal/payment-methods - list saved payment methods
- POST /api/portal/payment-methods - create SetupIntent for saving new card
- DELETE /api/portal/payment-methods/:id - detach payment method
- GET /api/portal/config - return Stripe publishable key

Admin routes:
- POST /api/invoices/:id/refund - manager-only refund endpoint

Validation:
- Cannot pay draft, void, or already-paid invoices
- Multi-invoice: all must belong to same client and be pending
- Refund requires invoice to be paid with stripePaymentIntentId

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 23:46:09 +00:00
Paperclip e15977ba82 GRO-605: Stripe SDK integration + payment service
- Add stripe dependency to @groombook/api
- Implement payment.ts service with:
  - getOrCreateStripeCustomer: look up or create Stripe customer
  - createPaymentIntent: create payment intent for invoice(s)
  - processRefund: full or partial refund
  - listPaymentMethods: list saved cards
  - attachPaymentMethod / detachPaymentMethod: manage saved cards
- Lazy Stripe client initialization; graceful null return when keys not set

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 23:41:29 +00:00
Paperclip 4079ed9d26 GRO-600: Extend reminder scheduler to send SMS alongside email
- Import smsSend from ./sms.js
- Add TCPA opt-out constant
- Check email+SMS logs separately to allow independent sends
- Add smsOptIn and phoneE164 to client query
- Conditionally send SMS for opted-in clients with valid E.164 phone
- SMS message: pet name, service, groomer, confirm/cancel links, TCPA text
- SMS failures logged but don't block email delivery
- Feature flag: only attempts SMS when SMS service is initialized
- Idempotency: per-channel reminder log prevents duplicate sends
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 23:24:56 +00:00
Paperclip 1cc708e6b4 feat(gro-194): SMS provider service with Telnyx SDK integration
- Added telnyx npm package
- Created sms.ts with SmsProvider interface
- Implemented TelnyxProvider with sendSms() and validateWebhookSignature()
- Added createSmsProvider() factory function
- Added smsSend() convenience function that skips when SMS_ENABLED=false
- Provider abstraction allows future Twilio or other providers
- E.164 phone validation on send
- Webhook signature verification using HMAC-SHA256

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 23:22:28 +00:00
Paperclip 6b300626a0 GRO-598/GRO-194 Phase 1.1: SMS schema - add consent fields to clients, channel to reminderLogs, E.164 phone validation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 23:18:32 +00:00
groombook-qa[bot] 4f6a1e8149 fix(GRO-574): switch rate limit to memory storage to unblock UAT
* feat(GRO-566): add SKIP_OOBE env var to bypass setup wizard

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* Add rate_limit table migration for Better Auth (GRO-574)

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-574): switch rate limit to memory storage to unblock UAT

Better Auth rate_limit table migration exists on branch but hasn't
been deployed to UAT. Switching to memory storage bypasses the
missing table entirely, restoring auth functionality immediately.

Memory storage is per-instance (not shared) — rate limiting still
functions but won't be distributed across pods. This is acceptable
for UAT while the migration is being promoted through the pipeline.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: groombook-qa[bot] <269744346+groombook-qa[bot]@users.noreply.github.com>
2026-04-12 12:20:00 +00:00
groombook-cto[bot] be3cfa9a54 Merge pull request #268 from groombook/feature/gro-565-better-auth-phase3
feat(GRO-565): Better Auth Phase 3 - password change, OIDC discovery, session cleanup, email verification
2026-04-12 11:29:06 +00:00
groombook-cto[bot] 06e7ddaa61 Merge branch 'main' into feature/gro-565-better-auth-phase3 2026-04-12 11:25:35 +00:00
groombook-cto[bot] 15131b72f0 fix(GRO-574): add rate_limit table migration for Better Auth
Adds the missing rate_limit table that Better Auth v1.5.6 requires when rateLimit.storage is set to 'database'. Without this table, all auth endpoints return HTTP 500.

Also includes GRO-566: SKIP_OOBE env var to bypass setup wizard in dev/test.

cc @cpfarhood
2026-04-12 03:30:45 +00:00
Paperclip bc1f11a901 feat(GRO-565): Better Auth Phase 3 - password change, OIDC discovery, session cleanup, email verification
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 02:47:17 +00:00
groombook-cto[bot] f4e34f2826 fix(GRO-564): prevent admin nav logout button overflow
fix(GRO-564): prevent admin nav logout button overflow
2026-04-12 02:31:46 +00:00
71 changed files with 7454 additions and 620 deletions
+6
View File
@@ -11,6 +11,12 @@ AUTH_DISABLED=false
OIDC_ISSUER=https://authentik.example.com
OIDC_AUDIENCE=groombook
# ── Setup Wizard ─────────────────────────────────────────────────────────────
# When SKIP_OOBE=true, the setup wizard is bypassed regardless of whether a
# super user exists in the database. Useful in dev/test environments where the
# database has data but the setup wizard would otherwise block access.
SKIP_OOBE=false
# ── API ───────────────────────────────────────────────────────────────────────
PORT=3000
CORS_ORIGIN=http://localhost:8080
View File
+2
View File
@@ -22,6 +22,8 @@
"hono": "^4.6.17",
"node-cron": "^3.0.3",
"nodemailer": "^6.9.16",
"stripe": "^22.0.0",
"telnyx": "^6.41.0",
"zod": "^4.3.6"
},
"devDependencies": {
+42
View File
@@ -418,6 +418,48 @@ describe("GET /setup/status — OOBE bootstrap logic", () => {
expect(body.showAuthProviderStep).toBe(false); // DB config already exists
expect(body.authConfigExists).toBe(true);
});
it("SKIP_OOBE=true bypasses setup check regardless of DB state", async () => {
dbStaffRows = []; // no super user
dbAuthConfigRows = [];
process.env.SKIP_OOBE = "true";
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(false);
expect(body.showAuthProviderStep).toBe(false);
expect(body.authConfigExists).toBe(false);
expect(body.authEnvVarsSet).toBe(false);
expect(body.skipped).toBe(true);
});
it("SKIP_OOBE=1 also bypasses setup check", async () => {
dbStaffRows = [];
dbAuthConfigRows = [];
process.env.SKIP_OOBE = "1";
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(false);
expect(body.skipped).toBe(true);
});
it("SKIP_OOBE=yes also bypasses setup check", async () => {
dbStaffRows = [];
dbAuthConfigRows = [];
process.env.SKIP_OOBE = "yes";
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(false);
expect(body.skipped).toBe(true);
});
});
describe("POST /setup/auth-provider — OOBE bootstrap", () => {
+64 -11
View File
@@ -3,6 +3,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { genericOAuth } from "better-auth/plugins";
import { getDb, authProviderConfig, eq } from "@groombook/db";
import { decryptSecret } from "@groombook/db";
import { sendEmail } from "../services/email.js";
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
@@ -94,7 +95,7 @@ export async function initAuth(): Promise<void> {
enabled: true,
max: 10,
window: 60,
storage: "database",
storage: "memory",
},
plugins: [
genericOAuth({
@@ -176,6 +177,52 @@ export async function initAuth(): Promise<void> {
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
// Fetch OIDC discovery document to derive canonical provider URLs.
// Replace the host of token/userinfo endpoints with internalBaseUrl when set,
// while keeping authorizationUrl public for browser redirects.
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
let oidcConfig: Record<string, string> = {};
try {
const discoveryRes = await fetch(discoveryUrlStr);
if (discoveryRes.ok) {
const discovery = await discoveryRes.json() as {
authorization_endpoint?: string;
token_endpoint?: string;
userinfo_endpoint?: string;
};
const replaceHost = (url: string, newHost: string) => {
try {
const parsed = new URL(url);
const newParsed = new URL(newHost);
return `${newParsed.origin}${parsed.pathname}${parsed.search}`;
} catch {
return url;
}
};
const authzUrl = discovery.authorization_endpoint;
const tokenUrl = discovery.token_endpoint;
const userInfoUrl = discovery.userinfo_endpoint;
if (authzUrl && tokenUrl && userInfoUrl) {
oidcConfig = {
authorizationUrl: authzUrl,
tokenUrl: providerConfig.internalBaseUrl
? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
: tokenUrl,
userInfoUrl: providerConfig.internalBaseUrl
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl)
: userInfoUrl,
};
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId);
} else {
console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only");
}
} else {
console.warn(`[auth] OIDC discovery failed (${discoveryRes.status}), using discoveryUrl only`);
}
} catch (err) {
console.warn(`[auth] OIDC discovery fetch failed: ${err}, using discoveryUrl only`);
}
// Build Better-Auth instance using resolved config
authInstance = betterAuth({
database: drizzleAdapter(db, {
@@ -187,11 +234,24 @@ export async function initAuth(): Promise<void> {
enabled: true,
max: 10,
window: 60,
storage: "database",
storage: "memory",
},
account: {
storeStateStrategy: "cookie" as const,
},
emailAndPassword: {
enabled: true,
emailVerification: {
sendVerificationEmail: async ({ user, url }: { user: { email: string }; url: string }) => {
await sendEmail({
to: user.email,
subject: "Verify your GroomBook email",
text: `Click the link to verify your email: ${url}`,
html: `<p>Click the link to verify your email:</p><a href="${url}">${url}</a>`,
});
},
},
},
plugins: [
genericOAuth({
config: [
@@ -199,15 +259,8 @@ export async function initAuth(): Promise<void> {
providerId: providerConfig.providerId,
clientId: providerConfig.clientId,
clientSecret: providerConfig.clientSecret,
...(providerConfig.internalBaseUrl
? {
authorizationUrl: `${new URL(providerConfig.issuerUrl).origin}/application/o/authorize/`,
tokenUrl: `${providerConfig.internalBaseUrl}/application/o/token/`,
userInfoUrl: `${providerConfig.internalBaseUrl}/application/o/userinfo/`,
}
: {
discoveryUrl: `${providerConfig.issuerUrl}/.well-known/openid-configuration`,
}),
discoveryUrl: discoveryUrlStr,
...(Object.keys(oidcConfig).length > 0 ? oidcConfig : {}),
scopes: providerConfig.scopes.split(" ").filter(Boolean),
},
],
+51
View File
@@ -0,0 +1,51 @@
import { Hono } from "hono";
import { getDb, businessSettings, reminderLogs, eq, sql, and, gte, lt } from "@groombook/db";
import { requireRole } from "../middleware/rbac.js";
import { createSmsProvider } from "../services/sms.js";
export const adminSmsRouter = new Hono();
adminSmsRouter.get("/status", requireManager(), async (c) => {
const db = getDb();
const [settings] = await db.select().from(businessSettings).limit(1);
const provider = createSmsProvider();
const smsEnabled = process.env.SMS_ENABLED === "true";
const providerName = process.env.SMS_PROVIDER ?? "none";
const fromNumber = process.env.TELNYX_FROM_NUMBER ?? null;
const connectionStatus = provider ? "connected" : "disconnected";
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const statsRows = await db
.select({
status: reminderLogs.deliveryStatus,
count: sql<number>`count(*)::int`,
})
.from(reminderLogs)
.where(
and(
eq(reminderLogs.channel, "sms"),
gte(reminderLogs.sentAt, startOfMonth)
)
)
.groupBy(reminderLogs.deliveryStatus);
const totals = { sent: 0, delivered: 0, failed: 0 };
for (const row of statsRows) {
if (row.status === "delivered") totals.delivered = row.count;
else if (row.status === "failed") totals.failed = row.count;
else totals.sent += row.count;
}
return c.json({
providerName,
fromNumber,
connectionStatus,
smsEnabled,
businessSmsEnabled: settings?.smsEnabled ?? false,
stats: totals,
});
});
+24 -1
View File
@@ -4,12 +4,35 @@ import { z } from "zod/v3";
import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
function normalizeE164(phone: string): string | null {
const digits = phone.replace(/\D/g, "");
if (digits.length === 10) return `+1${digits}`;
if (digits.length === 11 && digits.startsWith("1")) return `+${digits}`;
if (digits.length > 11 && digits.startsWith("1")) return `+${digits.slice(0, 11)}`;
return null;
}
function e164String() {
return z.string().transform((v, ctx) => {
if (!v) return v as unknown as undefined;
const normalized = normalizeE164(v);
if (!normalized) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid phone number. Must be a valid E.164 number (e.g. +12125551234).",
});
return z.NEVER;
}
return normalized;
});
}
export const clientsRouter = new Hono<AppEnv>();
const createClientSchema = z.object({
name: z.string().min(1).max(200),
email: z.string().email().optional(),
phone: z.string().max(50).optional(),
phone: e164String().optional(),
address: z.string().max(500).optional(),
notes: z.string().max(2000).optional(),
});
+38
View File
@@ -338,3 +338,41 @@ invoicesRouter.patch(
return c.json({ ...updated, lineItems });
}
);
// ─── Refund ───────────────────────────────────────────────────────────────────
import { processRefund } from "../services/payment.js";
const refundSchema = z.object({
amountCents: z.number().int().nonnegative().optional(),
});
invoicesRouter.post(
"/:id/refund",
zValidator("json", refundSchema),
async (c) => {
const db = getDb();
const staff = c.get("staff");
if (!staff) return c.json({ error: "Forbidden" }, 403);
if (staff.role !== "manager" && !staff.isSuperUser) {
return c.json({ error: "Manager role required" }, 403);
}
const id = c.req.param("id");
const body = c.req.valid("json");
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
if (!invoice) return c.json({ error: "Not found" }, 404);
if (invoice.status !== "paid") {
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
}
if (!invoice.stripePaymentIntentId) {
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
}
const result = await processRefund(id, body.amountCents);
if (!result) return c.json({ error: "Refund failed" }, 500);
return c.json({ refundId: result.refundId });
}
);
+139
View File
@@ -448,6 +448,145 @@ portalRouter.delete("/waitlist/:id", async (c) => {
return c.json({ ok: true });
});
// ─── Payment routes ───────────────────────────────────────────────────────────
import {
createPaymentIntent,
listPaymentMethods,
attachPaymentMethod,
detachPaymentMethod,
createSetupIntent,
getOrCreateStripeCustomer,
} from "../services/payment.js";
const payInvoiceSchema = z.object({
invoiceId: z.string().uuid(),
});
portalRouter.post(
"/invoices/:id/pay",
zValidator("json", payInvoiceSchema),
async (c) => {
const db = getDb();
const invoiceId = c.req.param("id");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const [invoice] = await db
.select()
.from(invoices)
.where(eq(invoices.id, invoiceId))
.limit(1);
if (!invoice) return c.json({ error: "Not found" }, 404);
if (invoice.clientId !== clientId) return c.json({ error: "Forbidden" }, 403);
if (invoice.status === "draft" || invoice.status === "void") {
return c.json({ error: "Cannot pay a draft or void invoice" }, 422);
}
if (invoice.status === "paid") {
return c.json({ error: "Invoice is already paid" }, 422);
}
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
const result = await createPaymentIntent(invoiceId, clientId);
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
}
);
const payMultipleSchema = z.object({
invoiceIds: z.array(z.string().uuid()).min(1),
});
portalRouter.post(
"/invoices/pay-multiple",
zValidator("json", payMultipleSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const invoiceRows = await db
.select()
.from(invoices)
.where(inArray(invoices.id, body.invoiceIds));
if (invoiceRows.length !== body.invoiceIds.length) {
return c.json({ error: "One or more invoices not found" }, 404);
}
for (const inv of invoiceRows) {
if (inv.clientId !== clientId) return c.json({ error: "Forbidden" }, 403);
if (inv.status === "draft" || inv.status === "void") {
return c.json({ error: `Invoice ${inv.id} cannot be paid (draft or void)` }, 422);
}
if (inv.status === "paid") {
return c.json({ error: `Invoice ${inv.id} is already paid` }, 422);
}
}
const firstInvoice = invoiceRows[0];
const allSameClient = invoiceRows.every(inv => inv.clientId === firstInvoice.clientId);
if (!allSameClient) {
return c.json({ error: "All invoices must belong to the same client" }, 422);
}
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
const result = await createPaymentIntent(body.invoiceIds, clientId);
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
}
);
portalRouter.get("/payment-methods", async (c) => {
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const methods = await listPaymentMethods(clientId);
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
return c.json(methods);
});
portalRouter.post("/payment-methods", async (c) => {
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
const customerId = await getOrCreateStripeCustomer(clientId);
if (!customerId) return c.json({ error: "Could not create customer" }, 500);
const result = await createSetupIntent(customerId);
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
});
portalRouter.delete("/payment-methods/:id", async (c) => {
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const paymentMethodId = c.req.param("id");
const ok = await detachPaymentMethod(paymentMethodId);
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
return c.json({ ok: true });
});
// ─── Config endpoint ─────────────────────────────────────────────────────────
portalRouter.get("/config", (c) => {
return c.json({
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "",
});
});
// ─── Dev-mode session creation ──────────────────────────────────────────────
// Allows the dev login selector to vend an impersonation session for a client
// without requiring manager auth. Only available when AUTH_DISABLED=true.
+11
View File
@@ -9,6 +9,17 @@ export const setupRouter = new Hono<AppEnv>();
// GET /api/setup/status — public (no auth), returns whether setup is needed
// and whether the auth provider bootstrap step should be shown
setupRouter.get("/status", async (c) => {
const skipOobe = ["true", "1", "yes"].includes((process.env.SKIP_OOBE || "").toLowerCase());
if (skipOobe) {
return c.json({
needsSetup: false,
showAuthProviderStep: false,
authConfigExists: false,
authEnvVarsSet: false,
skipped: true,
});
}
const db = getDb();
// Check if any super user exists
+137
View File
@@ -0,0 +1,137 @@
import { Hono } from "hono";
import {
and,
eq,
getDb,
clients,
reminderLogs,
smsSend,
} from "@groombook/db";
import { TelnyxProvider } from "../services/sms.js";
export const webhooksRouter = new Hono();
const telnyxProvider = new TelnyxProvider();
const STOP_KEYWORDS = new Set(["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"]);
const START_KEYWORDS = new Set(["START", "YES", "UNSTOP"]);
webhooksRouter.post("/sms/inbound", async (c) => {
if (!telnyxProvider.validateWebhookSignature(c.req.raw)) {
return c.json({ error: "Invalid signature" }, 401);
}
let body: Record<string, unknown>;
try {
body = await c.req.json();
} catch {
return c.json({ error: "Invalid JSON" }, 400);
}
const event = (body.data as Record<string, unknown>)?.event_type ?? body.event_type;
const payload = (body.data as Record<string, unknown>) ?? body;
if (event === "message.received") {
const fromField = payload.from;
const from = typeof fromField === "object" && fromField !== null
? (fromField as Record<string, unknown>).phone_number as string ?? (fromField as Record<string, unknown>).toString()
: String(fromField ?? "");
const text = String(payload.text ?? payload.body ?? "").trim().toUpperCase();
if (!from || !text) {
return c.json({ error: "Missing from or text" }, 400);
}
const db = getDb();
const [client] = await db
.select({ id: clients.id, smsOptIn: clients.smsOptIn })
.from(clients)
.where(eq(clients.phone, from))
.limit(1);
if (!client) {
return c.json({ received: true });
}
if (STOP_KEYWORDS.has(text)) {
await db
.update(clients)
.set({
smsOptIn: false,
smsOptOutDate: new Date(),
updatedAt: new Date(),
})
.where(eq(clients.id, client.id));
return c.json({ received: true });
}
if (START_KEYWORDS.has(text)) {
await db
.update(clients)
.set({
smsOptIn: true,
smsConsentDate: new Date(),
updatedAt: new Date(),
})
.where(eq(clients.id, client.id));
return c.json({ received: true });
}
if (text === "HELP") {
const supportUrl = process.env.SUPPORT_URL ?? "https://groombook.app/support";
await smsSend(from, `GroomBook appointment reminders. Reply STOP to opt out. For help, visit ${supportUrl}.`);
return c.json({ received: true });
}
return c.json({ received: true });
}
if (event === "message.finalized" || event === "message.status") {
const status = String(payload.status ?? "");
const toField = payload.to;
const toNumber = typeof toField === "object" && toField !== null
? (toField as Record<string, unknown>).phone_number as string ?? (toField as Record<string, unknown>).toString()
: String(toField ?? "");
if (!status || !toNumber) {
return c.json({ received: true });
}
const validDelivery = ["delivered", "sent", "failed", "sending", "queued"];
if (!validDelivery.includes(status)) {
return c.json({ received: true });
}
const db = getDb();
const [client] = await db
.select({ id: clients.id })
.from(clients)
.where(eq(clients.phone, toNumber))
.limit(1);
if (client) {
const [log] = await db
.select({ id: reminderLogs.id })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.channel, "sms")
)
)
.limit(1);
if (log) {
await db
.update(reminderLogs)
.set({ deliveryStatus: status })
.where(eq(reminderLogs.id, log.id));
}
}
return c.json({ received: true });
}
return c.json({ received: true });
});
+162
View File
@@ -0,0 +1,162 @@
import Stripe from "stripe";
import { getDb, clients, eq, invoices } from "@groombook/db";
let _stripe: Stripe | null | undefined;
function getStripeClient(): Stripe | null {
if (_stripe === undefined) {
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) return null;
_stripe = new Stripe(secretKey);
}
return _stripe;
}
export async function getOrCreateStripeCustomer(clientId: string): Promise<string | null> {
const stripe = getStripeClient();
if (!stripe) return null;
const db = getDb();
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
if (!client) return null;
if (client.stripeCustomerId) return client.stripeCustomerId;
const customer = await stripe.customers.create({
metadata: { groombook_client_id: clientId },
});
await db
.update(clients)
.set({ stripeCustomerId: customer.id, updatedAt: new Date() })
.where(eq(clients.id, clientId));
return customer.id;
}
export async function createPaymentIntent(
invoiceIdOrIds: string | string[],
clientId: string
): Promise<{ clientSecret: string; paymentIntentId: string } | null> {
const stripe = getStripeClient();
if (!stripe) return null;
const db = getDb();
const invoiceIds = Array.isArray(invoiceIdOrIds) ? invoiceIdOrIds : [invoiceIdOrIds];
const invoiceRows = await db
.select()
.from(invoices)
.where(eq(invoices.id, invoiceIds[0]));
const [invoice] = invoiceRows;
if (!invoice) return null;
let totalCents = invoice.totalCents;
if (invoiceIds.length > 1) {
const allInvoices = await db
.select({ totalCents: invoices.totalCents })
.from(invoices)
.where(eq(invoices.id, invoiceIds[0]));
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, totalCents);
}
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
if (!stripeCustomerId) return null;
const paymentIntent = await stripe.paymentIntents.create({
amount: totalCents,
currency: "usd",
customer: stripeCustomerId,
metadata: {
groombook_invoice_ids: invoiceIds.join(","),
groombook_client_id: clientId,
},
automatic_payment_methods: { enabled: true },
});
for (const invId of invoiceIds) {
await db
.update(invoices)
.set({ stripePaymentIntentId: paymentIntent.id, updatedAt: new Date() })
.where(eq(invoices.id, invId));
}
return {
clientSecret: paymentIntent.client_secret!,
paymentIntentId: paymentIntent.id,
};
}
export async function processRefund(
invoiceId: string,
amountCents?: number
): Promise<{ refundId: string } | null> {
const stripe = getStripeClient();
if (!stripe) return null;
const db = getDb();
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1);
if (!invoice?.stripePaymentIntentId) return null;
const refund = await stripe.refunds.create({
payment_intent: invoice.stripePaymentIntentId,
amount: amountCents,
});
await db
.update(invoices)
.set({ stripeRefundId: refund.id, updatedAt: new Date() })
.where(eq(invoices.id, invoiceId));
return { refundId: refund.id };
}
export async function listPaymentMethods(clientId: string): Promise<Stripe.PaymentMethod[] | null> {
const stripe = getStripeClient();
if (!stripe) return null;
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
if (!stripeCustomerId) return null;
const methods = await stripe.paymentMethods.list({
customer: stripeCustomerId,
type: "card",
});
return methods.data;
}
export async function attachPaymentMethod(
clientId: string,
paymentMethodId: string
): Promise<boolean> {
const stripe = getStripeClient();
if (!stripe) return false;
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
if (!stripeCustomerId) return false;
await stripe.paymentMethods.attach(paymentMethodId, { customer: stripeCustomerId });
return true;
}
export async function detachPaymentMethod(paymentMethodId: string): Promise<boolean> {
const stripe = getStripeClient();
if (!stripe) return false;
await stripe.paymentMethods.detach(paymentMethodId);
return true;
}
export async function createSetupIntent(customerId: string): Promise<{ clientSecret: string } | null> {
const stripe = getStripeClient();
if (!stripe) return null;
const setupIntent = await stripe.setupIntents.create({
customer: customerId,
payment_method_types: ["card"],
});
return { clientSecret: setupIntent.client_secret! };
}
+84 -29
View File
@@ -12,14 +12,16 @@ import {
services,
staff,
reminderLogs,
session,
} from "@groombook/db";
import {
buildReminderEmail,
sendEmail,
} from "./email.js";
import { smsSend } from "./sms.js";
// How many hours before the appointment to send each reminder.
// Override via env: REMINDER_HOURS_EARLY (default 24) and REMINDER_HOURS_LATE (default 2).
// TCPA-required opt-out text appended to every SMS reminder
const TCPA_OPT_OUT = "Reply STOP to opt out. Msg & data rates may apply.";
function getReminderWindows(): { label: string; hours: number }[] {
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
const late = Number(process.env.REMINDER_HOURS_LATE ?? 2);
@@ -64,23 +66,39 @@ export async function runReminderCheck(): Promise<void> {
);
for (const appt of upcoming) {
// Check if reminder already sent (unique constraint prevents double-send)
const existing = await db
const [emailLog] = await db
.select({ id: reminderLogs.id })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label)
eq(reminderLogs.reminderType, window.label),
eq(reminderLogs.channel, "email")
)
)
.limit(1);
if (existing.length > 0) continue; // already sent
const [smsLog] = await db
.select({ id: reminderLogs.id })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label),
eq(reminderLogs.channel, "sms")
)
)
.limit(1);
// Fetch related records for the email
const [client] = await db
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
.select({
name: clients.name,
email: clients.email,
emailOptOut: clients.emailOptOut,
smsOptIn: clients.smsOptIn,
phoneE164: clients.phoneE164,
})
.from(clients)
.where(eq(clients.id, appt.clientId))
.limit(1);
@@ -111,8 +129,6 @@ export async function runReminderCheck(): Promise<void> {
if (!pet || !service) continue;
// Ensure the appointment has a confirmation token before sending the reminder.
// Generate one if it doesn't have one yet (e.g. pre-existing appointments).
let confirmationToken = appt.confirmationToken;
if (!confirmationToken) {
confirmationToken = randomBytes(32).toString("hex");
@@ -122,27 +138,53 @@ export async function runReminderCheck(): Promise<void> {
.where(eq(appointments.id, appt.id));
}
const sent = await sendEmail(
buildReminderEmail(
client.email,
{
clientName: client.name,
petName: pet.name,
serviceName: service.name,
groomerName,
startTime: appt.startTime,
},
window.hours,
confirmationToken
)
);
if (!emailLog) {
const sent = await sendEmail(
buildReminderEmail(
client.email,
{
clientName: client.name,
petName: pet.name,
serviceName: service.name,
groomerName,
startTime: appt.startTime,
},
window.hours,
confirmationToken
)
);
if (sent) {
// Record send — ignore conflicts (race condition between instances)
await db
.insert(reminderLogs)
.values({ appointmentId: appt.id, reminderType: window.label })
.onConflictDoNothing();
if (sent) {
await db
.insert(reminderLogs)
.values({ appointmentId: appt.id, reminderType: window.label, channel: "email" })
.onConflictDoNothing();
}
}
if (!smsLog && client.smsOptIn && client.phoneE164) {
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`;
const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`;
const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`;
const smsBody = [
`Hi ${client.name}, just a reminder: ${pet.name}'s grooming appointment is ${when}.`,
`Service: ${service.name}${groomerName ? ` with ${groomerName}` : ""}`,
`Confirm: ${confirmUrl}`,
`Cancel: ${cancelUrl}`,
TCPA_OPT_OUT,
].join(". ");
try {
const smsOk = await smsSend(client.phoneE164, smsBody);
if (smsOk) {
await db
.insert(reminderLogs)
.values({ appointmentId: appt.id, reminderType: window.label, channel: "sms" })
.onConflictDoNothing();
}
} catch (err) {
console.error("[reminders] SMS send failed:", err);
}
}
}
}
@@ -155,6 +197,19 @@ export function startReminderScheduler(): void {
runReminderCheck().catch((err) => {
console.error("[reminders] Error during reminder check:", err);
});
runSessionCleanup().catch((err) => {
console.error("[reminders] Error during session cleanup:", err);
});
});
console.log("[reminders] Reminder scheduler started");
}
// Deletes expired sessions from the database.
// Runs every minute alongside reminder checks.
export async function runSessionCleanup(): Promise<void> {
const db = getDb();
const now = new Date();
await db
.delete(session)
.where(lt(session.expiresAt, now));
}
+140
View File
@@ -0,0 +1,140 @@
import { Telnyx } from "telnyx";
export interface SmsProvider {
sendSms(to: string, body: string, mediaUrls?: string[]): Promise<{ messageId: string; status: string }>;
validateWebhookSignature(req: Request): boolean;
}
interface TelnyxSmsResult {
message_id: string;
status: string;
}
function createTelnyxClient(): Telnyx | null {
const apiKey = process.env.TELNYX_API_KEY;
if (!apiKey) return null;
return new Telnyx(apiKey);
}
let _client: Telnyx | null | undefined;
function getClient(): Telnyx | null {
if (_client === undefined) _client = createTelnyxClient();
return _client;
}
function getFromNumber(): string | null {
return process.env.TELNYX_FROM_NUMBER ?? null;
}
function isE164(phone: string): boolean {
return /^\+[1-9]\d{7,14}$/.test(phone);
}
export async function sendSms(
to: string,
body: string,
mediaUrls?: string[]
): Promise<{ messageId: string; status: string }> {
const client = getClient();
if (!client) throw new Error("Telnyx client not initialized. Set TELNYX_API_KEY.");
const from = getFromNumber();
if (!from) throw new Error("TELNYX_FROM_NUMBER is not set");
if (!isE164(to)) throw new Error(`Invalid recipient phone format: ${to}. Expected E.164.`);
if (!isE164(from)) throw new Error(`Invalid sender phone format: ${from}. Expected E.164.`);
const payload: Record<string, unknown> = {
from,
to,
body,
};
if (mediaUrls && mediaUrls.length > 0) {
payload.media_urls = mediaUrls;
}
const result = await client.messages.create(payload as Record<string, string | string[]>);
const smsResult = result.data as unknown as TelnyxSmsResult;
return {
messageId: smsResult.message_id,
status: smsResult.status,
};
}
export class TelnyxProvider implements SmsProvider {
async sendSms(
to: string,
body: string,
mediaUrls?: string[]
): Promise<{ messageId: string; status: string }> {
return sendSms(to, body, mediaUrls);
}
validateWebhookSignature(req: Request): boolean {
const secret = process.env.TELNYX_WEBHOOK_SECRET;
if (!secret) return false;
const signature = req.headers.get("telnyx-signature");
if (!signature) return false;
const payload = JSON.stringify(req.body);
try {
const { createHmac } = await import("crypto");
const hmac = createHmac("sha256", secret);
const expected = `sha256=${hmac.update(payload).digest("hex")}`;
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length) return false;
let diff = 0;
for (let i = 0; i < sigBuf.length; i++) {
diff |= sigBuf[i] ^ expBuf[i];
}
return diff === 0;
} catch {
return false;
}
}
}
let _provider: SmsProvider | null | undefined;
export function createSmsProvider(): SmsProvider | null {
if (_provider === undefined) {
if (process.env.SMS_ENABLED !== "true") {
_provider = null;
return null;
}
switch (process.env.SMS_PROVIDER) {
case "telnyx": {
const client = getClient();
if (!client) {
_provider = null;
return null;
}
_provider = new TelnyxProvider();
break;
}
default:
_provider = null;
}
}
return _provider;
}
export async function smsSend(
to: string,
body: string,
mediaUrls?: string[]
): Promise<boolean> {
const provider = createSmsProvider();
if (!provider) return false;
await provider.sendSms(to, body, mediaUrls);
return true;
}
+2
View File
@@ -14,6 +14,8 @@
},
"dependencies": {
"@groombook/types": "workspace:*",
"@stripe/react-stripe-js": "^6.1.0",
"@stripe/stripe-js": "^9.1.0",
"@tailwindcss/vite": "^4.2.2",
"better-auth": "^1.5.6",
"lucide-react": "^0.577.0",
+1 -1
View File
@@ -4,4 +4,4 @@ export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL ?? "",
});
export const { signIn, signOut, useSession } = authClient;
export const { signIn, signOut, useSession, changePassword } = authClient;
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from "react";
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
import { PetForm } from "./PetForm.js";
import { authClient } from "../../lib/auth-client.js";
interface Props {
sessionId: string | null;
@@ -148,9 +149,11 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const passwordsMatch = newPassword === confirmPassword;
const canSubmit = currentPassword.length > 0 && newPassword.length > 0 && passwordsMatch;
const canSubmit = newPassword.length > 0 && passwordsMatch && !loading;
if (readOnly) {
return (
@@ -160,17 +163,34 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
);
}
function handleSubmit() {
async function handleSubmit() {
if (!canSubmit) return;
if (newPassword !== confirmPassword) {
setError("Passwords do not match.");
return;
}
// TODO: Wire up to actual password-change API endpoint once backend support exists
setError(null);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setLoading(true);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await (authClient as any).changePassword({
currentPassword,
newPassword,
});
if (result.error) {
setError(result.error.message ?? "Failed to change password.");
} else {
setSuccess(true);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setTimeout(() => setSuccess(false), 4000);
}
} catch {
setError("An unexpected error occurred.");
} finally {
setLoading(false);
}
}
return (
@@ -205,12 +225,13 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
{success && <p className="text-sm text-green-600">Password updated successfully.</p>}
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
>
Update Password
{loading ? "Updating..." : "Update Password"}
</button>
</div>
</div>
+182 -96
View File
@@ -1,4 +1,6 @@
import { useState, useEffect } from "react";
import { loadStripe, type Stripe } from "@stripe/stripe-js";
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
interface Invoice {
@@ -10,31 +12,28 @@ interface Invoice {
}
interface PaymentMethod {
id: string;
brand: string;
last4: string;
expiryMonth: number;
expiryYear: number;
}
interface Package {
name: string;
remaining: number;
}
interface BillingPaymentsProps {
sessionId: string | null;
readOnly: boolean;
}
export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
const [invoices, setInvoices] = useState<Invoice[]>([]);
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
const [packages, setPackages] = useState<Package[]>([]);
const [packages, setPackages] = useState<{ name: string; remaining: number }[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
const [autopay, setAutopay] = useState(false);
const [showPaymentModal, setShowPaymentModal] = useState(false);
const [publishableKey, setPublishableKey] = useState<string>("");
useEffect(() => {
async function fetchData() {
@@ -44,20 +43,37 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
}
try {
const response = await fetch("/api/portal/invoices", {
headers: {
"X-Impersonation-Session-Id": sessionId,
},
});
const [configRes, invoicesRes, methodsRes] = await Promise.all([
fetch("/api/portal/config", {
headers: { "X-Impersonation-Session-Id": sessionId },
}),
fetch("/api/portal/invoices", {
headers: { "X-Impersonation-Session-Id": sessionId },
}),
fetch("/api/portal/payment-methods", {
headers: { "X-Impersonation-Session-Id": sessionId },
}),
]);
if (!response.ok) {
throw new Error("Failed to fetch invoices");
if (!configRes.ok) throw new Error("Failed to fetch config");
const configData = await configRes.json();
setPublishableKey(configData.stripePublishableKey ?? "");
const invoicesData = await invoicesRes.json();
setInvoices(Array.isArray(invoicesData) ? invoicesData : invoicesData.invoices || []);
if (methodsRes.ok) {
const methodsData = await methodsRes.json();
setPaymentMethods(
(methodsData ?? []).map((m: { id: string; card: { brand: string; last4: string; exp_month: number; exp_year: number } }) => ({
id: m.id,
brand: m.card?.brand ?? "unknown",
last4: m.card?.last4 ?? "****",
expiryMonth: m.card?.exp_month ?? 0,
expiryYear: m.card?.exp_year ?? 0,
}))
);
}
const data = await response.json();
setInvoices(Array.isArray(data) ? data : data.invoices || []);
setPaymentMethods(data.paymentMethods || []);
setPackages(data.packages || []);
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
@@ -68,12 +84,8 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
fetchData();
}, [sessionId]);
const formatCents = (cents: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(cents / 100);
};
const formatCents = (cents: number) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
const pending = invoices.filter((i) => i.status === "pending");
const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0);
@@ -82,9 +94,9 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
return (
<div className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-6 bg-gray-200 rounded w-1/3"></div>
<div className="h-24 bg-gray-200 rounded"></div>
<div className="h-24 bg-gray-200 rounded"></div>
<div className="h-6 bg-gray-200 rounded w-1/3" />
<div className="h-24 bg-gray-200 rounded" />
<div className="h-24 bg-gray-200 rounded" />
</div>
</div>
);
@@ -100,7 +112,6 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
return (
<div className="space-y-6">
{/* Outstanding Balance Banner */}
{totalPending > 0 && (
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
@@ -110,16 +121,15 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
{pending.length} unpaid invoice{pending.length > 1 ? "s" : ""}
</p>
</div>
<button
onClick={() => setShowPaymentModal(true)}
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
>
Pay Now
</button>
<button
onClick={() => setShowPaymentModal(true)}
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
>
Pay Now
</button>
</div>
)}
{/* Tabs */}
<div className="flex gap-2">
{([
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
@@ -141,7 +151,6 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
))}
</div>
{/* Invoices */}
{tab === "invoices" && (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
@@ -152,7 +161,7 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
<th className="px-5 py-3 font-medium">Description</th>
<th className="px-5 py-3 font-medium">Amount</th>
<th className="px-5 py-3 font-medium">Status</th>
<th className="px-5 py-3 font-medium"></th>
<th className="px-5 py-3 font-medium" />
</tr>
</thead>
<tbody>
@@ -160,9 +169,7 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
<tr key={inv.id} className="border-b border-stone-50 hover:bg-stone-50/50">
<td className="px-5 py-3 text-stone-700">
{new Date(inv.date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
month: "short", day: "numeric", year: "numeric",
})}
</td>
<td className="px-5 py-3 text-stone-600">
@@ -201,7 +208,6 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
{/* Payment Methods */}
{tab === "payment" && (
<div className="space-y-4">
{paymentMethods.length === 0 ? (
@@ -210,7 +216,7 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
<div className="space-y-3">
{paymentMethods.map((method) => (
<div
key={`${method.brand}-${method.last4}`}
key={method.id}
className="flex items-center justify-between p-4 border border-stone-200 rounded-lg bg-white"
>
<div className="flex items-center gap-3">
@@ -223,7 +229,18 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
</span>
</div>
{!readOnly && (
<button className="text-sm text-blue-600 hover:underline">
<button
onClick={async () => {
const res = await fetch(`/api/portal/payment-methods/${method.id}`, {
method: "DELETE",
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
});
if (res.ok) {
setPaymentMethods((prev) => prev.filter((m) => m.id !== method.id));
}
}}
className="text-sm text-blue-600 hover:underline"
>
Remove
</button>
)}
@@ -232,7 +249,6 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
{/* Autopay */}
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -241,9 +257,7 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
<div>
<p className="text-sm font-medium text-stone-800">Autopay</p>
<p className="text-xs text-stone-500">
Automatically charge after each appointment
</p>
<p className="text-xs text-stone-500">Automatically charge after each appointment</p>
</div>
</div>
{!readOnly ? (
@@ -269,17 +283,13 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
{/* Packages */}
{tab === "packages" && (
<div className="space-y-4">
{packages.length === 0 ? (
<p className="text-gray-500 italic">No packages purchased</p>
) : (
packages.map((pkg, index) => (
<div
key={index}
className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm"
>
<div key={index} className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div className="flex items-center justify-between">
<span className="font-medium text-stone-800">{pkg.name}</span>
<span className="text-stone-600">{pkg.remaining} remaining</span>
@@ -290,59 +300,120 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
{/* Payment Modal */}
{showPaymentModal && (
<PaymentModal
{showPaymentModal && publishableKey && (
<PaymentModalWrapper
key={Date.now()}
sessionId={sessionId ?? ""}
publishableKey={publishableKey}
pending={pending}
totalPending={totalPending}
onClose={() => setShowPaymentModal(false)}
onSuccess={() => {
setInvoices((prev) =>
prev.map((inv) =>
pending.some((p) => p.id === inv.id) ? { ...inv, status: "paid" as const } : inv
)
);
setShowPaymentModal(false);
}}
/>
)}
</div>
);
}
function PaymentModal({
pending,
totalPending: _totalPending,
onClose,
}: {
interface PaymentModalWrapperProps {
sessionId: string;
publishableKey: string;
pending: Invoice[];
totalPending: number;
onClose: () => void;
}) {
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(
new Set(pending.map((i) => i.id))
onSuccess: () => void;
}
function PaymentModalWrapper({ sessionId, publishableKey, pending, onClose, onSuccess }: PaymentModalWrapperProps) {
const [stripePromise] = useState(() =>
publishableKey ? loadStripe(publishableKey) : Promise.resolve(null)
);
return (
<Elements stripe={stripePromise} options={{ mode: "payment", amount: pending.reduce((s, i) => s + i.totalCents, 0), currency: "usd" }}>
<PaymentModal sessionId={sessionId} pending={pending} onClose={onClose} onSuccess={onSuccess} />
</Elements>
);
}
interface PaymentModalProps {
sessionId: string;
pending: Invoice[];
onClose: () => void;
onSuccess: () => void;
}
function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalProps) {
const stripe = useStripe();
const elements = useElements();
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(new Set(pending.map((i) => i.id)));
const [saveCard, setSaveCard] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [isComplete, setIsComplete] = useState(false);
const [error, setError] = useState<string | null>(null);
const formatCents = (cents: number) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(cents / 100);
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
const toggleInvoice = (id: string) => {
const next = new Set(selectedInvoices);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedInvoices(next);
};
const handlePay = async () => {
setIsProcessing(true);
await new Promise((resolve) => setTimeout(resolve, 1500));
setIsProcessing(false);
setIsComplete(true);
};
const selectedTotal = pending.filter((i) => selectedInvoices.has(i.id)).reduce((sum, i) => sum + i.totalCents, 0);
const selectedTotal = pending
.filter((i) => selectedInvoices.has(i.id))
.reduce((sum, i) => sum + i.totalCents, 0);
const handlePay = async () => {
if (!stripe || !elements) return;
setIsProcessing(true);
setError(null);
try {
const isMulti = selectedInvoices.size > 1;
const endpoint = isMulti ? "/api/portal/invoices/pay-multiple" : `/api/portal/invoices/${[...selectedInvoices][0]}/pay`;
const body = isMulti ? { invoiceIds: [...selectedInvoices] } : {};
const res = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Impersonation-Session-Id": sessionId,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error ?? "Failed to initialize payment");
}
const { clientSecret } = await res.json();
const { error: stripeError } = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: saveCard ? { setup_future_usage: "off_session" } : undefined,
});
if (stripeError) {
setError(stripeError.message ?? "Payment failed");
setIsProcessing(false);
return;
}
setIsComplete(true);
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : "An unexpected error occurred");
setIsProcessing(false);
}
};
if (isComplete) {
return (
@@ -357,10 +428,7 @@ function PaymentModal({
<p className="text-stone-500 text-sm mb-6">
Your payment of {formatCents(selectedTotal)} has been processed. A receipt has been sent to your email.
</p>
<button
onClick={onClose}
className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium"
>
<button onClick={onClose} className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium">
Done
</button>
</div>
@@ -408,22 +476,36 @@ function PaymentModal({
</p>
</div>
</div>
<span className="text-sm font-medium text-stone-800">
{formatCents(inv.totalCents)}
</span>
<span className="text-sm font-medium text-stone-800">{formatCents(inv.totalCents)}</span>
</label>
))}
</div>
<div className="border-t border-stone-200 pt-4 mb-6">
<div className="flex justify-between items-center">
<div className="flex justify-between items-center mb-4">
<span className="text-sm text-stone-600">Total</span>
<span className="text-lg font-bold text-stone-800">
{formatCents(selectedTotal)}
</span>
<span className="text-lg font-bold text-stone-800">{formatCents(selectedTotal)}</span>
</div>
<PaymentElement />
</div>
<label className="flex items-center gap-2 mb-4">
<input
type="checkbox"
checked={saveCard}
onChange={(e) => setSaveCard(e.target.checked)}
className="w-4 h-4 rounded border-stone-300 text-(--color-accent) focus:ring-(--color-accent)"
/>
<span className="text-sm text-stone-600">Save card for future payments</span>
</label>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
<div className="flex gap-3">
<button
onClick={onClose}
@@ -433,7 +515,7 @@ function PaymentModal({
</button>
<button
onClick={handlePay}
disabled={selectedInvoices.size === 0 || isProcessing}
disabled={selectedInvoices.size === 0 || isProcessing || !stripe}
className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
>
{isProcessing ? "Processing..." : "Pay Now"}
@@ -444,4 +526,8 @@ function PaymentModal({
);
}
export function BillingPayments(props: BillingPaymentsProps) {
return <BillingPaymentsInner {...props} />;
}
export default BillingPayments;
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
API_HOST="https://api.minimax.io"
API_KEY="$MINIMAX_API_KEY"
OUTPUT_DIR="minimax-output"
mkdir -p "$OUTPUT_DIR"
# Diverse dog image prompts
declare -a PROMPTS=(
"A beautiful red Irish Setter with long flowing silky coat, standing proudly in golden hour sunlight, professional pet portrait photography, warm tones"
"A fluffy white Pomeranian puppy with thick fluffy coat, sitting alert with bright expression, studio white background, cute grooming"
"A black Schnauzer with distinctive full beard and mustache, freshly groomed with neat styling, professional grooming salon setting"
"A cream and white Cavalier King Charles Spaniel with silky coat, gentle sad eyes, soft warm indoor lighting, elegant pose"
"A brown and white Basset Hound with long droopy ears, lying down in relaxed pose, natural outdoor setting, peaceful expression"
"A black and tan miniature Dachshund with glossy coat, alert standing pose, warm studio lighting, detailed paws visible"
"A white fluffy Bichon Frise after professional grooming with rounded topknot, happy bouncy expression, bright cheerful background"
"A muscular fawn Boxer dog, athletic build, standing confidently outdoors in park, energetic expression, natural lighting"
"A blue merle Shetland Sheepdog with alert ears and fluffy coat, running happily, green grass field background, vibrant"
"A buff colored Cocker Spaniel with beautiful silky coat, friendly gentle expression, warm natural window lighting, indoor"
)
declare -a FILENAMES=(
"dog-setter-red-sunlit.png"
"dog-pomeranian-white-studio.png"
"dog-schnauzer-black-groomed.png"
"dog-cavalier-cream-gentle.png"
"dog-basset-brown-white.png"
"dog-dachshund-black-tan.png"
"dog-bichon-white-groomed.png"
"dog-boxer-fawn-athletic.png"
"dog-sheepdog-merle-running.png"
"dog-cocker-buff-friendly.png"
)
echo "Generating ${#PROMPTS[@]} diverse dog images..."
for i in "${!PROMPTS[@]}"; do
PROMPT="${PROMPTS[$i]}"
FILENAME="${FILENAMES[$i]}"
echo -n "[$((i+1))/${#PROMPTS[@]}] $FILENAME... "
RESPONSE=$(curl -s -X POST "${API_HOST}/v1/image_generation" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-d "{\"model\":\"image-01\",\"prompt\":\"${PROMPT}\",\"image_count\":1}")
# Extract image URL from response
IMAGE_URL=$(echo "$RESPONSE" | grep -o '"image_urls":\["\([^"]*\)' | cut -d'"' -f4)
if [ -n "$IMAGE_URL" ]; then
curl -s "$IMAGE_URL" -o "$OUTPUT_DIR/$FILENAME" 2>/dev/null
if [ -f "$OUTPUT_DIR/$FILENAME" ] && [ -s "$OUTPUT_DIR/$FILENAME" ]; then
echo "✓"
else
echo "✗ (download failed)"
fi
else
echo "✗ (no URL)"
fi
done
echo "Done! Generated images in $OUTPUT_DIR/"
ls -lh "$OUTPUT_DIR"/dog-*.png 2>/dev/null | wc -l
+80
View File
@@ -0,0 +1,80 @@
#!/bin/bash
# Use the configured MiniMax API host
API_HOST="${MINIMAX_API_HOST:-https://api.minimax.io}"
API_KEY="$MINIMAX_API_KEY"
# Test endpoint - check which one works
echo "Testing API endpoints..."
echo "API_HOST: $API_HOST"
echo "API_KEY: ${API_KEY:0:15}..."
# Array of diverse dog images to generate
declare -a PROMPTS=(
"A beautiful red Irish Setter with flowing silky coat, standing proudly in a sunny garden, warm natural lighting, professional pet photography"
"A fluffy white Pomeranian with thick coat, sitting alert, bright studio background, cute expression"
"A black Schnauzer with distinctive beard, freshly groomed, professional salon setting, dignified pose"
"A cream-colored Cavalier King Charles Spaniel, silky coat, gentle expression, soft warm lighting"
"A brown and white Basset Hound, long ears, relaxed sitting pose, natural outdoor background"
"A black and tan Dachshund, elongated body, alert posture, warm studio lighting"
"A white Bichon Frise, fluffy groomed coat, happy expression, bright cheerful background"
"A fawn Boxer with muscular build, athletic posture, outdoor park setting, energetic expression"
"A merle Shetland Sheepdog, alert ears, running pose, green garden background"
"A buff-colored Cocker Spaniel, silky coat, friendly expression, warm natural light"
)
declare -a FILENAMES=(
"dog-setter-red-sunny.png"
"dog-pomeranian-white-alert.png"
"dog-schnauzer-groomed.png"
"dog-cavalier-cream.png"
"dog-basset-hound-outdoor.png"
"dog-dachshund-alert.png"
"dog-bichon-frise-happy.png"
"dog-boxer-athletic.png"
"dog-sheepdog-merle.png"
"dog-cocker-spaniel-buff.png"
)
mkdir -p minimax-output
echo "Generating ${#PROMPTS[@]} diverse dog images..."
for i in "${!PROMPTS[@]}"; do
PROMPT="${PROMPTS[$i]}"
FILENAME="${FILENAMES[$i]}"
echo "[$((i+1))/${#PROMPTS[@]}] Generating: $FILENAME"
# Make API request
RESPONSE=$(curl -s -X POST "${API_HOST}/v1/image_generation" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-d "{
\"model\": \"image-01\",
\"prompt\": \"${PROMPT}\",
\"image_count\": 1
}")
# Check if response contains image data
if echo "$RESPONSE" | grep -q "data\|image_url\|file_content"; then
echo " ✓ Response received"
# Try to extract and save image data
# Different APIs format responses differently
IMAGE_DATA=$(echo "$RESPONSE" | grep -o '"file_content":"[^"]*' | head -1 | cut -d'"' -f4)
if [ -n "$IMAGE_DATA" ]; then
echo "$IMAGE_DATA" | base64 -d > "minimax-output/$FILENAME"
echo " ✓ Image saved to minimax-output/$FILENAME"
else
echo " ✗ Could not extract image data"
fi
else
echo " ✗ API response: ${RESPONSE:0:100}"
fi
sleep 1
done
echo "Image generation complete!"
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
import base64
import requests
import os
import json
api_key = os.environ.get("MINIMAX_API_KEY")
if not api_key:
raise ValueError("MINIMAX_API_KEY environment variable not set")
url = "https://api.minimax.io/v1/image_generation"
headers = {"Authorization": f"Bearer {api_key}"}
# Ensure output directory exists
os.makedirs("minimax-output", exist_ok=True)
prompts = [
{
"filename": "dog-puggle-fawn-playful.png",
"prompt": "Adorable fawn Puggle puppy with playful expression, compact muscular build, professional pet photography, studio lighting, photorealistic"
},
{
"filename": "dog-puggle-black-sitting.png",
"prompt": "Black and tan Puggle with alert sitting posture, pointed beagle-like ears, gentle eyes, professional studio lighting, photorealistic"
},
{
"filename": "dog-puggle-cream-groomed.png",
"prompt": "Cream Puggle freshly groomed with fluffy coat, happy expression, lying down comfortably, natural daylight, photorealistic"
},
{
"filename": "dog-puggle-tricolor-outdoor.png",
"prompt": "Tricolor Puggle in outdoor garden setting, alert playful pose, natural sunlight, professional pet photography, photorealistic"
},
{
"filename": "dog-puggle-fawn-grooming.png",
"prompt": "Fawn Puggle at grooming salon, gentle expression, compact muscular build with beagle-like features, professional grooming setup, warm lighting, photorealistic"
}
]
print(f"Generating {len(prompts)} Puggle images...")
for item in prompts:
filename = item["filename"]
prompt = item["prompt"]
print(f"\nGenerating {filename}...")
payload = {
"model": "image-01",
"prompt": prompt,
"aspect_ratio": "1:1",
"response_format": "base64",
}
try:
response = requests.post(url, headers=headers, json=payload, timeout=60)
response.raise_for_status()
data = response.json()
if "data" in data and "image_base64" in data["data"]:
images = data["data"]["image_base64"]
# Save the first (and usually only) image
output_path = f"minimax-output/{filename}"
with open(output_path, "wb") as f:
f.write(base64.b64decode(images[0]))
file_size = os.path.getsize(output_path)
print(f"✓ Saved {filename} ({file_size} bytes)")
else:
print(f"✗ Unexpected response format: {json.dumps(data, indent=2)}")
except requests.exceptions.RequestException as e:
print(f"✗ Error generating {filename}: {e}")
except Exception as e:
print(f"✗ Unexpected error for {filename}: {e}")
print("\n✓ Image generation complete!")
print("Files saved to minimax-output/")
+1 -1
Submodule infra updated: d6c0d13d02...b667a3f005
@@ -0,0 +1,6 @@
-- Better-Auth rate limiting table (GRO-574)
CREATE TABLE "rate_limit" (
key TEXT NOT NULL PRIMARY KEY,
count INTEGER NOT NULL,
last_request BIGINT NOT NULL
);
@@ -0,0 +1,9 @@
ALTER TABLE "reminder_logs" DROP CONSTRAINT "reminder_logs_appointment_id_reminder_type_unique";--> statement-breakpoint
ALTER TABLE "business_settings" ADD COLUMN "logo_key" text;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "sms_opt_in" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "sms_consent_date" timestamp;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "sms_opt_out_date" timestamp;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "sms_consent_text" text;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "image" text;--> statement-breakpoint
ALTER TABLE "reminder_logs" ADD COLUMN "channel" text DEFAULT 'email' NOT NULL;--> statement-breakpoint
ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE("appointment_id","reminder_type","channel");
@@ -0,0 +1,6 @@
ALTER TABLE "clients" ADD COLUMN "stripe_customer_id" text;--> statement-breakpoint
ALTER TABLE "clients" ADD CONSTRAINT "idx_clients_stripe_customer_id" UNIQUE("stripe_customer_id");--> statement-breakpoint
ALTER TABLE "invoices" ADD COLUMN "stripe_payment_intent_id" text;--> statement-breakpoint
ALTER TABLE "invoices" ADD COLUMN "stripe_refund_id" text;--> statement-breakpoint
ALTER TABLE "invoices" ADD COLUMN "payment_failure_reason" text;--> statement-breakpoint
ALTER TABLE "invoices" ADD CONSTRAINT "idx_invoices_stripe_payment_intent_id" UNIQUE("stripe_payment_intent_id");
@@ -0,0 +1 @@
ALTER TABLE "business_settings" ADD COLUMN "sms_enabled" boolean NOT NULL DEFAULT false;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
{
"id": "0027_stripe_identifiers",
"version": "7",
"dialect": "postgresql",
"tables": {
"authProviderConfig": {
"name": "auth_provider_config",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"providerId": { "name": "provider_id", "type": "text", "isNullable": false },
"displayName": { "name": "display_name", "type": "text", "isNullable": false },
"issuerUrl": { "name": "issuer_url", "type": "text", "isNullable": false },
"internalBaseUrl": { "name": "internal_base_url", "type": "text", "isNullable": true },
"clientId": { "name": "client_id", "type": "text", "isNullable": false },
"clientSecret": { "name": "client_secret", "type": "text", "isNullable": false },
"scopes": { "name": "scopes", "type": "text", "isNullable": false, "default": "'openid profile email'" },
"enabled": { "name": "enabled", "type": "boolean", "isNullable": false, "default": "true" },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {}
},
"businessSettings": {
"name": "business_settings",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"businessName": { "name": "business_name", "type": "text", "isNullable": false, "default": "'GroomBook'" },
"logoBase64": { "name": "logo_base64", "type": "text", "isNullable": true },
"logoMimeType": { "name": "logo_mime_type", "type": "text", "isNullable": true },
"logoKey": { "name": "logo_key", "type": "text", "isNullable": true },
"primaryColor": { "name": "primary_color", "type": "text", "isNullable": false, "default": "'#4f8a6f'" },
"accentColor": { "name": "accent_color", "type": "text", "isNullable": false, "default": "'#8b7355'" },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {}
},
"clients": {
"name": "clients",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"name": { "name": "name", "type": "text", "isNullable": false },
"email": { "name": "email", "type": "text", "isNullable": true },
"phone": { "name": "phone", "type": "text", "isNullable": true },
"address": { "name": "address", "type": "text", "isNullable": true },
"notes": { "name": "notes", "type": "text", "isNullable": true },
"emailOptOut": { "name": "email_opt_out", "type": "boolean", "isNullable": false, "default": "false" },
"smsOptIn": { "name": "sms_opt_in", "type": "boolean", "isNullable": false, "default": "false" },
"smsConsentDate": { "name": "sms_consent_date", "type": "timestamp", "isNullable": true },
"smsOptOutDate": { "name": "sms_opt_out_date", "type": "timestamp", "isNullable": true },
"smsConsentText": { "name": "sms_consent_text", "type": "text", "isNullable": true },
"stripeCustomerId": { "name": "stripe_customer_id", "type": "text", "isNullable": true },
"status": { "name": "status", "type": "client_status", "isNullable": false, "default": "'active'" },
"disabledAt": { "name": "disabled_at", "type": "timestamp", "isNullable": true },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": { "idx_clients_stripe_customer_id": { "columns": ["stripe_customer_id"] } }
},
"invoices": {
"name": "invoices",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"appointmentId": { "name": "appointment_id", "type": "uuid", "isNullable": true },
"clientId": { "name": "client_id", "type": "uuid", "isNullable": false },
"subtotalCents": { "name": "subtotal_cents", "type": "integer", "isNullable": false },
"taxCents": { "name": "tax_cents", "type": "integer", "isNullable": false, "default": "0" },
"tipCents": { "name": "tip_cents", "type": "integer", "isNullable": false, "default": "0" },
"totalCents": { "name": "total_cents", "type": "integer", "isNullable": false },
"status": { "name": "status", "type": "invoice_status", "isNullable": false, "default": "'draft'" },
"paymentMethod": { "name": "payment_method", "type": "payment_method", "isNullable": true },
"paidAt": { "name": "paid_at", "type": "timestamp", "isNullable": true },
"stripePaymentIntentId": { "name": "stripe_payment_intent_id", "type": "text", "isNullable": true },
"stripeRefundId": { "name": "stripe_refund_id", "type": "text", "isNullable": true },
"paymentFailureReason": { "name": "payment_failure_reason", "type": "text", "isNullable": true },
"notes": { "name": "notes", "type": "text", "isNullable": true },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": { "idx_invoices_client_id": { "columns": ["client_id"] }, "idx_invoices_status": { "columns": ["status"] }, "idx_invoices_created_at": { "columns": ["created_at"] } },
"foreignKeys": { "invoices_appointment_id_fkey": { "columns": ["appointmentId"], "reference": { "table": "appointments", "columns": ["id"] } }, "invoices_client_id_fkey": { "columns": ["clientId"], "reference": { "table": "clients", "columns": ["id"] } } },
"compositePrimaryKeys": {},
"uniqueConstraints": { "idx_invoices_stripe_payment_intent_id": { "columns": ["stripe_payment_intent_id"] } }
}
},
"enums": {
"appointment_status": { "name": "appointment_status", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] },
"client_status": { "name": "client_status", "values": ["active", "disabled"] },
"impersonation_session_status": { "name": "impersonation_session_status", "values": ["active", "ended", "expired"] },
"invoice_status": { "name": "invoice_status", "values": ["draft", "pending", "paid", "void"] },
"payment_method": { "name": "payment_method", "values": ["cash", "card", "check", "other"] },
"staff_role": { "name": "staff_role", "values": ["groomer", "receptionist", "manager"] },
"waitlist_status": { "name": "waitlist_status", "values": ["active", "notified", "expired", "cancelled"] }
},
"nativeEnums": {}
}
+14
View File
@@ -176,6 +176,20 @@
"when": 1775396067192,
"tag": "0024_invoice_indexes",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1775482467192,
"tag": "0025_rate_limit",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1776035812477,
"tag": "0026_boring_storm",
"breakpoints": true
}
]
}
+4
View File
@@ -71,6 +71,10 @@ export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
address: "1 Main St, Springfield, CA 90000",
notes: null,
emailOptOut: false,
smsOptIn: false,
smsConsentDate: null,
smsOptOutDate: null,
smsConsentText: null,
status: "active",
disabledAt: null,
createdAt: new Date("2025-01-01T00:00:00Z"),
+6 -3
View File
@@ -109,8 +109,11 @@ export const clients = pgTable("clients", {
phone: text("phone"),
address: text("address"),
notes: text("notes"),
// Set to true if the client has opted out of email reminders/notifications
emailOptOut: boolean("email_opt_out").notNull().default(false),
smsOptIn: boolean("sms_opt_in").notNull().default(false),
smsConsentDate: timestamp("sms_consent_date"),
smsOptOutDate: timestamp("sms_opt_out_date"),
smsConsentText: text("sms_consent_text"),
status: clientStatusEnum("status").notNull().default("active"),
disabledAt: timestamp("disabled_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
@@ -305,11 +308,11 @@ export const reminderLogs = pgTable(
appointmentId: uuid("appointment_id")
.notNull()
.references(() => appointments.id, { onDelete: "cascade" }),
// "confirmation" | "24h" | "2h"
reminderType: text("reminder_type").notNull(),
channel: text("channel").notNull().default("email"),
sentAt: timestamp("sent_at").notNull().defaultNow(),
},
(t) => [unique().on(t.appointmentId, t.reminderType)]
(t) => [unique().on(t.appointmentId, t.reminderType, t.channel)]
);
// ─── Impersonation ──────────────────────────────────────────────────────────
+105
View File
@@ -40,6 +40,12 @@ importers:
nodemailer:
specifier: ^6.9.16
version: 6.10.1
stripe:
specifier: ^22.0.0
version: 22.0.1(@types/node@22.19.15)
telnyx:
specifier: ^6.41.0
version: 6.41.0(ws@8.19.0)
zod:
specifier: ^4.3.6
version: 4.3.6
@@ -83,6 +89,12 @@ importers:
'@groombook/types':
specifier: workspace:*
version: link:../../packages/types
'@stripe/react-stripe-js':
specifier: ^6.1.0
version: 6.1.0(@stripe/stripe-js@9.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@stripe/stripe-js':
specifier: ^9.1.0
version: 9.1.0
'@tailwindcss/vite':
specifier: ^4.2.2
version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
@@ -2103,12 +2115,26 @@ packages:
resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==}
engines: {node: '>=18.0.0'}
'@stablelib/base64@1.0.1':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@stripe/react-stripe-js@6.1.0':
resolution: {integrity: sha512-LbKbRv4+wUSHLb5VNxqiYcKaqXPvTju0bJaF0RrzH0h4+aKWDXAk4RzUBcpNxxj8KtjuxICElANs1Li7aTv1IQ==}
peerDependencies:
'@stripe/stripe-js': '>=9.0.0 <10.0.0'
react: '>=16.8.0 <20.0.0'
react-dom: '>=16.8.0 <20.0.0'
'@stripe/stripe-js@9.1.0':
resolution: {integrity: sha512-v51LoEfZNiNS/5DcarWPCYgn24w4dqwwALR4GTbMW/N0DDzzj4DgYNoixX6PYvpt6uIJMucGUabn/BHhylggIQ==}
engines: {node: '>=12.16'}
'@surma/rollup-plugin-off-main-thread@2.2.3':
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
@@ -3073,6 +3099,9 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fast-sha256@1.3.0:
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
@@ -3608,6 +3637,10 @@ packages:
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
@@ -3699,6 +3732,10 @@ packages:
nwsapi@2.2.23:
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@@ -3816,6 +3853,9 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -3828,6 +3868,9 @@ packages:
peerDependencies:
react: ^19.2.4
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
@@ -4066,6 +4109,9 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
standardwebhooks@1.0.0:
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
@@ -4124,6 +4170,15 @@ packages:
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
stripe@22.0.1:
resolution: {integrity: sha512-Yw764pZ6s8Xu4CtUZdD5uWOkw6gc9xzO9OKylCuj1gMhMDLbyGbDtaPNNSFE4mB6njYSHESYIVbF1iIzUfAl2g==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
strnum@2.2.1:
resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==}
@@ -4145,6 +4200,14 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
telnyx@6.41.0:
resolution: {integrity: sha512-93eKksI6HnLYp8e4DGlpC3SkBAfagblE+uug0FNDLT/+mix3PP0RveoQ/YZeRdxDhjMcoXVgeusJsgFP6PvUdw==}
peerDependencies:
ws: ^8.18.0
peerDependenciesMeta:
ws:
optional: true
temp-dir@2.0.0:
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
engines: {node: '>=8'}
@@ -6667,10 +6730,21 @@ snapshots:
dependencies:
tslib: 2.8.1
'@stablelib/base64@1.0.1': {}
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@stripe/react-stripe-js@6.1.0(@stripe/stripe-js@9.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@stripe/stripe-js': 9.1.0
prop-types: 15.8.1
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@stripe/stripe-js@9.1.0': {}
'@surma/rollup-plugin-off-main-thread@2.2.3':
dependencies:
ejs: 3.1.10
@@ -7703,6 +7777,8 @@ snapshots:
fast-levenshtein@2.0.6: {}
fast-sha256@1.3.0: {}
fast-uri@3.1.0: {}
fast-xml-builder@1.1.4:
@@ -8225,6 +8301,10 @@ snapshots:
lodash@4.17.23: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
loupe@3.2.1: {}
lru-cache@10.4.3: {}
@@ -8299,6 +8379,8 @@ snapshots:
nwsapi@2.2.23: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
object-keys@1.1.1: {}
@@ -8403,6 +8485,12 @@ snapshots:
ansi-styles: 5.2.0
react-is: 17.0.2
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
punycode@2.3.1: {}
randombytes@2.1.0:
@@ -8414,6 +8502,8 @@ snapshots:
react: 19.2.4
scheduler: 0.27.0
react-is@16.13.1: {}
react-is@17.0.2: {}
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):
@@ -8690,6 +8780,11 @@ snapshots:
stackback@0.0.2: {}
standardwebhooks@1.0.0:
dependencies:
'@stablelib/base64': 1.0.1
fast-sha256: 1.3.0
std-env@3.10.0: {}
stop-iteration-iterator@1.1.0:
@@ -8774,6 +8869,10 @@ snapshots:
dependencies:
js-tokens: 9.0.1
stripe@22.0.1(@types/node@22.19.15):
optionalDependencies:
'@types/node': 22.19.15
strnum@2.2.1: {}
supports-color@7.2.0:
@@ -8788,6 +8887,12 @@ snapshots:
tapable@2.3.0: {}
telnyx@6.41.0(ws@8.19.0):
dependencies:
standardwebhooks: 1.0.0
optionalDependencies:
ws: 8.19.0
temp-dir@2.0.0: {}
tempy@0.6.0:
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB