feat(GRO-607): Stripe Elements payment UI replacing mock flow
* GRO-605: Stripe SDK integration + payment service Co-Authored-By: Paperclip <noreply@paperclip.ing> * GRO-606: Add payment API endpoints (pay invoice, payment methods, refunds) Co-Authored-By: Paperclip <noreply@paperclip.ing> * feat(GRO-597): Stripe payment backend — schema, service, API, webhooks Consolidates GRO-605, GRO-606, GRO-608 into a single clean PR: - GRO-605: Stripe SDK integration + payment service - GRO-606: Payment API endpoints (pay invoice, payment methods, refunds) - GRO-608: Stripe webhook handler Migration consolidation: - Single 0026_stripe_payment.sql migration adds stripeCustomerId to clients and stripe_payment_intent_id, stripe_refund_id, payment_failure_reason to invoices - Removed duplicate 0027_stripe_identifiers.sql Co-Authored-By: Paperclip <noreply@paperclip.ing> * GRO-607: Install Stripe frontend packages Co-Authored-By: Paperclip <noreply@paperclip.ing> * GRO-607: Add /portal/config endpoint + rename date field Co-Authored-By: Paperclip <noreply@paperclip.ing> * GRO-607: Replace mock payment flow with real Stripe Elements Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(GRO-607): Stripe Elements payment UI - lint/type fixes Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(GRO-607): remove unused eslint-disable directive in CustomerPortal Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(GRO-607): CTO review fixes — payment security and correctness - Fix multi-invoice total calculation: use inArray() instead of eq() on single ID, sum all invoices not just first - Add ownership check to payment method deletion: verify the payment method belongs to the authenticated Stripe customer before detaching - Remove duplicate /config endpoint in portal.ts - Fix webhook Stripe client: use getStripeClient() from payment service instead of constructing with WEBHOOK_SECRET - Remove unnecessary body validator on /invoices/:id/pay route - Export getStripeClient() for use by stripe-webhooks.ts - Add inArray import to payment.ts Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit was merged in pull request #275.
This commit is contained in:
committed by
GitHub
parent
4f6a1e8149
commit
c438f5772c
@@ -35,6 +35,12 @@ portalRouter.get("/me", async (c) => {
|
||||
return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone });
|
||||
});
|
||||
|
||||
portalRouter.get("/config", async (c) => {
|
||||
return c.json({
|
||||
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "",
|
||||
});
|
||||
});
|
||||
|
||||
portalRouter.get("/services", async (c) => {
|
||||
const db = getDb();
|
||||
const allServices = await db.select().from(services).where(eq(services.active, true));
|
||||
@@ -123,7 +129,7 @@ portalRouter.get("/invoices", async (c) => {
|
||||
id: inv.id,
|
||||
status: inv.status,
|
||||
totalCents: inv.totalCents,
|
||||
createdAt: inv.createdAt,
|
||||
date: inv.createdAt,
|
||||
lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })),
|
||||
})));
|
||||
});
|
||||
@@ -448,6 +454,113 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Payment routes ───────────────────────────────────────────────────────────
|
||||
|
||||
import {
|
||||
createPaymentIntent,
|
||||
listPaymentMethods,
|
||||
detachPaymentMethod,
|
||||
createSetupIntent,
|
||||
getOrCreateStripeCustomer,
|
||||
getStripeClient,
|
||||
} from "../services/payment.js";
|
||||
|
||||
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];
|
||||
if (!firstInvoice) return c.json({ error: "No invoices found" }, 400);
|
||||
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 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 });
|
||||
});
|
||||
|
||||
// ─── 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.
|
||||
|
||||
Reference in New Issue
Block a user