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>
This commit is contained in:
@@ -28,6 +28,7 @@ import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSup
|
|||||||
import { devRouter } from "./routes/dev.js";
|
import { devRouter } from "./routes/dev.js";
|
||||||
import { adminSeedRouter } from "./routes/admin/seed.js";
|
import { adminSeedRouter } from "./routes/admin/seed.js";
|
||||||
import { startReminderScheduler } from "./services/reminders.js";
|
import { startReminderScheduler } from "./services/reminders.js";
|
||||||
|
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -50,6 +51,9 @@ app.route("/api/book", bookRouter);
|
|||||||
// Public portal routes — client-facing, authenticated via impersonation session header
|
// Public portal routes — client-facing, authenticated via impersonation session header
|
||||||
app.route("/api/portal", portalRouter);
|
app.route("/api/portal", portalRouter);
|
||||||
|
|
||||||
|
// Public Stripe webhook endpoint — signature-verified, no auth required
|
||||||
|
app.route("/api/webhooks/stripe", webhooksRouter);
|
||||||
|
|
||||||
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
||||||
app.route("/api/dev", devRouter);
|
app.route("/api/dev", devRouter);
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import {
|
|||||||
clients,
|
clients,
|
||||||
sql,
|
sql,
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const invoicesRouter = new Hono();
|
export const invoicesRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
const createInvoiceSchema = z.object({
|
const createInvoiceSchema = z.object({
|
||||||
appointmentId: z.string().uuid().optional(),
|
appointmentId: z.string().uuid().optional(),
|
||||||
|
|||||||
@@ -453,7 +453,6 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
|||||||
import {
|
import {
|
||||||
createPaymentIntent,
|
createPaymentIntent,
|
||||||
listPaymentMethods,
|
listPaymentMethods,
|
||||||
attachPaymentMethod,
|
|
||||||
detachPaymentMethod,
|
detachPaymentMethod,
|
||||||
createSetupIntent,
|
createSetupIntent,
|
||||||
getOrCreateStripeCustomer,
|
getOrCreateStripeCustomer,
|
||||||
@@ -530,6 +529,7 @@ portalRouter.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const firstInvoice = invoiceRows[0];
|
const firstInvoice = invoiceRows[0];
|
||||||
|
if (!firstInvoice) return c.json({ error: "No invoices found" }, 400);
|
||||||
const allSameClient = invoiceRows.every(inv => inv.clientId === firstInvoice.clientId);
|
const allSameClient = invoiceRows.every(inv => inv.clientId === firstInvoice.clientId);
|
||||||
if (!allSameClient) {
|
if (!allSameClient) {
|
||||||
return c.json({ error: "All invoices must belong to the same client" }, 422);
|
return c.json({ error: "All invoices must belong to the same client" }, 422);
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
import { eq, getDb, invoices } from "@groombook/db";
|
||||||
|
|
||||||
|
export const webhooksRouter = new Hono();
|
||||||
|
|
||||||
|
webhooksRouter.post("/stripe", async (c) => {
|
||||||
|
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
return c.json({ error: "Webhook secret not configured" }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = c.req.header("stripe-signature");
|
||||||
|
if (!signature) {
|
||||||
|
return c.json({ error: "Missing signature" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawBody: string;
|
||||||
|
try {
|
||||||
|
rawBody = await c.req.text();
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "Could not read body" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = new Stripe(secret, { apiVersion: "2026-03-25.dahlia" });
|
||||||
|
|
||||||
|
let event: Stripe.Event;
|
||||||
|
try {
|
||||||
|
event = stripe.webhooks.constructEvent(rawBody, signature, secret);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Invalid signature";
|
||||||
|
return c.json({ error: message }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
if (event.type === "payment_intent.succeeded") {
|
||||||
|
const pi = event.data.object as Stripe.PaymentIntent;
|
||||||
|
if (pi.metadata?.groombook_invoice_ids) {
|
||||||
|
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||||
|
for (const invoiceId of invoiceIds) {
|
||||||
|
if (!invoiceId) continue;
|
||||||
|
const [inv] = await db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.id, invoiceId))
|
||||||
|
.limit(1);
|
||||||
|
if (!inv) continue;
|
||||||
|
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
||||||
|
await db
|
||||||
|
.update(invoices)
|
||||||
|
.set({
|
||||||
|
status: "paid",
|
||||||
|
paymentMethod: "card",
|
||||||
|
paidAt: new Date(),
|
||||||
|
stripePaymentIntentId: pi.id,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(invoices.id, invoiceId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (event.type === "payment_intent.payment_failed") {
|
||||||
|
const pi = event.data.object as Stripe.PaymentIntent;
|
||||||
|
if (pi.metadata?.groombook_invoice_ids) {
|
||||||
|
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||||
|
for (const invoiceId of invoiceIds) {
|
||||||
|
if (!invoiceId) continue;
|
||||||
|
await db
|
||||||
|
.update(invoices)
|
||||||
|
.set({
|
||||||
|
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(invoices.id, invoiceId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (event.type === "charge.refunded") {
|
||||||
|
const charge = event.data.object as Stripe.Charge;
|
||||||
|
if (typeof charge.payment_intent === "string" && charge.payment_intent) {
|
||||||
|
const [inv] = await db
|
||||||
|
.select({ id: invoices.id })
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.stripePaymentIntentId, charge.payment_intent))
|
||||||
|
.limit(1);
|
||||||
|
if (inv) {
|
||||||
|
const refundId =
|
||||||
|
typeof charge.refunded === "boolean" && charge.refunded
|
||||||
|
? `ch_${charge.id}_refund`
|
||||||
|
: null;
|
||||||
|
await db
|
||||||
|
.update(invoices)
|
||||||
|
.set({
|
||||||
|
status: "void",
|
||||||
|
stripeRefundId: refundId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(invoices.id, inv.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (event.type === "charge.dispute.created") {
|
||||||
|
const dispute = event.data.object as Stripe.Dispute;
|
||||||
|
console.error(
|
||||||
|
`[Stripe Webhook] Dispute created for payment intent: ${dispute.payment_intent}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ received: true });
|
||||||
|
});
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
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 });
|
|
||||||
});
|
|
||||||
@@ -43,11 +43,13 @@ export async function createPaymentIntent(
|
|||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const invoiceIds = Array.isArray(invoiceIdOrIds) ? invoiceIdOrIds : [invoiceIdOrIds];
|
const invoiceIds = Array.isArray(invoiceIdOrIds) ? invoiceIdOrIds : [invoiceIdOrIds];
|
||||||
|
const firstInvoiceId = invoiceIds[0];
|
||||||
|
if (!firstInvoiceId) return null;
|
||||||
|
|
||||||
const invoiceRows = await db
|
const invoiceRows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(eq(invoices.id, invoiceIds[0]));
|
.where(eq(invoices.id, firstInvoiceId));
|
||||||
|
|
||||||
const [invoice] = invoiceRows;
|
const [invoice] = invoiceRows;
|
||||||
if (!invoice) return null;
|
if (!invoice) return null;
|
||||||
@@ -57,7 +59,7 @@ export async function createPaymentIntent(
|
|||||||
const allInvoices = await db
|
const allInvoices = await db
|
||||||
.select({ totalCents: invoices.totalCents })
|
.select({ totalCents: invoices.totalCents })
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(eq(invoices.id, invoiceIds[0]));
|
.where(eq(invoices.id, firstInvoiceId));
|
||||||
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, totalCents);
|
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, totalCents);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,10 +84,10 @@ export async function createPaymentIntent(
|
|||||||
.where(eq(invoices.id, invId));
|
.where(eq(invoices.id, invId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const clientSecret = paymentIntent.client_secret;
|
||||||
clientSecret: paymentIntent.client_secret!,
|
if (!clientSecret) return null;
|
||||||
paymentIntentId: paymentIntent.id,
|
|
||||||
};
|
return { clientSecret, paymentIntentId: paymentIntent.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function processRefund(
|
export async function processRefund(
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE "clients" ADD COLUMN "stripe_customer_id" text;
|
||||||
|
ALTER TABLE "clients" ADD CONSTRAINT "idx_clients_stripe_customer_id" UNIQUE("stripe_customer_id");
|
||||||
|
ALTER TABLE "invoices" ADD COLUMN "stripe_payment_intent_id" text;
|
||||||
|
ALTER TABLE "invoices" ADD COLUMN "stripe_refund_id" text;
|
||||||
|
ALTER TABLE "invoices" ADD COLUMN "payment_failure_reason" text;
|
||||||
|
ALTER TABLE "invoices" ADD CONSTRAINT "idx_invoices_stripe_payment_intent_id" UNIQUE("stripe_payment_intent_id");
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
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");
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"id": "0027_stripe_identifiers",
|
"id": "0026_stripe_payment",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
"tables": {
|
"tables": {
|
||||||
@@ -183,6 +183,13 @@
|
|||||||
"when": 1775482467192,
|
"when": 1775482467192,
|
||||||
"tag": "0025_rate_limit",
|
"tag": "0025_rate_limit",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775568867192,
|
||||||
|
"tag": "0026_stripe_payment",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -71,6 +71,7 @@ export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
|
|||||||
address: "1 Main St, Springfield, CA 90000",
|
address: "1 Main St, Springfield, CA 90000",
|
||||||
notes: null,
|
notes: null,
|
||||||
emailOptOut: false,
|
emailOptOut: false,
|
||||||
|
stripeCustomerId: null,
|
||||||
status: "active",
|
status: "active",
|
||||||
disabledAt: null,
|
disabledAt: null,
|
||||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
|
|||||||
@@ -109,8 +109,8 @@ export const clients = pgTable("clients", {
|
|||||||
phone: text("phone"),
|
phone: text("phone"),
|
||||||
address: text("address"),
|
address: text("address"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
// Set to true if the client has opted out of email reminders/notifications
|
|
||||||
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
||||||
|
stripeCustomerId: text("stripe_customer_id"),
|
||||||
status: clientStatusEnum("status").notNull().default("active"),
|
status: clientStatusEnum("status").notNull().default("active"),
|
||||||
disabledAt: timestamp("disabled_at"),
|
disabledAt: timestamp("disabled_at"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
@@ -251,6 +251,9 @@ export const invoices = pgTable(
|
|||||||
status: invoiceStatusEnum("status").notNull().default("draft"),
|
status: invoiceStatusEnum("status").notNull().default("draft"),
|
||||||
paymentMethod: paymentMethodEnum("payment_method"),
|
paymentMethod: paymentMethodEnum("payment_method"),
|
||||||
paidAt: timestamp("paid_at"),
|
paidAt: timestamp("paid_at"),
|
||||||
|
stripePaymentIntentId: text("stripe_payment_intent_id"),
|
||||||
|
stripeRefundId: text("stripe_refund_id"),
|
||||||
|
paymentFailureReason: text("payment_failure_reason"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
@@ -259,6 +262,7 @@ export const invoices = pgTable(
|
|||||||
index("idx_invoices_client_id").on(t.clientId),
|
index("idx_invoices_client_id").on(t.clientId),
|
||||||
index("idx_invoices_status").on(t.status),
|
index("idx_invoices_status").on(t.status),
|
||||||
index("idx_invoices_created_at").on(t.createdAt),
|
index("idx_invoices_created_at").on(t.createdAt),
|
||||||
|
index("idx_invoices_stripe_payment_intent_id").on(t.stripePaymentIntentId),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Generated
+16
@@ -40,6 +40,9 @@ importers:
|
|||||||
nodemailer:
|
nodemailer:
|
||||||
specifier: ^6.9.16
|
specifier: ^6.9.16
|
||||||
version: 6.10.1
|
version: 6.10.1
|
||||||
|
stripe:
|
||||||
|
specifier: ^22.0.0
|
||||||
|
version: 22.0.1(@types/node@22.19.15)
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
@@ -4124,6 +4127,15 @@ packages:
|
|||||||
strip-literal@3.1.0:
|
strip-literal@3.1.0:
|
||||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
||||||
|
|
||||||
|
stripe@22.0.1:
|
||||||
|
resolution: {integrity: sha512-Yw764pZ6s8Xu4CtUZdD5uWOkw6gc9xzO9OKylCuj1gMhMDLbyGbDtaPNNSFE4mB6njYSHESYIVbF1iIzUfAl2g==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
strnum@2.2.1:
|
strnum@2.2.1:
|
||||||
resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==}
|
resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==}
|
||||||
|
|
||||||
@@ -8774,6 +8786,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 9.0.1
|
js-tokens: 9.0.1
|
||||||
|
|
||||||
|
stripe@22.0.1(@types/node@22.19.15):
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 22.19.15
|
||||||
|
|
||||||
strnum@2.2.1: {}
|
strnum@2.2.1: {}
|
||||||
|
|
||||||
supports-color@7.2.0:
|
supports-color@7.2.0:
|
||||||
|
|||||||
Reference in New Issue
Block a user