From 5298209205bd5f7d30a85c3f5554c95eb511bcdb Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Wed, 4 Dec 2024 13:35:24 +0100 Subject: [PATCH] test --- .../api/webhook/handlers/checkout-complete.ts | 40 ++- .../handlers/invoice-payment-failed.ts | 43 +++ web/app/api/webhook/route.ts | 32 +- web/app/api/webhook/types.ts | 11 +- web/app/dashboard/onboarding/page.tsx | 169 ++-------- web/app/dashboard/pricing/actions.ts | 181 ++++++++--- web/app/dashboard/pricing/page.tsx | 92 +----- web/app/layout.tsx | 4 +- web/components/pricing-cards.tsx | 139 +++++++++ web/components/ui/switch.tsx | 28 ++ web/package.json | 1 + web/pnpm-lock.yaml | 29 ++ web/srm.config.ts | 291 ++++++++++++------ 13 files changed, 665 insertions(+), 395 deletions(-) create mode 100644 web/app/api/webhook/handlers/invoice-payment-failed.ts create mode 100644 web/components/pricing-cards.tsx create mode 100644 web/components/ui/switch.tsx diff --git a/web/app/api/webhook/handlers/checkout-complete.ts b/web/app/api/webhook/handlers/checkout-complete.ts index a8be3443..2a13cc4b 100644 --- a/web/app/api/webhook/handlers/checkout-complete.ts +++ b/web/app/api/webhook/handlers/checkout-complete.ts @@ -7,40 +7,50 @@ import Stripe from "stripe"; function createCustomerDataFromSession( session: Stripe.Checkout.Session ): CustomerData { + const { type = "subscription", plan = "monthly" } = session.metadata || {}; + 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", + billingCycle: type === "lifetime" ? "lifetime" : plan as "monthly" | "yearly", + product: type, + plan: plan, lastPayment: new Date(), createdAt: new Date(session.created * 1000), }; } -// 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; + + // Validate required metadata + if (!session.metadata?.userId) { + throw new Error("Missing required userId in metadata"); + } + const customerData = createCustomerDataFromSession(session); await updateClerkMetadata(customerData); - await trackLoopsEvent({ - email: session.customer_details?.email || "", - firstName: session.customer_details?.name?.split(" ")[0], - lastName: session.customer_details?.name?.split(" ").slice(1).join(" "), - userId: customerData.userId, - eventName: "checkout_completed", - }); + + if (session.customer_details?.email) { + await trackLoopsEvent({ + email: session.customer_details.email, + firstName: session.customer_details?.name?.split(" ")[0], + lastName: session.customer_details?.name?.split(" ").slice(1).join(" "), + userId: customerData.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"], } ); diff --git a/web/app/api/webhook/handlers/invoice-payment-failed.ts b/web/app/api/webhook/handlers/invoice-payment-failed.ts new file mode 100644 index 00000000..7decfce6 --- /dev/null +++ b/web/app/api/webhook/handlers/invoice-payment-failed.ts @@ -0,0 +1,43 @@ +import { createWebhookHandler } from "../handler-factory"; +import { updateClerkMetadata } from "@/lib/services/clerk"; +import { trackLoopsEvent } from "@/lib/services/loops"; +import Stripe from "stripe"; + +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" }; + } + + 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}`, + }; + } +); \ No newline at end of file diff --git a/web/app/api/webhook/route.ts b/web/app/api/webhook/route.ts index 2a0550d1..3965e4f3 100644 --- a/web/app/api/webhook/route.ts +++ b/web/app/api/webhook/route.ts @@ -5,13 +5,17 @@ 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"; +import { WebhookEvent } from "./types"; const HANDLERS = { "checkout.session.completed": handleCheckoutComplete, "customer.subscription.deleted": handleSubscriptionCanceled, + "customer.subscription.updated": handleSubscriptionUpdated, "invoice.paid": handleInvoicePaid, + "invoice.payment_failed": handleInvoicePaymentFailed, "payment_intent.succeeded": handlePaymentIntentSucceeded, - "customer.subscription.updated": handleSubscriptionUpdated, } as const; export async function POST(req: NextRequest) { @@ -21,43 +25,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/web/app/api/webhook/types.ts b/web/app/api/webhook/types.ts index e914cf74..4471a1c8 100644 --- a/web/app/api/webhook/types.ts +++ b/web/app/api/webhook/types.ts @@ -1,7 +1,16 @@ +import Stripe from "stripe"; + export type WebhookEvent = { + id: string; type: string; data: { - object: any; + object: Stripe.Event.Data.Object & { + metadata?: { + userId?: string; + type?: string; + plan?: string; + }; + }; }; }; diff --git a/web/app/dashboard/onboarding/page.tsx b/web/app/dashboard/onboarding/page.tsx index 7f751a52..45fd22d5 100644 --- a/web/app/dashboard/onboarding/page.tsx +++ b/web/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} -
  • - ) - )} -
-
- - - -
- ); - })} -
+