diff --git a/db/atlas/stripe_invoice/add_new_migration.sh b/db/atlas/stripe_invoice/add_new_migration.sh index 7216dfb..543094f 100644 --- a/db/atlas/stripe_invoice/add_new_migration.sh +++ b/db/atlas/stripe_invoice/add_new_migration.sh @@ -1,2 +1,2 @@ -atlas migrate new add_stripe_history +atlas migrate new add_invoice_code diff --git a/db/atlas/stripe_invoice/migrations/20260120223114_add_stripe_history.sql b/db/atlas/stripe_invoice/migrations/20260120223114_add_stripe_history.sql new file mode 100644 index 0000000..dc7d675 --- /dev/null +++ b/db/atlas/stripe_invoice/migrations/20260120223114_add_stripe_history.sql @@ -0,0 +1,6 @@ +CREATE TABLE processed_stripe_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stripe_event_id TEXT NOT NULL UNIQUE, + stripe_account_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/db/atlas/stripe_invoice/migrations/20260120230059_add_invoice_code.sql b/db/atlas/stripe_invoice/migrations/20260120230059_add_invoice_code.sql new file mode 100644 index 0000000..69ca6da --- /dev/null +++ b/db/atlas/stripe_invoice/migrations/20260120230059_add_invoice_code.sql @@ -0,0 +1,9 @@ +ALTER TABLE xero_connections +ADD COLUMN sales_account_code TEXT, +ADD COLUMN stripe_clearing_account_code TEXT; + +UPDATE xero_connections +SET + sales_account_code = '200', + stripe_clearing_account_code = '090' +WHERE sales_account_code IS NULL; diff --git a/db/atlas/stripe_invoice/migrations/atlas.sum b/db/atlas/stripe_invoice/migrations/atlas.sum index 129cc14..5136272 100644 --- a/db/atlas/stripe_invoice/migrations/atlas.sum +++ b/db/atlas/stripe_invoice/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:0fwTZVrBXWktKhhAG17BTid9zjj4i3TUkkgQY7u5XEU= +h1:ZGGgmFGh8vPWzpumfnp/KWIz6dmAFtIg/tJmVP+w0CU= 0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs= 0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw= 0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg= @@ -10,3 +10,4 @@ h1:0fwTZVrBXWktKhhAG17BTid9zjj4i3TUkkgQY7u5XEU= 20260118191050_add_more_info_on_xero_for_refresh_token.sql h1:cIQZ81Q7mBX4o8Xb6k3CGSFFw9KoVbZgfYxhOtxxvu4= 20260118211854_add_last_updated_at.sql h1:y01AhrpxYmYWIIn9La73twwrfJteCj0r5PovRCiQoh4= 20260120223114_add_stripe_history.sql h1:+l14lHGfyoNBGh1w9TqOuxmETe1Bgo1sry1aXrvt4bU= +20260120230059_add_invoice_code.sql h1:9uItaHRhcuSuxnoqMOwxyPxiOUdm2+gadRZDeSwLmSY= diff --git a/package.json b/package.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{} diff --git a/stripe_to_invoice/app/api/stripe/webhook/route.ts b/stripe_to_invoice/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..cc19339 --- /dev/null +++ b/stripe_to_invoice/app/api/stripe/webhook/route.ts @@ -0,0 +1,265 @@ +export const runtime = "nodejs"; + +import { NextRequest, NextResponse } from "next/server"; +import Stripe from "stripe"; +import { XeroClient, Invoice, CurrencyCode } from "xero-node"; +import { db } from "@/lib/db"; +import { + stripeAccounts, + xeroConnections, + processedStripeEvents, +} from "@/lib/schema"; +import { eq } from "drizzle-orm"; + + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); + +export async function POST(req: NextRequest) { + // -------------------------------------------------- + // 0️⃣ Verify Stripe signature + // -------------------------------------------------- + const sig = req.headers.get("stripe-signature"); + if (!sig) { + 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! + ); + } catch (err: any) { + console.error("❌ Invalid Stripe signature", err.message); + return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); + } + + // -------------------------------------------------- + // 🔕 Only handle checkout.session.completed + // -------------------------------------------------- + if (event.type !== "checkout.session.completed") { + return NextResponse.json({ ignored: true }); + } + + const session = event.data.object as Stripe.Checkout.Session; + + // -------------------------------------------------- + // 1️⃣ Stripe account context + // -------------------------------------------------- + const stripeAccountId = + req.headers.get("stripe-account") ?? + "acct_1Sds1LB99GOwj1Ea"; // DEV ONLY + + // -------------------------------------------------- + // 2️⃣ IDEMPOTENCY CHECK + // -------------------------------------------------- + const existing = await db + .select() + .from(processedStripeEvents) + .where(eq(processedStripeEvents.stripeEventId, event.id)) + .limit(1); + + if (existing.length > 0) { + console.log("⏭️ Event already processed:", event.id); + return NextResponse.json({ received: true }); + } + + // -------------------------------------------------- + // 3️⃣ Stripe account → user + // -------------------------------------------------- + const [stripeAccount] = await db + .select() + .from(stripeAccounts) + .where(eq(stripeAccounts.stripeAccountId, stripeAccountId)) + .limit(1); + + if (!stripeAccount) { + console.error("❌ Stripe account not registered:", stripeAccountId); + return NextResponse.json( + { error: "Stripe account not registered" }, + { status: 500 } + ); + } + + // -------------------------------------------------- + // 4️⃣ User → Xero connection + // -------------------------------------------------- + const [xeroConn] = await db + .select() + .from(xeroConnections) + .where(eq(xeroConnections.userId, stripeAccount.userId)) + .limit(1); + + if (!xeroConn) { + console.error("❌ No Xero connection for user:", stripeAccount.userId); + return NextResponse.json( + { error: "User has no Xero connection" }, + { status: 500 } + ); + } + + if (!xeroConn.salesAccountCode || !xeroConn.stripeClearingAccountCode) { + throw new Error( + "Xero account codes not configured. User must select Sales and Stripe Clearing accounts." + ); + } + + // -------------------------------------------------- + // 5️⃣ Init Xero client + refresh token if needed + // -------------------------------------------------- + const xero = new XeroClient(); + + xero.setTokenSet({ + access_token: xeroConn.accessToken, + refresh_token: xeroConn.refreshToken, + expires_at: xeroConn.expiresAt.getTime(), + token_type: "Bearer", + }); + + const now = Date.now(); + if (xeroConn.expiresAt.getTime() <= now + 60_000) { + console.log("🔄 Refreshing Xero token"); + + const newTokenSet = await xero.refreshToken(); + + await db + .update(xeroConnections) + .set({ + accessToken: newTokenSet.access_token!, + refreshToken: newTokenSet.refresh_token!, + expiresAt: new Date(newTokenSet.expires_at!), + }) + .where(eq(xeroConnections.id, xeroConn.id)); + + xero.setTokenSet(newTokenSet); + } + + // -------------------------------------------------- + // 6️⃣ Resolve contact (email-only) + // -------------------------------------------------- + const email = session.customer_details?.email; + if (!email) { + return NextResponse.json( + { error: "Missing customer email" }, + { status: 400 } + ); + } + + const name = + session.customer_details?.business_name ?? + session.customer_details?.name ?? + email; + + const contactsResponse = await xero.accountingApi.getContacts( + xeroConn.tenantId, + undefined, + `EmailAddress=="${email}"` + ); + + let contact = contactsResponse.body.contacts?.[0]; + + if (!contact) { + const created = await xero.accountingApi.createContacts( + xeroConn.tenantId, + { + contacts: [{ name, emailAddress: email }], + } + ); + contact = created.body.contacts?.[0]; + } + + if (!contact?.contactID) { + throw new Error("Failed to resolve Xero contact"); + } + + // -------------------------------------------------- + // 7️⃣ Create AUTHORISED invoice + // -------------------------------------------------- + const amount = session.amount_total! / 100; + + if (!session.currency) { + throw new Error("Stripe session missing currency"); + } + + const currencyCode = session.currency.toUpperCase() as keyof typeof CurrencyCode; + + if (!(currencyCode in CurrencyCode)) { + throw new Error(`Unsupported currency: ${session.currency}`); + } + + const invoiceResponse = await xero.accountingApi.createInvoices( + xeroConn.tenantId, + { + invoices: [ + { + type: Invoice.TypeEnum.ACCREC, + status: Invoice.StatusEnum.AUTHORISED, + contact: { contactID: contact.contactID }, + lineItems: [ + { + description: `Stripe payment (${session.id})`, + quantity: 1, + unitAmount: amount, + accountCode: xeroConn.salesAccountCode, + }, + ], + currencyCode: CurrencyCode[currencyCode], + reference: session.id, + }, + ], + } + ); + + const invoice = invoiceResponse.body.invoices?.[0]; + if (!invoice?.invoiceID) { + throw new Error("Failed to create Xero invoice"); + } + + // -------------------------------------------------- + // 8️⃣ Mark invoice as PAID to Stripe Clearing + // + // STRIPE CLEARING (v1 behaviour) + // - Invoices are marked as PAID to a Stripe Clearing account + // - Stripe fees are NOT yet recorded in v1 + // + // TODO (planned): + // - Record Stripe fees as an expense + // - Reconcile Stripe payouts automatically + // -------------------------------------------------- + const paymentReference = + typeof session.payment_intent === "string" + ? session.payment_intent + : session.id; + await xero.accountingApi.createPayments(xeroConn.tenantId, { + payments: [ + { + invoice: { invoiceID: invoice.invoiceID }, + amount, + date: new Date().toISOString().slice(0, 10), + reference: paymentReference, + account: { + code: xeroConn.stripeClearingAccountCode, + }, + }, + ], + }); + + // -------------------------------------------------- + // 9️⃣ Record idempotency AFTER success + // -------------------------------------------------- + await db.insert(processedStripeEvents).values({ + stripeEventId: event.id, + stripeAccountId, + }); + + console.log("✅ Stripe payment fully processed", { + eventId: event.id, + invoiceId: invoice.invoiceID, + stripeAccountId, + }); + + return NextResponse.json({ received: true }); +} diff --git a/stripe_to_invoice/lib/schema/processedStripeEvents.ts b/stripe_to_invoice/lib/schema/processedStripeEvents.ts new file mode 100644 index 0000000..0e9d144 --- /dev/null +++ b/stripe_to_invoice/lib/schema/processedStripeEvents.ts @@ -0,0 +1,10 @@ +import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; + +export const processedStripeEvents = pgTable("processed_stripe_events", { + id: uuid("id").defaultRandom().primaryKey(), + stripeEventId: text("stripe_event_id").notNull().unique(), + stripeAccountId: text("stripe_account_id").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); diff --git a/stripe_to_invoice/lib/schema/xeroConnections.ts b/stripe_to_invoice/lib/schema/xeroConnections.ts index 173681d..572ce66 100644 --- a/stripe_to_invoice/lib/schema/xeroConnections.ts +++ b/stripe_to_invoice/lib/schema/xeroConnections.ts @@ -1,27 +1,18 @@ -import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core"; -import { users } from "./users"; +import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; export const xeroConnections = pgTable("xero_connections", { - id: uuid("id").defaultRandom().primaryKey(), - - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id").notNull(), tenantId: text("tenant_id").notNull(), - // 🔐 OAuth tokens accessToken: text("access_token").notNull(), refreshToken: text("refresh_token").notNull(), - - // When access_token expires expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), + // ✅ ADD THESE + salesAccountCode: text("sales_account_code"), + stripeClearingAccountCode: text("stripe_clearing_account_code"), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), });