added stripe payments
This commit is contained in:
parent
6fe597848a
commit
e060d145e8
10 changed files with 962 additions and 5 deletions
111
stripe_to_invoice/app/api/stripe/subscription/checkout/route.ts
Normal file
111
stripe_to_invoice/app/api/stripe/subscription/checkout/route.ts
Normal file
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
275
stripe_to_invoice/app/api/stripe/subscription/webhook/route.ts
Normal file
275
stripe_to_invoice/app/api/stripe/subscription/webhook/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
// --------------------------------------------------
|
||||
|
|
|
|||
28
stripe_to_invoice/app/api/subscription/status/route.ts
Normal file
28
stripe_to_invoice/app/api/subscription/status/route.ts
Normal file
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
278
stripe_to_invoice/app/billing/page.tsx
Normal file
278
stripe_to_invoice/app/billing/page.tsx
Normal file
|
|
@ -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<SubscriptionInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [checkoutLoading, setCheckoutLoading] = useState(false);
|
||||
const [cancelLoading, setCancelLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 mb-4">
|
||||
<span className="w-3 h-3 bg-blue-600 rounded-full animate-spin"></span>
|
||||
</div>
|
||||
<p className="text-slate-600">Loading billing information…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white">
|
||||
{/* Navigation */}
|
||||
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-sm border-b border-slate-200">
|
||||
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => router.push("/dashboard")}
|
||||
className="text-xl font-bold bg-gradient-to-r from-blue-600 to-blue-700 bg-clip-text text-transparent hover:opacity-80"
|
||||
>
|
||||
S2X
|
||||
</button>
|
||||
<div className="text-sm text-slate-600">Billing</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="pt-20">
|
||||
<div className="max-w-3xl mx-auto px-6 py-16">
|
||||
{/* Canceled Message */}
|
||||
{canceled && (
|
||||
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<p className="text-sm text-amber-700 font-medium">
|
||||
Payment was canceled. Please try again or contact support.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl font-bold text-slate-900 mb-3">Billing & Subscription</h1>
|
||||
<p className="text-lg text-slate-600">
|
||||
Manage your subscription and billing information.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Card */}
|
||||
{subscriptionInfo && (
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-8 mb-8">
|
||||
<h2 className="text-2xl font-bold text-slate-900 mb-6">Current Status</h2>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="mb-8">
|
||||
{subscriptionInfo.status === "active" && (
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-green-100 border border-green-300 rounded-full">
|
||||
<span className="w-2 h-2 bg-green-600 rounded-full"></span>
|
||||
<span className="text-sm font-semibold text-green-700">Subscription Active</span>
|
||||
</div>
|
||||
)}
|
||||
{subscriptionInfo.status === "trialing" && (
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-blue-100 border border-blue-300 rounded-full">
|
||||
<span className="w-2 h-2 bg-blue-600 rounded-full animate-pulse"></span>
|
||||
<span className="text-sm font-semibold text-blue-700">
|
||||
{subscriptionInfo.daysRemainingInTrial} days free trial remaining
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{subscriptionInfo.status === "expired" && (
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-red-100 border border-red-300 rounded-full">
|
||||
<span className="w-2 h-2 bg-red-600 rounded-full"></span>
|
||||
<span className="text-sm font-semibold text-red-700">Trial Expired</span>
|
||||
</div>
|
||||
)}
|
||||
{subscriptionInfo.status === "canceling" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-amber-100 border border-amber-300 rounded-full w-fit">
|
||||
<span className="w-2 h-2 bg-amber-600 rounded-full animate-pulse"></span>
|
||||
<span className="text-sm font-semibold text-amber-700">Scheduled for Cancellation</span>
|
||||
</div>
|
||||
{subscriptionInfo.subscriptionEndsAt && (
|
||||
<p className="text-sm text-slate-700">
|
||||
Ends on <span className="font-semibold text-amber-700">{new Date(subscriptionInfo.subscriptionEndsAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{subscriptionInfo.status === "canceled" && (
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-orange-100 border border-orange-300 rounded-full">
|
||||
<span className="w-2 h-2 bg-orange-600 rounded-full"></span>
|
||||
<span className="text-sm font-semibold text-orange-700">Subscription Canceled</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manage Subscription Section */}
|
||||
{(subscriptionInfo.status === "active" || subscriptionInfo.status === "canceling") && (
|
||||
<div className="pt-4 border-t border-slate-200">
|
||||
<p className="text-xs text-slate-600">
|
||||
{subscriptionInfo.status === "canceling"
|
||||
? "You can reactivate your subscription anytime. "
|
||||
: "Manage your subscription: "}
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={cancelLoading}
|
||||
className="text-xs text-slate-500 hover:text-slate-700 underline disabled:opacity-50"
|
||||
>
|
||||
{cancelLoading ? "Opening..." : "Billing Portal"}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pricing Card */}
|
||||
{!subscriptionInfo?.isActive && (
|
||||
<div className="bg-gradient-to-br from-blue-50 to-blue-100 border-2 border-blue-200 rounded-xl p-8 mb-8">
|
||||
<h2 className="text-2xl font-bold text-slate-900 mb-2">Upgrade to Pro</h2>
|
||||
<p className="text-slate-700 mb-6">
|
||||
Continue using Stripe to Invoice after your free trial ends.
|
||||
</p>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-baseline gap-2 mb-4">
|
||||
<span className="text-4xl font-bold text-slate-900">£50</span>
|
||||
<span className="text-slate-600">/month</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-700 mb-4">
|
||||
✓ Unlimited invoice creation<br />
|
||||
✓ Real-time Stripe to Xero sync<br />
|
||||
✓ Priority support<br />
|
||||
✓ Cancel anytime
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCheckout}
|
||||
disabled={checkoutLoading}
|
||||
className="w-full px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white font-semibold rounded-lg hover:shadow-lg hover:from-blue-700 hover:to-blue-800 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{checkoutLoading ? "Redirecting..." : "Upgrade Now →"}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-slate-600 text-center mt-4">
|
||||
Secure payment powered by Stripe
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FAQ */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-8">
|
||||
<h2 className="text-xl font-bold text-slate-900 mb-6">FAQ</h2>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900 mb-2">Can I cancel anytime?</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
Yes, you can cancel your subscription anytime. Just click "Manage subscription" in the Current Status section above.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900 mb-2">What happens after the trial?</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
After your 14-day trial ends, your access will be limited until you upgrade to a paid subscription.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900 mb-2">Is my payment information secure?</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
All payments are processed securely through Stripe. We never see or store your credit card information.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string | null>(null);
|
||||
const [subscriptionInfo, setSubscriptionInfo] = useState<SubscriptionInfo | null>(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() {
|
|||
<div className="text-xl font-bold bg-gradient-to-r from-blue-600 to-blue-700 bg-clip-text text-transparent">
|
||||
S2X
|
||||
</div>
|
||||
<div className="text-sm text-slate-600">Dashboard</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/billing"
|
||||
className="text-sm text-slate-600 hover:text-slate-900 transition"
|
||||
>
|
||||
Billing
|
||||
</Link>
|
||||
<div className="text-sm text-slate-600">Dashboard</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
|
@ -174,6 +193,59 @@ export default function DashboardPage() {
|
|||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Subscription Status Card */}
|
||||
{subscriptionInfo && (
|
||||
<button
|
||||
onClick={() => window.location.href = "/billing"}
|
||||
className={`rounded-xl p-6 border w-full text-left hover:shadow-md transition cursor-pointer ${
|
||||
subscriptionInfo.status === "active"
|
||||
? "bg-green-50 border-green-200"
|
||||
: subscriptionInfo.status === "trialing"
|
||||
? "bg-blue-50 border-blue-200"
|
||||
: "bg-red-50 border-red-200"
|
||||
}`}
|
||||
>
|
||||
<h3 className="font-semibold text-slate-900 mb-2">📅 Subscription Status</h3>
|
||||
{subscriptionInfo.status === "active" && (
|
||||
<p className="text-sm text-green-700">
|
||||
✓ Active subscription - Unlimited access
|
||||
</p>
|
||||
)}
|
||||
{subscriptionInfo.status === "trialing" && (
|
||||
<>
|
||||
<p className="text-sm text-blue-700 font-medium mb-3">
|
||||
Free trial active
|
||||
</p>
|
||||
<p className="text-sm text-blue-700 mb-3">
|
||||
{subscriptionInfo.daysRemainingInTrial} days remaining until {" "}
|
||||
{subscriptionInfo.trialEndsAt
|
||||
? new Date(subscriptionInfo.trialEndsAt).toLocaleDateString()
|
||||
: "trial ends"}
|
||||
</p>
|
||||
<Link
|
||||
href="/billing"
|
||||
className="inline-block text-sm font-medium text-blue-600 hover:text-blue-700 underline"
|
||||
>
|
||||
Upgrade now →
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{subscriptionInfo.status === "expired" && (
|
||||
<>
|
||||
<p className="text-sm text-red-700 font-medium mb-3">
|
||||
Trial expired - Upgrade to continue
|
||||
</p>
|
||||
<Link
|
||||
href="/billing"
|
||||
className="inline-block px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 transition"
|
||||
>
|
||||
Upgrade Now →
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Info Card */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">ℹ️ How it works</h3>
|
||||
|
|
|
|||
101
stripe_to_invoice/lib/subscription/check-status.ts
Normal file
101
stripe_to_invoice/lib/subscription/check-status.ts
Normal file
|
|
@ -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<SubscriptionStatus> {
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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*"],
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue