From 60f59e5c174029368af42e427d190e43cb714fa4 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 7 Dec 2024 01:45:53 +0000 Subject: [PATCH] Update repository with latest /web content from upstream --- app/api/cron/reset-tokens/route.test.ts | 119 + app/api/cron/reset-tokens/route.ts | 45 + app/api/top-up/route.ts | 34 +- app/api/webhook/handler-factory.ts | 6 +- app/api/webhook/handlers/checkout-complete.ts | 180 +- app/api/webhook/handlers/invoice-paid.ts | 104 +- .../handlers/invoice-payment-failed.ts | 53 + .../handlers/payment-intent-succeeded.ts | 18 +- .../webhook/handlers/subscription-canceled.ts | 48 +- .../webhook/handlers/subscription-updated.ts | 4 +- app/api/webhook/route.ts | 33 +- app/api/webhook/types.ts | 12 +- app/api/webhook/utils.ts | 4 - app/api/webhook/verify.ts | 2 +- app/dashboard/onboarding/page.tsx | 169 +- app/dashboard/pricing/actions.ts | 199 +- app/dashboard/pricing/page.tsx | 92 +- app/layout.tsx | 4 +- app/page.tsx | 18 +- automation.js | 94 + components/pricing-cards.tsx | 139 + components/ui/switch.tsx | 28 + jest.config.ts | 27 + jest.setup.ts | 3 + lib/getUrl.ts | 11 + lib/handleAuthorization.ts | 2 +- lib/services/clerk.ts | 2 +- lib/srm.ts | 12 - package.json | 50 +- pnpm-lock.yaml | 4095 +++++++++++++---- scripts/webhooks.test.ts | 229 + srm.config.ts | 264 +- vercel.json | 8 + 33 files changed, 4675 insertions(+), 1433 deletions(-) create mode 100644 app/api/cron/reset-tokens/route.test.ts create mode 100644 app/api/cron/reset-tokens/route.ts create mode 100644 app/api/webhook/handlers/invoice-payment-failed.ts create mode 100644 automation.js create mode 100644 components/pricing-cards.tsx create mode 100644 components/ui/switch.tsx create mode 100644 jest.config.ts create mode 100644 jest.setup.ts create mode 100644 lib/getUrl.ts delete mode 100644 lib/srm.ts create mode 100644 scripts/webhooks.test.ts create mode 100644 vercel.json diff --git a/app/api/cron/reset-tokens/route.test.ts b/app/api/cron/reset-tokens/route.test.ts new file mode 100644 index 0000000..99a83fe --- /dev/null +++ b/app/api/cron/reset-tokens/route.test.ts @@ -0,0 +1,119 @@ +import { db, UserUsageTable } from "@/drizzle/schema"; +import { PRODUCTS } from "@/srm.config"; +import { eq } from "drizzle-orm"; +import type { Server } from "http"; +import { createServer } from "http"; +import { NextApiHandler } from "next"; +import { GET } from "./route"; +import type { SuperTest, Test } from "supertest"; +import supertest from "supertest"; +/** + * @jest-environment node + */ + +describe("Token Reset Cron Job", () => { + const mockUserId = "test-user-123"; + const monthlyTokenLimit = 5000 * 1000; // 5M tokens + let server: Server; + let request: SuperTest; + + beforeAll(() => { + const handler: NextApiHandler = (req, res) => { + if (req.method === "GET") { + return GET(req as any); + } + }; + server = createServer(handler as any); + request = supertest(server); + }); + + afterAll((done) => { + server.close(done); + }); + + beforeEach(async () => { + // Setup test data + await db.insert(UserUsageTable).values({ + userId: mockUserId, + subscriptionStatus: "active", + paymentStatus: "paid", + tokenUsage: 1000000, // 1M tokens used + maxTokenUsage: monthlyTokenLimit, + billingCycle: "subscription", + currentPlan: PRODUCTS.SubscriptionMonthly.metadata.plan, + }); + }); + + afterEach(async () => { + // Cleanup test data + await db.delete(UserUsageTable).where(eq(UserUsageTable.userId, mockUserId)); + }); + + it("should reset token usage for active subscribers", async () => { + const response = await request + .get("/api/cron/reset-tokens") + .set("authorization", `Bearer ${process.env.CRON_SECRET}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + success: true, + message: "Token usage reset successful", + }); + + // Verify token usage was reset + const userUsage = await db + .select() + .from(UserUsageTable) + .where(eq(UserUsageTable.userId, mockUserId)); + + expect(userUsage[0].tokenUsage).toBe(0); + expect(userUsage[0].maxTokenUsage).toBe(monthlyTokenLimit); + }); + + it("should not reset tokens for inactive subscriptions", async () => { + // Update user to inactive + await db + .update(UserUsageTable) + .set({ subscriptionStatus: "inactive" }) + .where(eq(UserUsageTable.userId, mockUserId)); + + const response = await request + .get("/api/cron/reset-tokens") + .set("authorization", `Bearer ${process.env.CRON_SECRET}`); + + expect(response.status).toBe(200); + + // Verify token usage was not reset + const userUsage = await db + .select() + .from(UserUsageTable) + .where(eq(UserUsageTable.userId, mockUserId)); + + expect(userUsage[0].tokenUsage).toBe(1000000); // Should remain unchanged + }); + + it("should return 401 for unauthorized requests", async () => { + const response = await request + .get("/api/cron/reset-tokens") + .set("authorization", "Bearer invalid-token"); + + expect(response.status).toBe(401); + }); + + it("should handle database errors gracefully", async () => { + // Mock a database error + jest.spyOn(db, "update").mockRejectedValueOnce( + new Error("Database error") as never + ); + + const response = await request + .get("/api/cron/reset-tokens") + .set("authorization", `Bearer ${process.env.CRON_SECRET}`); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ + success: false, + error: "Failed to reset token usage", + }); + }); +}); \ No newline at end of file diff --git a/app/api/cron/reset-tokens/route.ts b/app/api/cron/reset-tokens/route.ts new file mode 100644 index 0000000..59f9fbf --- /dev/null +++ b/app/api/cron/reset-tokens/route.ts @@ -0,0 +1,45 @@ +import { db, UserUsageTable } from "@/drizzle/schema"; +import { PRODUCTS } from "@/srm.config"; +import { eq, and } from "drizzle-orm"; +import { NextResponse } from "next/server"; + +export const runtime = "edge"; + +async function resetTokenUsage() { + const monthlyTokenLimit = 5000 * 1000; // 5M tokens + + // Reset tokens for active subscribers with valid plans + await db + .update(UserUsageTable) + .set({ + tokenUsage: 0, + maxTokenUsage: monthlyTokenLimit, + }) + .where( + and( + eq(UserUsageTable.subscriptionStatus, "active"), + eq(UserUsageTable.paymentStatus, "paid") + ) + ); + + return { success: true, message: "Token usage reset successful" }; +} + +export async function GET(request: Request) { + try { + // Verify that the request is coming from Vercel Cron + const authHeader = request.headers.get("authorization"); + if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const result = await resetTokenUsage(); + return NextResponse.json(result); + } catch (error) { + console.error("Error resetting token usage:", error); + return NextResponse.json( + { success: false, error: "Failed to reset token usage" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/top-up/route.ts b/app/api/top-up/route.ts index 6fe581c..6b94025 100644 --- a/app/api/top-up/route.ts +++ b/app/api/top-up/route.ts @@ -4,10 +4,11 @@ import { getToken, handleAuthorizationV2 } from "@/lib/handleAuthorization"; import { createAnonymousUser } from "../anon"; import { createLicenseKeyFromUserId } from "@/app/actions"; import { createEmptyUserUsage } from "@/drizzle/schema"; -import { getTargetUrl } from "@/srm.config"; - +import { config, PRICES } from "@/srm.config"; +import { getUrl } from "@/lib/getUrl"; + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { - apiVersion: "2022-11-15", + apiVersion: "2024-06-20", }); async function createFallbackUser() { @@ -24,7 +25,7 @@ async function createFallbackUser() { async function ensureAuthorizedUser(req: NextRequest) { const initialLicenseKey = getToken(req); - + try { const { userId } = await handleAuthorizationV2(req); return { userId, licenseKey: initialLicenseKey }; @@ -36,7 +37,7 @@ async function ensureAuthorizedUser(req: NextRequest) { export async function POST(req: NextRequest) { let userId, licenseKey; - + try { ({ userId, licenseKey } = await ensureAuthorizedUser(req)); } catch (error) { @@ -45,20 +46,18 @@ export async function POST(req: NextRequest) { { status: 401 } ); } - - const baseUrl = getTargetUrl(); - const targetUrl = - baseUrl === "localhost:3000" ? `http://${baseUrl}` : `https://${baseUrl}`; + + const baseUrl = getUrl(); + console.log("baseUrl", baseUrl); const session = await stripe.checkout.sessions.create({ payment_method_types: ["card"], payment_intent_data: { metadata: { userId, - type: "top_up", + type: config.products.PayOnceTopUp.metadata.type, + plan: config.products.PayOnceTopUp.metadata.plan, tokens: "5000000", // 5M tokens - price_key: "top_up_5m", - product_key: "top_up_5m", }, }, line_items: [ @@ -69,21 +68,20 @@ export async function POST(req: NextRequest) { name: "5M Tokens Top-up", description: "One-time purchase of 5M additional tokens", }, - unit_amount: 1500, // $15 in cents + unit_amount: PRICES.TOP_UP, }, quantity: 1, }, ], mode: "payment", - success_url: `${targetUrl}/top-up-success`, - cancel_url: `${targetUrl}/top-up-cancelled`, + success_url: `${baseUrl}/top-up-success`, + cancel_url: `${baseUrl}/top-up-cancelled`, allow_promotion_codes: true, metadata: { userId, - type: "top_up", + type: config.products.PayOnceTopUp.metadata.type, + plan: config.products.PayOnceTopUp.metadata.plan, tokens: "5000000", // 5M tokens - price_key: "top_up_5m", - product_key: "top_up_5m", }, }); diff --git a/app/api/webhook/handler-factory.ts b/app/api/webhook/handler-factory.ts index fad78a3..653ac2e 100644 --- a/app/api/webhook/handler-factory.ts +++ b/app/api/webhook/handler-factory.ts @@ -16,9 +16,9 @@ export function createWebhookHandler( if (options.requiredMetadata) { const metadata = event.data.object.metadata || {}; const missingFields = options.requiredMetadata.filter( - field => !metadata[field] + (field) => !metadata[field] ); - + if (missingFields.length > 0) { throw new Error( `Missing required metadata fields: ${missingFields.join(", ")}` @@ -53,4 +53,4 @@ export function createWebhookHandler( }; } }; -} \ No newline at end of file +} diff --git a/app/api/webhook/handlers/checkout-complete.ts b/app/api/webhook/handlers/checkout-complete.ts index a8be344..7455345 100644 --- a/app/api/webhook/handlers/checkout-complete.ts +++ b/app/api/webhook/handlers/checkout-complete.ts @@ -1,46 +1,160 @@ +import { UserUsageTable, db } from "@/drizzle/schema"; import { createWebhookHandler } from "../handler-factory"; -import { CustomerData } from "../types"; -import { updateClerkMetadata } from "@/lib/services/clerk"; import { trackLoopsEvent } from "@/lib/services/loops"; import Stripe from "stripe"; +import { config, ProductMetadata } from "@/srm.config"; +import { sql } from "drizzle-orm"; -function createCustomerDataFromSession( - session: Stripe.Checkout.Session -): CustomerData { - return { - userId: session.metadata?.userId, - customerId: session.customer?.toString(), - status: session.status, - paymentStatus: session.payment_status, - billingCycle: session.mode === "subscription" ? "monthly" : "lifetime", - product: session.metadata?.product_key || "default", - plan: session.metadata?.price_key || "default", +// sample yearly metadata +// "metadata": { +// "plan": +// "yearly", +// "type": +// "subscription", +// "userId": +// "user_2fxYYN5l4R3BkYc2UW4yuMTHj2G", +// }, + +const handleSubscription = async ( + session: Stripe.Checkout.Session & { metadata: ProductMetadata } +) => { + const metadata = session.metadata; + console.log("creating subscription with metadata", metadata); + + // insert or update + await db + .insert(UserUsageTable) + .values({ + userId: metadata.userId, + subscriptionStatus: "active", + paymentStatus: "paid", + maxTokenUsage: 5000 * 1000, + billingCycle: metadata.type, + lastPayment: new Date(), + currentPlan: metadata.plan, + currentProduct: metadata.type, + }) + .onConflictDoUpdate({ + target: [UserUsageTable.userId], + set: { + lastPayment: new Date(), + currentPlan: metadata.plan, + currentProduct: metadata.type, + }, + }); +}; + +const handlePayOnce = async ( + session: Stripe.Checkout.Session & { metadata: ProductMetadata } +) => { + const metadata = session.metadata; + console.log("creating pay once with metadata", metadata); + await db.insert(UserUsageTable).values({ + userId: metadata.userId, + subscriptionStatus: "active", + paymentStatus: "paid", + maxTokenUsage: 0, + billingCycle: metadata.type, lastPayment: new Date(), - createdAt: new Date(session.created * 1000), - }; + currentPlan: metadata.plan, + currentProduct: metadata.type, + }) + .onConflictDoUpdate({ + target: [UserUsageTable.userId], + set: { + lastPayment: new Date(), + }, + }); +}; +async function handleTopUp(userId: string, tokens: number) { + console.log("Handling top-up for user", userId, "with", tokens, "tokens"); + + await db + .insert(UserUsageTable) + .values({ + userId, + maxTokenUsage: tokens, + tokenUsage: 0, + subscriptionStatus: "active", + paymentStatus: "succeeded", + currentProduct: config.products.PayOnceTopUp.metadata.type, + currentPlan: config.products.PayOnceTopUp.metadata.plan, + billingCycle: config.products.PayOnceTopUp.metadata.type, + lastPayment: new Date(), + }) + .onConflictDoUpdate({ + target: [UserUsageTable.userId], + set: { + maxTokenUsage: sql`COALESCE(${UserUsageTable.maxTokenUsage}, 0) + ${tokens}`, + lastPayment: new Date(), + subscriptionStatus: "active", + paymentStatus: "succeeded", + }, + }); } -// focused on updating non-critical data like sending emails and tracking events -// most of the decisions are made either in payment intent , invoice-paid, subscription-updated. -export const handleCheckoutComplete = createWebhookHandler( - async (event) => { - const session = event.data.object as Stripe.Checkout.Session; - const customerData = createCustomerDataFromSession(session); - await updateClerkMetadata(customerData); +export const handleCheckoutComplete = createWebhookHandler(async (event) => { + const session = event.data.object as Stripe.Checkout.Session; + console.log("checkout complete", session); + + // Validate required metadata + if (!session.metadata?.userId) { + throw new Error("Missing required userId in metadata"); + } + if (!session.metadata?.type) { + throw new Error("Missing required type in metadata"); + } + if (!session.metadata?.plan) { + throw new Error("Missing required plan in metadata"); + } + + // either yearly or monthly subscription + if ( + session.metadata?.plan === + config.products.SubscriptionYearly.metadata.plan || + session.metadata?.plan === config.products.SubscriptionMonthly.metadata.plan + ) { + await handleSubscription( + session as Stripe.Checkout.Session & { metadata: ProductMetadata } + ); + } + // either pay once year or pay once lifetime + if ( + session.metadata?.plan === config.products.PayOnceOneYear.metadata.plan || + session.metadata?.plan === config.products.PayOnceLifetime.metadata.plan + ) { + await handlePayOnce( + session as Stripe.Checkout.Session & { metadata: ProductMetadata } + ); + } + // pay once top up + if (session.metadata?.plan === config.products.PayOnceTopUp.metadata.plan) { + console.log("handling top-up", session.metadata); + if (!session.metadata.tokens) { + throw new Error("Missing required tokens in metadata"); + } + await handleTopUp( + session.metadata.userId, + parseInt(session.metadata.tokens) + ); + } + + if (session.customer_details?.email) { await trackLoopsEvent({ - email: session.customer_details?.email || "", + email: session.customer_details.email, firstName: session.customer_details?.name?.split(" ")[0], lastName: session.customer_details?.name?.split(" ").slice(1).join(" "), - userId: customerData.userId, + userId: session.metadata?.userId, eventName: "checkout_completed", + data: { + type: session.metadata?.type, + plan: session.metadata?.plan, + }, }); - - return { - success: true, - message: `Successfully processed checkout for ${customerData.userId}`, - }; - }, - { - requiredMetadata: ["userId", "product_key", "price_key"], } -); + + return { + success: true, + message: `Successfully processed checkout for ${session.metadata?.userId}`, + }; +}); diff --git a/app/api/webhook/handlers/invoice-paid.ts b/app/api/webhook/handlers/invoice-paid.ts index 9ae75e1..adb8e6a 100644 --- a/app/api/webhook/handlers/invoice-paid.ts +++ b/app/api/webhook/handlers/invoice-paid.ts @@ -1,13 +1,13 @@ -import { createWebhookHandler } from '../handler-factory'; -import { CustomerData } from '../types'; -import { db, UserUsageTable } from '@/drizzle/schema'; -import { eq } from 'drizzle-orm'; -import { updateUserSubscriptionData } from '../utils'; -import { trackLoopsEvent } from '@/lib/services/loops'; -import Stripe from 'stripe'; +import { createWebhookHandler } from "../handler-factory"; +import { CustomerData } from "../types"; +import { db, UserUsageTable } from "@/drizzle/schema"; +import { eq } from "drizzle-orm"; +import { updateUserSubscriptionData } from "../utils"; +import { trackLoopsEvent } from "@/lib/services/loops"; +import Stripe from "stripe"; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { - apiVersion: '2022-11-15', + apiVersion: "2024-06-20", }); async function resetUserUsageAndSetLastPayment(userId: string) { @@ -22,58 +22,62 @@ async function resetUserUsageAndSetLastPayment(userId: string) { .where(eq(UserUsageTable.userId, userId)); } -async function getSrmPriceKey(invoice: Stripe.Invoice) { - return invoice.lines.data[0].price?.metadata?.srm_price_key || "default"; -} - -async function getStripeProduct(invoice: Stripe.Invoice) { - const product = await stripe.products.retrieve( - invoice.lines.data[0].price?.product as string - ); - return product; -} - -async function getSrmProductKey(invoice: Stripe.Invoice) { - const product = await getStripeProduct(invoice); - return product.metadata?.srm_product_key || "default"; -} -function createCustomerDataFromInvoice( - invoice: Stripe.Invoice, - priceKey: string, - productKey: string -): CustomerData { - return { - userId: invoice.subscription_details?.metadata?.userId, - customerId: invoice.customer.toString(), - status: invoice.status, - billingCycle: priceKey as "monthly" | "lifetime" | "yearly", - paymentStatus: invoice.status, - product: productKey, - plan: priceKey, - lastPayment: new Date(), - }; -} export const handleInvoicePaid = createWebhookHandler( async (event) => { const invoice = event.data.object as Stripe.Invoice; - const priceKey = await getSrmPriceKey(invoice); - const productKey = await getSrmProductKey(invoice); - - const customerData = createCustomerDataFromInvoice(invoice, priceKey, productKey); - - await updateUserSubscriptionData(customerData); + console.log("invoice paid", invoice); + if (!invoice.subscription_details) { + return { + success: false, + message: "No subscription details found", + }; + } + const metadata = invoice.subscription_details.metadata; + + await db + .insert(UserUsageTable) + .values({ + userId: metadata?.userId, + subscriptionStatus: invoice.status, + paymentStatus: invoice.status, + billingCycle: metadata?.type as + | "monthly" + | "yearly" + | "lifetime", + maxTokenUsage: 5000 * 1000, + lastPayment: new Date(), + currentProduct: metadata?.product, + currentPlan: metadata?.plan, + }) + .onConflictDoUpdate({ + target: [UserUsageTable.userId], + set: { + subscriptionStatus: invoice.status, + paymentStatus: invoice.status, + maxTokenUsage: 5000 * 1000, + billingCycle: metadata?.type as + | "monthly" + | "yearly" + | "lifetime", + lastPayment: new Date(), + currentProduct: metadata?.product, + currentPlan: metadata?.plan, + }, + }); + await resetUserUsageAndSetLastPayment(invoice.metadata?.userId); await trackLoopsEvent({ - email: invoice.customer_email || '', - userId: customerData.userId, - eventName: 'invoice_paid', + email: invoice.customer_email || "", + userId: invoice.metadata?.userId, + eventName: "invoice_paid", data: { amount: invoice.amount_paid, - product: customerData.product, - plan: customerData.plan, + product: + invoice.lines.data[0].price?.metadata?.srm_product_key || "default", + plan: invoice.lines.data[0].price?.metadata?.srm_price_key || "default", }, }); diff --git a/app/api/webhook/handlers/invoice-payment-failed.ts b/app/api/webhook/handlers/invoice-payment-failed.ts new file mode 100644 index 0000000..4d0c5ef --- /dev/null +++ b/app/api/webhook/handlers/invoice-payment-failed.ts @@ -0,0 +1,53 @@ +import { db, UserUsageTable } from "@/drizzle/schema"; +import { createWebhookHandler } from "../handler-factory"; +import { updateClerkMetadata } from "@/lib/services/clerk"; +import { trackLoopsEvent } from "@/lib/services/loops"; +import Stripe from "stripe"; +import { eq } from "drizzle-orm"; + +export const handleInvoicePaymentFailed = createWebhookHandler( + async (event) => { + const invoice = event.data.object as Stripe.Invoice; + const userId = invoice.metadata?.userId; + + if (!userId) { + console.warn("No userId found in invoice metadata"); + return { success: true, message: "Skipped invoice without userId" }; + } + // set user to inactive + await db + .update(UserUsageTable) + .set({ + paymentStatus: "payment_failed", + maxTokenUsage: 0, + }) + .where(eq(UserUsageTable.userId, userId)); + + await updateClerkMetadata({ + userId, + customerId: invoice.customer?.toString() || "", + status: "payment_failed", + paymentStatus: invoice.status, + product: "subscription", + plan: "none", + lastPayment: new Date(), + }); + + if (invoice.customer_email) { + await trackLoopsEvent({ + email: invoice.customer_email, + userId, + eventName: "invoice_payment_failed", + data: { + amount: invoice.amount_due, + status: invoice.status, + }, + }); + } + + return { + success: true, + message: `Successfully processed failed payment for ${userId}`, + }; + } +); diff --git a/app/api/webhook/handlers/payment-intent-succeeded.ts b/app/api/webhook/handlers/payment-intent-succeeded.ts index fbaf5f6..efe8e8f 100644 --- a/app/api/webhook/handlers/payment-intent-succeeded.ts +++ b/app/api/webhook/handlers/payment-intent-succeeded.ts @@ -1,24 +1,10 @@ import { createWebhookHandler } from '../handler-factory'; -import { CustomerData, WebhookEvent, WebhookHandlerResponse } from "../types"; +import { CustomerData, } from "../types"; import { db, UserUsageTable } from "@/drizzle/schema"; -import { eq, sql } from "drizzle-orm"; +import { sql } from "drizzle-orm"; import { updateUserSubscriptionData } from "../utils"; import Stripe from "stripe"; -import { trackLoopsEvent } from '@/lib/services/loops'; -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { - apiVersion: "2022-11-15", -}); - -async function getCustomerEmail(customerId: string): Promise { - try { - const customer = await stripe.customers.retrieve(customerId) as Stripe.Customer; - return typeof customer === 'string' ? '' : customer.email || ''; - } catch (error) { - console.error("Error retrieving customer email:", error); - return ''; - } -} async function handleTopUp(userId: string, tokens: number,) { console.log("Handling top-up for user", userId, "with", tokens, "tokens"); diff --git a/app/api/webhook/handlers/subscription-canceled.ts b/app/api/webhook/handlers/subscription-canceled.ts index a955926..507ff5b 100644 --- a/app/api/webhook/handlers/subscription-canceled.ts +++ b/app/api/webhook/handlers/subscription-canceled.ts @@ -1,12 +1,14 @@ -import { createWebhookHandler } from '../handler-factory'; -import { WebhookEvent, WebhookHandlerResponse, CustomerData } from '../types'; -import { updateClerkMetadata } from '@/lib/services/clerk'; -import { db, UserUsageTable } from '@/drizzle/schema'; -import { eq } from 'drizzle-orm'; -import { updateUserSubscriptionData } from '../utils'; +import { createWebhookHandler } from "../handler-factory"; +import { WebhookEvent, WebhookHandlerResponse, CustomerData } from "../types"; +import { updateClerkMetadata } from "@/lib/services/clerk"; +import { db, UserUsageTable } from "@/drizzle/schema"; +import { eq } from "drizzle-orm"; +import { updateUserSubscriptionData } from "../utils"; +import Stripe from "stripe"; function getSubscriptionProduct(subscription: any): string | null { - const productKey = subscription.items?.data?.[0]?.price?.product?.metadata?.srm_product_key; + const productKey = + subscription.items?.data?.[0]?.price?.product?.metadata?.srm_product_key; return productKey || null; } @@ -15,26 +17,32 @@ function getSubscriptionPrice(subscription: any): string | null { } async function deleteUserSubscriptionData(userId: string) { - await db.update(UserUsageTable).set({ - subscriptionStatus: 'canceled', - paymentStatus: 'canceled', - }).where(eq(UserUsageTable.userId, userId)); + await db + .update(UserUsageTable) + .set({ + subscriptionStatus: "canceled", + paymentStatus: "canceled", + }) + .where(eq(UserUsageTable.userId, userId)); } export const handleSubscriptionCanceled = createWebhookHandler( - async (event) => { + async (event: Stripe.CustomerSubscriptionDeletedEvent) => { const subscription = event.data.object; const userId = subscription.metadata?.userId; - + await deleteUserSubscriptionData(userId); const customerData: CustomerData = { userId, - customerId: subscription.customer, - status: 'canceled', - paymentStatus: 'canceled', - product: getSubscriptionProduct(subscription) || 'none', - plan: getSubscriptionPrice(subscription) || 'none', + customerId: + typeof subscription.customer === "string" + ? subscription.customer + : subscription.customer.id, + status: "canceled", + paymentStatus: "canceled", + product: getSubscriptionProduct(subscription) || "none", + plan: getSubscriptionPrice(subscription) || "none", lastPayment: new Date(), }; @@ -47,6 +55,6 @@ export const handleSubscriptionCanceled = createWebhookHandler( }; }, { - requiredMetadata: ['userId'], + requiredMetadata: ["userId"], } -); \ No newline at end of file +); diff --git a/app/api/webhook/handlers/subscription-updated.ts b/app/api/webhook/handlers/subscription-updated.ts index f9d998c..1e0b7de 100644 --- a/app/api/webhook/handlers/subscription-updated.ts +++ b/app/api/webhook/handlers/subscription-updated.ts @@ -1,12 +1,12 @@ import { createWebhookHandler } from '../handler-factory'; -import { WebhookEvent, WebhookHandlerResponse, CustomerData } from '../types'; +import { CustomerData } from '../types'; import { updateClerkMetadata } from '@/lib/services/clerk'; import { updateUserSubscriptionData } from '../utils'; import Stripe from 'stripe'; import { trackLoopsEvent } from '@/lib/services/loops'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, { - apiVersion: '2022-11-15', + apiVersion: '2024-06-20', }); const getStripeProduct = async (productId: string) => { diff --git a/app/api/webhook/route.ts b/app/api/webhook/route.ts index 2a0550d..4c4a112 100644 --- a/app/api/webhook/route.ts +++ b/app/api/webhook/route.ts @@ -5,13 +5,16 @@ import { handleSubscriptionCanceled } from "./handlers/subscription-canceled"; import { handleCheckoutComplete } from "./handlers/checkout-complete"; import { handleInvoicePaid } from "./handlers/invoice-paid"; import { handlePaymentIntentSucceeded } from "./handlers/payment-intent-succeeded"; +import { handleInvoicePaymentFailed } from "./handlers/invoice-payment-failed"; +import { validateWebhookMetadata } from "@/srm.config"; const HANDLERS = { "checkout.session.completed": handleCheckoutComplete, "customer.subscription.deleted": handleSubscriptionCanceled, - "invoice.paid": handleInvoicePaid, - "payment_intent.succeeded": handlePaymentIntentSucceeded, "customer.subscription.updated": handleSubscriptionUpdated, + // "invoice.paid": handleInvoicePaid, + "invoice.payment_failed": handleInvoicePaymentFailed, + // "payment_intent.succeeded": handlePaymentIntentSucceeded, } as const; export async function POST(req: NextRequest) { @@ -21,43 +24,41 @@ export async function POST(req: NextRequest) { const event = await verifyStripeWebhook(req); const handler = HANDLERS[event.type as keyof typeof HANDLERS]; - if (!handler) { - console.log({ - message: `Unhandled webhook event type: ${event.type}`, - eventId: event.data.object.id, - }); + // Use the validateWebhookMetadata helper from srm.config + const metadata = event.data.object.metadata; + if (metadata && !validateWebhookMetadata(metadata)) { + console.warn(`Invalid metadata for event ${event.type}`); + // Continue processing as some events may not need complete metadata + } - return NextResponse.json({ - status: 200, - message: `Unhandled event type: ${event.type}`, - }); + if (!handler) { + console.log(`Unhandled webhook event type: ${event.type}`); + return NextResponse.json({ message: `Unhandled event type: ${event.type}` }, { status: 200 }); } const result = await handler(event); if (!result.success) { console.error({ - message: "Webhook processing failed", + message: `Webhook ${event.type} processing failed`, error: result.error, - eventId: event.data.object.id, + eventId: event.id, duration: Date.now() - startTime, }); - return NextResponse.json({ error: result.message }, { status: 400 }); } return NextResponse.json({ - status: 200, message: result.message, duration: Date.now() - startTime, }); + } catch (error) { console.error({ message: "Webhook processing error", error, duration: Date.now() - startTime, }); - return NextResponse.json({ error: error.message }, { status: 400 }); } } diff --git a/app/api/webhook/types.ts b/app/api/webhook/types.ts index e914cf7..f87d458 100644 --- a/app/api/webhook/types.ts +++ b/app/api/webhook/types.ts @@ -1,7 +1,17 @@ +import Stripe from "stripe"; + export type WebhookEvent = { + id: string; type: string; data: { - object: any; + object: Stripe.Event.Data.Object & { + id: string; + metadata?: { + userId?: string; + type?: string; + plan?: string; + }; + }; }; }; diff --git a/app/api/webhook/utils.ts b/app/api/webhook/utils.ts index e7acad5..d08c532 100644 --- a/app/api/webhook/utils.ts +++ b/app/api/webhook/utils.ts @@ -1,9 +1,5 @@ import { db, UserUsageTable } from "@/drizzle/schema"; import { CustomerData } from "./types"; -import Stripe from "stripe"; -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { - apiVersion: "2022-11-15", -}); export async function updateUserSubscriptionData( diff --git a/app/api/webhook/verify.ts b/app/api/webhook/verify.ts index 193b6dd..336c6da 100644 --- a/app/api/webhook/verify.ts +++ b/app/api/webhook/verify.ts @@ -3,7 +3,7 @@ import Stripe from "stripe"; import { WebhookEvent } from "./types"; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { - apiVersion: "2022-11-15", + apiVersion: "2024-06-20", }); const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; diff --git a/app/dashboard/onboarding/page.tsx b/app/dashboard/onboarding/page.tsx index 7f751a5..45fd22d 100644 --- a/app/dashboard/onboarding/page.tsx +++ b/app/dashboard/onboarding/page.tsx @@ -1,38 +1,32 @@ "use client"; +import { FileText, Zap, Check } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Check, FileText, Folder, Zap } from "lucide-react"; -import { - createOneTimePaymentCheckout, - createSubscriptionCheckout, - createYearlySubscriptionCheckout, -} from "../pricing/actions"; -import { config } from "@/srm.config"; -import { twMerge } from "tailwind-merge"; +import { PricingCards } from "@/components/pricing-cards"; -export default function OnboardingPage() { - const handlePlanSelection = (planKey: string) => { - switch (planKey) { - case "Monthly": - return createSubscriptionCheckout(); - case "Yearly": - return createYearlySubscriptionCheckout(); - case "Lifetime": - return createOneTimePaymentCheckout(); - } - }; +const FEATURES = [ + { + icon: , + title: "Smart File Organization", + description: "AI-powered sorting and categorization", + }, + { + icon: , + title: "Chat with your files", + description: "Ask questions about your files and get instant answers", + }, + { + icon: , + title: "Image digitization & Audio Transcription", + description: + "Convert your hand-written notes and audio notes to text by simply dropping them into your Obsidian vault", + }, +]; +export default function OnboardingPage() { return (
- {/* Left Column: Welcome & Features */}
@@ -50,32 +44,14 @@ export default function OnboardingPage() { src="https://youtube.com/embed/videoseries?list=PLgRcC-DFR5jcwwg0Dr3gNZrkZxkztraKE&controls=1&rel=0&modestbranding=1" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowFullScreen - > + />

Key Features

- {[ - { - icon: , - title: "Smart File Organization", - description: "AI-powered sorting and categorization", - }, - { - icon: , - title: "Chat with your files", - description: - "Ask questions about your files and get instant answers", - }, - { - icon: , - title: "Image digitization & Audio Transcription", - description: - "Convert your hand-written notes and audio notes to text by simply dropping them into your Obsidian vault", - }, - ].map((feature, index) => ( + {FEATURES.map((feature, index) => (
{feature.icon}
@@ -93,104 +69,13 @@ export default function OnboardingPage() {
{/* Right Column: Pricing */} -
-

Choose Your Plan

- {Object.entries(config.products) - // Sort to put Lifetime first, then Yearly, then Monthly - .sort(([keyA], [keyB]) => { - const order = { Lifetime: 0, Yearly: 1, Monthly: 2 }; - const planA = keyA.replace("Hobby", ""); - const planB = keyB.replace("Hobby", ""); - return ( - order[planA as keyof typeof order] - - order[planB as keyof typeof order] - ); - }) - .map(([key, product]: [string, any]) => { - const planKey = key.replace("Hobby", ""); - const price = product.prices[planKey.toLowerCase()]; - const isLifetime = planKey === "Lifetime"; - - return ( - - {isLifetime ? ( -
- - Best Value - -
- ) : ( - planKey === "Monthly" && ( -
- - First month $9 with code ARIGATO - -
- ) - )} - -
- {planKey} - - {planKey === "Yearly" && ( - - $180 - - )} - ${price.amount / 100} - {price.type === "recurring" && ( - /{price.interval} - )} - -
-
- -
    - {product.features.map( - (feature: string, index: number) => ( -
  • - - {feature} -
  • - ) - )} -
-
- - - -
- ); - })} -
+