diff --git a/stripe_to_invoice/app/api/stripe/subscription/checkout/route.ts b/stripe_to_invoice/app/api/stripe/subscription/checkout/route.ts new file mode 100644 index 0000000..36f93c1 --- /dev/null +++ b/stripe_to_invoice/app/api/stripe/subscription/checkout/route.ts @@ -0,0 +1,111 @@ +import { NextRequest, NextResponse } from "next/server"; +import Stripe from "stripe"; +import { eq } from "drizzle-orm"; + +import { getStripe } from "@/lib/stripe/service"; +import { getUserFromSession } from "@/lib/auth/get-user"; +import { db } from "@/lib/db"; +import { subscriptions } from "@/lib/schema"; + +const stripe = getStripe(); + +const SUBSCRIPTION_PRICE_ID = process.env.STRIPE_SUBSCRIPTION_PRICE_ID!; +const ORIGIN = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; + +export async function POST(req: NextRequest) { + try { + console.log("πŸ’³ [SUBSCRIPTION CHECKOUT] Request received"); + + // -------------------------------------------------- + // 1️⃣ Get authenticated user + // -------------------------------------------------- + const user = await getUserFromSession(); + if (!user) { + console.error("❌ [SUBSCRIPTION CHECKOUT] Unauthorized"); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + console.log("βœ… [SUBSCRIPTION CHECKOUT] User authenticated:", user.id); + + // -------------------------------------------------- + // 2️⃣ Check if user already has an active subscription + // -------------------------------------------------- + const [existingSubscription] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.userId, user.id)) + .limit(1); + + if (existingSubscription?.status === "active") { + console.log("⚠️ [SUBSCRIPTION CHECKOUT] User already has active subscription"); + return NextResponse.json( + { error: "User already has an active subscription" }, + { status: 400 } + ); + } + + console.log("βœ… [SUBSCRIPTION CHECKOUT] No active subscription found"); + + // -------------------------------------------------- + // 3️⃣ Create or get Stripe customer + // -------------------------------------------------- + let stripeCustomerId = existingSubscription?.stripeCustomerId; + + if (!stripeCustomerId) { + console.log("βž• [SUBSCRIPTION CHECKOUT] Creating Stripe customer"); + const customer = await stripe.customers.create({ + email: user.email, + metadata: { + userId: user.id, + }, + }); + stripeCustomerId = customer.id; + console.log("βœ… [SUBSCRIPTION CHECKOUT] Stripe customer created:", stripeCustomerId); + } else { + console.log("βœ… [SUBSCRIPTION CHECKOUT] Using existing Stripe customer:", stripeCustomerId); + } + + // -------------------------------------------------- + // 4️⃣ Create checkout session + // -------------------------------------------------- + console.log("πŸ“ [SUBSCRIPTION CHECKOUT] Creating checkout session"); + + const session = await stripe.checkout.sessions.create({ + customer: stripeCustomerId, + mode: "subscription", + line_items: [ + { + price: SUBSCRIPTION_PRICE_ID, + quantity: 1, + }, + ], + success_url: `${ORIGIN}/billing?success=true`, + cancel_url: `${ORIGIN}/billing?canceled=true`, + metadata: { + userId: user.id, + }, + }); + + console.log("βœ… [SUBSCRIPTION CHECKOUT] Checkout session created:", session.id); + + // -------------------------------------------------- + // 5️⃣ Store Stripe customer ID if this is first time + // -------------------------------------------------- + if (!existingSubscription) { + await db.insert(subscriptions).values({ + userId: user.id, + stripeCustomerId, + status: "trialing", + }); + console.log("βœ… [SUBSCRIPTION CHECKOUT] Subscription record created"); + } + + return NextResponse.json({ url: session.url }); + } catch (error: any) { + console.error("❌ [SUBSCRIPTION CHECKOUT] Error:", error.message); + return NextResponse.json( + { error: error.message }, + { status: 500 } + ); + } +} diff --git a/stripe_to_invoice/app/api/stripe/subscription/portal/route.ts b/stripe_to_invoice/app/api/stripe/subscription/portal/route.ts new file mode 100644 index 0000000..a2b8ade --- /dev/null +++ b/stripe_to_invoice/app/api/stripe/subscription/portal/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; + +import { getStripe } from "@/lib/stripe/service"; +import { getUserFromSession } from "@/lib/auth/get-user"; +import { db } from "@/lib/db"; +import { subscriptions } from "@/lib/schema"; + +const stripe = getStripe(); +const ORIGIN = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; + +export async function POST(req: NextRequest) { + try { + console.log("πŸ”— [BILLING PORTAL] Request received"); + + // -------------------------------------------------- + // 1️⃣ Get authenticated user + // -------------------------------------------------- + const user = await getUserFromSession(); + if (!user) { + console.error("❌ [BILLING PORTAL] Unauthorized"); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + console.log("βœ… [BILLING PORTAL] User authenticated:", user.id); + + // -------------------------------------------------- + // 2️⃣ Get user's subscription + // -------------------------------------------------- + const [userSubscription] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.userId, user.id)) + .limit(1); + + if (!userSubscription) { + console.error("❌ [BILLING PORTAL] Subscription not found"); + return NextResponse.json( + { error: "No subscription found" }, + { status: 404 } + ); + } + + if (!userSubscription.stripeCustomerId) { + console.error("❌ [BILLING PORTAL] Stripe customer ID not found"); + return NextResponse.json( + { error: "Customer not initialized" }, + { status: 400 } + ); + } + + console.log("βœ… [BILLING PORTAL] Customer found:", userSubscription.stripeCustomerId); + + // -------------------------------------------------- + // 3️⃣ Create billing portal session + // -------------------------------------------------- + console.log("πŸ“ [BILLING PORTAL] Creating billing portal session"); + + const portalSession = await stripe.billingPortal.sessions.create({ + customer: userSubscription.stripeCustomerId, + return_url: `${ORIGIN}/billing`, + }); + + console.log("βœ… [BILLING PORTAL] Portal session created:", portalSession.id); + + return NextResponse.json({ url: portalSession.url }); + } catch (error: any) { + console.error("❌ [BILLING PORTAL] Error:", error.message); + return NextResponse.json( + { error: error.message }, + { status: 500 } + ); + } +} diff --git a/stripe_to_invoice/app/api/stripe/subscription/webhook/route.ts b/stripe_to_invoice/app/api/stripe/subscription/webhook/route.ts new file mode 100644 index 0000000..8d441da --- /dev/null +++ b/stripe_to_invoice/app/api/stripe/subscription/webhook/route.ts @@ -0,0 +1,275 @@ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +import { NextRequest, NextResponse } from "next/server"; +import Stripe from "stripe"; +import { eq } from "drizzle-orm"; + +import { getStripe } from "@/lib/stripe/service"; +import { db } from "@/lib/db"; +import { subscriptions, payments } from "@/lib/schema"; + +const stripe = getStripe(); + +export async function POST(req: NextRequest) { + try { + console.log("πŸ”” [SUBSCRIPTION WEBHOOK] Received request"); + + // -------------------------------------------------- + // 1️⃣ Verify Stripe signature + // -------------------------------------------------- + const sig = req.headers.get("stripe-signature"); + if (!sig) { + console.error("❌ [SUBSCRIPTION WEBHOOK] Missing Stripe signature"); + return NextResponse.json({ error: "Missing Stripe signature" }, { status: 400 }); + } + + const body = await req.text(); + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent( + body, + sig, + process.env.STRIPE_WEBHOOK_SECRET! + ); + console.log("βœ… [SUBSCRIPTION WEBHOOK] Signature verified", { eventType: event.type }); + } catch (err: any) { + console.error("❌ [SUBSCRIPTION WEBHOOK] Invalid signature", err.message); + return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); + } + + // -------------------------------------------------- + // 2️⃣ Handle subscription events + // -------------------------------------------------- + if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated") { + console.log("🎯 [SUBSCRIPTION WEBHOOK] Processing subscription event:", event.type); + + const subscription = event.data.object as Stripe.Subscription; + const stripeCustomerId = subscription.customer as string; + + // Look up user by stripe customer ID (subscription metadata may not have userId) + const [existingSubscription] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.stripeCustomerId, stripeCustomerId)) + .limit(1); + + if (!existingSubscription) { + console.error("❌ [SUBSCRIPTION WEBHOOK] No subscription found for customer:", stripeCustomerId); + return NextResponse.json({ error: "Subscription not found" }, { status: 400 }); + } + + const userId = existingSubscription.userId; + + console.log("πŸ“ [SUBSCRIPTION WEBHOOK] Subscription details:", { + subscriptionId: subscription.id, + status: subscription.status, + canceledAt: subscription.canceled_at, + canceledAtParsed: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null, + userId, + }); + console.log("πŸ” [SUBSCRIPTION WEBHOOK] Full subscription object:", JSON.stringify(subscription, null, 2)); + + // Map Stripe status to our status + // Note: Stripe uses cancel_at for scheduled cancellations and canceled_at for immediate cancellations + let mappedStatus: "trialing" | "active" | "canceled" | "canceling" = "active"; + + if (subscription.status === "trialing") { + mappedStatus = "trialing"; + } else if (subscription.canceled_at) { + // Immediately canceled + mappedStatus = "canceled"; + } else if (subscription.cancel_at) { + // Scheduled for cancellation in the future + mappedStatus = "canceling"; + } + + // Update subscription record + const updateData: any = { + stripeSubscriptionId: subscription.id, + stripeCustomerId, + status: mappedStatus, + updatedAt: new Date(), + }; + + // Always update period dates (even for canceling, user has access until period end) + updateData.currentPeriodStart = new Date(subscription.current_period_start * 1000); + updateData.currentPeriodEnd = new Date(subscription.current_period_end * 1000); + + // Update canceledAt based on cancellation status + if (subscription.canceled_at) { + updateData.canceledAt = new Date(subscription.canceled_at * 1000); + } else if (subscription.cancel_at) { + // If scheduled for cancellation, use the cancel_at date + updateData.canceledAt = new Date(subscription.cancel_at * 1000); + } else { + // If subscription is active and not being canceled, clear canceledAt + updateData.canceledAt = null; + } + + await db + .update(subscriptions) + .set(updateData) + .where(eq(subscriptions.userId, userId)); + + console.log("βœ… [SUBSCRIPTION WEBHOOK] Subscription updated with status:", mappedStatus); + + return NextResponse.json({ received: true }); + } + + // -------------------------------------------------- + // 3️⃣ Handle invoice payment events (payment confirmation) + // -------------------------------------------------- + if (event.type === "invoice.payment_succeeded") { + console.log("πŸ’° [SUBSCRIPTION WEBHOOK] Processing invoice payment_succeeded"); + + const invoice = event.data.object as Stripe.Invoice; + const stripeCustomerId = invoice.customer as string; + + if (!invoice.id) { + console.error("❌ [SUBSCRIPTION WEBHOOK] Missing invoice ID"); + return NextResponse.json({ error: "Missing invoice ID" }, { status: 400 }); + } + + console.log("πŸ“ [SUBSCRIPTION WEBHOOK] Invoice details:", { + invoiceId: invoice.id, + amount: invoice.amount_paid, + status: invoice.status, + }); + + // Find user by stripe customer ID + const [subscription] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.stripeCustomerId, stripeCustomerId)) + .limit(1); + + if (!subscription) { + console.error("❌ [SUBSCRIPTION WEBHOOK] Subscription not found for customer:", stripeCustomerId); + return NextResponse.json({ error: "Subscription not found" }, { status: 400 }); + } + + // Check if payment already recorded + const existingPayment = await db + .select() + .from(payments) + .where(eq(payments.stripeInvoiceId, invoice.id)) + .limit(1); + + if (existingPayment.length === 0) { + // Record payment + // Note: invoice.paid_at is already a Unix timestamp (seconds), convert to milliseconds + const paidAtMs = invoice.paid_at ? invoice.paid_at * 1000 : Date.now(); + + await db.insert(payments).values({ + userId: subscription.userId, + stripeInvoiceId: invoice.id, + amount: invoice.amount_paid || 0, + currency: invoice.currency || "usd", + status: invoice.status || "paid", + paidAt: new Date(paidAtMs), + }); + + console.log("βœ… [SUBSCRIPTION WEBHOOK] Payment recorded"); + } + + // Check Stripe subscription status before updating + let mappedStatus: "trialing" | "active" | "canceled" | "canceling" = "active"; + let canceledAt: Date | null = null; + + const subscriptionId = typeof (invoice as any).subscription === "string" + ? (invoice as any).subscription + : (invoice as any).subscription?.id; + + if (subscriptionId) { + try { + const stripeSubscription = await stripe.subscriptions.retrieve(subscriptionId); + + console.log("πŸ“ [SUBSCRIPTION WEBHOOK] Retrieved Stripe subscription for status check:", { + subscriptionId: stripeSubscription.id, + status: stripeSubscription.status, + canceled_at: stripeSubscription.canceled_at, + cancel_at: stripeSubscription.cancel_at, + }); + + // Map Stripe status to our status + if (stripeSubscription.status === "trialing") { + mappedStatus = "trialing"; + } else if (stripeSubscription.canceled_at) { + mappedStatus = "canceled"; + canceledAt = new Date(stripeSubscription.canceled_at * 1000); + } else if (stripeSubscription.cancel_at) { + mappedStatus = "canceling"; + canceledAt = new Date(stripeSubscription.cancel_at * 1000); + } + } catch (err: any) { + console.error("❌ [SUBSCRIPTION WEBHOOK] Failed to retrieve Stripe subscription:", err.message); + // Fall back to "active" if retrieval fails + mappedStatus = "active"; + canceledAt = null; + } + } + + // Update subscription with mapped status + await db + .update(subscriptions) + .set({ + status: mappedStatus, + canceledAt, + updatedAt: new Date(), + }) + .where(eq(subscriptions.userId, subscription.userId)); + + console.log("βœ… [SUBSCRIPTION WEBHOOK] Subscription updated with status:", mappedStatus); + + return NextResponse.json({ received: true }); + } + + // -------------------------------------------------- + // 4️⃣ Handle subscription cancellation + // -------------------------------------------------- + if (event.type === "customer.subscription.deleted") { + console.log("πŸ”΄ [SUBSCRIPTION WEBHOOK] Processing subscription deletion"); + + const subscription = event.data.object as Stripe.Subscription; + const stripeCustomerId = subscription.customer as string; + + // Look up user by stripe customer ID + const [existingSubscription] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.stripeCustomerId, stripeCustomerId)) + .limit(1); + + if (!existingSubscription) { + console.error("❌ [SUBSCRIPTION WEBHOOK] Subscription not found for customer:", stripeCustomerId); + return NextResponse.json({ error: "Subscription not found" }, { status: 400 }); + } + + const userId = existingSubscription.userId; + + await db + .update(subscriptions) + .set({ + status: "canceled", + canceledAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(subscriptions.userId, userId)); + + console.log("βœ… [SUBSCRIPTION WEBHOOK] Subscription marked as canceled"); + + return NextResponse.json({ received: true }); + } + + // Ignore other event types + console.log("⏭️ [SUBSCRIPTION WEBHOOK] Ignoring event type:", event.type); + return NextResponse.json({ ignored: true }); + } catch (error: any) { + console.error("❌ [SUBSCRIPTION WEBHOOK] Error:", error.message); + console.error("❌ [SUBSCRIPTION WEBHOOK] Stack:", error.stack); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/stripe_to_invoice/app/api/stripe/webhook/route.ts b/stripe_to_invoice/app/api/stripe/webhook/route.ts index 8d7255c..8154a5d 100644 --- a/stripe_to_invoice/app/api/stripe/webhook/route.ts +++ b/stripe_to_invoice/app/api/stripe/webhook/route.ts @@ -10,6 +10,7 @@ import { eq } from "drizzle-orm"; import { getStripe } from "@/lib/stripe/service"; import { getXeroClient } from "@/lib/xero/service"; import { getValidXeroAccessToken } from "@/lib/xero/auth"; +import { checkSubscriptionStatus } from "@/lib/subscription/check-status"; import { db } from "@/lib/db"; import { @@ -112,6 +113,21 @@ export async function POST(req: NextRequest) { console.log("βœ… [WEBHOOK] Found stripe account, userId:", stripeAccount.userId); + // -------------------------------------------------- + // 3️⃣a Check subscription status + // -------------------------------------------------- + const subscriptionStatus = await checkSubscriptionStatus(stripeAccount.userId); + + if (subscriptionStatus.status !== "active" && subscriptionStatus.status !== "canceling" && subscriptionStatus.status !== "trialing") { + console.log("⏭️ [WEBHOOK] User subscription not active:", { + userId: stripeAccount.userId, + status: subscriptionStatus.status, + }); + return NextResponse.json({ received: true }); + } + + console.log("βœ… [WEBHOOK] User subscription is valid:", subscriptionStatus.status); + // -------------------------------------------------- // 4️⃣ User β†’ Xero connection // -------------------------------------------------- diff --git a/stripe_to_invoice/app/api/subscription/status/route.ts b/stripe_to_invoice/app/api/subscription/status/route.ts new file mode 100644 index 0000000..01f4888 --- /dev/null +++ b/stripe_to_invoice/app/api/subscription/status/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getUserFromSession } from "@/lib/auth/get-user"; +import { checkSubscriptionStatus } from "@/lib/subscription/check-status"; + +export async function GET(req: NextRequest) { + try { + const user = await getUserFromSession(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const status = await checkSubscriptionStatus(user.id); + + return NextResponse.json({ + status: status.status, + isActive: status.isActive, + daysRemainingInTrial: status.daysRemainingInTrial, + trialEndsAt: status.trialEndsAt?.toISOString() || null, + subscriptionEndsAt: status.subscriptionEndsAt?.toISOString() || null, + }); + } catch (error: any) { + console.error("❌ [SUBSCRIPTION STATUS] Error:", error.message); + return NextResponse.json( + { error: error.message }, + { status: 500 } + ); + } +} diff --git a/stripe_to_invoice/app/billing/page.tsx b/stripe_to_invoice/app/billing/page.tsx new file mode 100644 index 0000000..9cee4ae --- /dev/null +++ b/stripe_to_invoice/app/billing/page.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { getUserFromSession } from "@/lib/auth/get-user"; + +interface SubscriptionInfo { + status: "trialing" | "active" | "expired" | "canceled" | "canceling"; + isActive: boolean; + daysRemainingInTrial: number | null; + trialEndsAt: string | null; + subscriptionEndsAt: string | null; +} + +export default function BillingPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [subscriptionInfo, setSubscriptionInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [checkoutLoading, setCheckoutLoading] = useState(false); + const [cancelLoading, setCancelLoading] = useState(false); + const [error, setError] = useState(null); + const canceled = searchParams.get("canceled"); + + useEffect(() => { + async function loadSubscriptionStatus() { + try { + const res = await fetch("/api/subscription/status"); + if (!res.ok) { + throw new Error("Failed to load subscription status"); + } + const data = await res.json(); + setSubscriptionInfo(data); + setLoading(false); + } catch (err: any) { + setError(err.message); + setLoading(false); + } + } + + loadSubscriptionStatus(); + }, [searchParams]); + + const handleCheckout = async () => { + setCheckoutLoading(true); + setError(null); + + try { + const res = await fetch("/api/stripe/subscription/checkout", { + method: "POST", + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || "Failed to create checkout session"); + } + + const { url } = await res.json(); + if (url) { + window.location.href = url; + } + } catch (err: any) { + setError(err.message); + setCheckoutLoading(false); + } + }; + + const handleCancel = async () => { + setCancelLoading(true); + setError(null); + + try { + const res = await fetch("/api/stripe/subscription/portal", { + method: "POST", + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || "Failed to open billing portal"); + } + + const { url } = await res.json(); + if (url) { + window.location.href = url; + } + } catch (err: any) { + setError(err.message); + setCancelLoading(false); + } + }; + + + if (loading) { + return ( +
+
+
+ +
+

Loading billing information…

+
+
+ ); + } + + return ( +
+ {/* Navigation */} + + +
+
+ {/* Canceled Message */} + {canceled && ( +
+

+ Payment was canceled. Please try again or contact support. +

+
+ )} + + {/* Header */} +
+

Billing & Subscription

+

+ Manage your subscription and billing information. +

+
+ + {/* Error */} + {error && ( +
+

{error}

+
+ )} + + {/* Status Card */} + {subscriptionInfo && ( +
+

Current Status

+ + {/* Status Badge */} +
+ {subscriptionInfo.status === "active" && ( +
+ + Subscription Active +
+ )} + {subscriptionInfo.status === "trialing" && ( +
+ + + {subscriptionInfo.daysRemainingInTrial} days free trial remaining + +
+ )} + {subscriptionInfo.status === "expired" && ( +
+ + Trial Expired +
+ )} + {subscriptionInfo.status === "canceling" && ( +
+
+ + Scheduled for Cancellation +
+ {subscriptionInfo.subscriptionEndsAt && ( +

+ Ends on {new Date(subscriptionInfo.subscriptionEndsAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })} +

+ )} +
+ )} + {subscriptionInfo.status === "canceled" && ( +
+ + Subscription Canceled +
+ )} +
+ + {/* Manage Subscription Section */} + {(subscriptionInfo.status === "active" || subscriptionInfo.status === "canceling") && ( +
+

+ {subscriptionInfo.status === "canceling" + ? "You can reactivate your subscription anytime. " + : "Manage your subscription: "} + +

+
+ )} +
+ )} + + {/* Pricing Card */} + {!subscriptionInfo?.isActive && ( +
+

Upgrade to Pro

+

+ Continue using Stripe to Invoice after your free trial ends. +

+ +
+
+ Β£50 + /month +
+

+ βœ“ Unlimited invoice creation
+ βœ“ Real-time Stripe to Xero sync
+ βœ“ Priority support
+ βœ“ Cancel anytime +

+
+ + + +

+ Secure payment powered by Stripe +

+
+ )} + + {/* FAQ */} +
+

FAQ

+
+
+

Can I cancel anytime?

+

+ Yes, you can cancel your subscription anytime. Just click "Manage subscription" in the Current Status section above. +

+
+
+

What happens after the trial?

+

+ After your 14-day trial ends, your access will be limited until you upgrade to a paid subscription. +

+
+
+

Is my payment information secure?

+

+ All payments are processed securely through Stripe. We never see or store your credit card information. +

+
+
+
+
+
+
+ ); +} diff --git a/stripe_to_invoice/app/dashboard/page.tsx b/stripe_to_invoice/app/dashboard/page.tsx index 2e5ff2f..2340163 100644 --- a/stripe_to_invoice/app/dashboard/page.tsx +++ b/stripe_to_invoice/app/dashboard/page.tsx @@ -1,6 +1,14 @@ "use client"; import { useEffect, useState } from "react"; +import Link from "next/link"; + +interface SubscriptionInfo { + status: "trialing" | "active" | "expired" | "canceled"; + isActive: boolean; + daysRemainingInTrial: number | null; + trialEndsAt: string | null; +} export default function DashboardPage() { const [salesAccountCode, setSalesAccountCode] = useState(""); @@ -10,16 +18,19 @@ export default function DashboardPage() { const [loading, setLoading] = useState(true); const [saved, setSaved] = useState(false); const [error, setError] = useState(null); + const [subscriptionInfo, setSubscriptionInfo] = useState(null); useEffect(() => { Promise.all([ fetch("/api/dashboard/xero-settings").then((res) => res.json()), fetch("/api/dashboard/connections").then((res) => res.json()), - ]).then(([settings, connections]) => { + fetch("/api/subscription/status").then((res) => res.json()), + ]).then(([settings, connections, subscription]) => { setSalesAccountCode(settings.salesAccountCode ?? ""); setStripeClearingAccountCode(settings.stripeClearingAccountCode ?? ""); setStripeAccountId(connections.stripeAccountId ?? ""); setXeroTenantId(connections.xeroTenantId ?? ""); + setSubscriptionInfo(subscription); setLoading(false); }); }, []); @@ -66,7 +77,15 @@ export default function DashboardPage() {
S2X
-
Dashboard
+
+ + Billing + +
Dashboard
+
@@ -174,6 +193,59 @@ export default function DashboardPage() { {/* Sidebar */}
+ {/* Subscription Status Card */} + {subscriptionInfo && ( + + )} + {/* Info Card */}

ℹ️ How it works

diff --git a/stripe_to_invoice/lib/subscription/check-status.ts b/stripe_to_invoice/lib/subscription/check-status.ts new file mode 100644 index 0000000..3c37760 --- /dev/null +++ b/stripe_to_invoice/lib/subscription/check-status.ts @@ -0,0 +1,101 @@ +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { subscriptions, users } from "@/lib/schema"; + +const TRIAL_DAYS = 14; + +export interface SubscriptionStatus { + isActive: boolean; + status: "trialing" | "active" | "expired" | "canceled" | "canceling"; + daysRemainingInTrial: number | null; + trialEndsAt: Date | null; + subscriptionEndsAt: Date | null; +} + +/** + * Check if a user has an active subscription or valid trial + */ +export async function checkSubscriptionStatus(userId: string): Promise { + // Get user creation date + const [user] = await db + .select({ createdAt: users.createdAt }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user) { + return { + isActive: false, + status: "expired", + daysRemainingInTrial: null, + trialEndsAt: null, + subscriptionEndsAt: null, + }; + } + + // Get subscription record + const [subscription] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.userId, userId)) + .limit(1); + + const now = new Date(); + const trialEndsAt = new Date(user.createdAt); + trialEndsAt.setDate(trialEndsAt.getDate() + TRIAL_DAYS); + + // If user has active or canceling subscription, they can use the service + if (subscription?.status === "active" || subscription?.status === "canceling") { + // If subscription is marked as canceling, return canceling status + if (subscription.status === "canceling") { + return { + isActive: true, + status: "canceling", + daysRemainingInTrial: null, + trialEndsAt: null, + subscriptionEndsAt: subscription.currentPeriodEnd || null, + }; + } + + return { + isActive: true, + status: "active", + daysRemainingInTrial: null, + trialEndsAt: null, + subscriptionEndsAt: subscription.currentPeriodEnd || null, + }; + } + + // If still in trial period + if (now < trialEndsAt) { + const daysRemaining = Math.ceil( + (trialEndsAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24) + ); + return { + isActive: true, + status: "trialing", + daysRemainingInTrial: daysRemaining, + trialEndsAt, + subscriptionEndsAt: null, + }; + } + + // Trial expired and no paid subscription + if (subscription?.status === "canceled") { + return { + isActive: false, + status: "canceled", + daysRemainingInTrial: null, + trialEndsAt, + subscriptionEndsAt: null, + }; + } + + return { + isActive: false, + status: "expired", + daysRemainingInTrial: null, + trialEndsAt, + subscriptionEndsAt: null, + }; +} diff --git a/stripe_to_invoice/middleware.ts b/stripe_to_invoice/middleware.ts index f44f856..163f3ee 100644 --- a/stripe_to_invoice/middleware.ts +++ b/stripe_to_invoice/middleware.ts @@ -3,12 +3,14 @@ import { NextRequest, NextResponse } from "next/server"; export function middleware(req: NextRequest) { const session = req.cookies.get("session"); + const pathname = req.nextUrl.pathname; - if (!session && req.nextUrl.pathname.startsWith("/app")) { + // Redirect to login if no session and trying to access protected routes + if (!session && (pathname.startsWith("/app") || pathname.startsWith("/dashboard") || pathname.startsWith("/billing"))) { return NextResponse.redirect(new URL("/login", req.url)); } } export const config = { - matcher: ["/app/:path*"], + matcher: ["/app/:path*", "/dashboard/:path*", "/billing/:path*"], }; \ No newline at end of file diff --git a/stripe_to_invoice/stripe_webhook_payment.sh b/stripe_to_invoice/stripe_webhook_payment.sh index 9eec129..fa07878 100644 --- a/stripe_to_invoice/stripe_webhook_payment.sh +++ b/stripe_to_invoice/stripe_webhook_payment.sh @@ -1,2 +1,2 @@ echo "note you need to do 'stripe login' to make the below command work" -stripe listen --forward-to http://localhost:3000/api/billing/webhook +stripe listen --forward-to http://localhost:3000/api/stripe/subscription/webhook