c438f5772c
* 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>
165 lines
4.6 KiB
TypeScript
165 lines
4.6 KiB
TypeScript
import Stripe from "stripe";
|
|
import { getDb, clients, eq, inArray, invoices } from "@groombook/db";
|
|
|
|
let _stripe: Stripe | null | undefined;
|
|
|
|
export 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 firstInvoiceId = invoiceIds[0];
|
|
if (!firstInvoiceId) return null;
|
|
|
|
const invoiceRows = await db
|
|
.select()
|
|
.from(invoices)
|
|
.where(eq(invoices.id, firstInvoiceId));
|
|
|
|
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(inArray(invoices.id, invoiceIds));
|
|
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
const clientSecret = paymentIntent.client_secret;
|
|
if (!clientSecret) return null;
|
|
|
|
return { clientSecret, 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! };
|
|
}
|