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>
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -148,3 +148,15 @@ export async function detachPaymentMethod(paymentMethodId: string): Promise<bool
|
||||
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! };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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": {}
|
||||
}
|
||||
Reference in New Issue
Block a user