diff --git a/.github/workflows/stripe-to-invoice.yml b/.github/workflows/stripe-to-invoice.yml
index 2f82987..7e66301 100644
--- a/.github/workflows/stripe-to-invoice.yml
+++ b/.github/workflows/stripe-to-invoice.yml
@@ -124,17 +124,37 @@ jobs:
STRIPE_SECRET_KEY="$PROD_STRIPE_SECRET_KEY"
STRIPE_CLIENT_ID="$PROD_STRIPE_CLIENT_ID"
APP_URL="$PROD_APP_URL"
+ XERO_CLIENT_ID="$PROD_XERO_CLIENT_ID"
+ XERO_CLIENT_SECRET="$PROD_CLIENT_SECRET"
+ XERO_REDIRECT_URI="$PROD_REDIRECT_URI"
+
+
else
STRIPE_SECRET_KEY="$DEV_STRIPE_SECRET_KEY"
STRIPE_CLIENT_ID="$DEV_STRIPE_CLIENT_ID"
APP_URL="$DEV_APP_URL"
+ XERO_CLIENT_ID="$DEV_XERO_CLIENT_ID"
+ XERO_CLIENT_SECRET="$DEV_CLIENT_SECRET"
+ XERO_REDIRECT_URI="$DEV_REDIRECT_URI"
fi
: "${STRIPE_SECRET_KEY:?missing STRIPE_SECRET_KEY}"
: "${STRIPE_CLIENT_ID:?missing STRIPE_CLIENT_ID}"
: "${APP_URL:?missing APP_URL}"
+ : "${STRIPE_REDIRECT_URI:?missing STRIPE_REDIRECT_URI}"
+ : "${XERO_CLIENT_ID:?missing XERO_CLIENT_ID}"
+ : "${XERO_CLIENT_SECRET:?missing XERO_CLIENT_SECRET}"
+ : "${XERO_REDIRECT_URI:?missing XERO_REDIRECT_URI}"
- export STRIPE_SECRET_KEY STRIPE_CLIENT_ID APP_URL NAMESPACE
+ export \
+ STRIPE_SECRET_KEY \
+ STRIPE_CLIENT_ID \
+ STRIPE_REDIRECT_URI \
+ APP_URL \
+ XERO_CLIENT_ID \
+ XERO_CLIENT_SECRET \
+ XERO_REDIRECT_URI \
+ NAMESPACE
envsubst < stripe_to_invoice/deployment/secrets/stripe-secrets.yaml \
| kubectl apply -f -
diff --git a/db/.env b/db/.env
index 4dcaee0..d7f3828 100644
--- a/db/.env
+++ b/db/.env
@@ -1,4 +1,5 @@
# Dev Stripe-to-invoice
+# postgres-dev.dev.svc.cluster.local
DEV_POSTGRES_USER=postgres
DEV_POSTGRES_PASSWORD=averysecretpasswordPersonAppleWinter938
diff --git a/db/atlas/stripe_invoice/migrations/20260118165004_add_unique_for_xero.sql b/db/atlas/stripe_invoice/migrations/20260118165004_add_unique_for_xero.sql
new file mode 100644
index 0000000..8741258
--- /dev/null
+++ b/db/atlas/stripe_invoice/migrations/20260118165004_add_unique_for_xero.sql
@@ -0,0 +1,7 @@
+-- Ensure one Xero connection per user
+CREATE UNIQUE INDEX xero_connections_user_unique
+ON xero_connections (user_id);
+
+-- Prevent the same Xero organisation being linked twice
+CREATE UNIQUE INDEX xero_connections_tenant_unique
+ON xero_connections (tenant_id);
diff --git a/db/atlas/stripe_invoice/migrations/atlas.sum b/db/atlas/stripe_invoice/migrations/atlas.sum
index ee95fa4..5b9f45d 100644
--- a/db/atlas/stripe_invoice/migrations/atlas.sum
+++ b/db/atlas/stripe_invoice/migrations/atlas.sum
@@ -1,4 +1,4 @@
-h1:DR4yJ9fatAVhOP+U23Yz+bOzijyhq/720tACLkaFuXw=
+h1:FS8jSKRjrxTpVXMVhNisHxEgUk/fmiQEEpBMvdqVh88=
0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs=
0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw=
0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg=
@@ -6,3 +6,4 @@ h1:DR4yJ9fatAVhOP+U23Yz+bOzijyhq/720tACLkaFuXw=
20251228182659_add_used_at_to_login_tokens.sql h1:/0puYQvwBFzpfSKjiZj2XR/7Mui39lS/IbFZW1TPQOc=
20251230154354_add_used_at_to_login_tokens.sql h1:FIP2MMRnfhi4hmFC3VBuABZZrxZQ1icranrXy0ljERc=
20260118151944_add_unique_index_to_stripe_accounts.sql h1:PyI8cM8Xyn4bG7BBlD7YRwK1iRQ8HPfzf0r1+Swfe1Y=
+20260118165004_add_unique_for_xero.sql h1:/qk/tJiDo6wMnOdDnmEjKMwx2TmxpdQWmpdliaw6xZ8=
diff --git a/stripe_to_invoice/app/api/xero/callback/route.ts b/stripe_to_invoice/app/api/xero/callback/route.ts
new file mode 100644
index 0000000..0a16570
--- /dev/null
+++ b/stripe_to_invoice/app/api/xero/callback/route.ts
@@ -0,0 +1,102 @@
+import { cookies } from "next/headers";
+import { NextRequest, NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { xeroConnections } from "@/lib/schema/xeroConnections";
+import { eq } from "drizzle-orm";
+
+type XeroTokenResponse = {
+ access_token: string;
+ refresh_token: string;
+};
+
+type XeroTenant = {
+ tenantId: string;
+};
+
+export async function GET(req: NextRequest) {
+ const cookieStore = await cookies();
+ const session = cookieStore.get("session");
+
+ // Must be logged in
+ if (!session) {
+ return NextResponse.redirect(
+ new URL("/login", process.env.APP_URL)
+ );
+ }
+
+ const userId = session.value;
+
+ const { searchParams } = new URL(req.url);
+ const code = searchParams.get("code");
+
+ if (!code) {
+ return NextResponse.json(
+ { error: "Missing OAuth code" },
+ { status: 400 }
+ );
+ }
+
+ // Exchange code for token
+ const tokenRes = await fetch("https://identity.xero.com/connect/token", {
+ method: "POST",
+ headers: {
+ Authorization:
+ "Basic " +
+ Buffer.from(
+ `${process.env.XERO_CLIENT_ID}:${process.env.XERO_CLIENT_SECRET}`
+ ).toString("base64"),
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams({
+ grant_type: "authorization_code",
+ code,
+ redirect_uri: process.env.XERO_REDIRECT_URI!,
+ }),
+ });
+
+ if (!tokenRes.ok) {
+ const text = await tokenRes.text();
+ console.error("Xero token exchange failed:", text);
+ return NextResponse.redirect(
+ new URL("/connect/xero?error=token_failed", process.env.APP_URL)
+ );
+ }
+
+ const tokenData = (await tokenRes.json()) as XeroTokenResponse;
+
+ // Fetch connected tenants (organisations)
+ const tenantRes = await fetch(
+ "https://api.xero.com/connections",
+ {
+ headers: {
+ Authorization: `Bearer ${tokenData.access_token}`,
+ },
+ }
+ );
+
+ const tenants = (await tenantRes.json()) as XeroTenant[];
+ const tenantId = tenants[0]?.tenantId;
+
+ if (!tenantId) {
+ return NextResponse.json(
+ { error: "No Xero organisation found" },
+ { status: 400 }
+ );
+ }
+
+ // Save user ↔ tenant (minimal MVP)
+ await db
+ .insert(xeroConnections)
+ .values({
+ userId,
+ tenantId,
+ })
+ .onConflictDoUpdate({
+ target: xeroConnections.userId,
+ set: { tenantId },
+ });
+
+ return NextResponse.redirect(
+ new URL("/connect/xero/success", process.env.APP_URL)
+ );
+}
diff --git a/stripe_to_invoice/app/api/xero/connect/route.ts b/stripe_to_invoice/app/api/xero/connect/route.ts
new file mode 100644
index 0000000..4b81ab2
--- /dev/null
+++ b/stripe_to_invoice/app/api/xero/connect/route.ts
@@ -0,0 +1,20 @@
+import { NextResponse } from "next/server";
+
+export async function GET() {
+ const params = new URLSearchParams({
+ response_type: "code",
+ client_id: process.env.XERO_CLIENT_ID!,
+ redirect_uri: process.env.XERO_REDIRECT_URI!,
+ scope: [
+ "offline_access",
+ "accounting.transactions",
+ "accounting.contacts",
+ "accounting.settings",
+ ].join(" "),
+ state: "xero_oauth",
+ });
+
+ return NextResponse.redirect(
+ `https://login.xero.com/identity/connect/authorize?${params}`
+ );
+}
diff --git a/stripe_to_invoice/app/connect/stripe/success/page.tsx b/stripe_to_invoice/app/connect/stripe/success/page.tsx
index c46b272..85efe16 100644
--- a/stripe_to_invoice/app/connect/stripe/success/page.tsx
+++ b/stripe_to_invoice/app/connect/stripe/success/page.tsx
@@ -8,8 +8,8 @@ export default function StripeSuccessPage() {
- Your Stripe account is now linked. We can now automate payments and
- reconciliation for you.
+ Your Stripe account is now linked. We can now detect successful
+ payments and automatically reconcile invoices in Xero.
{/* Progress */}
@@ -31,19 +31,12 @@ export default function StripeSuccessPage() {
{/* Primary CTA */}
-
+
- Continue setup
-
-
-
- Go to dashboard
+ Continue → Connect Xero
diff --git a/stripe_to_invoice/app/connect/xero/page.tsx b/stripe_to_invoice/app/connect/xero/page.tsx
new file mode 100644
index 0000000..30afae5
--- /dev/null
+++ b/stripe_to_invoice/app/connect/xero/page.tsx
@@ -0,0 +1,74 @@
+// STEP 3 — Connect Xero
+// Purpose:
+// - Explain why Xero access is needed
+// - Make the next step obvious
+// - Match the Stripe connect page exactly
+
+import { cookies } from "next/headers";
+import { redirect } from "next/navigation";
+
+export default async function ConnectXeroPage() {
+ const cookieStore = await cookies();
+ const session = cookieStore.get("session");
+
+ // Safety: if not logged in, bounce to login
+ if (!session) {
+ redirect("/login");
+ }
+
+ return (
+
+ {/* --------------------------------------------------
+ Header
+ -------------------------------------------------- */}
+
+
+ Connect Xero
+
+
+
+ We need access to your Xero organisation so we can automatically
+ create invoices and mark them as paid when Stripe payments succeed.
+
+
+
+ {/* --------------------------------------------------
+ What will happen
+ -------------------------------------------------- */}
+
+
+ What happens next
+
+
+
+ You’ll be redirected to Xero
+ You’ll choose which organisation to connect
+ You’ll be sent back here once connected
+
+
+
+ {/* --------------------------------------------------
+ Trust / reassurance
+ -------------------------------------------------- */}
+
+
+ We never see your Xero password.
+
+ Access can be revoked at any time from Xero.
+
+
+
+ {/* --------------------------------------------------
+ Primary action
+ -------------------------------------------------- */}
+
+
+ );
+}
diff --git a/stripe_to_invoice/app/connect/xero/success/page.tsx b/stripe_to_invoice/app/connect/xero/success/page.tsx
new file mode 100644
index 0000000..46dd5c9
--- /dev/null
+++ b/stripe_to_invoice/app/connect/xero/success/page.tsx
@@ -0,0 +1,44 @@
+import Link from "next/link";
+
+export default function XeroSuccessPage() {
+ return (
+
+
+ Xero connected 🎉
+
+
+
+ Your Xero organisation is now linked. We can now automatically
+ create invoices and mark them as paid when Stripe payments succeed.
+
+
+ {/* Progress */}
+
+
+ ✔
+ Logged in
+
+
+
+ ✔
+ Stripe connected
+
+
+
+ ✔
+ Xero connected
+
+
+
+ {/* Primary CTA */}
+
+
+ Go to dashboard →
+
+
+
+ );
+}
diff --git a/stripe_to_invoice/deployment/deployment.yaml b/stripe_to_invoice/deployment/deployment.yaml
index 750ccf6..76e867d 100644
--- a/stripe_to_invoice/deployment/deployment.yaml
+++ b/stripe_to_invoice/deployment/deployment.yaml
@@ -84,6 +84,24 @@ spec:
name: stripe-secrets
key: STRIPE_REDIRECT_URI
+ - name: XERO_CLIENT_ID
+ valueFrom:
+ secretKeyRef:
+ name: stripe-secrets
+ key: XERO_CLIENT_ID
+
+ - name: XERO_CLIENT_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: stripe-secrets
+ key: XERO_CLIENT_SECRET
+
+ - name: XERO_REDIRECT_URI
+ valueFrom:
+ secretKeyRef:
+ name: stripe-secrets
+ key: XERO_REDIRECT_URI
+
imagePullSecrets:
- name: registrypullsecret
diff --git a/stripe_to_invoice/deployment/secrets/.env b/stripe_to_invoice/deployment/secrets/.env
index f3f109b..315b2d3 100644
--- a/stripe_to_invoice/deployment/secrets/.env
+++ b/stripe_to_invoice/deployment/secrets/.env
@@ -7,6 +7,9 @@ DEV_AWS_ACCESS_KEY_ID=AKIAQL67W6HI2547OPVG
DEV_AWS_SECRET_ACCESS_KEY=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
DEV_SES_FROM_EMAIL=no-reply@juntekim.com
DEV_STRIPE_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/stripe/callback
+DEV_XERO_CLIENT_ID=4C24EEA5583046519AD39B3905ED2BD3
+DEV_XERO_SECRET_KEY=PAYDhzqMLvNtPrN5vDC7iwtXkgu99yG8Gbu86IlrdHH8hGjA
+DEV_XERO_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/connect/xero/callback
# Prod
@@ -18,4 +21,7 @@ PROD_AWS_ACCESS_KEY_ID=AKIAQL67W6HI2547OPVG
PROD_AWS_SECRET_ACCESS_KEY=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
PROD_SES_FROM_EMAIL=no-reply@juntekim.com
PROD_STRIPE_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/stripe/callback
+PROD_XERO_CLIENT_ID=4C24EEA5583046519AD39B3905ED2BD3
+PROD_XERO_SECRET_KEY=PAYDhzqMLvNtPrN5vDC7iwtXkgu99yG8Gbu86IlrdHH8hGjA
+PROD_XERO_REDIRECT_URI=https://stripe-to-invoice.juntekim.com/api/connect/xero/callback
diff --git a/stripe_to_invoice/lib/schema/xeroConnections.ts b/stripe_to_invoice/lib/schema/xeroConnections.ts
new file mode 100644
index 0000000..eeb0c4b
--- /dev/null
+++ b/stripe_to_invoice/lib/schema/xeroConnections.ts
@@ -0,0 +1,13 @@
+import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core";
+import { users } from "./users";
+
+export const xeroConnections = pgTable("xero_connections", {
+ id: uuid("id").defaultRandom().primaryKey(),
+ userId: uuid("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ tenantId: text("tenant_id").notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+});