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! };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user