added new scripts
This commit is contained in:
parent
64c8bb321b
commit
80dd2e5710
8 changed files with 302 additions and 19 deletions
|
|
@ -1,2 +1,2 @@
|
||||||
atlas migrate new add_stripe_history
|
atlas migrate new add_invoice_code
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
);
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
h1:0fwTZVrBXWktKhhAG17BTid9zjj4i3TUkkgQY7u5XEU=
|
h1:ZGGgmFGh8vPWzpumfnp/KWIz6dmAFtIg/tJmVP+w0CU=
|
||||||
0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs=
|
0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs=
|
||||||
0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw=
|
0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw=
|
||||||
0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg=
|
0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg=
|
||||||
|
|
@ -10,3 +10,4 @@ h1:0fwTZVrBXWktKhhAG17BTid9zjj4i3TUkkgQY7u5XEU=
|
||||||
20260118191050_add_more_info_on_xero_for_refresh_token.sql h1:cIQZ81Q7mBX4o8Xb6k3CGSFFw9KoVbZgfYxhOtxxvu4=
|
20260118191050_add_more_info_on_xero_for_refresh_token.sql h1:cIQZ81Q7mBX4o8Xb6k3CGSFFw9KoVbZgfYxhOtxxvu4=
|
||||||
20260118211854_add_last_updated_at.sql h1:y01AhrpxYmYWIIn9La73twwrfJteCj0r5PovRCiQoh4=
|
20260118211854_add_last_updated_at.sql h1:y01AhrpxYmYWIIn9La73twwrfJteCj0r5PovRCiQoh4=
|
||||||
20260120223114_add_stripe_history.sql h1:+l14lHGfyoNBGh1w9TqOuxmETe1Bgo1sry1aXrvt4bU=
|
20260120223114_add_stripe_history.sql h1:+l14lHGfyoNBGh1w9TqOuxmETe1Bgo1sry1aXrvt4bU=
|
||||||
|
20260120230059_add_invoice_code.sql h1:9uItaHRhcuSuxnoqMOwxyPxiOUdm2+gadRZDeSwLmSY=
|
||||||
|
|
|
||||||
1
package.json
Normal file
1
package.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
||||||
265
stripe_to_invoice/app/api/stripe/webhook/route.ts
Normal file
265
stripe_to_invoice/app/api/stripe/webhook/route.ts
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
10
stripe_to_invoice/lib/schema/processedStripeEvents.ts
Normal file
10
stripe_to_invoice/lib/schema/processedStripeEvents.ts
Normal file
|
|
@ -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(),
|
||||||
|
});
|
||||||
|
|
@ -1,27 +1,18 @@
|
||||||
import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core";
|
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||||
import { users } from "./users";
|
|
||||||
|
|
||||||
export const xeroConnections = pgTable("xero_connections", {
|
export const xeroConnections = pgTable("xero_connections", {
|
||||||
id: uuid("id").defaultRandom().primaryKey(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id").notNull(),
|
||||||
userId: uuid("user_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
|
||||||
|
|
||||||
tenantId: text("tenant_id").notNull(),
|
tenantId: text("tenant_id").notNull(),
|
||||||
|
|
||||||
// 🔐 OAuth tokens
|
|
||||||
accessToken: text("access_token").notNull(),
|
accessToken: text("access_token").notNull(),
|
||||||
refreshToken: text("refresh_token").notNull(),
|
refreshToken: text("refresh_token").notNull(),
|
||||||
|
|
||||||
// When access_token expires
|
|
||||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||||
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
// ✅ ADD THESE
|
||||||
.notNull()
|
salesAccountCode: text("sales_account_code"),
|
||||||
.defaultNow(),
|
stripeClearingAccountCode: text("stripe_clearing_account_code"),
|
||||||
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
|
||||||
.notNull()
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
|
||||||
.defaultNow(),
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue