diff --git a/stripe_to_invoice/app/api/auth/callback/route.ts b/stripe_to_invoice/app/api/auth/callback/route.ts
new file mode 100644
index 0000000..edaa847
--- /dev/null
+++ b/stripe_to_invoice/app/api/auth/callback/route.ts
@@ -0,0 +1,53 @@
+// app/api/auth/callback/route.ts
+import { NextResponse } from "next/server";
+import { cookies } from "next/headers";
+import { db } from "@/lib/db";
+import { loginTokens } from "@/lib/schema";
+import { and, eq, gt, isNull } from "drizzle-orm";
+import { hashToken } from "@/lib/auth/tokens";
+
+export async function POST(req: Request) {
+ const { token } = await req.json();
+
+ if (!token) {
+ return NextResponse.json({ error: "Missing token" }, { status: 400 });
+ }
+
+ const tokenHash = hashToken(token);
+
+ const loginToken = await db
+ .select()
+ .from(loginTokens)
+ .where(
+ and(
+ eq(loginTokens.tokenHash, tokenHash),
+ isNull(loginTokens.usedAt),
+ gt(loginTokens.expiresAt, new Date())
+ )
+ )
+ .limit(1)
+ .then((rows) => rows[0]);
+
+ if (!loginToken) {
+ return NextResponse.json(
+ { error: "Invalid or expired token" },
+ { status: 401 }
+ );
+ }
+
+ // ✅ mark token as used
+ await db
+ .update(loginTokens)
+ .set({ usedAt: new Date() })
+ .where(eq(loginTokens.id, loginToken.id));
+
+ // ✅ FIX: cookies() is async in Next 15+
+ const cookieStore = await cookies();
+ cookieStore.set("session", loginToken.userId, {
+ httpOnly: true,
+ sameSite: "lax",
+ path: "/",
+ });
+
+ return NextResponse.json({ ok: true });
+}
diff --git a/stripe_to_invoice/app/api/auth/request-link/route.ts b/stripe_to_invoice/app/api/auth/request-link/route.ts
new file mode 100644
index 0000000..4468063
--- /dev/null
+++ b/stripe_to_invoice/app/api/auth/request-link/route.ts
@@ -0,0 +1,49 @@
+// app/api/auth/request-link/route.ts
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { users, loginTokens } from "@/lib/schema";
+import { eq } from "drizzle-orm";
+import { generateToken, hashToken } from "@/lib/auth/tokens";
+import { sendMagicLinkEmail } from "@/lib/email/sendMagicLink";
+
+export async function POST(req: Request) {
+ const { email } = await req.json();
+
+ if (!email) {
+ return NextResponse.json({ error: "Email required" }, { status: 400 });
+ }
+
+ // 1. find user
+ let user = await db
+ .select()
+ .from(users)
+ .where(eq(users.email, email))
+ .then((rows) => rows[0]);
+
+ // 2. create user if missing
+ if (!user) {
+ const inserted = await db
+ .insert(users)
+ .values({ email })
+ .returning();
+
+ user = inserted[0];
+ }
+
+ // 3. generate token
+ const token = generateToken();
+ const tokenHash = hashToken(token);
+
+ // 4. store login token
+ await db.insert(loginTokens).values({
+ userId: user.id,
+ tokenHash,
+ expiresAt: new Date(Date.now() + 15 * 60 * 1000),
+ });
+
+ // 5. send email
+ const link = `${process.env.APP_URL}/auth/callback?token=${token}`;
+ await sendMagicLinkEmail(email, link);
+
+ return NextResponse.json({ ok: true });
+}
diff --git a/stripe_to_invoice/app/api/db-test/route.ts b/stripe_to_invoice/app/api/db-test/route.ts
new file mode 100644
index 0000000..03d9297
--- /dev/null
+++ b/stripe_to_invoice/app/api/db-test/route.ts
@@ -0,0 +1,9 @@
+// app/api/db-test/route.ts
+import { db } from "@/lib/db";
+import { sql } from "drizzle-orm";
+import { NextResponse } from "next/server";
+
+export async function GET() {
+ await db.execute(sql`select 1`);
+ return NextResponse.json({ ok: true });
+}
diff --git a/stripe_to_invoice/app/app/page.tsx b/stripe_to_invoice/app/app/page.tsx
new file mode 100644
index 0000000..2562c25
--- /dev/null
+++ b/stripe_to_invoice/app/app/page.tsx
@@ -0,0 +1,66 @@
+// app/app/page.tsx
+import { cookies } from "next/headers";
+import { db } from "@/lib/db";
+import { users } from "@/lib/schema";
+import { eq } from "drizzle-orm";
+import Link from "next/link";
+
+export default async function AppPage() {
+ const cookieStore = await cookies();
+ const userId = cookieStore.get("session")?.value;
+
+ if (!userId) {
+ return (
+
+ You are not logged in.
+
+ Go to login
+
+
+ );
+ }
+
+ const user = await db
+ .select()
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1)
+ .then((rows) => rows[0]);
+
+ return (
+
+
+ Welcome{user?.email ? `, ${user.email}` : ""}
+
+
+ {/* Progress */}
+
+ -
+ ✔
+
+ Logged in as {user?.email}
+
+
+
+ -
+ →
+ Connect Stripe
+
+
+ -
+ Xero will be connected after Stripe
+
+
+
+ {/* Primary CTA */}
+
+
+ Connect Stripe
+
+
+
+ );
+}
diff --git a/stripe_to_invoice/app/auth/callback/page.tsx b/stripe_to_invoice/app/auth/callback/page.tsx
new file mode 100644
index 0000000..1f76a57
--- /dev/null
+++ b/stripe_to_invoice/app/auth/callback/page.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { useSearchParams, useRouter } from "next/navigation";
+
+export default function AuthCallbackPage() {
+ const params = useSearchParams();
+ const router = useRouter();
+ const ran = useRef(false);
+
+ useEffect(() => {
+ if (ran.current) return;
+ ran.current = true;
+
+ const token = params.get("token");
+ if (!token) {
+ router.replace("/login");
+ return;
+ }
+
+ fetch("/api/auth/callback", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token }),
+ })
+ .then(async (res) => {
+ if (!res.ok) throw new Error(await res.text());
+ router.replace("/app");
+ })
+ .catch(() => {
+ router.replace("/login");
+ });
+ }, [params, router]);
+
+ return (
+
+ Signing you in…
+
+ );
+}
diff --git a/stripe_to_invoice/lib/auth/tokens.ts b/stripe_to_invoice/lib/auth/tokens.ts
new file mode 100644
index 0000000..4443e05
--- /dev/null
+++ b/stripe_to_invoice/lib/auth/tokens.ts
@@ -0,0 +1,10 @@
+// lib/auth/tokens.ts
+import crypto from "crypto";
+
+export function generateToken() {
+ return crypto.randomBytes(32).toString("hex");
+}
+
+export function hashToken(token: string) {
+ return crypto.createHash("sha256").update(token).digest("hex");
+}
diff --git a/stripe_to_invoice/lib/db.ts b/stripe_to_invoice/lib/db.ts
new file mode 100644
index 0000000..09a2bf7
--- /dev/null
+++ b/stripe_to_invoice/lib/db.ts
@@ -0,0 +1,19 @@
+// lib/db.ts
+import { drizzle } from "drizzle-orm/node-postgres";
+import { Pool } from "pg";
+
+// Fail fast if env is missing
+if (!process.env.DATABASE_URL) {
+ throw new Error("DATABASE_URL is not set");
+}
+
+const pool = new Pool({
+ connectionString: process.env.DATABASE_URL,
+ ssl:
+ process.env.NODE_ENV === "production"
+ ? { rejectUnauthorized: false }
+ : false,
+});
+
+// Export a single db instance
+export const db = drizzle(pool);
diff --git a/stripe_to_invoice/lib/email/sendMagicLink.ts b/stripe_to_invoice/lib/email/sendMagicLink.ts
new file mode 100644
index 0000000..0401645
--- /dev/null
+++ b/stripe_to_invoice/lib/email/sendMagicLink.ts
@@ -0,0 +1,29 @@
+// lib/email/sendMagicLink.ts
+import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
+
+const ses = new SESClient({
+ region: process.env.AWS_REGION!,
+});
+
+export async function sendMagicLinkEmail(
+ to: string,
+ link: string
+) {
+ await ses.send(
+ new SendEmailCommand({
+ Source: process.env.SES_FROM_EMAIL!,
+ Destination: { ToAddresses: [to] },
+ Message: {
+ Subject: { Data: "Your login link" },
+ Body: {
+ Text: {
+ Data: `Click the link below to log in.
+This link expires in 15 minutes.
+
+${link}`,
+ },
+ },
+ },
+ })
+ );
+}
diff --git a/stripe_to_invoice/lib/schema/index.ts b/stripe_to_invoice/lib/schema/index.ts
new file mode 100644
index 0000000..58cd6d0
--- /dev/null
+++ b/stripe_to_invoice/lib/schema/index.ts
@@ -0,0 +1,3 @@
+// lib/schema/index.ts
+export * from "./users";
+export * from "./loginTokens";
diff --git a/stripe_to_invoice/lib/schema/loginTokens.ts b/stripe_to_invoice/lib/schema/loginTokens.ts
new file mode 100644
index 0000000..7673d2d
--- /dev/null
+++ b/stripe_to_invoice/lib/schema/loginTokens.ts
@@ -0,0 +1,34 @@
+// lib/schema/loginTokens.ts
+import {
+ pgTable,
+ uuid,
+ text,
+ timestamp,
+} from "drizzle-orm/pg-core";
+import { users } from "./users";
+
+export const loginTokens = pgTable("login_tokens", {
+ id: uuid("id").primaryKey().defaultRandom(),
+
+ userId: uuid("user_id")
+ .notNull()
+ .references(() => users.id, {
+ onDelete: "cascade",
+ }),
+
+ tokenHash: text("token_hash").notNull(),
+
+ expiresAt: timestamp("expires_at", {
+ withTimezone: true,
+ }).notNull(),
+
+ usedAt: timestamp("used_at", {
+ withTimezone: true,
+ }),
+
+ createdAt: timestamp("created_at", {
+ withTimezone: true,
+ })
+ .notNull()
+ .defaultNow(),
+});
diff --git a/stripe_to_invoice/lib/schema/users.ts b/stripe_to_invoice/lib/schema/users.ts
new file mode 100644
index 0000000..0aee763
--- /dev/null
+++ b/stripe_to_invoice/lib/schema/users.ts
@@ -0,0 +1,19 @@
+// lib/schema/users.ts
+import {
+ pgTable,
+ uuid,
+ text,
+ timestamp,
+} from "drizzle-orm/pg-core";
+
+export const users = pgTable("users", {
+ id: uuid("id").primaryKey().defaultRandom(),
+
+ email: text("email").notNull().unique(),
+
+ createdAt: timestamp("created_at", {
+ withTimezone: true,
+ })
+ .notNull()
+ .defaultNow(),
+});
diff --git a/stripe_to_invoice/middleware.ts b/stripe_to_invoice/middleware.ts
new file mode 100644
index 0000000..f44f856
--- /dev/null
+++ b/stripe_to_invoice/middleware.ts
@@ -0,0 +1,14 @@
+// middleware.ts
+import { NextRequest, NextResponse } from "next/server";
+
+export function middleware(req: NextRequest) {
+ const session = req.cookies.get("session");
+
+ if (!session && req.nextUrl.pathname.startsWith("/app")) {
+ return NextResponse.redirect(new URL("/login", req.url));
+ }
+}
+
+export const config = {
+ matcher: ["/app/:path*"],
+};
\ No newline at end of file