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 + -------------------------------------------------- */} +
+ + Connect Xero → + +
+
+ ); +} 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 */} +
    +
  1. + + Logged in +
  2. + +
  3. + + Stripe connected +
  4. + +
  5. + + Xero connected +
  6. +
+ + {/* 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(), +});