diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index b109e89..8b10b56 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -462,45 +462,9 @@ import { detachPaymentMethod, createSetupIntent, getOrCreateStripeCustomer, + getStripeClient, } 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), }); @@ -580,19 +544,23 @@ portalRouter.delete("/payment-methods/:id", async (c) => { if (!clientId) return c.json({ error: "Unauthorized" }, 401); const paymentMethodId = c.req.param("id"); + + const stripeCustomerId = await getOrCreateStripeCustomer(clientId); + if (!stripeCustomerId) return c.json({ error: "No payment method found" }, 404); + + const stripe = getStripeClient(); + if (!stripe) return c.json({ error: "Payment service unavailable" }, 503); + + const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId); + if (!paymentMethod || paymentMethod.customer !== stripeCustomerId) { + return c.json({ error: "Payment method not found" }, 404); + } + 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. diff --git a/apps/api/src/routes/stripe-webhooks.ts b/apps/api/src/routes/stripe-webhooks.ts index de168c5..4a948a1 100644 --- a/apps/api/src/routes/stripe-webhooks.ts +++ b/apps/api/src/routes/stripe-webhooks.ts @@ -1,12 +1,13 @@ import { Hono } from "hono"; import Stripe from "stripe"; import { eq, getDb, invoices } from "@groombook/db"; +import { getStripeClient } from "../services/payment.js"; export const webhooksRouter = new Hono(); webhooksRouter.post("/stripe", async (c) => { - const secret = process.env.STRIPE_WEBHOOK_SECRET; - if (!secret) { + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + if (!webhookSecret) { return c.json({ error: "Webhook secret not configured" }, 503); } @@ -22,11 +23,14 @@ webhooksRouter.post("/stripe", async (c) => { return c.json({ error: "Could not read body" }, 400); } - const stripe = new Stripe(secret, { apiVersion: "2026-03-25.dahlia" }); + const stripe = getStripeClient(); + if (!stripe) { + return c.json({ error: "Stripe not configured" }, 503); + } let event: Stripe.Event; try { - event = stripe.webhooks.constructEvent(rawBody, signature, secret); + event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret); } catch (err) { const message = err instanceof Error ? err.message : "Invalid signature"; return c.json({ error: message }, 401); diff --git a/apps/api/src/services/payment.ts b/apps/api/src/services/payment.ts index 1ef8feb..d09d1db 100644 --- a/apps/api/src/services/payment.ts +++ b/apps/api/src/services/payment.ts @@ -1,9 +1,9 @@ import Stripe from "stripe"; -import { getDb, clients, eq, invoices } from "@groombook/db"; +import { getDb, clients, eq, inArray, invoices } from "@groombook/db"; let _stripe: Stripe | null | undefined; -function getStripeClient(): Stripe | null { +export function getStripeClient(): Stripe | null { if (_stripe === undefined) { const secretKey = process.env.STRIPE_SECRET_KEY; if (!secretKey) return null; @@ -59,8 +59,8 @@ export async function createPaymentIntent( const allInvoices = await db .select({ totalCents: invoices.totalCents }) .from(invoices) - .where(eq(invoices.id, firstInvoiceId)); - totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, totalCents); + .where(inArray(invoices.id, invoiceIds)); + totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, 0); } const stripeCustomerId = await getOrCreateStripeCustomer(clientId); diff --git a/infra b/infra index d6c0d13..b667a3f 160000 --- a/infra +++ b/infra @@ -1 +1 @@ -Subproject commit d6c0d13d0256cb9209acb9445f0fdf44fa9801ad +Subproject commit b667a3f0054beef8bb40f502197e7aee10bd9977