added new migration stuff
This commit is contained in:
parent
b02ee5f74b
commit
f285e97843
12 changed files with 313 additions and 14 deletions
22
.github/workflows/stripe-to-invoice.yml
vendored
22
.github/workflows/stripe-to-invoice.yml
vendored
|
|
@ -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 -
|
||||
|
|
|
|||
1
db/.env
1
db/.env
|
|
@ -1,4 +1,5 @@
|
|||
# Dev Stripe-to-invoice
|
||||
# postgres-dev.dev.svc.cluster.local
|
||||
DEV_POSTGRES_USER=postgres
|
||||
DEV_POSTGRES_PASSWORD=averysecretpasswordPersonAppleWinter938
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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=
|
||||
|
|
|
|||
102
stripe_to_invoice/app/api/xero/callback/route.ts
Normal file
102
stripe_to_invoice/app/api/xero/callback/route.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
20
stripe_to_invoice/app/api/xero/connect/route.ts
Normal file
20
stripe_to_invoice/app/api/xero/connect/route.ts
Normal 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}`
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
74
stripe_to_invoice/app/connect/xero/page.tsx
Normal file
74
stripe_to_invoice/app/connect/xero/page.tsx
Normal 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>You’ll be redirected to Xero</li>
|
||||
<li>You’ll choose which organisation to connect</li>
|
||||
<li>You’ll 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>
|
||||
);
|
||||
}
|
||||
44
stripe_to_invoice/app/connect/xero/success/page.tsx
Normal file
44
stripe_to_invoice/app/connect/xero/success/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ DEV_AWS_ACCESS_KEY_ID=AKIAQL67W6HI2547OPVG
|
|||
DEV_AWS_SECRET_ACCESS_KEY=qCTirw/OCdw6P2aVknGlyh8MQVMmOkrm0NrXTz4j
|
||||
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=qCTirw/OCdw6P2aVknGlyh8MQVMmOkrm0NrXTz4j
|
||||
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
|
||||
|
||||
|
|
|
|||
13
stripe_to_invoice/lib/schema/xeroConnections.ts
Normal file
13
stripe_to_invoice/lib/schema/xeroConnections.ts
Normal 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(),
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue