added new migration stuff

This commit is contained in:
Jun-te Kim 2026-01-18 16:53:06 +00:00
parent b02ee5f74b
commit f285e97843
12 changed files with 313 additions and 14 deletions

View file

@ -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 -

View file

@ -1,4 +1,5 @@
# Dev Stripe-to-invoice
# postgres-dev.dev.svc.cluster.local
DEV_POSTGRES_USER=postgres
DEV_POSTGRES_PASSWORD=averysecretpasswordPersonAppleWinter938

View file

@ -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);

View file

@ -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=

View file

@ -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)
);
}

View file

@ -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}`
);
}

View file

@ -8,8 +8,8 @@ export default function StripeSuccessPage() {
</h1>
<p className="text-gray-600">
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.
</p>
{/* Progress */}
@ -31,19 +31,12 @@ export default function StripeSuccessPage() {
</ol>
{/* Primary CTA */}
<div className="pt-6 flex gap-4">
<div className="pt-6 border-t">
<Link
href="/app"
href="/connect/xero"
className="inline-block rounded bg-black text-white px-5 py-3"
>
Continue setup
</Link>
<Link
href="/app"
className="inline-block rounded border px-5 py-3"
>
Go to dashboard
Continue Connect Xero
</Link>
</div>
</main>

View file

@ -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 (
<main className="max-w-2xl mx-auto p-8 space-y-10">
{/* --------------------------------------------------
Header
-------------------------------------------------- */}
<section>
<h1 className="text-2xl font-semibold">
Connect Xero
</h1>
<p className="mt-3 text-gray-700">
We need access to your Xero organisation so we can automatically
create invoices and mark them as paid when Stripe payments succeed.
</p>
</section>
{/* --------------------------------------------------
What will happen
-------------------------------------------------- */}
<section>
<h2 className="text-lg font-medium">
What happens next
</h2>
<ul className="mt-3 space-y-2 list-disc list-inside text-gray-700">
<li>Youll be redirected to Xero</li>
<li>Youll choose which organisation to connect</li>
<li>Youll be sent back here once connected</li>
</ul>
</section>
{/* --------------------------------------------------
Trust / reassurance
-------------------------------------------------- */}
<section className="text-sm text-gray-600">
<p>
We never see your Xero password.
<br />
Access can be revoked at any time from Xero.
</p>
</section>
{/* --------------------------------------------------
Primary action
-------------------------------------------------- */}
<section className="pt-4 border-t">
<a
href="/api/xero/connect"
className="inline-block px-6 py-3 bg-black text-white rounded text-sm"
>
Connect Xero
</a>
</section>
</main>
);
}

View file

@ -0,0 +1,44 @@
import Link from "next/link";
export default function XeroSuccessPage() {
return (
<main className="max-w-2xl mx-auto p-8 space-y-10">
<h1 className="text-2xl font-semibold">
Xero connected 🎉
</h1>
<p className="text-gray-600">
Your Xero organisation is now linked. We can now automatically
create invoices and mark them as paid when Stripe payments succeed.
</p>
{/* Progress */}
<ol className="space-y-4">
<li className="flex items-center gap-3">
<span className="text-green-600"></span>
<span>Logged in</span>
</li>
<li className="flex items-center gap-3">
<span className="text-green-600"></span>
<span>Stripe connected</span>
</li>
<li className="flex items-center gap-3">
<span className="text-green-600"></span>
<span>Xero connected</span>
</li>
</ol>
{/* Primary CTA */}
<div className="pt-6 border-t">
<Link
href="/app"
className="inline-block rounded bg-black text-white px-5 py-3"
>
Go to dashboard
</Link>
</div>
</main>
);
}

View file

@ -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

View file

@ -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

View file

@ -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(),
});