diff --git a/backend/src/external/quickbooks/client.ts b/backend/src/external/quickbooks/client.ts index 1867be6a..ec380d1a 100644 --- a/backend/src/external/quickbooks/client.ts +++ b/backend/src/external/quickbooks/client.ts @@ -11,6 +11,9 @@ import { const PROD_BASE_URL = "https://quickbooks.api.intuit.com"; const SANDBOX_BASE_URL = "https://sandbox-quickbooks.api.intuit.com"; +const PROD_PRISERE_API_URL = process.env.PROD_PRISERE_API_URL; +const DEV_PRISERE_API_URL = process.env.DEV_PRISERE_API_URL; + export const QB_SCOPES = { accounting: "com.intuit.quickbooks.accounting", payment: "com.intuit.quickbooks.payment", @@ -71,11 +74,9 @@ export class QuickbooksClient implements IQuickbooksClient { this.BASE_URL = environment === "production" ? PROD_BASE_URL : SANDBOX_BASE_URL; this.redirectUri = environment === "production" - ? // TODO: get a real redirect for prod - "" - : // TODO: finalize if this is the real route we want to redirect to, I think we need a better frontend page or something, - // maybe we redirect from the server after going here? - "http://localhost:3001/quickbooks/redirect"; + ? // Note: Quickbooks Redirect URI was updated to match these URLs: + `${PROD_PRISERE_API_URL}/quickbooks/redirect` + : `${DEV_PRISERE_API_URL}/quickbooks/redirect`; } public generateUrl({ scopes }: { scopes: (keyof typeof QB_SCOPES)[] }) { diff --git a/backend/src/modules/company/transaction.ts b/backend/src/modules/company/transaction.ts index 28a260b3..fa6864a0 100644 --- a/backend/src/modules/company/transaction.ts +++ b/backend/src/modules/company/transaction.ts @@ -93,7 +93,12 @@ export class CompanyTransaction implements ICompanyTransaction { } try { - const result: Company | null = await this.db.getRepository(Company).findOneBy({ id: payload.id }); + const result: Company | null = await this.db.getRepository(Company).findOne({ + where: { id: payload.id }, + relations: { + externals: true, + }, + }); return result; } catch (error) { diff --git a/backend/src/modules/openapi/quickbooks-routes.ts b/backend/src/modules/openapi/quickbooks-routes.ts index 048a9666..bdd82d81 100644 --- a/backend/src/modules/openapi/quickbooks-routes.ts +++ b/backend/src/modules/openapi/quickbooks-routes.ts @@ -22,7 +22,7 @@ export const addOpenApiQBRoutes = (openApi: OpenAPIHono, db: DataSource): OpenAP const client = new QuickbooksClient({ clientId: process.env.QUICKBOOKS_CLIENT_ID!, clientSecret: process.env.QUICKBOOKS_CLIENT_SECRET!, - environment: "sandbox", + environment: process.env.NODE_ENV === "production" ? "production" : "sandbox", }); const service = new QuickbooksService( @@ -48,8 +48,15 @@ const generateAuthRoute = createRoute({ summary: "Generates an OAuth URL for the user and redirects them", tags: ["quickbooks"], responses: { - 302: { - description: "Redirected to QuickBooks OAuth url", + 200: { + description: "Successfully redirected to quickbooks auth", + content: { + "application/json": { + schema: z.object({ + url: z.string(), + }), + }, + }, }, }, }); @@ -65,18 +72,11 @@ const generateSessionRoute = createRoute({ params: RedirectEndpointSuccessParams, }, responses: { - 200: { - description: "Successfully logged in through QB", - content: { - "application/json": { - schema: z.object({ - success: z.literal(true), - }), - }, - }, + 302: { + description: "Successfully authenticated, redirecting", }, 400: { - description: "Did not grant permissions", + description: "Authentication failed", content: { "application/json": { schema: z.object({ diff --git a/backend/src/modules/quickbooks/controller.ts b/backend/src/modules/quickbooks/controller.ts index e2cbcbde..4fc4aba0 100644 --- a/backend/src/modules/quickbooks/controller.ts +++ b/backend/src/modules/quickbooks/controller.ts @@ -6,10 +6,8 @@ import Boom from "@hapi/boom"; import { validate } from "uuid"; export interface IQuickbooksController { - redirectToAuthorization(ctx: Context): ControllerResponse>; - generateSession( - ctx: Context - ): ControllerResponse | TypedResponse<{ error: string }, 400>>; + redirectToAuthorization(ctx: Context): ControllerResponse>; + generateSession(ctx: Context): Promise; updateUnprocessedInvoices(ctx: Context): ControllerResponse>; importQuickbooksData(ctx: Context): ControllerResponse>; } @@ -22,7 +20,7 @@ export class QuickbooksController implements IQuickbooksController { const { url } = await this.service.generateAuthUrl({ userId }); - return ctx.redirect(url); + return ctx.json({ url }, 200); } async generateSession(ctx: Context) { @@ -36,7 +34,7 @@ export class QuickbooksController implements IQuickbooksController { await this.service.createQuickbooksSession(params); - return ctx.json({ success: true }, 200); + return ctx.redirect(`${process.env.FRONTEND_URL || "http://localhost:3000"}`); } async updateUnprocessedInvoices(ctx: Context) { diff --git a/backend/src/modules/quickbooks/routes.ts b/backend/src/modules/quickbooks/routes.ts index 641dff76..f5de70e5 100644 --- a/backend/src/modules/quickbooks/routes.ts +++ b/backend/src/modules/quickbooks/routes.ts @@ -22,7 +22,7 @@ export function quickbooksRoutes(db: DataSource) { const client = new QuickbooksClient({ clientId: process.env.QUICKBOOKS_CLIENT_ID!, clientSecret: process.env.QUICKBOOKS_CLIENT_SECRET!, - environment: "sandbox", // TODO: dev vs. prod + environment: process.env.NODE_ENV === "production" ? "production" : "sandbox", }); const service = new QuickbooksService( diff --git a/backend/src/modules/quickbooks/service.ts b/backend/src/modules/quickbooks/service.ts index 963eaace..cdaed747 100644 --- a/backend/src/modules/quickbooks/service.ts +++ b/backend/src/modules/quickbooks/service.ts @@ -90,7 +90,7 @@ export class QuickbooksService implements IQuickbooksService { if (!external) { if (!maybeToken.initiatorUser.companyId) { - throw Boom.badRequest("The requesting user deos not belong to a company"); + throw Boom.badRequest("The requesting user does not belong to a company"); } await this.transaction.createCompanyRealm({ diff --git a/backend/src/server.ts b/backend/src/server.ts index 60ed12ad..29faca76 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -38,7 +38,15 @@ const app = new Hono(); const apiPrefix = process.env.NODE_ENV === "production" ? "/prisere" : "/api/prisere"; - app.use(`${apiPrefix}/*`, isAuthorized()); + app.use(`${apiPrefix}/*`, async (ctx, next) => { + // Skip the middleware for the /quickbooks/redirect route + if (ctx.req.path === `${apiPrefix}/quickbooks/redirect`) { + return next(); + } + + // Apply the isAuthorized middleware for all other routes + return isAuthorized()(ctx, next); + }); setUpRoutes(app, AppDataSource, apiPrefix); diff --git a/backend/terraform/INSTRUCTIONS.md b/backend/terraform/INSTRUCTIONS.md index e80de282..4e2d4b1d 100644 --- a/backend/terraform/INSTRUCTIONS.md +++ b/backend/terraform/INSTRUCTIONS.md @@ -41,6 +41,7 @@ export TF_VAR_ses_from_email=$(grep SES_FROM_EMAIL ../.env | cut -d '=' -f2 | tr export TF_VAR_sqs_queue_url=$(grep SQS_QUEUE_URL_PROD ../.env | cut -d '=' -f2 | tr -d '"') ``` ## Deploying the resources +NOTE: We can only have 2 Access key ID/secrets per account - Only necesarry to do when the resources: 1) Dont exist in AWS (need to be created) 2) You need to update any AWS resources (It does not matter what you need to change/update) diff --git a/frontend/actions/auth.ts b/frontend/actions/auth.ts index dfaa43f7..56c2d4c5 100644 --- a/frontend/actions/auth.ts +++ b/frontend/actions/auth.ts @@ -1,10 +1,11 @@ "use server"; import { revalidatePath } from "next/cache"; -import { redirect } from "next/navigation"; import { createSupabaseClient } from "@/utils/supabase/server"; import { loginInitialState, requiredOnboardingProgress, signupInitialState } from "@/types/user"; import { createClient } from "@supabase/supabase-js"; +import { importQuickbooksData } from "@/api/quickbooks"; +import { getCompany } from "@/api/company"; export async function login(prevState: loginInitialState, formData: FormData) { const supabase = await createSupabaseClient(); @@ -19,8 +20,14 @@ export async function login(prevState: loginInitialState, formData: FormData) { message: error.message || "Login failed", }; } + + const company = await getCompany(); + if (company?.externals) { + importQuickbooksData(); + } + revalidatePath("/", "layout"); - redirect("/"); + return { success: true, message: "Login successful" }; } export async function logoutUser() { diff --git a/frontend/api/quickbooks.ts b/frontend/api/quickbooks.ts index b7cad24a..f37ec878 100644 --- a/frontend/api/quickbooks.ts +++ b/frontend/api/quickbooks.ts @@ -18,3 +18,22 @@ export const importQuickbooksData = async (): Promise<{ success: true } | undefi }; return authWrapper<{ success: true } | undefined>()(req); }; + +export const redirectToQuickbooks = async (): Promise => { + const req = async (token: string): Promise => { + const client = getClient(); + const { data, response } = await client.GET("/quickbooks", { + headers: authHeader(token), + }); + + if (response.ok && data?.url) { + return data.url; + } else if (response.status === 401) { + console.log("Warning: Unauthorized access to QuickBooks"); + } else { + console.log("Error: Unable to fetch QuickBooks URL"); + } + }; + + return authWrapper()(req); +}; diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 4b2db945..83d23317 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -1,7 +1,5 @@ "use client"; import { login } from "@/actions/auth"; -import { getCompany } from "@/api/company"; -import { importQuickbooksData } from "@/api/quickbooks"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -53,13 +51,6 @@ export default function LoginPage() { useEffect(() => { if (state?.success) { - // Start import in the background, do not wait before redirect happens - (async () => { - const company = await getCompany(); - if (company?.externals) { - importQuickbooksData(); - } - })(); redirect("/"); } }, [state?.success]); diff --git a/frontend/app/profile/linked-accounts.tsx b/frontend/app/profile/linked-accounts.tsx index b6f56783..55c12bdf 100644 --- a/frontend/app/profile/linked-accounts.tsx +++ b/frontend/app/profile/linked-accounts.tsx @@ -1,9 +1,20 @@ +"use client"; import { Button } from "@/components/ui/button"; import { QuickBooksIcon } from "@/icons/quickbooks"; import { CirclePlusIcon } from "lucide-react"; import { ProfileSettingsCard } from "./common"; +import { redirectToQuickbooks } from "@/api/quickbooks"; export function LinkedAccountsSettings() { + const quickbooksAuth = async () => { + const url = await redirectToQuickbooks(); + if (url) { + window.location.href = url; + } else { + console.error("Failed to retrieve QuickBooks URL"); + } + }; + return ( { + await quickbooksAuth(); + }} > Connect account diff --git a/frontend/app/signup/quickbooks.tsx b/frontend/app/signup/quickbooks.tsx index df378fe1..5f7f94e8 100644 --- a/frontend/app/signup/quickbooks.tsx +++ b/frontend/app/signup/quickbooks.tsx @@ -2,12 +2,22 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { GoSync } from "react-icons/go"; import { FiUpload } from "react-icons/fi"; +import { redirectToQuickbooks } from "@/api/quickbooks"; interface QuickbooksInfoProps { handleNext: () => void; } export default function Quickbooks({ handleNext }: QuickbooksInfoProps) { + const quickbooksAuth = async () => { + const url = await redirectToQuickbooks(); + if (url) { + window.location.href = url; + } else { + console.error("Failed to retrieve QuickBooks URL"); + } + }; + return (
@@ -27,6 +37,9 @@ export default function Quickbooks({ handleNext }: QuickbooksInfoProps) { type="button" className="max-h-[45px] w-fit bg-[var(--fuchsia)] text-white py-[12px] text-[16px]" style={{ paddingInline: "20px" }} + onClick={async () => { + await quickbooksAuth(); + }} > Sync Quickbooks diff --git a/frontend/bun.lock b/frontend/bun.lock index 78414ea1..55d887d2 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -46,6 +46,7 @@ "react-dom": "19.1.0", "react-dropzone": "^14.3.8", "react-icons": "^5.5.0", + "react-switch": "^7.1.0", "recharts": "2.15.4", "tailwind-merge": "^3.3.1", "vaul": "^1.1.2", @@ -978,6 +979,8 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "react-switch": ["react-switch@7.1.0", "", { "dependencies": { "prop-types": "^15.7.2" }, "peerDependencies": { "react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4xVeyImZE8QOTDw2FmhWz0iqo2psoRiS7XzdjaZBCIP8Dzo3rT0esHUjLee5WsAPSFXWWl1eVA5arp9n2C6yQA=="], + "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], diff --git a/frontend/components/dashboard/NoDataPopup.tsx b/frontend/components/dashboard/NoDataPopup.tsx index 912818ec..227c2f88 100644 --- a/frontend/components/dashboard/NoDataPopup.tsx +++ b/frontend/components/dashboard/NoDataPopup.tsx @@ -1,5 +1,6 @@ "use client"; +import { redirectToQuickbooks } from "@/api/quickbooks"; import { Button } from "@/components/ui/button"; import Link from "next/link"; @@ -11,6 +12,15 @@ type Props = { export default function NoDataPopup({ isOpen, onClose }: Props) { if (!isOpen) return <>; + const quickbooksAuth = async () => { + const url = await redirectToQuickbooks(); + if (url) { + window.open(url, "_blank"); + } else { + console.error("Failed to retrieve QuickBooks URL"); + } + }; + return (
@@ -26,11 +36,14 @@ export default function NoDataPopup({ isOpen, onClose }: Props) {

- - - +