From c3651b169503d24625c5cde9973a741e718968fb Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 21 Jan 2026 00:02:04 +0000 Subject: [PATCH] ensure correct env --- .github/workflows/stripe-to-invoice.yml | 7 +- .../app/api/stripe/webhook/route.ts | 87 ++++++------------- stripe_to_invoice/lib/xero/service.ts | 24 ++++- 3 files changed, 53 insertions(+), 65 deletions(-) diff --git a/.github/workflows/stripe-to-invoice.yml b/.github/workflows/stripe-to-invoice.yml index f3bc1d0..73a009f 100644 --- a/.github/workflows/stripe-to-invoice.yml +++ b/.github/workflows/stripe-to-invoice.yml @@ -128,6 +128,7 @@ jobs: XERO_CLIENT_ID="$PROD_XERO_CLIENT_ID" XERO_CLIENT_SECRET="$PROD_XERO_SECRET_KEY" XERO_REDIRECT_URI="$PROD_REDIRECT_URI" + AWS_REGION="$DEV_AWS_REGION" else @@ -138,6 +139,7 @@ jobs: XERO_CLIENT_ID="$DEV_XERO_CLIENT_ID" XERO_CLIENT_SECRET="$DEV_XERO_SECRET_KEY" XERO_REDIRECT_URI="$DEV_XERO_REDIRECT_URI" + AWS_REGION="$PROD_AWS_REGION" fi : "${STRIPE_SECRET_KEY:?missing STRIPE_SECRET_KEY}" @@ -147,6 +149,7 @@ jobs: : "${XERO_CLIENT_ID:?missing XERO_CLIENT_ID}" : "${XERO_CLIENT_SECRET:?missing XERO_CLIENT_SECRET}" : "${XERO_REDIRECT_URI:?missing XERO_REDIRECT_URI}" + : "${AWS_REGION:?missing AWS_REGION}" export \ STRIPE_SECRET_KEY \ @@ -156,7 +159,9 @@ jobs: XERO_CLIENT_ID \ XERO_CLIENT_SECRET \ XERO_REDIRECT_URI \ - NAMESPACE + AWS_REGION \ + NAMESPACE + envsubst < stripe_to_invoice/deployment/secrets/stripe-secrets.yaml \ | kubectl apply -f - diff --git a/stripe_to_invoice/app/api/stripe/webhook/route.ts b/stripe_to_invoice/app/api/stripe/webhook/route.ts index 4f9f5fe..0be4f4f 100644 --- a/stripe_to_invoice/app/api/stripe/webhook/route.ts +++ b/stripe_to_invoice/app/api/stripe/webhook/route.ts @@ -3,18 +3,20 @@ export const dynamic = "force-dynamic"; export const revalidate = 0; import { NextRequest, NextResponse } from "next/server"; -import { getStripe } from "@/lib/stripe/service"; -import { XeroClient, Invoice, CurrencyCode } from "xero-node"; import Stripe from "stripe"; +import { Invoice, CurrencyCode } from "xero-node"; +import { eq } from "drizzle-orm"; + +import { getStripe } from "@/lib/stripe/service"; +import { getXeroClient } from "@/lib/xero/service"; +import { getValidXeroAccessToken } from "@/lib/xero/auth"; + import { db } from "@/lib/db"; import { stripeAccounts, xeroConnections, processedStripeEvents, } from "@/lib/schema"; -import { eq } from "drizzle-orm"; -import { getXeroClient } from "@/lib/xero/service"; - const stripe = getStripe(); @@ -98,7 +100,6 @@ export async function POST(req: NextRequest) { .limit(1); if (!xeroConn) { - console.error("❌ No Xero connection for user:", stripeAccount.userId); return NextResponse.json( { error: "User has no Xero connection" }, { status: 500 } @@ -107,39 +108,15 @@ export async function POST(req: NextRequest) { if (!xeroConn.salesAccountCode || !xeroConn.stripeClearingAccountCode) { throw new Error( - "Xero account codes not configured. User must select Sales and Stripe Clearing accounts." + "Xero account codes not configured (sales / stripe clearing)" ); } // -------------------------------------------------- - // 5️⃣ Init Xero client + refresh token if needed + // 5️⃣ Get VALID Xero access token (refresh handled centrally) // -------------------------------------------------- - const xero = getXeroClient(); - - 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); - } + const accessToken = await getValidXeroAccessToken(stripeAccount.userId); + const xero = getXeroClient(accessToken); // -------------------------------------------------- // 6️⃣ Resolve contact (email-only) @@ -168,9 +145,7 @@ export async function POST(req: NextRequest) { if (!contact) { const created = await xero.accountingApi.createContacts( xeroConn.tenantId, - { - contacts: [{ name, emailAddress: email }], - } + { contacts: [{ name, emailAddress: email }] } ); contact = created.body.contacts?.[0]; } @@ -182,15 +157,14 @@ export async function POST(req: NextRequest) { // -------------------------------------------------- // 7️⃣ Create AUTHORISED invoice // -------------------------------------------------- - const amount = session.amount_total! / 100; - - if (!session.currency) { - throw new Error("Stripe session missing currency"); + if (!session.amount_total || !session.currency) { + throw new Error("Stripe session missing amount or currency"); } - const currencyCode = session.currency.toUpperCase() as keyof typeof CurrencyCode; + const amount = session.amount_total / 100; + const currencyKey = session.currency.toUpperCase() as keyof typeof CurrencyCode; - if (!(currencyCode in CurrencyCode)) { + if (!(currencyKey in CurrencyCode)) { throw new Error(`Unsupported currency: ${session.currency}`); } @@ -210,7 +184,7 @@ export async function POST(req: NextRequest) { accountCode: xeroConn.salesAccountCode, }, ], - currencyCode: CurrencyCode[currencyCode], + currencyCode: CurrencyCode[currencyKey], reference: session.id, }, ], @@ -223,20 +197,13 @@ export async function POST(req: NextRequest) { } // -------------------------------------------------- - // 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 + // 8️⃣ Mark invoice as PAID → Stripe Clearing // -------------------------------------------------- const paymentReference = - typeof session.payment_intent === "string" - ? session.payment_intent - : session.id; + typeof session.payment_intent === "string" + ? session.payment_intent + : session.id; + await xero.accountingApi.createPayments(xeroConn.tenantId, { payments: [ { @@ -244,22 +211,20 @@ export async function POST(req: NextRequest) { amount, date: new Date().toISOString().slice(0, 10), reference: paymentReference, - account: { - code: xeroConn.stripeClearingAccountCode, - }, + account: { code: xeroConn.stripeClearingAccountCode }, }, ], }); // -------------------------------------------------- - // 9️⃣ Record idempotency AFTER success + // 9️⃣ Record idempotency (LAST STEP) // -------------------------------------------------- await db.insert(processedStripeEvents).values({ stripeEventId: event.id, stripeAccountId, }); - console.log("✅ Stripe payment fully processed", { + console.log("✅ Stripe → Xero sync complete", { eventId: event.id, invoiceId: invoice.invoiceID, stripeAccountId, diff --git a/stripe_to_invoice/lib/xero/service.ts b/stripe_to_invoice/lib/xero/service.ts index ce651d2..c4d1ac5 100644 --- a/stripe_to_invoice/lib/xero/service.ts +++ b/stripe_to_invoice/lib/xero/service.ts @@ -1,5 +1,23 @@ import { XeroClient } from "xero-node"; -export function getXeroClient(): XeroClient { - return new XeroClient(); -} \ No newline at end of file +/** + * Creates a XeroClient using an already-valid access token. + * + * IMPORTANT: + * - Token refresh is handled elsewhere (auth.ts) + * - This client MUST NOT call refreshToken() + */ +export function getXeroClient(accessToken: string): XeroClient { + if (!accessToken) { + throw new Error("Xero access token missing"); + } + + const xero = new XeroClient(); + + xero.setTokenSet({ + access_token: accessToken, + token_type: "Bearer", + }); + + return xero; +}