diff --git a/apps/erp/app/components/Form/ItemPostingGroup.tsx b/apps/erp/app/components/Form/ItemPostingGroup.tsx index 96b196e33..be37e494b 100644 --- a/apps/erp/app/components/Form/ItemPostingGroup.tsx +++ b/apps/erp/app/components/Form/ItemPostingGroup.tsx @@ -6,8 +6,19 @@ import { useMemo, useRef, useState } from "react"; import type { getItemPostingGroupsList } from "~/modules/items"; import ItemPostingGroupForm from "~/modules/items/ui/ItemPostingGroups/ItemPostingGroupForm"; import { path } from "~/utils/path"; +import { Enumerable } from "../Enumerable"; -type ItemPostingGroupSelectProps = Omit; +type ItemPostingGroupSelectProps = Omit & { + inline?: boolean; +}; + +const ItemPostingGroupPreview = ( + value: string, + options: { value: string; label: string }[] +) => { + const itemGroup = options.find((o) => o.value === value); + return ; +}; const ItemPostingGroup = (props: ItemPostingGroupSelectProps) => { const newItemPostingGroupModal = useDisclosure(); @@ -22,6 +33,7 @@ const ItemPostingGroup = (props: ItemPostingGroupSelectProps) => { ref={triggerRef} options={options} {...props} + inline={props.inline ? ItemPostingGroupPreview : undefined} label={props?.label ?? "Posting Group"} onCreateOption={(option) => { newItemPostingGroupModal.onOpen(); diff --git a/apps/erp/app/modules/items/items.models.ts b/apps/erp/app/modules/items/items.models.ts index c43e6a91b..15c1eb9af 100644 --- a/apps/erp/app/modules/items/items.models.ts +++ b/apps/erp/app/modules/items/items.models.ts @@ -81,6 +81,7 @@ export const itemValidator = z.object({ message: "Part type is required", }), }), + postingGroupId: zfd.text(z.string().optional()), unitOfMeasureCode: z .string() .min(1, { message: "Unit of Measure is required" }), @@ -339,7 +340,7 @@ export const methodOperationValidator = z export const itemCostValidator = z.object({ itemId: z.string().min(1, { message: "Item ID is required" }), - // itemPostingGroupId: zfd.text(z.string().optional()), + itemPostingGroupId: zfd.text(z.string().optional()), // costingMethod: z.enum(itemCostingMethods, { // errorMap: (issue, ctx) => ({ // message: "Costing method is required", diff --git a/apps/erp/app/modules/items/items.service.ts b/apps/erp/app/modules/items/items.service.ts index 3b1b49392..e5f29c9d9 100644 --- a/apps/erp/app/modules/items/items.service.ts +++ b/apps/erp/app/modules/items/items.service.ts @@ -2001,14 +2001,27 @@ export async function upsertConsumable( if (itemInsert.error) return itemInsert; const itemId = itemInsert.data?.id; - const consumableInsert = await client.from("consumable").upsert({ - id: consumable.id, - companyId: consumable.companyId, - createdBy: consumable.createdBy, - customFields: consumable.customFields, - }); + const [consumableInsert, itemCostUpdate] = await Promise.all([ + client.from("consumable").upsert({ + id: consumable.id, + companyId: consumable.companyId, + createdBy: consumable.createdBy, + customFields: consumable.customFields, + }), + client + .from("itemCost") + .update( + sanitize({ + itemPostingGroupId: consumable.postingGroupId, + }) + ) + .eq("itemId", itemId), + ]); if (consumableInsert.error) return consumableInsert; + if (itemCostUpdate.error) { + console.error(itemCostUpdate.error); + } const costUpdate = await client .from("itemCost") @@ -2100,14 +2113,27 @@ export async function upsertPart( if (itemInsert.error) return itemInsert; const itemId = itemInsert.data?.id; - const partInsert = await client.from("part").upsert({ - id: part.id, - companyId: part.companyId, - createdBy: part.createdBy, - customFields: part.customFields, - }); + const [partInsert, itemCostUpdate] = await Promise.all([ + client.from("part").upsert({ + id: part.id, + companyId: part.companyId, + createdBy: part.createdBy, + customFields: part.customFields, + }), + client + .from("itemCost") + .update( + sanitize({ + itemPostingGroupId: part.postingGroupId, + }) + ) + .eq("itemId", itemId), + ]); if (partInsert.error) return partInsert; + if (itemCostUpdate.error) { + console.error(itemCostUpdate.error); + } if (part.replenishmentSystem !== "Make") { const costUpdate = await client @@ -2633,15 +2659,21 @@ export async function upsertMaterial( const firstError = itemInserts.find((insert) => insert.error); return firstError!; } - - const itemIds = itemInserts.map((insert) => insert.data!.id); - - await client - .from("itemCost") - .update({ - unitCost: material.unitCost, - }) - .in("itemId", itemIds); + const itemCostUpdate = await Promise.all( + itemInserts.map((insert) => + client + .from("itemCost") + .update( + sanitize({ + itemPostingGroupId: material.postingGroupId, + }) + ) + .eq("itemId", insert.data?.id ?? "") + ) + ); + if (itemCostUpdate.some((update) => update.error)) { + console.error(itemCostUpdate.find((update) => update.error)); + } } else { const itemInsert = await client .from("item") @@ -2660,13 +2692,18 @@ export async function upsertMaterial( .select("id") .single(); if (itemInsert.error) return itemInsert; - - await client + const itemId = itemInsert.data?.id; + const itemCostUpdate = await client .from("itemCost") - .update({ - unitCost: material.unitCost, - }) - .eq("itemId", itemInsert.data!.id); + .update( + sanitize({ + itemPostingGroupId: material.postingGroupId, + }) + ) + .eq("itemId", itemId); + if (itemCostUpdate.error) { + console.error(itemCostUpdate.error); + } } const materialInsert = await client.from("material").upsert({ @@ -3136,14 +3173,27 @@ export async function upsertTool( if (itemInsert.error) return itemInsert; const itemId = itemInsert.data?.id; - const toolInsert = await client.from("tool").upsert({ - id: tool.id, - companyId: tool.companyId, - createdBy: tool.createdBy, - customFields: tool.customFields, - }); + const [toolInsert, itemCostUpdate] = await Promise.all([ + client.from("tool").upsert({ + id: tool.id, + companyId: tool.companyId, + createdBy: tool.createdBy, + customFields: tool.customFields, + }), + client + .from("itemCost") + .update( + sanitize({ + itemPostingGroupId: tool.postingGroupId, + }) + ) + .eq("itemId", itemId), + ]); if (toolInsert.error) return toolInsert; + if (itemCostUpdate.error) { + console.error(itemCostUpdate.error); + } const costUpdate = await client .from("itemCost") diff --git a/apps/erp/app/modules/items/ui/Consumables/ConsumableForm.tsx b/apps/erp/app/modules/items/ui/Consumables/ConsumableForm.tsx index 7cf12c10d..7e1c72d60 100644 --- a/apps/erp/app/modules/items/ui/Consumables/ConsumableForm.tsx +++ b/apps/erp/app/modules/items/ui/Consumables/ConsumableForm.tsx @@ -21,6 +21,7 @@ import { Hidden, Input, InputControlled, + ItemPostingGroup, Number, Select, Submit, @@ -156,6 +157,9 @@ const ConsumableForm = ({ name="unitOfMeasureCode" label="Unit of Measure" /> + {!isEditing && ( + + )} {!isEditing && ( { | "replenishmentSystem" | "defaultMethodType" | "itemTrackingType" + | "itemPostingGroupId" | "consumableId" | "active", value: string | null @@ -196,7 +197,8 @@ const ConsumableProperties = () => { { /> */} + + { + onUpdate("itemPostingGroupId", value?.value ?? null); + }} + /> + +

Tracking Type

diff --git a/apps/erp/app/modules/items/ui/Consumables/ConsumablesTable.tsx b/apps/erp/app/modules/items/ui/Consumables/ConsumablesTable.tsx index 59e614469..236296b9e 100644 --- a/apps/erp/app/modules/items/ui/Consumables/ConsumablesTable.tsx +++ b/apps/erp/app/modules/items/ui/Consumables/ConsumablesTable.tsx @@ -1,5 +1,6 @@ import { Badge, + Button, Checkbox, DropdownMenuContent, DropdownMenuGroup, @@ -19,7 +20,7 @@ import { VStack, } from "@carbon/react"; import { formatDate } from "@carbon/utils"; -import { useFetcher, useNavigate } from "@remix-run/react"; +import { Link, useFetcher, useNavigate } from "@remix-run/react"; import type { ColumnDef } from "@tanstack/react-table"; import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { @@ -27,6 +28,7 @@ import { LuBookMarked, LuCalendar, LuCheck, + LuGroup, LuPencil, LuTag, LuTrash, @@ -43,6 +45,8 @@ import { Table, TrackingTypeIcon, } from "~/components"; +import { Enumerable } from "~/components/Enumerable"; +import { useItemPostingGroups } from "~/components/Form/ItemPostingGroup"; import { ConfirmDelete } from "~/components/Modals"; import { usePermissions } from "~/hooks"; import { useCustomColumns } from "~/hooks/useCustomColumns"; @@ -68,6 +72,7 @@ const ConsumablesTable = memo( const [selectedItem, setSelectedItem] = useState(null); const [people] = usePeople(); + const itemPostingGroups = useItemPostingGroups(); const customColumns = useCustomColumns("consumable"); const columns = useMemo[]>(() => { @@ -107,6 +112,27 @@ const ConsumablesTable = memo( icon: , }, }, + { + accessorKey: "itemPostingGroupId", + header: "Item Group", + cell: (item) => { + const itemPostingGroupId = item.getValue(); + const itemPostingGroup = itemPostingGroups.find( + (group) => group.value === itemPostingGroupId + ); + return ; + }, + meta: { + filter: { + type: "static", + options: itemPostingGroups.map((group) => ({ + value: group.value, + label: , + })), + }, + icon: , + }, + }, { accessorKey: "itemTrackingType", header: "Tracking", @@ -268,7 +294,7 @@ const ConsumablesTable = memo( }, ]; return [...defaultColumns, ...customColumns]; - }, [tags, people, customColumns]); + }, [tags, people, customColumns, itemPostingGroups]); const fetcher = useFetcher(); useEffect(() => { @@ -279,7 +305,11 @@ const ConsumablesTable = memo( const onBulkUpdate = useCallback( ( selectedRows: typeof data, - field: "replenishmentSystem" | "defaultMethodType" | "itemTrackingType", + field: + | "replenishmentSystem" + | "defaultMethodType" + | "itemTrackingType" + | "itemPostingGroupId", value: string ) => { const formData = new FormData(); @@ -304,6 +334,27 @@ const ConsumablesTable = memo( Update + + Item Group + + + {itemPostingGroups.map((group) => ( + + onBulkUpdate( + selectedRows, + "itemPostingGroupId", + group.value + ) + } + > + + + ))} + + + Default Method Type @@ -354,7 +405,7 @@ const ConsumablesTable = memo( ); }, - [onBulkUpdate] + [onBulkUpdate, itemPostingGroups] ); const renderContextMenu = useMemo(() => { @@ -405,7 +456,12 @@ const ConsumablesTable = memo( ]} primaryAction={ permissions.can("create", "parts") && ( - +
+ + +
) } renderActions={renderActions} diff --git a/apps/erp/app/modules/items/ui/Consumables/useConsumableNavigation.tsx b/apps/erp/app/modules/items/ui/Consumables/useConsumableNavigation.tsx index fc4aaa141..c93aa7dc0 100644 --- a/apps/erp/app/modules/items/ui/Consumables/useConsumableNavigation.tsx +++ b/apps/erp/app/modules/items/ui/Consumables/useConsumableNavigation.tsx @@ -41,12 +41,12 @@ export function useConsumableNavigation() { shortcut: "Command+Shift+p", }, { - name: "Costing", + name: "Accounting", to: path.to.consumableCosting(itemId), role: ["employee"], permission: "purchasing", icon: LuTags, - shortcut: "Command+Shift+c", + shortcut: "Command+Shift+a", }, { name: "Planning", diff --git a/apps/erp/app/modules/items/ui/Item/ItemCostingForm.tsx b/apps/erp/app/modules/items/ui/Item/ItemCostingForm.tsx index 358e677a0..e7955863d 100644 --- a/apps/erp/app/modules/items/ui/Item/ItemCostingForm.tsx +++ b/apps/erp/app/modules/items/ui/Item/ItemCostingForm.tsx @@ -6,8 +6,14 @@ import { CardHeader, CardTitle, } from "@carbon/react"; -import type { z } from 'zod/v3'; -import { CustomFormFields, Hidden, Number, Submit } from "~/components/Form"; +import type { z } from "zod/v3"; +import { + CustomFormFields, + Hidden, + ItemPostingGroup, + Number, + Submit, +} from "~/components/Form"; import { usePermissions, useUser } from "~/hooks"; import { itemCostValidator } from "../../items.models"; @@ -39,11 +45,16 @@ const ItemCostingForm = ({ initialValues }: ItemCostingFormProps) => { defaultValues={initialValues} > - Costing + Costing & Posting -
+
+ {/* + setFilter(value as "all" | "installed" | "available") + } + > + + + + + All + Installed + Available + + +
- {availableIntegrations.map((integration) => { + {filteredIntegrations.map((integration) => { return ( (); - const navigate = useNavigate(); - - const handleInstall = async () => { - if (integration.settings.some((setting) => setting.required)) { - navigate(path.to.integration(integration.id)); - } else if (integration.onInitialize) { - await integration.onInitialize?.(); - } else { - const formData = new FormData(); - fetcher.submit(formData, { - method: "post", - action: path.to.integration(integration.id), - }); - } - }; - - const handleUninstall = async () => { - await integration?.onUninstall?.(); - }; - - return ( - -
- - {integration.active ? ( - installed ? ( - - Installed - - ) : null - ) : ( - - Coming soon - - )} -
- -
- - {integration.name} - -
-
- - {integration.description} - - - - {installed ? ( - - - - ) : ( - - )} - -
- ); -} - export default IntegrationsList; diff --git a/apps/erp/app/modules/shared/shared.models.ts b/apps/erp/app/modules/shared/shared.models.ts index b21b92292..57490e14a 100644 --- a/apps/erp/app/modules/shared/shared.models.ts +++ b/apps/erp/app/modules/shared/shared.models.ts @@ -96,6 +96,11 @@ export const feedbackValidator = z.object({ location: z.string(), }); +export const oAuthCallbackSchema = z.object({ + code: z.string(), + state: z.string(), +}); + export const operationStepValidator = z .object({ id: zfd.text(z.string().optional()), diff --git a/apps/erp/app/root.tsx b/apps/erp/app/root.tsx index cd99ec3db..e07ce96f3 100644 --- a/apps/erp/app/root.tsx +++ b/apps/erp/app/root.tsx @@ -52,6 +52,7 @@ export async function loader({ request }: LoaderFunctionArgs) { CARBON_EDITION, CLOUDFLARE_TURNSTILE_SITE_KEY, CONTROLLED_ENVIRONMENT, + GOOGLE_PLACES_API_KEY, POSTHOG_API_HOST, POSTHOG_PROJECT_PUBLIC_KEY, SUPABASE_URL, @@ -59,7 +60,8 @@ export async function loader({ request }: LoaderFunctionArgs) { NOVU_APPLICATION_ID, VERCEL_ENV, VERCEL_URL, - GOOGLE_PLACES_API_KEY, + QUICKBOOKS_CLIENT_ID, + XERO_CLIENT_ID, } = getBrowserEnv(); const sessionFlash = await getSessionFlash(request); @@ -78,6 +80,8 @@ export async function loader({ request }: LoaderFunctionArgs) { VERCEL_ENV, VERCEL_URL, GOOGLE_PLACES_API_KEY, + QUICKBOOKS_CLIENT_ID, + XERO_CLIENT_ID, }, mode: getMode(request), theme: getTheme(request), diff --git a/apps/erp/app/routes/api+/integrations.quickbooks.oauth.ts b/apps/erp/app/routes/api+/integrations.quickbooks.oauth.ts new file mode 100644 index 000000000..9827c6b3a --- /dev/null +++ b/apps/erp/app/routes/api+/integrations.quickbooks.oauth.ts @@ -0,0 +1,104 @@ +import { + QUICKBOOKS_CLIENT_ID, + QUICKBOOKS_CLIENT_SECRET, + VERCEL_URL, +} from "@carbon/auth"; +import { requirePermissions } from "@carbon/auth/auth.server"; +import { QuickBooks } from "@carbon/ee"; +import { QuickBooksProvider } from "@carbon/ee/quickbooks"; +import { json, redirect, type LoaderFunctionArgs } from "@vercel/remix"; +import z from "zod"; +import { upsertCompanyIntegration } from "~/modules/settings/settings.server"; +import { oAuthCallbackSchema } from "~/modules/shared"; +import { path } from "~/utils/path"; + +export const config = { + runtime: "nodejs", +}; + +export async function loader({ request }: LoaderFunctionArgs) { + const { client, userId, companyId } = await requirePermissions(request, { + update: "settings", + }); + + const url = new URL(request.url); + const searchParams = Object.fromEntries(url.searchParams.entries()); + + const quickbooksAuthResponse = oAuthCallbackSchema + .extend({ + realmId: z.string(), + }) + .safeParse(searchParams); + + if (!quickbooksAuthResponse.success) { + return json({ error: "Invalid QuickBooks auth response" }, { status: 400 }); + } + + const { data } = quickbooksAuthResponse; + + // TODO: Verify state parameter + if (!data.state) { + return json({ error: "Invalid state parameter" }, { status: 400 }); + } + + if (!QUICKBOOKS_CLIENT_ID || !QUICKBOOKS_CLIENT_SECRET) { + return json({ error: "QuickBooks OAuth not configured" }, { status: 500 }); + } + + try { + // Use the QuickBooksProvider to handle the OAuth flow + const provider = new QuickBooksProvider({ + clientId: QUICKBOOKS_CLIENT_ID, + clientSecret: QUICKBOOKS_CLIENT_SECRET, + redirectUri: `${url.origin}/api/integrations/quickbooks/oauth`, + environment: + process.env.NODE_ENV === "production" ? "production" : "sandbox", + }); + + // Exchange the authorization code for tokens + const auth = await provider.exchangeCodeForToken(data.code); + if (!auth) { + return json( + { error: "Failed to exchange code for token" }, + { status: 500 } + ); + } + + const createdQuickBooksIntegration = await upsertCompanyIntegration( + client, + { + id: QuickBooks.id, + active: true, + // @ts-ignore + metadata: { + ...auth, + tenantId: data.realmId, + }, + updatedBy: userId, + companyId: companyId, + } + ); + + if (createdQuickBooksIntegration?.data?.metadata) { + const requestUrl = new URL(request.url); + + if (!VERCEL_URL || VERCEL_URL.includes("localhost")) { + requestUrl.protocol = "http"; + } + + const redirectUrl = `${requestUrl.origin}${path.to.integrations}`; + + return redirect(redirectUrl); + } else { + return json( + { error: "Failed to save QuickBooks integration" }, + { status: 500 } + ); + } + } catch (err) { + return json( + { error: "Failed to exchange code for token" }, + { status: 500 } + ); + } +} diff --git a/apps/erp/app/routes/api+/integrations.slack.interactive.ts b/apps/erp/app/routes/api+/integrations.slack.interactive.ts index 159396051..89d280a0d 100644 --- a/apps/erp/app/routes/api+/integrations.slack.interactive.ts +++ b/apps/erp/app/routes/api+/integrations.slack.interactive.ts @@ -178,9 +178,8 @@ async function handleShortcut( slackToken: string, serviceRole: SupabaseClient ) { - // Handle different shortcut types based on callback_id const callbackId = payload.callback_id; - console.log({ function: "handleShortcut", callbackId }); + switch (callbackId) { case "create_ncr_modal": return handleCreateNcrShortcut( @@ -375,7 +374,6 @@ async function handleViewSubmission( serviceRole: SupabaseClient, integration: Database["public"]["Tables"]["companyIntegration"]["Row"] ) { - console.log({ function: "handleViewSubmission", payload }); const view = payload.view; if (view.callback_id !== "create_ncr_modal") { diff --git a/apps/erp/app/routes/api+/integrations.slack.oauth.ts b/apps/erp/app/routes/api+/integrations.slack.oauth.ts index 68cc9249c..6c2dea780 100644 --- a/apps/erp/app/routes/api+/integrations.slack.oauth.ts +++ b/apps/erp/app/routes/api+/integrations.slack.oauth.ts @@ -9,12 +9,12 @@ import { Slack } from "@carbon/ee"; import { createSlackApp, getSlackInstaller, - slackOAuthCallbackSchema, slackOAuthTokenResponseSchema, } from "@carbon/ee/slack.server"; import { json, redirect, type LoaderFunctionArgs } from "@vercel/remix"; import { z } from "zod/v3"; import { upsertCompanyIntegration } from "~/modules/settings/settings.server"; +import { oAuthCallbackSchema } from "~/modules/shared"; import { path } from "~/utils/path"; export async function loader({ request }: LoaderFunctionArgs) { @@ -25,7 +25,7 @@ export async function loader({ request }: LoaderFunctionArgs) { const url = new URL(request.url); const searchParams = Object.fromEntries(url.searchParams.entries()); - const slackAuthResponse = slackOAuthCallbackSchema.safeParse(searchParams); + const slackAuthResponse = oAuthCallbackSchema.safeParse(searchParams); if (!slackAuthResponse.success) { return json({ error: "Invalid Slack auth response" }, { status: 400 }); diff --git a/apps/erp/app/routes/api+/integrations.xero.oauth.ts b/apps/erp/app/routes/api+/integrations.xero.oauth.ts new file mode 100644 index 000000000..e507887c8 --- /dev/null +++ b/apps/erp/app/routes/api+/integrations.xero.oauth.ts @@ -0,0 +1,125 @@ +import { VERCEL_URL, XERO_CLIENT_ID, XERO_CLIENT_SECRET } from "@carbon/auth"; +import { requirePermissions } from "@carbon/auth/auth.server"; +import { Xero } from "@carbon/ee"; +import { XeroProvider } from "@carbon/ee/xero"; +import { json, redirect, type LoaderFunctionArgs } from "@vercel/remix"; +import { upsertCompanyIntegration } from "~/modules/settings/settings.server"; +import { oAuthCallbackSchema } from "~/modules/shared"; +import { path } from "~/utils/path"; + +export const config = { + runtime: "nodejs", +}; + +export async function loader({ request }: LoaderFunctionArgs) { + const { client, userId, companyId } = await requirePermissions(request, { + update: "settings", + }); + + const url = new URL(request.url); + const searchParams = Object.fromEntries(url.searchParams.entries()); + + const xeroAuthResponse = oAuthCallbackSchema.safeParse(searchParams); + + if (!xeroAuthResponse.success) { + return json({ error: "Invalid Xero auth response" }, { status: 400 }); + } + + const { data } = xeroAuthResponse; + + // TODO: Verify state parameter + if (!data.state) { + return json({ error: "Invalid state parameter" }, { status: 400 }); + } + + if (!XERO_CLIENT_ID || !XERO_CLIENT_SECRET) { + return json({ error: "Xero OAuth not configured" }, { status: 500 }); + } + + try { + // Use the XeroProvider to handle the OAuth flow + const provider = new XeroProvider({ + clientId: XERO_CLIENT_ID, + clientSecret: XERO_CLIENT_SECRET, + redirectUri: `${url.origin}/api/integrations/xero/oauth`, + }); + + // Exchange the authorization code for tokens + const auth = await provider.exchangeCodeForToken(data.code); + if (!auth) { + return json( + { error: "Failed to exchange code for token" }, + { status: 500 } + ); + } + + // Fetch tenant ID from Xero connections endpoint + const connectionsResponse = await fetch( + "https://api.xero.com/connections", + { + method: "GET", + headers: { + Authorization: `Bearer ${auth.accessToken}`, + "Content-Type": "application/json", + }, + } + ); + + if (!connectionsResponse.ok) { + return json( + { error: "Failed to fetch Xero connections" }, + { status: 500 } + ); + } + + const connections = await connectionsResponse.json(); + + if (!Array.isArray(connections) || connections.length === 0) { + return json({ error: "No Xero connections found" }, { status: 500 }); + } + + // Get the first connection's tenant ID + const tenantId = connections[0].tenantId; + + if (!tenantId) { + return json( + { error: "No tenant ID found in Xero connections" }, + { status: 500 } + ); + } + + const createdXeroIntegration = await upsertCompanyIntegration(client, { + id: Xero.id, + active: true, + // @ts-ignore + metadata: { + ...auth, + tenantId, + }, + updatedBy: userId, + companyId: companyId, + }); + + if (createdXeroIntegration?.data?.metadata) { + const requestUrl = new URL(request.url); + + if (!VERCEL_URL || VERCEL_URL.includes("localhost")) { + requestUrl.protocol = "http"; + } + + const redirectUrl = `${requestUrl.origin}${path.to.integrations}`; + + return redirect(redirectUrl); + } else { + return json( + { error: "Failed to save Xero integration" }, + { status: 500 } + ); + } + } catch (err) { + return json( + { error: "Failed to exchange code for token" }, + { status: 500 } + ); + } +} diff --git a/apps/erp/app/routes/api+/items.groups.ts b/apps/erp/app/routes/api+/items.groups.ts index ade75ece0..b601e82df 100644 --- a/apps/erp/app/routes/api+/items.groups.ts +++ b/apps/erp/app/routes/api+/items.groups.ts @@ -1,10 +1,33 @@ import { requirePermissions } from "@carbon/auth/auth.server"; -import type { LoaderFunctionArgs } from "@vercel/remix"; +import type { ClientLoaderFunctionArgs } from "@remix-run/react"; +import type { LoaderFunctionArgs, SerializeFrom } from "@vercel/remix"; import { json } from "@vercel/remix"; import { getItemPostingGroupsList } from "~/modules/items"; +import { getCompanyId, itemPostingGroupsQuery } from "~/utils/react-query"; export async function loader({ request }: LoaderFunctionArgs) { const { client, companyId } = await requirePermissions(request, {}); return json(await getItemPostingGroupsList(client, companyId)); } + +export async function clientLoader({ serverLoader }: ClientLoaderFunctionArgs) { + const companyId = getCompanyId(); + + if (!companyId) { + return await serverLoader(); + } + + const queryKey = itemPostingGroupsQuery(companyId).queryKey; + const data = + window?.clientCache?.getQueryData>(queryKey); + + if (!data) { + const serverData = await serverLoader(); + window?.clientCache?.setQueryData(queryKey, serverData); + return serverData; + } + + return data; +} +clientLoader.hydrate = true; diff --git a/apps/erp/app/routes/api+/kanban.$id.tsx b/apps/erp/app/routes/api+/kanban.$id.tsx index 772dcf4ac..81e2ed7b9 100644 --- a/apps/erp/app/routes/api+/kanban.$id.tsx +++ b/apps/erp/app/routes/api+/kanban.$id.tsx @@ -184,7 +184,7 @@ async function handleKanban({ serviceRole .from("job") .update({ - status: "Ready", + status: "Ready" as const, }) .eq("id", jobReadableId), ]); diff --git a/apps/erp/app/routes/api+/webhook.quickbooks.ts b/apps/erp/app/routes/api+/webhook.quickbooks.ts new file mode 100644 index 000000000..a23de201c --- /dev/null +++ b/apps/erp/app/routes/api+/webhook.quickbooks.ts @@ -0,0 +1,266 @@ +/** + * QuickBooks Webhook Handler + * + * This endpoint receives webhook notifications from QuickBooks when entities + * (customers, vendors, etc.) are created, updated, or deleted in QuickBooks. + * + * The webhook handler: + * 1. Validates the webhook payload structure + * 2. Verifies the webhook signature for security + * 3. Looks up the company integration by QuickBooks realm ID + * 4. Triggers background sync jobs to process the entity changes + * + * Supported entity types: + * - Customer: Synced to Carbon's customer table + * - Vendor: Synced to Carbon's supplier table + * + * The actual sync logic is handled asynchronously by the accounting-sync + * background job to prevent webhook timeouts and ensure reliability. + */ + +import { getCarbonServiceRole, QUICKBOOKS_WEBHOOK_SECRET } from "@carbon/auth"; +import { tasks } from "@trigger.dev/sdk/v3"; +import type { ActionFunctionArgs } from "@vercel/remix"; +import { json } from "@vercel/remix"; +import crypto from "crypto"; +import { z } from "zod"; + +export const config = { + runtime: "nodejs", +}; + +const quickbooksEventValidator = z.object({ + eventNotifications: z.array( + z.object({ + realmId: z.string(), + dataChangeEvent: z.object({ + entities: z.array( + z.object({ + id: z.string(), + name: z.string(), + operation: z.enum(["Create", "Update", "Delete"]), + }) + ), + }), + }) + ), +}); + +function verifyQuickBooksSignature( + payload: string, + signature: string +): boolean { + if (!QUICKBOOKS_WEBHOOK_SECRET) { + console.warn("QUICKBOOKS_WEBHOOK_SECRET is not set"); + return true; + } + + const expectedSignature = crypto + .createHmac("sha256", QUICKBOOKS_WEBHOOK_SECRET) + .update(payload) + .digest("base64"); + + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); +} + +async function triggerAccountingSync( + companyId: string, + realmId: string, + entities: Array<{ + entityType: "customer" | "vendor"; + entityId: string; + operation: "Create" | "Update" | "Delete"; + }> +) { + // Prepare the payload for the accounting sync job + const payload = { + companyId, + provider: "quickbooks" as const, + syncType: "webhook" as const, + syncDirection: "fromAccounting" as const, + entities: entities.map((entity) => ({ + entityType: entity.entityType, + entityId: entity.entityId, + operation: entity.operation.toLowerCase() as + | "create" + | "update" + | "delete", + externalId: entity.entityId, // In QuickBooks, the entity ID is the external ID + })), + metadata: { + tenantId: realmId, + webhookId: crypto.randomUUID(), + timestamp: new Date().toISOString(), + }, + }; + + // Trigger the background job using Trigger.dev + const handle = await tasks.trigger("accounting-sync", payload); + + console.log( + `Triggered accounting sync job ${handle.id} for ${entities.length} entities` + ); + + return handle; +} + +export async function action({ request, params }: ActionFunctionArgs) { + const serviceRole = await getCarbonServiceRole(); + + // Parse and validate the webhook payload + const payload = await request.clone().json(); + const parsedPayload = quickbooksEventValidator.safeParse(payload); + + if (!parsedPayload.success) { + console.error("Invalid QuickBooks webhook payload:", parsedPayload.error); + return json( + { + success: false, + error: "Invalid payload format", + }, + { status: 400 } + ); + } + + // Verify webhook signature for security + const payloadText = await request.text(); + const signatureHeader = request.headers.get("intuit-signature"); + + if (!signatureHeader) { + console.warn("QuickBooks webhook received without signature"); + return json( + { + success: false, + error: "Missing signature", + }, + { status: 401 } + ); + } + + const requestIsValid = verifyQuickBooksSignature( + payloadText, + signatureHeader + ); + + if (!requestIsValid) { + console.error("QuickBooks webhook signature verification failed"); + return json( + { + success: false, + error: "Invalid signature", + }, + { status: 401 } + ); + } + + console.log( + "Processing QuickBooks webhook with", + parsedPayload.data.eventNotifications.length, + "events" + ); + + const events = parsedPayload.data.eventNotifications; + const syncJobs = []; + const errors = []; + + // Process each event notification + for (const event of events) { + try { + const { realmId, dataChangeEvent } = event; + + // Find the company integration for this QuickBooks realm + const companyIntegration = await serviceRole + .from("companyIntegration") + .select("*") + .eq("metadata->>tenantId", realmId) + .eq("id", "quickbooks") + .single(); + + if (companyIntegration.error || !companyIntegration.data.companyId) { + console.error(`No QuickBooks integration found for realm ${realmId}`); + errors.push({ + realmId, + error: "Integration not found", + }); + continue; + } + + const companyId = companyIntegration.data.companyId; + const { entities } = dataChangeEvent; + + // Group entities by type for efficient batch processing + const entitiesToSync: Array<{ + entityType: "customer" | "vendor"; + entityId: string; + operation: "Create" | "Update" | "Delete"; + }> = []; + + for (const entity of entities) { + const { id, name, operation } = entity; + + // Log each entity change for debugging + console.log( + `QuickBooks ${operation}: ${name} ${id} (realm: ${realmId})` + ); + + // Map QuickBooks entity types to our internal types + if (name === "Customer") { + entitiesToSync.push({ + entityType: "customer", + entityId: id, + operation, + }); + } else if (name === "Vendor") { + entitiesToSync.push({ + entityType: "vendor", + entityId: id, + operation, + }); + } else { + console.log(`Skipping unsupported entity type: ${name}`); + } + } + + // Trigger background sync job if there are entities to process + if (entitiesToSync.length > 0) { + try { + const jobHandle = await triggerAccountingSync( + companyId, + realmId, + entitiesToSync + ); + syncJobs.push({ + id: jobHandle.id, + companyId, + realmId, + entityCount: entitiesToSync.length, + }); + } catch (error) { + console.error("Failed to trigger sync job:", error); + errors.push({ + realmId, + error: + error instanceof Error ? error.message : "Failed to trigger job", + }); + } + } + } catch (error) { + console.error("Error processing event:", error); + errors.push({ + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + // Return detailed response + return json({ + success: errors.length === 0, + jobsTriggered: syncJobs.length, + jobs: syncJobs, + errors: errors.length > 0 ? errors : undefined, + timestamp: new Date().toISOString(), + }); +} diff --git a/apps/erp/app/routes/api+/webhook.xero.ts b/apps/erp/app/routes/api+/webhook.xero.ts new file mode 100644 index 000000000..e168523d5 --- /dev/null +++ b/apps/erp/app/routes/api+/webhook.xero.ts @@ -0,0 +1,571 @@ +/** + * Xero Webhook Handler + * + * This endpoint receives webhook notifications from Xero when entities + * (contacts, invoices, etc.) are created, updated, or deleted in Xero. + * + * The webhook handler implements Xero's intent-to-receive workflow: + * 1. Validates the webhook signature for security + * 2. Returns HTTP 200 for valid signatures (intent-to-receive) + * 3. Once Xero confirms the webhook is working, processes actual events + * 4. Looks up the company integration by Xero tenant ID + * 5. Triggers background sync jobs to process the entity changes + * + * Supported entity types: + * - Contact: Synced to Carbon's customer/supplier table (based on IsCustomer/IsSupplier flags) + * - Invoice: Synced to Carbon's invoice table + * + * The actual sync logic is handled asynchronously by the accounting-sync + * background job to prevent webhook timeouts and ensure reliability. + */ + +import { + getCarbonServiceRole, + XERO_CLIENT_ID, + XERO_CLIENT_SECRET, + XERO_WEBHOOK_SECRET, +} from "@carbon/auth"; +import { XeroProvider } from "@carbon/ee/xero"; +import { tasks } from "@trigger.dev/sdk/v3"; +import type { ActionFunctionArgs } from "@vercel/remix"; +import { json } from "@vercel/remix"; +import crypto from "crypto"; +import { z } from "zod"; + +export const config = { + runtime: "nodejs", +}; + +const xeroEventValidator = z.object({ + events: z.array( + z.object({ + tenantId: z.string(), + eventCategory: z.enum(["INVOICE", "CONTACT"]), + eventType: z.enum(["CREATE", "UPDATE", "DELETE"]), + resourceId: z.string(), + eventDateUtc: z.string(), + }) + ), + firstEventSequence: z.number(), + lastEventSequence: z.number(), +}); + +function verifyXeroSignature(payload: string, signature: string): boolean { + if (!XERO_WEBHOOK_SECRET) { + console.warn("XERO_WEBHOOK_SECRET is not set"); + return true; + } + + const expectedSignature = crypto + .createHmac("sha256", XERO_WEBHOOK_SECRET) + .update(payload) + .digest("base64"); + + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); +} + +async function fetchInvoiceAndDetermineContactType( + companyId: string, + tenantId: string, + invoiceId: string, + serviceRole: any +): Promise<{ + contactId: string; + entityType: "customer" | "vendor" | "both"; + isCustomer: boolean; + isSupplier: boolean; + invoiceType: "ACCREC" | "ACCPAY"; +}> { + try { + // Get the Xero integration credentials + const integration = await serviceRole + .from("companyIntegration") + .select("*") + .eq("companyId", companyId) + .eq("id", "xero") + .single(); + + if (integration.error || !integration.data) { + throw new Error("Xero integration not found"); + } + + const { accessToken, refreshToken, tenantId } = + integration.data.metadata || {}; + + if (!accessToken) { + throw new Error("No access token available for Xero integration"); + } + + const xeroProvider = new XeroProvider({ + clientId: XERO_CLIENT_ID!, + clientSecret: XERO_CLIENT_SECRET!, + accessToken, + refreshToken, + tenantId, + }); + + // Fetch the invoice to get the contact ID and type + const invoiceData = await xeroProvider.getInvoice(invoiceId); + const xeroInvoice = invoiceData.Invoices?.[0]; + + if (!xeroInvoice) { + throw new Error(`Invoice ${invoiceId} not found`); + } + + const contactId = xeroInvoice.Contact?.ContactID; + const invoiceType = xeroInvoice.Type; // ACCREC (receivable/customer) or ACCPAY (payable/supplier) + + if (!contactId) { + throw new Error(`No contact found for invoice ${invoiceId}`); + } + + // Now fetch the contact details + const contactData = await xeroProvider.getContact(contactId); + const xeroContact = contactData.Contacts?.[0]; + + if (!xeroContact) { + throw new Error(`Contact ${contactId} not found`); + } + + // Check IsCustomer and IsSupplier fields from Xero + const isCustomer = xeroContact.IsCustomer === true; + const isSupplier = xeroContact.IsSupplier === true; + + console.log( + `Invoice ${invoiceId} (${invoiceType}) - Contact ${contactId} (${xeroContact.Name}) - IsCustomer: ${isCustomer}, IsSupplier: ${isSupplier}` + ); + + // Determine entity type based on flags and invoice type + let entityType: "customer" | "vendor" | "both"; + + if (isCustomer && isSupplier) { + entityType = "both"; + } else if (invoiceType === "ACCPAY" || isSupplier) { + // Accounts payable (bills) or supplier flag = vendor + entityType = "vendor"; + } else { + // Accounts receivable (invoices) or customer flag = customer (default) + entityType = "customer"; + } + + return { contactId, entityType, isCustomer, isSupplier, invoiceType }; + } catch (error) { + console.error(`Error fetching invoice ${invoiceId}:`, error); + // Default based on typical patterns + throw error; + } +} + +async function fetchContactAndDetermineType( + companyId: string, + tenantId: string, + contactId: string, + serviceRole: any +): Promise<{ + entityType: "customer" | "vendor" | "both"; + isCustomer: boolean; + isSupplier: boolean; +}> { + try { + // Get the Xero integration credentials + const integration = await serviceRole + .from("companyIntegration") + .select("*") + .eq("companyId", companyId) + .eq("id", "xero") + .single(); + + if (integration.error || !integration.data) { + throw new Error("Xero integration not found"); + } + + const { accessToken, refreshToken, tenantId } = + integration.data.metadata || {}; + + if (!accessToken) { + throw new Error("No access token available for Xero integration"); + } + + const xeroProvider = new XeroProvider({ + clientId: XERO_CLIENT_ID!, + clientSecret: XERO_CLIENT_SECRET!, + accessToken, + refreshToken, + tenantId, + }); + + const data = await xeroProvider.getContact(contactId); + const xeroContact = data.Contacts?.[0]; + console.log({ xeroContact }); + + if (!xeroContact) { + throw new Error(`Contact ${contactId} not found`); + } + + // Check IsCustomer and IsSupplier fields from Xero + const isCustomer = xeroContact.IsCustomer === true; + const isSupplier = xeroContact.IsSupplier === true; + + console.log( + `Contact ${contactId} (${xeroContact.Name}) - IsCustomer: ${isCustomer}, IsSupplier: ${isSupplier}` + ); + + // Determine entity type based on flags + let entityType: "customer" | "vendor" | "both"; + + if (isCustomer && isSupplier) { + entityType = "both"; + } else if (isSupplier) { + entityType = "vendor"; + } else { + // Default to customer if only customer or neither flag is set + entityType = "customer"; + } + + return { entityType, isCustomer, isSupplier }; + } catch (error) { + console.error(`Error fetching contact ${contactId}:`, error); + // Default to customer if we can't fetch the contact + return { entityType: "customer", isCustomer: true, isSupplier: false }; + } +} + +async function triggerAccountingSync( + companyId: string, + tenantId: string, + entities: Array<{ + entityType: "customer" | "vendor" | "invoice"; + entityId: string; + operation: "create" | "update" | "delete"; + }> +) { + // Prepare the payload for the accounting sync job + const payload = { + companyId, + provider: "xero" as const, + syncType: "webhook" as const, + syncDirection: "fromAccounting" as const, + entities: entities.map((entity) => ({ + entityType: entity.entityType, + entityId: entity.entityId, + operation: entity.operation, + externalId: entity.entityId, // In Xero, the resource ID is the external ID + })), + metadata: { + tenantId: tenantId, + webhookId: crypto.randomUUID(), + timestamp: new Date().toISOString(), + }, + }; + + // Trigger the background job using Trigger.dev + const handle = await tasks.trigger("accounting-sync", payload); + + console.log( + `Triggered accounting sync job ${handle.id} for ${entities.length} entities` + ); + + return handle; +} + +export async function action({ request }: ActionFunctionArgs) { + // Get the raw payload for signature verification + const payloadText = await request.text(); + + // Verify webhook signature for security (Xero's intent-to-receive workflow) + const signatureHeader = request.headers.get("x-xero-signature"); + + if (!signatureHeader) { + console.warn("Xero webhook received without signature"); + return json( + { + success: false, + error: "Missing signature", + }, + { status: 401 } + ); + } + + const requestIsValid = verifyXeroSignature(payloadText, signatureHeader); + + if (!requestIsValid) { + console.error("Xero webhook signature verification failed"); + return json( + { + success: false, + error: "Invalid signature", + }, + { status: 401 } + ); + } + + // If payload is empty or just contains intent-to-receive data, return 200 + if (!payloadText || payloadText.trim() === "" || payloadText === "{}") { + console.log("Xero intent-to-receive webhook confirmed"); + return new Response("", { status: 200 }); + } + + // Parse and validate the webhook payload for actual events + let payload; + try { + payload = JSON.parse(payloadText); + } catch (error) { + console.error("Failed to parse webhook payload:", error); + return json( + { + success: false, + error: "Invalid JSON payload", + }, + { status: 400 } + ); + } + + const parsedPayload = xeroEventValidator.safeParse(payload); + + if (!parsedPayload.success) { + console.error("Invalid Xero webhook payload:", parsedPayload.error); + return json( + { + success: false, + error: "Invalid payload format", + }, + { status: 400 } + ); + } + + console.log( + "Processing Xero webhook with", + parsedPayload.data.events.length, + "events" + ); + + const serviceRole = await getCarbonServiceRole(); + const events = parsedPayload.data.events; + const syncJobs = []; + const errors = []; + + // Group events by tenant ID for efficient processing + const eventsByTenant = events.reduce((acc, event) => { + if (!acc[event.tenantId]) { + acc[event.tenantId] = []; + } + acc[event.tenantId].push(event); + return acc; + }, {} as Record); + + // Process events for each tenant + for (const [tenantId, tenantEvents] of Object.entries(eventsByTenant)) { + try { + // Find the company integration for this Xero tenant by checking metadata + const integrationQuery = await serviceRole + .from("companyIntegration") + .select("*") + .eq("id", "xero" as any); + + if ( + integrationQuery.error || + !integrationQuery.data || + integrationQuery.data.length === 0 + ) { + console.error(`No Xero integration found for tenant ${tenantId}`); + errors.push({ + tenantId, + error: "Integration not found", + }); + continue; + } + + // Find the integration that matches this tenant ID + const companyIntegration = integrationQuery.data.find( + (integration: any) => integration.metadata?.tenantId === tenantId + ); + + if (!companyIntegration) { + console.error(`No Xero integration found for tenant ${tenantId}`); + errors.push({ + tenantId, + error: "Tenant ID not found in integrations", + }); + continue; + } + + const companyId = (companyIntegration as any).companyId; + + // Group entities by type for efficient batch processing + const entitiesToSync: Array<{ + entityType: "customer" | "vendor" | "invoice"; + entityId: string; + operation: "create" | "update" | "delete"; + }> = []; + + for (const event of tenantEvents) { + const { resourceId, eventCategory, eventType } = event; + + // Log each entity change for debugging + console.log( + `Xero ${eventType}: ${eventCategory} ${resourceId} (tenant: ${tenantId})` + ); + + // Map Xero entity types to our internal types + if (eventCategory === "CONTACT") { + // Fetch the contact from Xero to determine if it's a customer or vendor + const contactInfo = await fetchContactAndDetermineType( + companyId, + tenantId, + resourceId, + serviceRole + ); + + // If the contact is both customer and supplier, sync to both tables + if (contactInfo.entityType === "both") { + console.log( + `Contact ${resourceId} is both customer and supplier, syncing to both` + ); + + // Add as customer + entitiesToSync.push({ + entityType: "customer", + entityId: resourceId, + operation: eventType.toLowerCase() as + | "create" + | "update" + | "delete", + }); + + // Add as vendor + entitiesToSync.push({ + entityType: "vendor", + entityId: resourceId, + operation: eventType.toLowerCase() as + | "create" + | "update" + | "delete", + }); + } else { + // Add as either customer or vendor based on the determination + entitiesToSync.push({ + entityType: contactInfo.entityType as "customer" | "vendor", + entityId: resourceId, + operation: eventType.toLowerCase() as + | "create" + | "update" + | "delete", + }); + } + } else if (eventCategory === "INVOICE") { + // Fetch the invoice to determine the associated customer/vendor + try { + const invoiceInfo = await fetchInvoiceAndDetermineContactType( + companyId, + tenantId, + resourceId, + serviceRole + ); + + console.log( + `Invoice ${resourceId} is for contact ${invoiceInfo.contactId} (${invoiceInfo.entityType})` + ); + + // Add the invoice for sync + entitiesToSync.push({ + entityType: "invoice", + entityId: resourceId, + operation: eventType.toLowerCase() as + | "create" + | "update" + | "delete", + }); + + // Also sync the associated contact + if (invoiceInfo.entityType === "both") { + // Sync both customer and vendor if contact serves both roles + entitiesToSync.push( + { + entityType: "customer", + entityId: invoiceInfo.contactId, + operation: eventType.toLowerCase() as + | "create" + | "update" + | "delete", + }, + { + entityType: "vendor", + entityId: invoiceInfo.contactId, + operation: eventType.toLowerCase() as + | "create" + | "update" + | "delete", + } + ); + } else { + // Sync the specific entity type (customer or vendor) + entitiesToSync.push({ + entityType: invoiceInfo.entityType as "customer" | "vendor", + entityId: invoiceInfo.contactId, + operation: eventType.toLowerCase() as + | "create" + | "update" + | "delete", + }); + } + } catch (error) { + console.error(`Failed to process invoice ${resourceId}:`, error); + // Still add the invoice for sync even if we can't determine the contact + entitiesToSync.push({ + entityType: "invoice", + entityId: resourceId, + operation: eventType.toLowerCase() as + | "create" + | "update" + | "delete", + }); + } + } else { + console.log(`Skipping unsupported entity type: ${eventCategory}`); + } + } + + // Trigger background sync job if there are entities to process + if (entitiesToSync.length > 0) { + try { + const jobHandle = await triggerAccountingSync( + companyId, + tenantId, + entitiesToSync + ); + syncJobs.push({ + id: jobHandle.id, + companyId, + tenantId, + entityCount: entitiesToSync.length, + }); + } catch (error) { + console.error("Failed to trigger sync job:", error); + errors.push({ + tenantId, + error: + error instanceof Error ? error.message : "Failed to trigger job", + }); + } + } + } catch (error) { + console.error("Error processing events for tenant:", tenantId, error); + errors.push({ + tenantId, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + // Return detailed response + return json({ + success: errors.length === 0, + jobsTriggered: syncJobs.length, + jobs: syncJobs, + errors: errors.length > 0 ? errors : undefined, + timestamp: new Date().toISOString(), + }); +} diff --git a/apps/erp/app/routes/x+/api+/_layout.tsx b/apps/erp/app/routes/x+/api+/_layout.tsx index 6e79e0b99..93e23ee0f 100644 --- a/apps/erp/app/routes/x+/api+/_layout.tsx +++ b/apps/erp/app/routes/x+/api+/_layout.tsx @@ -83,10 +83,12 @@ export default function ApiDocsRoute() { const tableBlacklist = new Set([ "apiKey", + "challengeAttempt", "documentTransaction", "feedback", "groups_recursive", "invite", + "lessonCompletion", "oauthClient", "oauthCode", "oauthToken", diff --git a/apps/erp/app/routes/x+/consumable+/$itemId.view.costing.tsx b/apps/erp/app/routes/x+/consumable+/$itemId.view.costing.tsx index 005025827..89743c24f 100644 --- a/apps/erp/app/routes/x+/consumable+/$itemId.view.costing.tsx +++ b/apps/erp/app/routes/x+/consumable+/$itemId.view.costing.tsx @@ -93,6 +93,7 @@ export default function ConsumableCostingRoute() { key={itemCost.itemId} initialValues={{ ...itemCost, + itemPostingGroupId: itemCost.itemPostingGroupId ?? undefined, ...getCustomFields(itemCost.customFields), }} /> diff --git a/apps/erp/app/routes/x+/inventory+/shelves.delete.$shelfId.tsx b/apps/erp/app/routes/x+/inventory+/shelves.delete.$shelfId.tsx index bd14b6797..27d78015c 100644 --- a/apps/erp/app/routes/x+/inventory+/shelves.delete.$shelfId.tsx +++ b/apps/erp/app/routes/x+/inventory+/shelves.delete.$shelfId.tsx @@ -1,14 +1,15 @@ import { error, notFound, success } from "@carbon/auth"; import { requirePermissions } from "@carbon/auth/auth.server"; import { flash } from "@carbon/auth/session.server"; +import { validationError, validator } from "@carbon/form"; import type { ClientActionFunctionArgs } from "@remix-run/react"; import { useLoaderData, useNavigate, useParams } from "@remix-run/react"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "@vercel/remix"; import { json, redirect } from "@vercel/remix"; import { ConfirmDelete } from "~/components/Modals"; -import { deleteShelf, getShelf } from "~/modules/inventory"; +import { deleteShelf, getShelf, shelfValidator } from "~/modules/inventory"; import { getParams, path } from "~/utils/path"; -import { getCompanyId } from "~/utils/react-query"; +import { getCompanyId, shelvesQuery } from "~/utils/react-query"; export async function loader({ request, params }: LoaderFunctionArgs) { const { client } = await requirePermissions(request, { @@ -55,16 +56,25 @@ export async function action({ request, params }: ActionFunctionArgs) { ); } -export async function clientAction({ serverAction }: ClientActionFunctionArgs) { +export async function clientAction({ + request, + serverAction, +}: ClientActionFunctionArgs) { const companyId = getCompanyId(); - window.clientCache?.invalidateQueries({ - predicate: (query) => { - const queryKey = query.queryKey as string[]; - return queryKey[0] === "shelves" && queryKey[1] === companyId; - }, - }); + const formData = await request.clone().formData(); + const validation = await validator(shelfValidator).validate(formData); + + if (validation.error) { + return validationError(validation.error); + } + if (companyId && validation.data.locationId) { + window.clientCache?.setQueryData( + shelvesQuery(companyId, validation.data.locationId).queryKey, + null + ); + } return await serverAction(); } diff --git a/apps/erp/app/routes/x+/items+/groups.$groupId.tsx b/apps/erp/app/routes/x+/items+/groups.$groupId.tsx index 34e303e51..5bbb0a726 100644 --- a/apps/erp/app/routes/x+/items+/groups.$groupId.tsx +++ b/apps/erp/app/routes/x+/items+/groups.$groupId.tsx @@ -2,6 +2,7 @@ import { assertIsPost, error, notFound, success } from "@carbon/auth"; import { requirePermissions } from "@carbon/auth/auth.server"; import { flash } from "@carbon/auth/session.server"; import { validationError, validator } from "@carbon/form"; +import type { ClientActionFunctionArgs } from "@remix-run/react"; import { useLoaderData, useNavigate } from "@remix-run/react"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "@vercel/remix"; import { json, redirect } from "@vercel/remix"; @@ -13,6 +14,7 @@ import { import { ItemPostingGroupForm } from "~/modules/items/ui/ItemPostingGroups"; import { getCustomFields, setCustomFields } from "~/utils/form"; import { getParams, path } from "~/utils/path"; +import { getCompanyId, itemPostingGroupsQuery } from "~/utils/react-query"; export async function loader({ request, params }: LoaderFunctionArgs) { const { client } = await requirePermissions(request, { @@ -71,6 +73,14 @@ export async function action({ request, params }: ActionFunctionArgs) { ); } +export async function clientAction({ serverAction }: ClientActionFunctionArgs) { + window.clientCache?.setQueryData( + itemPostingGroupsQuery(getCompanyId()).queryKey, + null + ); + return await serverAction(); +} + export default function EditItemPostingGroupsRoute() { const { itemPostingGroup } = useLoaderData(); const navigate = useNavigate(); diff --git a/apps/erp/app/routes/x+/items+/groups.delete.$groupId.tsx b/apps/erp/app/routes/x+/items+/groups.delete.$groupId.tsx index 252665a71..0f463479d 100644 --- a/apps/erp/app/routes/x+/items+/groups.delete.$groupId.tsx +++ b/apps/erp/app/routes/x+/items+/groups.delete.$groupId.tsx @@ -1,12 +1,14 @@ import { error, notFound, success } from "@carbon/auth"; import { requirePermissions } from "@carbon/auth/auth.server"; import { flash } from "@carbon/auth/session.server"; +import type { ClientActionFunctionArgs } from "@remix-run/react"; import { useLoaderData, useNavigate, useParams } from "@remix-run/react"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "@vercel/remix"; import { json, redirect } from "@vercel/remix"; import { ConfirmDelete } from "~/components/Modals"; import { deleteItemPostingGroup, getItemPostingGroup } from "~/modules/items"; import { getParams, path } from "~/utils/path"; +import { getCompanyId, itemPostingGroupsQuery } from "~/utils/react-query"; export async function loader({ request, params }: LoaderFunctionArgs) { const { client } = await requirePermissions(request, { @@ -62,6 +64,14 @@ export async function action({ request, params }: ActionFunctionArgs) { ); } +export async function clientAction({ serverAction }: ClientActionFunctionArgs) { + window.clientCache?.setQueryData( + itemPostingGroupsQuery(getCompanyId()).queryKey, + null + ); + return await serverAction(); +} + export default function DeleteItemPostingGroupRoute() { const { groupId } = useParams(); if (!groupId) throw new Error("groupId not found"); diff --git a/apps/erp/app/routes/x+/items+/groups.new.tsx b/apps/erp/app/routes/x+/items+/groups.new.tsx index 09993802b..c363db738 100644 --- a/apps/erp/app/routes/x+/items+/groups.new.tsx +++ b/apps/erp/app/routes/x+/items+/groups.new.tsx @@ -2,6 +2,7 @@ import { assertIsPost, error, success } from "@carbon/auth"; import { requirePermissions } from "@carbon/auth/auth.server"; import { flash } from "@carbon/auth/session.server"; import { validationError, validator } from "@carbon/form"; +import type { ClientActionFunctionArgs } from "@remix-run/react"; import { useNavigate } from "@remix-run/react"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "@vercel/remix"; import { json, redirect } from "@vercel/remix"; @@ -12,6 +13,7 @@ import { import { ItemPostingGroupForm } from "~/modules/items/ui/ItemPostingGroups"; import { setCustomFields } from "~/utils/form"; import { getParams, path } from "~/utils/path"; +import { getCompanyId, itemPostingGroupsQuery } from "~/utils/react-query"; export async function loader({ request }: LoaderFunctionArgs) { await requirePermissions(request, { @@ -75,6 +77,14 @@ export async function action({ request }: ActionFunctionArgs) { ); } +export async function clientAction({ serverAction }: ClientActionFunctionArgs) { + window.clientCache?.setQueryData( + itemPostingGroupsQuery(getCompanyId()).queryKey, + null + ); + return await serverAction(); +} + export default function NewItemPostingGroupsRoute() { const navigate = useNavigate(); const initialValues = { diff --git a/apps/erp/app/routes/x+/items+/update.tsx b/apps/erp/app/routes/x+/items+/update.tsx index 4bf99768c..41eaf3941 100644 --- a/apps/erp/app/routes/x+/items+/update.tsx +++ b/apps/erp/app/routes/x+/items+/update.tsx @@ -26,7 +26,6 @@ export async function action({ request }: ActionFunctionArgs) { case "name": case "replenishmentSystem": case "unitOfMeasureCode": - // For other fields, just update the specified field if (field === "replenishmentSystem" && value !== "Buy and Make") { return json( await client @@ -324,6 +323,60 @@ export async function action({ request }: ActionFunctionArgs) { .in("id", items as string[]) .eq("companyId", companyId) ); + case "itemPostingGroupId": + // Update itemCost table for all selected items + const itemCostUpdates = await Promise.all( + (items as string[]).map(async (itemId) => { + const existingCost = await client + .from("itemCost") + .select("itemId") + .eq("itemId", itemId) + .single(); + + if (existingCost.data) { + // Update existing record + return client + .from("itemCost") + .update({ + itemPostingGroupId: value || null, + updatedBy: userId, + updatedAt: new Date().toISOString(), + }) + .eq("itemId", itemId); + } else { + // Create new record + return client.from("itemCost").insert({ + itemId, + itemPostingGroupId: value || null, + costingMethod: "Standard", + standardCost: 0, + unitCost: 0, + costIsAdjusted: false, + companyId, + createdBy: userId, + updatedBy: userId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + } + }) + ); + + // Check for any errors + const errors = itemCostUpdates.filter((result) => result.error); + if (errors.length > 0) { + return json({ + error: { + message: errors[0].error?.message || "Failed to update item costs", + }, + data: null, + }); + } + + return json({ + data: null, + error: null, + }); case "partId": if (items.length > 1) { return json({ diff --git a/apps/erp/app/routes/x+/material+/$itemId.view.costing.tsx b/apps/erp/app/routes/x+/material+/$itemId.view.costing.tsx index 632ec8cad..87702eeca 100644 --- a/apps/erp/app/routes/x+/material+/$itemId.view.costing.tsx +++ b/apps/erp/app/routes/x+/material+/$itemId.view.costing.tsx @@ -93,6 +93,7 @@ export default function MaterialCostingRoute() { key={itemCost.itemId} initialValues={{ ...itemCost, + itemPostingGroupId: itemCost.itemPostingGroupId ?? undefined, ...getCustomFields(itemCost.customFields), }} /> diff --git a/apps/erp/app/routes/x+/part+/$itemId.view.costing.tsx b/apps/erp/app/routes/x+/part+/$itemId.view.costing.tsx index e22ef2d54..0a9639909 100644 --- a/apps/erp/app/routes/x+/part+/$itemId.view.costing.tsx +++ b/apps/erp/app/routes/x+/part+/$itemId.view.costing.tsx @@ -90,6 +90,7 @@ export default function PartCostingRoute() { key={itemCost.itemId} initialValues={{ ...itemCost, + itemPostingGroupId: itemCost.itemPostingGroupId ?? undefined, ...getCustomFields(itemCost.customFields), }} /> diff --git a/apps/erp/app/routes/x+/settings+/integrations.tsx b/apps/erp/app/routes/x+/settings+/integrations.tsx index 6e5bf8d9c..a51a0ec37 100644 --- a/apps/erp/app/routes/x+/settings+/integrations.tsx +++ b/apps/erp/app/routes/x+/settings+/integrations.tsx @@ -3,10 +3,14 @@ import { requirePermissions } from "@carbon/auth/auth.server"; import { flash } from "@carbon/auth/session.server"; import { integrations as availableIntegrations } from "@carbon/ee"; import { Outlet, useLoaderData } from "@remix-run/react"; -import { redirect, type LoaderFunctionArgs } from "@vercel/remix"; +import { json, redirect, type LoaderFunctionArgs } from "@vercel/remix"; import { IntegrationsList, getIntegrations } from "~/modules/settings"; import { path } from "~/utils/path"; +export const config = { + runtime: "nodejs", +}; + export async function loader({ request }: LoaderFunctionArgs) { const { client, companyId } = await requirePermissions(request, { view: "settings", @@ -23,11 +27,12 @@ export async function loader({ request }: LoaderFunctionArgs) { ); } - return { + return json({ installedIntegrations: (integrations.data .filter((i) => i.active) .map((i) => i.id) ?? []) as string[], - }; + state: crypto.randomUUID(), + }); } export default function IntegrationsRoute() { diff --git a/apps/erp/app/routes/x+/tool+/$itemId.view.costing.tsx b/apps/erp/app/routes/x+/tool+/$itemId.view.costing.tsx index 87fdf1616..95805dd82 100644 --- a/apps/erp/app/routes/x+/tool+/$itemId.view.costing.tsx +++ b/apps/erp/app/routes/x+/tool+/$itemId.view.costing.tsx @@ -90,6 +90,7 @@ export default function ToolCostingRoute() { key={itemCost.itemId} initialValues={{ ...itemCost, + itemPostingGroupId: itemCost.itemPostingGroupId ?? undefined, ...getCustomFields(itemCost.customFields), }} /> diff --git a/apps/erp/app/utils/react-query.ts b/apps/erp/app/utils/react-query.ts index 88185cee2..fcd448419 100644 --- a/apps/erp/app/utils/react-query.ts +++ b/apps/erp/app/utils/react-query.ts @@ -48,6 +48,11 @@ export const docsQuery = () => ({ staleTime: RefreshRate.Never, }); +export const itemPostingGroupsQuery = (companyId: string | null) => ({ + queryKey: ["itemPostingGroups", companyId ?? "null"], + staleTime: RefreshRate.Low, +}); + export const locationsQuery = (companyId: string | null) => ({ queryKey: ["locations", companyId ?? "null"], staleTime: RefreshRate.Low, diff --git a/apps/erp/vite.config.ts b/apps/erp/vite.config.ts index 8a77d6fb6..5a1817e09 100644 --- a/apps/erp/vite.config.ts +++ b/apps/erp/vite.config.ts @@ -33,7 +33,7 @@ export default defineConfig({ }, server: { port: 3000, - allowedHosts: ["8d53c60dbab3.ngrok-free.app"], + allowedHosts: ["6ecc74199853.ngrok-free.app"], }, plugins: [ remix({ diff --git a/ci/src/deploy.ts b/ci/src/deploy.ts index 8efe2af7e..a7c29bc13 100644 --- a/ci/src/deploy.ts +++ b/ci/src/deploy.ts @@ -39,6 +39,9 @@ export type Workspace = { openai_api_key: string | null; posthog_api_host: string | null; posthog_project_public_key: string | null; + quickbooks_client_id: string | null; + quickbooks_client_secret: string | null; + quickbooks_webhook_secret: string | null; resend_api_key: string | null; session_secret: string | null; slack_bot_token: string | null; @@ -57,6 +60,9 @@ export type Workspace = { upstash_redis_rest_url: string | null; url_erp: string | null; url_mes: string | null; + xero_client_id: string | null; + xero_client_secret: string | null; + xero_webhook_secret: string | null; }; async function deploy(): Promise { @@ -97,6 +103,9 @@ async function deploy(): Promise { openai_api_key, posthog_api_host, posthog_project_public_key, + quickbooks_client_id, + quickbooks_client_secret, + quickbooks_webhook_secret, resend_api_key, session_secret, slack_bot_token, @@ -115,6 +124,9 @@ async function deploy(): Promise { upstash_redis_rest_url, url_erp, url_mes, + xero_client_id, + xero_client_secret, + xero_webhook_secret, } = workspace; if (slug === "app") { @@ -252,6 +264,9 @@ async function deploy(): Promise { OPENAI_API_KEY: openai_api_key, POSTHOG_API_HOST: posthog_api_host ?? undefined, POSTHOG_PROJECT_PUBLIC_KEY: posthog_project_public_key ?? undefined, + QUICKBOOKS_CLIENT_ID: quickbooks_client_id ?? undefined, + QUICKBOOKS_CLIENT_SECRET: quickbooks_client_secret ?? undefined, + QUICKBOOKS_WEBHOOK_SECRET: quickbooks_webhook_secret ?? undefined, RESEND_API_KEY: resend_api_key, SESSION_SECRET: session_secret, SLACK_BOT_TOKEN: slack_bot_token ?? undefined, @@ -277,17 +292,20 @@ async function deploy(): Promise { URL_ERP: url_erp, URL_MES: url_mes, VERCEL_ENV: "production", + XERO_CLIENT_ID: xero_client_id ?? undefined, + XERO_CLIENT_SECRET: xero_client_secret ?? undefined, + XERO_WEBHOOK_SECRET: xero_webhook_secret ?? undefined, }, // Run SST from the repository root where sst.config.ts is located cwd: "..", stdio: "pipe", }); - console.log(`🚀 🧰 Deploying apps for ${workspace.id} with SST`); + console.log(`🚀 🐓 Deploying apps for ${workspace.id} with SST`); await $$`npx --yes sst deploy --stage prod`; - console.log(`✅ 🐓 Successfully deployed ${workspace.id}`); + console.log(`✅ 🍗 Successfully deployed ${workspace.id}`); } catch (error) { console.error(`🔴 🍳 Failed to deploy ${workspace.id}`, error); } diff --git a/llm/cache/customer-supplier-database-schema.md b/llm/cache/customer-supplier-database-schema.md new file mode 100644 index 000000000..470374065 --- /dev/null +++ b/llm/cache/customer-supplier-database-schema.md @@ -0,0 +1,298 @@ +# Customer and Supplier Database Schema in Carbon + +## Overview + +The Carbon system uses PostgreSQL (via Supabase) to store customer and supplier information across multiple related tables with comprehensive relationship management and audit trails. + +## Core Customer Tables + +### `customer` Table (Main Entity) + +**Primary Fields:** +- `id` TEXT PRIMARY KEY (UUID, auto-generated) +- `name` TEXT NOT NULL (unique per company) +- `customerTypeId` TEXT (FK to customerType.id) +- `customerStatusId` TEXT (FK to customerStatus.id) +- `taxId` TEXT (tax identification number) +- `accountManagerId` TEXT (FK to user.id) +- `logo` TEXT (logo URL/path) +- `assignee` TEXT (FK to user.id) + +**Contact Information (added in later migrations):** +- `phone` TEXT +- `fax` TEXT +- `website` TEXT +- `currencyCode` TEXT (FK to currencyCode.code) + +**Audit Fields:** +- `companyId` TEXT NOT NULL (FK to company.id) +- `createdAt` TIMESTAMP WITH TIME ZONE DEFAULT NOW() +- `createdBy` TEXT (FK to user.id) +- `updatedAt` TIMESTAMP WITH TIME ZONE +- `updatedBy` TEXT (FK to user.id) +- `customFields` JSONB (extensible custom data) + +### `customerType` Table (Classification) + +**Fields:** +- `id` TEXT PRIMARY KEY (UUID) +- `name` TEXT NOT NULL (unique per company) +- `protected` BOOLEAN DEFAULT FALSE +- Standard audit fields (companyId, createdBy, updatedBy, etc.) +- `customFields` JSONB + +### `customerStatus` Table (Status Tracking) + +**Fields:** +- `id` TEXT PRIMARY KEY (xid) +- `name` TEXT NOT NULL (unique per company) +- Standard audit fields (companyId, createdBy, updatedBy, etc.) +- `customFields` JSONB + +## Customer Related Tables + +### `customerLocation` Table (Address Management) + +**Fields:** +- `id` TEXT PRIMARY KEY (xid) +- `customerId` TEXT NOT NULL (FK to customer.id) +- `addressId` TEXT NOT NULL (FK to address.id) +- `name` TEXT NOT NULL (location name, added in later migration) +- `customFields` JSONB + +### `customerContact` Table (Contact Management) + +**Fields:** +- `id` TEXT PRIMARY KEY (xid) +- `customerId` TEXT NOT NULL (FK to customer.id) +- `contactId` TEXT NOT NULL (FK to contact.id) +- `customerLocationId` TEXT (FK to customerLocation.id, optional) +- `userId` TEXT (FK to user.id, optional - for user-linked contacts) +- `customFields` JSONB + +### `customerPayment` Table (Payment Settings) + +**Fields:** +- `customerId` TEXT PRIMARY KEY (1:1 with customer) +- `invoiceCustomerId` TEXT (FK to customer.id - can be different for billing) +- `invoiceCustomerLocationId` TEXT (FK to customerLocation.id) +- `invoiceCustomerContactId` TEXT (FK to customerContact.id) +- `paymentTermId` TEXT (FK to paymentTerm.id) +- `currencyCode` TEXT (FK to currency table) +- `companyId` TEXT NOT NULL +- Standard update audit fields + +### `customerShipping` Table (Shipping Settings) + +**Fields:** +- `customerId` TEXT PRIMARY KEY (1:1 with customer) +- `shippingCustomerId` TEXT (FK to customer.id - can be different for shipping) +- `shippingCustomerLocationId` TEXT (FK to customerLocation.id) +- `shippingCustomerContactId` TEXT (FK to customerContact.id) +- `shippingTermId` TEXT (FK to shippingTerm.id) +- `shippingMethodId` TEXT (FK to shippingMethod.id) +- `companyId` TEXT NOT NULL +- Standard update audit fields + +### `customerAccount` Table (User Access) + +**Fields:** +- `id` TEXT (FK to user.id) +- `customerId` TEXT NOT NULL (FK to customer.id) +- `companyId` TEXT NOT NULL +- Composite primary key: (id, companyId) + +## Core Supplier Tables + +### `supplier` Table (Main Entity) + +**Primary Fields:** +- `id` TEXT PRIMARY KEY (UUID, auto-generated) +- `name` TEXT NOT NULL (unique per company) +- `supplierTypeId` TEXT (FK to supplierType.id) +- `supplierStatusId` TEXT (FK to supplierStatus.id) +- `taxId` TEXT (tax identification number) +- `accountManagerId` TEXT (FK to user.id) +- `logo` TEXT (logo URL/path) +- `assignee` TEXT (FK to user.id) + +**Contact Information (added in later migrations):** +- `phone` TEXT +- `fax` TEXT +- `website` TEXT +- `currencyCode` TEXT (FK to currencyCode.code) + +**Audit Fields:** +- `companyId` TEXT NOT NULL (FK to company.id) +- `createdAt` TIMESTAMP WITH TIME ZONE DEFAULT NOW() +- `createdBy` TEXT (FK to user.id) +- `updatedAt` TIMESTAMP WITH TIME ZONE +- `updatedBy` TEXT (FK to user.id) +- `customFields` JSONB (extensible custom data) + +### `supplierType` Table (Classification) + +**Fields:** +- `id` TEXT PRIMARY KEY (UUID) +- `name` TEXT NOT NULL (unique per company) +- `protected` BOOLEAN DEFAULT FALSE +- Standard audit fields (companyId, createdBy, updatedBy, etc.) +- `customFields` JSONB + +### `supplierStatus` Table (Status Tracking) + +**Fields:** +- `id` TEXT PRIMARY KEY (xid) +- `name` TEXT NOT NULL (unique per company) +- Standard audit fields (companyId, createdBy, updatedBy, etc.) +- `customFields` JSONB + +## Supplier Related Tables + +### `supplierLocation` Table (Address Management) + +**Fields:** +- `id` TEXT PRIMARY KEY (xid) +- `supplierId` TEXT NOT NULL (FK to supplier.id) +- `addressId` TEXT NOT NULL (FK to address.id) +- `name` TEXT NOT NULL (location name, added in later migration) +- `customFields` JSONB + +### `supplierContact` Table (Contact Management) + +**Fields:** +- `id` TEXT PRIMARY KEY (xid) +- `supplierId` TEXT NOT NULL (FK to supplier.id) +- `contactId` TEXT NOT NULL (FK to contact.id) +- `supplierLocationId` TEXT (FK to supplierLocation.id, optional) +- `userId` TEXT (FK to user.id, optional - for user-linked contacts) +- `customFields` JSONB + +### `supplierPayment` Table (Payment Settings) + +**Fields:** +- `supplierId` TEXT PRIMARY KEY (1:1 with supplier) +- `invoiceSupplierId` TEXT (FK to supplier.id - can be different for billing) +- `invoiceSupplierLocationId` TEXT (FK to supplierLocation.id) +- `invoiceSupplierContactId` TEXT (FK to supplierContact.id) +- `paymentTermId` TEXT (FK to paymentTerm.id) +- `currencyCode` TEXT (FK to currency table) +- `companyId` TEXT NOT NULL +- Standard update audit fields +- `customFields` JSONB + +### `supplierShipping` Table (Shipping Settings) + +**Fields:** +- `supplierId` TEXT PRIMARY KEY (1:1 with supplier) +- `shippingSupplierId` TEXT (FK to supplier.id - can be different for shipping) +- `shippingSupplierLocationId` TEXT (FK to supplierLocation.id) +- `shippingSupplierContactId` TEXT (FK to supplierContact.id) +- `shippingTermId` TEXT (FK to shippingTerm.id) +- `shippingMethodId` TEXT (FK to shippingMethod.id) +- `companyId` TEXT NOT NULL +- Standard update audit fields +- `customFields` JSONB + +### `supplierAccount` Table (User Access) + +**Fields:** +- `id` TEXT (FK to user.id) +- `supplierId` TEXT NOT NULL (FK to supplier.id) +- `companyId` TEXT NOT NULL +- Composite primary key: (id, companyId) + +## Shared Supporting Tables + +### `contact` Table (Contact Information) + +**Fields:** +- `id` TEXT PRIMARY KEY (xid) +- `firstName` TEXT NOT NULL +- `lastName` TEXT NOT NULL +- `fullName` TEXT GENERATED (firstName + ' ' + lastName) +- `email` TEXT NOT NULL +- `title` TEXT (job title) +- `mobilePhone` TEXT +- `homePhone` TEXT +- `workPhone` TEXT +- `fax` TEXT +- `addressLine1` TEXT +- `addressLine2` TEXT +- `city` TEXT +- `state` TEXT +- `postalCode` TEXT +- `countryCode` INTEGER (FK to country.id) +- `birthday` DATE +- `notes` TEXT +- `companyId` TEXT NOT NULL + +### `address` Table (Address Information) + +**Fields:** +- `id` TEXT PRIMARY KEY (xid) +- `addressLine1` TEXT +- `addressLine2` TEXT +- `city` TEXT +- `state` TEXT +- `postalCode` TEXT +- `countryCode` INTEGER (FK to country.id) +- `phone` TEXT +- `fax` TEXT +- `companyId` TEXT NOT NULL + +## Database Views + +### `customers` View + +Aggregated view that includes: +- All customer table fields +- `type` (from customerType.name) +- `status` (from customerStatus.name) +- `orderCount` (count of sales orders) + +### `suppliers` View + +Aggregated view that includes: +- All supplier table fields +- `type` (from supplierType.name) +- `status` (from supplierStatus.name) +- `orderCount` (count of purchase orders) +- `partCount` (count of supplier parts/buy methods) + +## Key Features + +### Automatic Entry Creation + +When a customer or supplier is created, triggers automatically create: +- Payment settings record (customerPayment/supplierPayment) +- Shipping settings record (customerShipping/supplierShipping) + +### Custom Fields Support + +Both customers and suppliers support: +- JSONB `customFields` on main entities and related tables +- Custom field type system via `attributeDataType` table +- Specific customer and supplier attribute data types + +### Multi-tenancy + +- All tables are scoped by `companyId` +- Row Level Security (RLS) policies enforce data isolation +- Unique constraints are per-company (e.g., customer name unique per company) + +### Audit Trail + +- Standard created/updated by/at fields on all main entities +- Change tracking for all updates +- User attribution for all modifications + +## Migration History + +Key migrations that built this schema: +- `20230123004612_suppliers-and-customers.sql` - Core tables +- `20231109050252_customer-details.sql` - Payment/shipping tables +- `20231109050239_supplier-details.sql` - Supplier payment/shipping +- `20241009181230_customer-supplier-additional-fields.sql` - Contact info +- `20240813213122_add-customer-supplier-location-name.sql` - Location names +- `20250125121403_add-customer-and-supplier-custom-fields.sql` - Custom fields \ No newline at end of file diff --git a/llm/cache/xero-api-contact-structure.md b/llm/cache/xero-api-contact-structure.md new file mode 100644 index 000000000..e9f013f0f --- /dev/null +++ b/llm/cache/xero-api-contact-structure.md @@ -0,0 +1,138 @@ +# Xero API Contact Structure Documentation + +## Contact Response Structure + +Based on analysis of the Carbon codebase's XeroProvider implementation and web research, here are the key field names used by Xero API for contact responses: + +### Phone Fields +Xero uses a `Phones` array structure for phone numbers: + +```typescript +// Xero API Response Structure +{ + "Phones": [ + { + "PhoneType": "DEFAULT" | "MOBILE" | "WORK" | "HOME" | "FAX", + "PhoneNumber": "555-1234" + } + ] +} +``` + +**Implementation Notes:** +- The Carbon XeroProvider accesses phone numbers via `xeroContact.Phones?.[0]?.PhoneNumber` +- When creating contacts, phones are structured as: `[{ PhoneType: "DEFAULT", PhoneNumber: customer.phone }]` +- Multiple phone numbers can be stored in the array with different types + +### Website Field +**Important Limitation:** The Xero API **does not support** website fields in the standard contact object. + +From research findings: +- Website field is not available for setting via the Xero API +- There are community requests to include Website URL in the API feed +- Developers have requested that all contact fields should be updatable via API +- Currently, website information must be managed through the Xero web interface + +### Email Field +```typescript +{ + "EmailAddress": "contact@example.com" +} +``` + +### Other Key Contact Fields Used by Carbon + +```typescript +{ + "ContactID": "uuid-string", + "Name": "Contact Name", + "FirstName": "First", + "LastName": "Last", + "EmailAddress": "email@example.com", + "Phones": [ + { + "PhoneType": "DEFAULT", + "PhoneNumber": "555-1234" + } + ], + "Addresses": [ + { + "AddressType": "STREET", + "AddressLine1": "123 Main St", + "City": "City", + "Region": "State", + "PostalCode": "12345", + "Country": "Country" + } + ], + "TaxNumber": "123456789", + "DefaultCurrency": "USD", + "ContactStatus": "ACTIVE", + "IsCustomer": true, + "IsSupplier": false, + "CreatedDateUTC": "2024-01-01T00:00:00.000Z", + "UpdatedDateUTC": "2024-01-01T00:00:00.000Z" +} +``` + +### Contact Type Classification +Xero uses boolean flags to determine contact types: +- `IsCustomer`: Boolean flag indicating if contact is a customer +- `IsSupplier`: Boolean flag indicating if contact is a supplier +- A contact can be both customer and supplier (both flags true) + +### Address Structure +```typescript +{ + "Addresses": [ + { + "AddressType": "STREET" | "POBOX", + "AddressLine1": "Street address", + "AddressLine2": "Additional address info", + "AddressLine3": "More address info", + "AddressLine4": "Even more address info", + "City": "City name", + "Region": "State/Province", + "PostalCode": "Postal code", + "Country": "Country name" + } + ] +} +``` + +## Implementation in Carbon + +The Carbon XeroProvider transforms Xero contacts as follows: + +```typescript +// For customers and vendors +{ + id: xeroContact.ContactID, + name: xeroContact.Name, + email: xeroContact.EmailAddress, + phone: xeroContact.Phones?.[0]?.PhoneNumber, // Takes first phone number + addresses: xeroContact.Addresses?.[0] ? [/* transformed address */] : undefined, + taxNumber: xeroContact.TaxNumber, + currency: xeroContact.DefaultCurrency || "USD", + isActive: xeroContact.ContactStatus === "ACTIVE", + // Note: No website field due to Xero API limitation +} +``` + +## API Limitations + +1. **Website Field**: Not supported by Xero API +2. **Phone Numbers**: Only first phone number is used by Carbon implementation +3. **Address**: Only first address is used by Carbon implementation +4. **Contact Bank Details**: BSB and Account number can only be updated via API, other bank details require UI + +## Recommendations + +1. **For Website Data**: Consider storing in Carbon's internal customer/supplier tables since Xero doesn't support it +2. **For Multiple Phones**: Carbon could be enhanced to support multiple phone numbers from the Phones array +3. **For Complete Address Data**: Carbon could store multiple addresses if needed + +## Related Files + +- `/Users/barbinbrad/Code/carbon/packages/ee/src/accounting/providers/xero.ts` - XeroProvider implementation +- `/Users/barbinbrad/Code/carbon/apps/erp/app/routes/api+/webhook.xero.ts` - Webhook handling with contact processing \ No newline at end of file diff --git a/llm/cache/xero-webhooks.md b/llm/cache/xero-webhooks.md new file mode 100644 index 000000000..318358a32 --- /dev/null +++ b/llm/cache/xero-webhooks.md @@ -0,0 +1,112 @@ +# Xero Webhooks Information + +## Signature Verification + +Xero webhooks use HMAC SHA-256 for signature verification, following industry standards for webhook security. + +### Key Details: + +- **Algorithm**: HMAC-SHA256 +- **Header**: `x-xero-signature` (based on common Xero webhook implementations) +- **Signature Format**: Base64-encoded HMAC SHA-256 hash +- **Timeout**: Endpoints must respond within 5 seconds with HTTP 200 OK +- **Validation Requirements**: + - HTTP 200 for valid signatures + - HTTP 401 for invalid signatures + - Raw payload must be used for signature calculation (not parsed JSON) + +### Signature Verification Process: + +1. Extract the signature from the `x-xero-signature` header +2. Compute HMAC SHA-256 hash of the raw request body using the webhook secret +3. Base64 encode the computed hash +4. Compare with the received signature using timing-safe comparison + +## Supported Entity Types and Events + +Based on current Xero API capabilities, webhooks support the following: + +### Core Entity Types: + +1. **Contacts** (Customers/Suppliers) + - CREATE events + - UPDATE events + +2. **Invoices** + - CREATE events + - UPDATE events + +3. **Event Structure**: + - `EventCategory`: The type of entity (e.g., "CONTACT", "INVOICE") + - `EventType`: The operation type (e.g., "CREATE", "UPDATE") + +### Webhook Payload Structure: + +Xero webhooks typically follow this structure: + +```json +{ + "events": [ + { + "resourceId": "entity-uuid", + "resourceUrl": "https://api.xero.com/api.xro/2.0/EntityType/entity-uuid", + "eventCategory": "CONTACT", + "eventType": "UPDATE", + "eventDateUtc": "2025-01-01T00:00:00.000Z", + "tenantId": "tenant-uuid", + "tenantType": "ORGANISATION" + } + ], + "lastEventSequence": 1, + "firstEventSequence": 1, + "entropy": "random-string" +} +``` + +## Implementation Considerations + +### Rate Limits: +- 5 concurrent calls +- 60 calls per minute +- 5,000 calls per day + +### Security Requirements: +- Must validate signature using HMAC SHA-256 +- Must respond within 5 seconds +- Raw request body required for signature validation +- Frameworks that auto-parse JSON can break validation + +### Integration Points: + +Based on existing Carbon patterns: + +1. **Webhook Endpoint**: `/api/webhook/xero` +2. **Authentication**: Uses Xero webhook secret stored in environment +3. **Processing**: Triggers async accounting sync jobs +4. **Entity Mapping**: + - Xero Contacts → Carbon Customers/Suppliers + - Xero Invoices → Carbon Invoices + +### Current Implementation Status in Carbon: + +- Xero provider class exists but webhook methods are stubbed +- Need to implement `verifyWebhook()` and `processWebhook()` methods +- Follow QuickBooks webhook pattern for async job triggering +- Use existing accounting sync infrastructure + +## Environment Variables Required: + +``` +XERO_WEBHOOK_SECRET=your-webhook-secret-key +``` + +This secret is used for HMAC SHA-256 signature verification and should be stored securely. + +## Next Steps for Implementation: + +1. Implement `verifyWebhook()` method in XeroProvider +2. Implement `processWebhook()` method to parse event payload +3. Create webhook endpoint route similar to QuickBooks +4. Map Xero entity types to Carbon entity types +5. Test webhook signature verification +6. Configure webhook URL in Xero developer console \ No newline at end of file diff --git a/packages/auth/src/config/env.ts b/packages/auth/src/config/env.ts index d68ef8e53..39ffc8c67 100644 --- a/packages/auth/src/config/env.ts +++ b/packages/auth/src/config/env.ts @@ -12,6 +12,8 @@ declare global { SUPABASE_ANON_KEY: string; VERCEL_URL: string; VERCEL_ENV: string; + QUICKBOOKS_CLIENT_ID: string; + XERO_CLIENT_ID: string; }; } } @@ -20,10 +22,16 @@ declare global { namespace NodeJS { interface ProcessEnv { CARBON_EDITION: string; + CLOUDFLARE_TURNSTILE_SITE_KEY: string; + CLOUDFLARE_TURNSTILE_SECRET_KEY: string; DOMAIN: string; NOVU_SECRET_KEY: string; POSTHOG_API_HOST: string; POSTHOG_PROJECT_PUBLIC_KEY: string; + QUICKBOOKS_CLIENT_SECRET: string; + QUICKBOOKS_WEBHOOK_SECRET: string; + RESEND_API_KEY: string; + RESEND_DOMAIN: string; SESSION_SECRET: string; SESSION_KEY: string; SESSION_ERROR_KEY: string; @@ -42,8 +50,8 @@ declare global { UPSTASH_REDIS_REST_TOKEN: string; VERCEL_URL: string; VERCEL_ENV: string; - CLOUDFLARE_TURNSTILE_SITE_KEY: string; - CLOUDFLARE_TURNSTILE_SECRET_KEY: string; + XERO_CLIENT_SECRET: string; + XERO_WEBHOOK_SECRET: string; } } } @@ -91,6 +99,15 @@ const getEdition = () => { export const CarbonEdition = getEdition(); +export const CLOUDFLARE_TURNSTILE_SITE_KEY = getEnv( + "CLOUDFLARE_TURNSTILE_SITE_KEY", + { isSecret: false, isRequired: false } +); +export const CLOUDFLARE_TURNSTILE_SECRET_KEY = getEnv( + "CLOUDFLARE_TURNSTILE_SECRET_KEY", + { isRequired: false } +); + export const DOMAIN = getEnv("DOMAIN", { isRequired: false }); // preview environments need no domain export const EXCHANGE_RATES_API_KEY = getEnv("EXCHANGE_RATES_API_KEY", { isRequired: false, @@ -113,6 +130,21 @@ export const NOVU_APPLICATION_ID = getEnv("NOVU_APPLICATION_ID", { isSecret: false, }); export const NOVU_SECRET_KEY = getEnv("NOVU_SECRET_KEY"); + +export const QUICKBOOKS_CLIENT_ID = getEnv("QUICKBOOKS_CLIENT_ID", { + isRequired: false, +}); + +export const QUICKBOOKS_CLIENT_SECRET = getEnv("QUICKBOOKS_CLIENT_SECRET", { + isRequired: false, + isSecret: true, +}); + +export const QUICKBOOKS_WEBHOOK_SECRET = getEnv("QUICKBOOKS_WEBHOOK_SECRET", { + isRequired: false, + isSecret: true, +}); + export const RESEND_DOMAIN = getEnv("RESEND_DOMAIN", { isRequired: false, @@ -163,6 +195,18 @@ export const SESSION_MAX_AGE = 60 * 60 * 24 * 7; // 7 days; export const REFRESH_ACCESS_TOKEN_THRESHOLD = 60 * 10; // 10 minutes left before token expires export const VERCEL_URL = getEnv("VERCEL_URL", { isSecret: false }); +export const XERO_CLIENT_ID = getEnv("XERO_CLIENT_ID", { + isRequired: false, +}); +export const XERO_CLIENT_SECRET = getEnv("XERO_CLIENT_SECRET", { + isRequired: false, + isSecret: true, +}); +export const XERO_WEBHOOK_SECRET = getEnv("XERO_WEBHOOK_SECRET", { + isRequired: false, + isSecret: true, +}); + /** * Shared envs */ @@ -184,14 +228,7 @@ export const SUPABASE_URL = getEnv("SUPABASE_URL", { isSecret: false }); export const SUPABASE_ANON_KEY = getEnv("SUPABASE_ANON_KEY", { isSecret: false, }); -export const CLOUDFLARE_TURNSTILE_SITE_KEY = getEnv( - "CLOUDFLARE_TURNSTILE_SITE_KEY", - { isSecret: false, isRequired: false } -); -export const CLOUDFLARE_TURNSTILE_SECRET_KEY = getEnv( - "CLOUDFLARE_TURNSTILE_SECRET_KEY", - { isRequired: false } -); + export const RATE_LIMIT = parseInt( getEnv("RATE_LIMIT", { isRequired: false, isSecret: false }) || "5", 10 @@ -229,16 +266,18 @@ export function getBrowserEnv() { return { CARBON_EDITION, CONTROLLED_ENVIRONMENT, - SUPABASE_URL, - SUPABASE_ANON_KEY, + CLOUDFLARE_TURNSTILE_SITE_KEY, + GOOGLE_PLACES_API_KEY, POSTHOG_API_HOST, POSTHOG_PROJECT_PUBLIC_KEY, + NODE_ENV, NOVU_APPLICATION_ID, + QUICKBOOKS_CLIENT_ID, + SUPABASE_URL, + SUPABASE_ANON_KEY, VERCEL_ENV, VERCEL_URL, - NODE_ENV, - CLOUDFLARE_TURNSTILE_SITE_KEY, - GOOGLE_PLACES_API_KEY, + XERO_CLIENT_ID, }; } diff --git a/packages/auth/src/lib/supabase/client.ts b/packages/auth/src/lib/supabase/client.ts index 2a870aa10..2f64ea976 100644 --- a/packages/auth/src/lib/supabase/client.ts +++ b/packages/auth/src/lib/supabase/client.ts @@ -50,11 +50,11 @@ export const getCarbonAPIKeyClient = (apiKey: string) => { return client; }; -export const getCarbon = (accessToken?: string) => { +export const getCarbon = (accessToken?: string): SupabaseClient => { return getCarbonClient(SUPABASE_ANON_KEY, accessToken); }; -export const getCarbonServiceRole = () => { +export const getCarbonServiceRole = (): SupabaseClient => { if (isBrowser) throw new Error( "getCarbonServiceRole is not available in browser and should NOT be used in insecure environments" diff --git a/packages/database/src/swagger-docs-schema.ts b/packages/database/src/swagger-docs-schema.ts index 4b9f5855a..47ed268c7 100644 --- a/packages/database/src/swagger-docs-schema.ts +++ b/packages/database/src/swagger-docs-schema.ts @@ -1350,6 +1350,9 @@ export default { { $ref: "#/parameters/rowFilter.consumables.tags", }, + { + $ref: "#/parameters/rowFilter.consumables.itemPostingGroupId", + }, { $ref: "#/parameters/rowFilter.consumables.createdBy", }, @@ -36663,6 +36666,9 @@ export default { { $ref: "#/parameters/rowFilter.parts.tags", }, + { + $ref: "#/parameters/rowFilter.parts.itemPostingGroupId", + }, { $ref: "#/parameters/rowFilter.parts.createdBy", }, @@ -38583,6 +38589,9 @@ export default { { $ref: "#/parameters/rowFilter.tools.tags", }, + { + $ref: "#/parameters/rowFilter.tools.itemPostingGroupId", + }, { $ref: "#/parameters/rowFilter.tools.createdBy", }, @@ -55554,6 +55563,9 @@ export default { { $ref: "#/parameters/rowFilter.companySettings.gaugeCalibrationExpiredNotificationGroup", }, + { + $ref: "#/parameters/rowFilter.companySettings.purchasePriceUpdateTiming", + }, { $ref: "#/parameters/select", }, @@ -55652,6 +55664,9 @@ export default { { $ref: "#/parameters/rowFilter.companySettings.gaugeCalibrationExpiredNotificationGroup", }, + { + $ref: "#/parameters/rowFilter.companySettings.purchasePriceUpdateTiming", + }, { $ref: "#/parameters/preferReturn", }, @@ -55704,6 +55719,9 @@ export default { { $ref: "#/parameters/rowFilter.companySettings.gaugeCalibrationExpiredNotificationGroup", }, + { + $ref: "#/parameters/rowFilter.companySettings.purchasePriceUpdateTiming", + }, { $ref: "#/parameters/body.companySettings", }, @@ -56946,6 +56964,41 @@ export default { tags: ["(rpc) get_companies_with_any_role"], }, }, + "/rpc/get_unscheduled_jobs": { + post: { + parameters: [ + { + in: "body", + name: "args", + required: true, + schema: { + properties: { + location_id: { + format: "text", + type: "string", + }, + }, + required: ["location_id"], + type: "object", + }, + }, + { + $ref: "#/parameters/preferParams", + }, + ], + produces: [ + "application/json", + "application/vnd.pgrst.object+json;nulls=stripped", + "application/vnd.pgrst.object+json", + ], + responses: { + "200": { + description: "OK", + }, + }, + tags: ["(rpc) get_unscheduled_jobs"], + }, + }, "/rpc/get_direct_ancestors_of_tracked_entity": { post: { parameters: [ @@ -60789,6 +60842,12 @@ export default { }, type: "array", }, + itemPostingGroupId: { + description: + "Note:\nThis is a Foreign Key to `itemPostingGroup.id`.", + format: "text", + type: "string", + }, createdBy: { description: "Note:\nThis is a Foreign Key to `user.id`.", @@ -65972,6 +66031,7 @@ export default { "Job Consumption", "Job Receipt", "Batch Split", + "Purchase Order", ], format: 'public."itemLedgerDocumentType"', type: "string", @@ -77374,6 +77434,12 @@ export default { }, type: "array", }, + itemPostingGroupId: { + description: + "Note:\nThis is a Foreign Key to `itemPostingGroup.id`.", + format: "text", + type: "string", + }, createdBy: { description: "Note:\nThis is a Foreign Key to `user.id`.", @@ -77840,6 +77906,7 @@ export default { "Job Consumption", "Job Receipt", "Batch Split", + "Purchase Order", ], format: 'public."itemLedgerDocumentType"', type: "string", @@ -78309,6 +78376,12 @@ export default { }, type: "array", }, + itemPostingGroupId: { + description: + "Note:\nThis is a Foreign Key to `itemPostingGroup.id`.", + format: "text", + type: "string", + }, createdBy: { description: "Note:\nThis is a Foreign Key to `user.id`.", @@ -86508,6 +86581,7 @@ export default { "useMetric", "kanbanOutput", "gaugeCalibrationExpiredNotificationGroup", + "purchasePriceUpdateTiming", ], properties: { id: { @@ -86587,6 +86661,12 @@ export default { }, type: "array", }, + purchasePriceUpdateTiming: { + default: "Purchase Invoice Post", + enum: ["Purchase Invoice Post", "Purchase Order Finalize"], + format: 'public."purchasePriceUpdateTiming"', + type: "string", + }, }, type: "object", }, @@ -87679,6 +87759,12 @@ export default { in: "query", type: "string", }, + "rowFilter.consumables.itemPostingGroupId": { + name: "itemPostingGroupId", + required: false, + in: "query", + type: "string", + }, "rowFilter.consumables.createdBy": { name: "createdBy", required: false, @@ -106429,6 +106515,12 @@ export default { in: "query", type: "string", }, + "rowFilter.parts.itemPostingGroupId": { + name: "itemPostingGroupId", + required: false, + in: "query", + type: "string", + }, "rowFilter.parts.createdBy": { name: "createdBy", required: false, @@ -107515,6 +107607,12 @@ export default { in: "query", type: "string", }, + "rowFilter.tools.itemPostingGroupId": { + name: "itemPostingGroupId", + required: false, + in: "query", + type: "string", + }, "rowFilter.tools.createdBy": { name: "createdBy", required: false, @@ -116725,6 +116823,12 @@ export default { in: "query", type: "string", }, + "rowFilter.companySettings.purchasePriceUpdateTiming": { + name: "purchasePriceUpdateTiming", + required: false, + in: "query", + type: "string", + }, "body.part": { name: "part", description: "part", diff --git a/packages/database/src/types.ts b/packages/database/src/types.ts index 054701f12..6446a04c1 100644 --- a/packages/database/src/types.ts +++ b/packages/database/src/types.ts @@ -19639,6 +19639,7 @@ export type Database = { name: string processId: string | null status: Database["public"]["Enums"]["procedureStatus"] + tags: string[] | null updatedAt: string | null updatedBy: string | null version: number @@ -19654,6 +19655,7 @@ export type Database = { name: string processId?: string | null status?: Database["public"]["Enums"]["procedureStatus"] + tags?: string[] | null updatedAt?: string | null updatedBy?: string | null version?: number @@ -19669,6 +19671,7 @@ export type Database = { name?: string processId?: string | null status?: Database["public"]["Enums"]["procedureStatus"] + tags?: string[] | null updatedAt?: string | null updatedBy?: string | null version?: number @@ -37745,6 +37748,7 @@ export type Database = { defaultMethodType: Database["public"]["Enums"]["methodType"] | null description: string | null id: string | null + itemPostingGroupId: string | null itemTrackingType: | Database["public"]["Enums"]["itemTrackingType"] | null @@ -37931,6 +37935,13 @@ export type Database = { referencedRelation: "userDefaults" referencedColumns: ["userId"] }, + { + foreignKeyName: "itemCost_itemPostingGroupId_fkey" + columns: ["itemPostingGroupId"] + isOneToOne: false + referencedRelation: "itemPostingGroup" + referencedColumns: ["id"] + }, ] } contractors: { @@ -41878,6 +41889,7 @@ export type Database = { defaultMethodType: Database["public"]["Enums"]["methodType"] | null description: string | null id: string | null + itemPostingGroupId: string | null itemTrackingType: | Database["public"]["Enums"]["itemTrackingType"] | null @@ -42042,6 +42054,13 @@ export type Database = { referencedRelation: "userDefaults" referencedColumns: ["userId"] }, + { + foreignKeyName: "itemCost_itemPostingGroupId_fkey" + columns: ["itemPostingGroupId"] + isOneToOne: false + referencedRelation: "itemPostingGroup" + referencedColumns: ["id"] + }, ] } procedures: { @@ -42052,6 +42071,7 @@ export type Database = { name: string | null processId: string | null status: Database["public"]["Enums"]["procedureStatus"] | null + tags: string[] | null version: number | null versions: Json | null } @@ -45708,14 +45728,14 @@ export type Database = { Relationships: [ { foreignKeyName: "address_countryCode_fkey" - columns: ["customerCountryCode"] + columns: ["invoiceCountryCode"] isOneToOne: false referencedRelation: "country" referencedColumns: ["alpha2"] }, { foreignKeyName: "address_countryCode_fkey" - columns: ["invoiceCountryCode"] + columns: ["customerCountryCode"] isOneToOne: false referencedRelation: "country" referencedColumns: ["alpha2"] @@ -46251,14 +46271,14 @@ export type Database = { Relationships: [ { foreignKeyName: "address_countryCode_fkey" - columns: ["paymentCountryCode"] + columns: ["customerCountryCode"] isOneToOne: false referencedRelation: "country" referencedColumns: ["alpha2"] }, { foreignKeyName: "address_countryCode_fkey" - columns: ["customerCountryCode"] + columns: ["paymentCountryCode"] isOneToOne: false referencedRelation: "country" referencedColumns: ["alpha2"] @@ -48375,6 +48395,7 @@ export type Database = { defaultMethodType: Database["public"]["Enums"]["methodType"] | null description: string | null id: string | null + itemPostingGroupId: string | null itemTrackingType: | Database["public"]["Enums"]["itemTrackingType"] | null @@ -48539,6 +48560,13 @@ export type Database = { referencedRelation: "userDefaults" referencedColumns: ["userId"] }, + { + foreignKeyName: "itemCost_itemPostingGroupId_fkey" + columns: ["itemPostingGroupId"] + isOneToOne: false + referencedRelation: "itemPostingGroup" + referencedColumns: ["id"] + }, ] } userDefaults: { @@ -48923,6 +48951,7 @@ export type Database = { defaultMethodType: Database["public"]["Enums"]["methodType"] description: string id: string + itemPostingGroupId: string itemTrackingType: Database["public"]["Enums"]["itemTrackingType"] modelName: string modelPath: string @@ -49315,6 +49344,7 @@ export type Database = { grade: string gradeId: string id: string + itemPostingGroupId: string itemTrackingType: Database["public"]["Enums"]["itemTrackingType"] materialForm: string materialFormId: string @@ -50368,7 +50398,9 @@ export type Database = { | "Reject" | "Request Approval" purchaseOrderType: "Purchase" | "Return" | "Outside Processing" - purchasePriceUpdateTiming: "Invoice Post" | "Purchase Order Finalize" + purchasePriceUpdateTiming: + | "Purchase Invoice Post" + | "Purchase Order Finalize" qualityDocumentStatus: "Draft" | "Active" | "Archived" quoteLineStatus: "Not Started" | "In Progress" | "Complete" | "No Quote" quoteStatus: @@ -51472,7 +51504,10 @@ export const Constants = { "Request Approval", ], purchaseOrderType: ["Purchase", "Return", "Outside Processing"], - purchasePriceUpdateTiming: ["Invoice Post", "Purchase Order Finalize"], + purchasePriceUpdateTiming: [ + "Purchase Invoice Post", + "Purchase Order Finalize", + ], qualityDocumentStatus: ["Draft", "Active", "Archived"], quoteLineStatus: ["Not Started", "In Progress", "Complete", "No Quote"], quoteStatus: [ diff --git a/packages/database/supabase/functions/lib/types.ts b/packages/database/supabase/functions/lib/types.ts index 054701f12..6446a04c1 100644 --- a/packages/database/supabase/functions/lib/types.ts +++ b/packages/database/supabase/functions/lib/types.ts @@ -19639,6 +19639,7 @@ export type Database = { name: string processId: string | null status: Database["public"]["Enums"]["procedureStatus"] + tags: string[] | null updatedAt: string | null updatedBy: string | null version: number @@ -19654,6 +19655,7 @@ export type Database = { name: string processId?: string | null status?: Database["public"]["Enums"]["procedureStatus"] + tags?: string[] | null updatedAt?: string | null updatedBy?: string | null version?: number @@ -19669,6 +19671,7 @@ export type Database = { name?: string processId?: string | null status?: Database["public"]["Enums"]["procedureStatus"] + tags?: string[] | null updatedAt?: string | null updatedBy?: string | null version?: number @@ -37745,6 +37748,7 @@ export type Database = { defaultMethodType: Database["public"]["Enums"]["methodType"] | null description: string | null id: string | null + itemPostingGroupId: string | null itemTrackingType: | Database["public"]["Enums"]["itemTrackingType"] | null @@ -37931,6 +37935,13 @@ export type Database = { referencedRelation: "userDefaults" referencedColumns: ["userId"] }, + { + foreignKeyName: "itemCost_itemPostingGroupId_fkey" + columns: ["itemPostingGroupId"] + isOneToOne: false + referencedRelation: "itemPostingGroup" + referencedColumns: ["id"] + }, ] } contractors: { @@ -41878,6 +41889,7 @@ export type Database = { defaultMethodType: Database["public"]["Enums"]["methodType"] | null description: string | null id: string | null + itemPostingGroupId: string | null itemTrackingType: | Database["public"]["Enums"]["itemTrackingType"] | null @@ -42042,6 +42054,13 @@ export type Database = { referencedRelation: "userDefaults" referencedColumns: ["userId"] }, + { + foreignKeyName: "itemCost_itemPostingGroupId_fkey" + columns: ["itemPostingGroupId"] + isOneToOne: false + referencedRelation: "itemPostingGroup" + referencedColumns: ["id"] + }, ] } procedures: { @@ -42052,6 +42071,7 @@ export type Database = { name: string | null processId: string | null status: Database["public"]["Enums"]["procedureStatus"] | null + tags: string[] | null version: number | null versions: Json | null } @@ -45708,14 +45728,14 @@ export type Database = { Relationships: [ { foreignKeyName: "address_countryCode_fkey" - columns: ["customerCountryCode"] + columns: ["invoiceCountryCode"] isOneToOne: false referencedRelation: "country" referencedColumns: ["alpha2"] }, { foreignKeyName: "address_countryCode_fkey" - columns: ["invoiceCountryCode"] + columns: ["customerCountryCode"] isOneToOne: false referencedRelation: "country" referencedColumns: ["alpha2"] @@ -46251,14 +46271,14 @@ export type Database = { Relationships: [ { foreignKeyName: "address_countryCode_fkey" - columns: ["paymentCountryCode"] + columns: ["customerCountryCode"] isOneToOne: false referencedRelation: "country" referencedColumns: ["alpha2"] }, { foreignKeyName: "address_countryCode_fkey" - columns: ["customerCountryCode"] + columns: ["paymentCountryCode"] isOneToOne: false referencedRelation: "country" referencedColumns: ["alpha2"] @@ -48375,6 +48395,7 @@ export type Database = { defaultMethodType: Database["public"]["Enums"]["methodType"] | null description: string | null id: string | null + itemPostingGroupId: string | null itemTrackingType: | Database["public"]["Enums"]["itemTrackingType"] | null @@ -48539,6 +48560,13 @@ export type Database = { referencedRelation: "userDefaults" referencedColumns: ["userId"] }, + { + foreignKeyName: "itemCost_itemPostingGroupId_fkey" + columns: ["itemPostingGroupId"] + isOneToOne: false + referencedRelation: "itemPostingGroup" + referencedColumns: ["id"] + }, ] } userDefaults: { @@ -48923,6 +48951,7 @@ export type Database = { defaultMethodType: Database["public"]["Enums"]["methodType"] description: string id: string + itemPostingGroupId: string itemTrackingType: Database["public"]["Enums"]["itemTrackingType"] modelName: string modelPath: string @@ -49315,6 +49344,7 @@ export type Database = { grade: string gradeId: string id: string + itemPostingGroupId: string itemTrackingType: Database["public"]["Enums"]["itemTrackingType"] materialForm: string materialFormId: string @@ -50368,7 +50398,9 @@ export type Database = { | "Reject" | "Request Approval" purchaseOrderType: "Purchase" | "Return" | "Outside Processing" - purchasePriceUpdateTiming: "Invoice Post" | "Purchase Order Finalize" + purchasePriceUpdateTiming: + | "Purchase Invoice Post" + | "Purchase Order Finalize" qualityDocumentStatus: "Draft" | "Active" | "Archived" quoteLineStatus: "Not Started" | "In Progress" | "Complete" | "No Quote" quoteStatus: @@ -51472,7 +51504,10 @@ export const Constants = { "Request Approval", ], purchaseOrderType: ["Purchase", "Return", "Outside Processing"], - purchasePriceUpdateTiming: ["Invoice Post", "Purchase Order Finalize"], + purchasePriceUpdateTiming: [ + "Purchase Invoice Post", + "Purchase Order Finalize", + ], qualityDocumentStatus: ["Draft", "Active", "Archived"], quoteLineStatus: ["Not Started", "In Progress", "Complete", "No Quote"], quoteStatus: [ diff --git a/packages/database/supabase/migrations/20250825175837_quickbooks-integration.sql b/packages/database/supabase/migrations/20250825175837_quickbooks-integration.sql new file mode 100644 index 000000000..07355a010 --- /dev/null +++ b/packages/database/supabase/migrations/20250825175837_quickbooks-integration.sql @@ -0,0 +1,5 @@ +INSERT INTO "integration" ("id", "jsonschema") +VALUES + ('quickbooks', '{"type": "object", "properties": {}}'::json), + ('xero', '{"type": "object", "properties": {}}'::json), + ('sage', '{"type": "object", "properties": {}}'::json); diff --git a/packages/database/supabase/migrations/20250913023107_item-posting-group-in-parts.sql b/packages/database/supabase/migrations/20250913023107_item-posting-group-in-parts.sql new file mode 100644 index 000000000..b870240f3 --- /dev/null +++ b/packages/database/supabase/migrations/20250913023107_item-posting-group-in-parts.sql @@ -0,0 +1,833 @@ +DROP FUNCTION IF EXISTS get_part_details; +CREATE OR REPLACE FUNCTION get_part_details(item_id TEXT) +RETURNS TABLE ( + "active" BOOLEAN, + "assignee" TEXT, + "defaultMethodType" "methodType", + "description" TEXT, + "itemTrackingType" "itemTrackingType", + "name" TEXT, + "replenishmentSystem" "itemReplenishmentSystem", + "unitOfMeasureCode" TEXT, + "notes" JSONB, + "thumbnailPath" TEXT, + "modelId" TEXT, + "modelPath" TEXT, + "modelName" TEXT, + "modelSize" BIGINT, + "id" TEXT, + "companyId" TEXT, + "unitOfMeasure" TEXT, + "readableId" TEXT, + "revision" TEXT, + "readableIdWithRevision" TEXT, + "revisions" JSON, + "customFields" JSONB, + "tags" TEXT[], + "itemPostingGroupId" TEXT, + "createdBy" TEXT, + "createdAt" TIMESTAMP WITH TIME ZONE, + "updatedBy" TEXT, + "updatedAt" TIMESTAMP WITH TIME ZONE +) AS $$ +DECLARE + v_readable_id TEXT; + v_company_id TEXT; +BEGIN + -- First get the readableId and companyId for the item + SELECT i."readableId", i."companyId" INTO v_readable_id, v_company_id + FROM "item" i + WHERE i.id = item_id; + + RETURN QUERY + WITH item_revisions AS ( + SELECT + json_agg( + json_build_object( + 'id', i.id, + 'revision', i."revision", + 'methodType', i."defaultMethodType", + 'type', i."type" + ) ORDER BY + i."createdAt" + ) as "revisions" + FROM "item" i + WHERE i."readableId" = v_readable_id + AND i."companyId" = v_company_id + ) + SELECT + i."active", + i."assignee", + i."defaultMethodType", + i."description", + i."itemTrackingType", + i."name", + i."replenishmentSystem", + i."unitOfMeasureCode", + i."notes", + CASE + WHEN i."thumbnailPath" IS NULL AND mu."thumbnailPath" IS NOT NULL THEN mu."thumbnailPath" + ELSE i."thumbnailPath" + END as "thumbnailPath", + mu.id as "modelId", + mu."modelPath", + mu."name" as "modelName", + mu."size" as "modelSize", + i."id", + i."companyId", + uom.name as "unitOfMeasure", + i."readableId", + i."revision", + i."readableIdWithRevision", + ir."revisions", + p."customFields", + p."tags", + ic."itemPostingGroupId", + i."createdBy", + i."createdAt", + i."updatedBy", + i."updatedAt" + FROM "part" p + LEFT JOIN "item" i ON i."readableId" = p."id" AND i."companyId" = p."companyId" + LEFT JOIN item_revisions ir ON true + LEFT JOIN ( + SELECT + ps."itemId", + string_agg(ps."supplierPartId", ',') AS "supplierIds" + FROM "supplierPart" ps + GROUP BY ps."itemId" + ) ps ON ps."itemId" = i.id + LEFT JOIN "modelUpload" mu ON mu.id = i."modelUploadId" + LEFT JOIN "unitOfMeasure" uom ON uom.code = i."unitOfMeasureCode" AND uom."companyId" = i."companyId" + LEFT JOIN "itemCost" ic ON ic."itemId" = i.id + WHERE i."id" = item_id; +END; +$$ LANGUAGE plpgsql; + + + +DROP VIEW IF EXISTS "parts"; +CREATE OR REPLACE VIEW "parts" WITH (SECURITY_INVOKER=true) AS +WITH latest_items AS ( + SELECT DISTINCT ON (i."readableId", i."companyId") + i.*, + mu.id as "modelUploadId", + + mu."modelPath", + mu."thumbnailPath" as "modelThumbnailPath", + mu."name" as "modelName", + mu."size" as "modelSize" + FROM "item" i + LEFT JOIN "modelUpload" mu ON mu.id = i."modelUploadId" + ORDER BY i."readableId", i."companyId", i."createdAt" DESC NULLS LAST +), +item_revisions AS ( + SELECT + i."readableId", + i."companyId", + json_agg( + json_build_object( + 'id', i.id, + 'revision', i."revision", + 'name', i."name", + 'description', i."description", + 'active', i."active", + 'createdAt', i."createdAt" + ) ORDER BY i."createdAt" + ) as "revisions" + FROM "item" i + GROUP BY i."readableId", i."companyId" +) +SELECT + li."active", + li."assignee", + li."defaultMethodType", + li."description", + li."itemTrackingType", + li."name", + li."replenishmentSystem", + li."unitOfMeasureCode", + li."notes", + li."revision", + li."readableId", + li."readableIdWithRevision", + li."id", + li."companyId", + CASE + WHEN li."thumbnailPath" IS NULL AND li."modelThumbnailPath" IS NOT NULL THEN li."modelThumbnailPath" + ELSE li."thumbnailPath" + END as "thumbnailPath", + + li."modelPath", + li."modelName", + li."modelSize", + ps."supplierIds", + uom.name as "unitOfMeasure", + ir."revisions", + p."customFields", + p."tags", + ic."itemPostingGroupId", + li."createdBy", + li."createdAt", + li."updatedBy", + li."updatedAt" +FROM "part" p +INNER JOIN latest_items li ON li."readableId" = p."id" AND li."companyId" = p."companyId" +LEFT JOIN item_revisions ir ON ir."readableId" = p."id" AND ir."companyId" = p."companyId" +LEFT JOIN ( + SELECT + "itemId", + "companyId", + string_agg(ps."supplierPartId", ',') AS "supplierIds" + FROM "supplierPart" ps + GROUP BY "itemId", "companyId" +) ps ON ps."itemId" = li."id" AND ps."companyId" = li."companyId" +LEFT JOIN "unitOfMeasure" uom ON uom.code = li."unitOfMeasureCode" AND uom."companyId" = li."companyId" +LEFT JOIN "itemCost" ic ON ic."itemId" = li.id; + +DROP VIEW IF EXISTS "materials"; +CREATE OR REPLACE VIEW "materials" WITH (SECURITY_INVOKER=true) AS +WITH latest_items AS ( + SELECT DISTINCT ON (i."readableId", i."companyId") + i.*, + + mu."modelPath", + mu."thumbnailPath" as "modelThumbnailPath", + mu."name" as "modelName", + mu."size" as "modelSize" + FROM "item" i + LEFT JOIN "modelUpload" mu ON mu.id = i."modelUploadId" + ORDER BY i."readableId", i."companyId", i."createdAt" DESC NULLS LAST +), +item_revisions AS ( + SELECT + i."readableId", + i."companyId", + json_agg( + json_build_object( + 'id', i.id, + 'revision', i."revision", + 'methodType', i."defaultMethodType", + 'type', i."type" + ) ORDER BY i."createdAt" + ) as "revisions" + FROM "item" i + GROUP BY i."readableId", i."companyId" +) +SELECT + i."active", + i."assignee", + i."defaultMethodType", + i."description", + i."itemTrackingType", + i."name", + i."replenishmentSystem", + i."unitOfMeasureCode", + i."notes", + i."revision", + i."readableId", + i."readableIdWithRevision", + i."id", + i."companyId", + CASE + WHEN i."thumbnailPath" IS NULL AND i."modelThumbnailPath" IS NOT NULL THEN i."modelThumbnailPath" + ELSE i."thumbnailPath" + END as "thumbnailPath", + i."modelUploadId", + + i."modelPath", + i."modelName", + i."modelSize", + ps."supplierIds", + uom.name as "unitOfMeasure", + ir."revisions", + mf."name" AS "materialForm", + ms."name" AS "materialSubstance", + md."name" AS "dimensions", + mfin."name" AS "finish", + mg."name" AS "grade", + mt."name" AS "materialType", + m."materialSubstanceId", + m."materialFormId", + m."customFields", + m."tags", + ic."itemPostingGroupId", + i."createdBy", + i."createdAt", + i."updatedBy", + i."updatedAt" +FROM "material" m + INNER JOIN latest_items i ON i."readableId" = m."id" AND i."companyId" = m."companyId" + LEFT JOIN item_revisions ir ON ir."readableId" = m."id" AND ir."companyId" = i."companyId" + LEFT JOIN ( + SELECT + ps."itemId", + ps."companyId", + string_agg(ps."supplierPartId", ',') AS "supplierIds" + FROM "supplierPart" ps + GROUP BY ps."itemId", ps."companyId" + ) ps ON ps."itemId" = i."id" AND ps."companyId" = i."companyId" + LEFT JOIN "modelUpload" mu ON mu.id = i."modelUploadId" + LEFT JOIN "unitOfMeasure" uom ON uom.code = i."unitOfMeasureCode" AND uom."companyId" = i."companyId" + LEFT JOIN "materialForm" mf ON mf."id" = m."materialFormId" + LEFT JOIN "materialSubstance" ms ON ms."id" = m."materialSubstanceId" + LEFT JOIN "materialDimension" md ON m."dimensionId" = md."id" + LEFT JOIN "materialFinish" mfin ON m."finishId" = mfin."id" + LEFT JOIN "materialGrade" mg ON m."gradeId" = mg."id" + LEFT JOIN "materialType" mt ON m."materialTypeId" = mt."id" + LEFT JOIN "itemCost" ic ON ic."itemId" = i.id; + + +-- Update get_material_details function to use new ID-based relationships +DROP FUNCTION IF EXISTS get_material_details; +CREATE OR REPLACE FUNCTION get_material_details(item_id TEXT) +RETURNS TABLE ( + "active" BOOLEAN, + "assignee" TEXT, + "defaultMethodType" "methodType", + "description" TEXT, + "itemTrackingType" "itemTrackingType", + "name" TEXT, + "replenishmentSystem" "itemReplenishmentSystem", + "unitOfMeasureCode" TEXT, + "notes" JSONB, + "thumbnailPath" TEXT, + "modelUploadId" TEXT, + "modelPath" TEXT, + "modelName" TEXT, + "modelSize" BIGINT, + "id" TEXT, + "companyId" TEXT, + "readableId" TEXT, + "revision" TEXT, + "readableIdWithRevision" TEXT, + "supplierIds" TEXT, + "unitOfMeasure" TEXT, + "revisions" JSON, + "materialForm" TEXT, + "materialSubstance" TEXT, + "finish" TEXT, + "grade" TEXT, + "dimensions" TEXT, + "materialType" TEXT, + "materialSubstanceId" TEXT, + "materialFormId" TEXT, + "materialTypeId" TEXT, + "dimensionId" TEXT, + "gradeId" TEXT, + "finishId" TEXT, + "customFields" JSONB, + "tags" TEXT[], + "itemPostingGroupId" TEXT, + "createdBy" TEXT, + "createdAt" TIMESTAMP WITH TIME ZONE, + "updatedBy" TEXT, + "updatedAt" TIMESTAMP WITH TIME ZONE +) AS $$ +DECLARE + v_readable_id TEXT; + v_company_id TEXT; +BEGIN + -- First get the readableId and companyId for the item + SELECT i."readableId", i."companyId" INTO v_readable_id, v_company_id + FROM "item" i + WHERE i.id = item_id; + + RETURN QUERY + WITH item_revisions AS ( + SELECT + json_agg( + json_build_object( + 'id', i.id, + 'revision', i."revision", + 'methodType', i."defaultMethodType", + 'type', i."type" + ) ORDER BY + i."createdAt" + ) as "revisions" + FROM "item" i + WHERE i."readableId" = v_readable_id + AND i."companyId" = v_company_id + ) + SELECT + i."active", + i."assignee", + i."defaultMethodType", + i."description", + i."itemTrackingType", + i."name", + i."replenishmentSystem", + i."unitOfMeasureCode", + i."notes", + CASE + WHEN i."thumbnailPath" IS NULL AND mu."thumbnailPath" IS NOT NULL THEN mu."thumbnailPath" + ELSE i."thumbnailPath" + END as "thumbnailPath", + mu.id as "modelUploadId", + mu."modelPath", + mu."name" as "modelName", + mu."size" as "modelSize", + i."id", + i."companyId", + i."readableId", + i."revision", + i."readableIdWithRevision", + ps."supplierIds", + uom.name as "unitOfMeasure", + ir."revisions", + mf."name" AS "materialForm", + ms."name" AS "materialSubstance", + mfin."name" AS "finish", + mg."name" AS "grade", + md."name" AS "dimensions", + mt."name" AS "materialType", + m."materialSubstanceId", + m."materialFormId", + m."materialTypeId", + m."dimensionId", + m."gradeId", + m."finishId", + m."customFields", + m."tags", + ic."itemPostingGroupId", + i."createdBy", + i."createdAt", + i."updatedBy", + i."updatedAt" + FROM "material" m + LEFT JOIN "item" i ON i."readableId" = m."id" AND i."companyId" = m."companyId" + LEFT JOIN item_revisions ir ON true + LEFT JOIN ( + SELECT + ps."itemId", + string_agg(ps."supplierPartId", ',') AS "supplierIds" + FROM "supplierPart" ps + GROUP BY ps."itemId" + ) ps ON ps."itemId" = i.id + LEFT JOIN "modelUpload" mu ON mu.id = i."modelUploadId" + LEFT JOIN "unitOfMeasure" uom ON uom.code = i."unitOfMeasureCode" AND uom."companyId" = i."companyId" + LEFT JOIN "materialForm" mf ON mf."id" = m."materialFormId" + LEFT JOIN "materialSubstance" ms ON ms."id" = m."materialSubstanceId" + LEFT JOIN "materialDimension" md ON m."dimensionId" = md."id" + LEFT JOIN "materialFinish" mfin ON m."finishId" = mfin."id" + LEFT JOIN "materialGrade" mg ON m."gradeId" = mg."id" + LEFT JOIN "materialType" mt ON m."materialTypeId" = mt."id" + LEFT JOIN "itemCost" ic ON ic."itemId" = i.id + WHERE i."id" = item_id; +END; +$$ LANGUAGE plpgsql STABLE; + + +DROP VIEW IF EXISTS "consumables"; +CREATE OR REPLACE VIEW "consumables" WITH (SECURITY_INVOKER=true) AS +WITH latest_items AS ( + SELECT DISTINCT ON (i."readableId", i."companyId") + i.*, + mu."modelPath", + mu."thumbnailPath" as "modelThumbnailPath", + mu."name" as "modelName", + mu."size" as "modelSize" + FROM "item" i + LEFT JOIN "modelUpload" mu ON mu.id = i."modelUploadId" + ORDER BY i."readableId", i."companyId", i."createdAt" DESC NULLS LAST +), +item_revisions AS ( + SELECT + i."readableId", + i."companyId", + json_agg( + json_build_object( + 'id', i.id, + 'revision', i."revision", + 'methodType', i."defaultMethodType", + 'type', i."type" + ) ORDER BY i."createdAt" + ) as "revisions" + FROM "item" i + GROUP BY i."readableId", i."companyId" +) +SELECT + li."active", + li."assignee", + li."defaultMethodType", + li."description", + li."itemTrackingType", + li."name", + li."replenishmentSystem", + li."unitOfMeasureCode", + li."notes", + li."revision", + li."readableId", + li."readableIdWithRevision", + li."id", + li."companyId", + CASE + WHEN li."thumbnailPath" IS NULL AND li."modelThumbnailPath" IS NOT NULL THEN li."modelThumbnailPath" + ELSE li."thumbnailPath" + END as "thumbnailPath", + li."modelUploadId", + li."modelPath", + li."modelName", + li."modelSize", + ps."supplierIds", + uom.name as "unitOfMeasure", + ir."revisions", + c."customFields", + c."tags", + ic."itemPostingGroupId", + li."createdBy", + li."createdAt", + li."updatedBy", + li."updatedAt" +FROM "consumable" c + INNER JOIN latest_items li ON li."readableId" = c."id" AND li."companyId" = c."companyId" +LEFT JOIN item_revisions ir ON ir."readableId" = c."id" AND ir."companyId" = li."companyId" +LEFT JOIN ( + SELECT + "itemId", + "companyId", + string_agg(ps."supplierPartId", ',') AS "supplierIds" + FROM "supplierPart" ps + GROUP BY "itemId", "companyId" +) ps ON ps."itemId" = li."id" AND ps."companyId" = li."companyId" +LEFT JOIN "unitOfMeasure" uom ON uom.code = li."unitOfMeasureCode" AND uom."companyId" = li."companyId" +LEFT JOIN "itemCost" ic ON ic."itemId" = li.id; + + +DROP FUNCTION IF EXISTS get_consumable_details; +CREATE OR REPLACE FUNCTION get_consumable_details(item_id TEXT) +RETURNS TABLE ( + "active" BOOLEAN, + "assignee" TEXT, + "defaultMethodType" "methodType", + "description" TEXT, + "itemTrackingType" "itemTrackingType", + "name" TEXT, + "replenishmentSystem" "itemReplenishmentSystem", + "unitOfMeasureCode" TEXT, + "notes" JSONB, + "thumbnailPath" TEXT, + "modelUploadId" TEXT, + "modelPath" TEXT, + "modelName" TEXT, + "modelSize" BIGINT, + "id" TEXT, + "companyId" TEXT, + "readableId" TEXT, + "revision" TEXT, + "readableIdWithRevision" TEXT, + "supplierIds" TEXT, + "unitOfMeasure" TEXT, + "revisions" JSON, + "customFields" JSONB, + "tags" TEXT[], + "itemPostingGroupId" TEXT, + "createdBy" TEXT, + "createdAt" TIMESTAMP WITH TIME ZONE, + "updatedBy" TEXT, + "updatedAt" TIMESTAMP WITH TIME ZONE +) AS $$ +DECLARE + v_readable_id TEXT; + v_company_id TEXT; +BEGIN + -- First get the readableId and companyId for the item + SELECT i."readableId", i."companyId" INTO v_readable_id, v_company_id + FROM "item" i + WHERE i.id = item_id; + + RETURN QUERY + WITH item_revisions AS ( + SELECT + json_agg( + json_build_object( + 'id', i.id, + 'revision', i."revision", + 'methodType', i."defaultMethodType", + 'type', i."type" + ) ORDER BY + i."createdAt" + ) as "revisions" + FROM "item" i + WHERE i."readableId" = v_readable_id + AND i."companyId" = v_company_id + ) + SELECT + i."active", + i."assignee", + i."defaultMethodType", + i."description", + i."itemTrackingType", + i."name", + i."replenishmentSystem", + i."unitOfMeasureCode", + i."notes", + CASE + WHEN i."thumbnailPath" IS NULL AND mu."thumbnailPath" IS NOT NULL THEN mu."thumbnailPath" + ELSE i."thumbnailPath" + END as "thumbnailPath", + mu.id as "modelUploadId", + mu."modelPath", + mu."name" as "modelName", + mu."size" as "modelSize", + i."id", + i."companyId", + i."readableId", + i."revision", + i."readableIdWithRevision", + ps."supplierIds", + uom.name as "unitOfMeasure", + ir."revisions", + c."customFields", + c."tags", + ic."itemPostingGroupId", + i."createdBy", + i."createdAt", + i."updatedBy", + i."updatedAt" + FROM "consumable" c + LEFT JOIN "item" i ON i."readableId" = c."id" AND i."companyId" = c."companyId" + LEFT JOIN item_revisions ir ON true + LEFT JOIN ( + SELECT + ps."itemId", + string_agg(ps."supplierPartId", ',') AS "supplierIds" + FROM "supplierPart" ps + GROUP BY ps."itemId" + ) ps ON ps."itemId" = i.id + LEFT JOIN "modelUpload" mu ON mu.id = i."modelUploadId" + LEFT JOIN "unitOfMeasure" uom ON uom.code = i."unitOfMeasureCode" AND uom."companyId" = i."companyId" + LEFT JOIN "itemCost" ic ON ic."itemId" = i.id + WHERE i."id" = item_id; +END; +$$ LANGUAGE plpgsql STABLE; + + +DROP FUNCTION IF EXISTS get_tool_details; +CREATE OR REPLACE FUNCTION get_tool_details(item_id TEXT) +RETURNS TABLE ( + "active" BOOLEAN, + "assignee" TEXT, + "defaultMethodType" "methodType", + "description" TEXT, + "itemTrackingType" "itemTrackingType", + "name" TEXT, + "replenishmentSystem" "itemReplenishmentSystem", + "unitOfMeasureCode" TEXT, + "notes" JSONB, + "thumbnailPath" TEXT, + "modelId" TEXT, + "modelPath" TEXT, + "modelName" TEXT, + "modelSize" BIGINT, + "id" TEXT, + "companyId" TEXT, + "unitOfMeasure" TEXT, + "readableId" TEXT, + "revision" TEXT, + "readableIdWithRevision" TEXT, + "revisions" JSON, + "customFields" JSONB, + "tags" TEXT[], + "itemPostingGroupId" TEXT, + "createdBy" TEXT, + "createdAt" TIMESTAMP WITH TIME ZONE, + "updatedBy" TEXT, + "updatedAt" TIMESTAMP WITH TIME ZONE +) AS $$ +DECLARE + v_readable_id TEXT; + v_company_id TEXT; +BEGIN + -- First get the readableId and companyId for the item + SELECT i."readableId", i."companyId" INTO v_readable_id, v_company_id + FROM "item" i + WHERE i.id = item_id; + + RETURN QUERY + WITH item_revisions AS ( + SELECT + json_agg( + json_build_object( + 'id', i.id, + 'revision', i."revision", + 'methodType', i."defaultMethodType", + 'type', i."type" + ) ORDER BY + i."createdAt" + ) as "revisions" + FROM "item" i + WHERE i."readableId" = v_readable_id + AND i."companyId" = v_company_id + ) + SELECT + i."active", + i."assignee", + i."defaultMethodType", + i."description", + i."itemTrackingType", + i."name", + i."replenishmentSystem", + i."unitOfMeasureCode", + i."notes", + CASE + WHEN i."thumbnailPath" IS NULL AND mu."thumbnailPath" IS NOT NULL THEN mu."thumbnailPath" + ELSE i."thumbnailPath" + END as "thumbnailPath", + mu.id as "modelId", + mu."modelPath", + mu."name" as "modelName", + mu."size" as "modelSize", + i."id", + i."companyId", + uom.name as "unitOfMeasure", + i."readableId", + i."revision", + i."readableIdWithRevision", + ir."revisions", + t."customFields", + t."tags", + ic."itemPostingGroupId", + i."createdBy", + i."createdAt", + i."updatedBy", + i."updatedAt" + FROM "tool" t + LEFT JOIN "item" i ON i."readableId" = t."id" AND i."companyId" = t."companyId" + LEFT JOIN item_revisions ir ON true + LEFT JOIN ( + SELECT + ps."itemId", + string_agg(ps."supplierPartId", ',') AS "supplierIds" + FROM "supplierPart" ps + GROUP BY ps."itemId" + ) ps ON ps."itemId" = i.id + LEFT JOIN "modelUpload" mu ON mu.id = i."modelUploadId" + LEFT JOIN "unitOfMeasure" uom ON uom.code = i."unitOfMeasureCode" AND uom."companyId" = i."companyId" + LEFT JOIN "itemCost" ic ON ic."itemId" = i.id + WHERE i."id" = item_id; +END; +$$ LANGUAGE plpgsql; + +DROP VIEW IF EXISTS "tools"; +CREATE OR REPLACE VIEW "tools" WITH (SECURITY_INVOKER=true) AS +WITH latest_items AS ( + SELECT DISTINCT ON (i."readableId", i."companyId") + i.*, + mu.id as "modelUploadId", + + mu."modelPath", + mu."thumbnailPath" as "modelThumbnailPath", + mu."name" as "modelName", + mu."size" as "modelSize" + FROM "item" i + LEFT JOIN "modelUpload" mu ON mu.id = i."modelUploadId" + ORDER BY i."readableId", i."companyId", i."createdAt" DESC NULLS LAST +), +item_revisions AS ( + SELECT + i."readableId", + i."companyId", + json_agg( + json_build_object( + 'id', i.id, + 'revision', i."revision", + 'methodType', i."defaultMethodType", + 'type', i."type" + ) ORDER BY i."createdAt" + ) as "revisions" + FROM "item" i + GROUP BY i."readableId", i."companyId" +) +SELECT + li."active", + li."assignee", + li."defaultMethodType", + li."description", + li."itemTrackingType", + li."name", + li."replenishmentSystem", + li."unitOfMeasureCode", + li."notes", + li."revision", + li."readableId", + li."readableIdWithRevision", + li."id", + li."companyId", + CASE + WHEN li."thumbnailPath" IS NULL AND li."modelThumbnailPath" IS NOT NULL THEN li."modelThumbnailPath" + ELSE li."thumbnailPath" + END as "thumbnailPath", + + li."modelPath", + li."modelName", + li."modelSize", + ps."supplierIds", + uom.name as "unitOfMeasure", + ir."revisions", + t."customFields", + t."tags", + ic."itemPostingGroupId", + li."createdBy", + li."createdAt", + li."updatedBy", + li."updatedAt" +FROM "tool" t + INNER JOIN latest_items li ON li."readableId" = t."id" AND li."companyId" = t."companyId" +LEFT JOIN item_revisions ir ON ir."readableId" = t."id" AND ir."companyId" = li."companyId" +LEFT JOIN ( + SELECT + "itemId", + "companyId", + string_agg(ps."supplierPartId", ',') AS "supplierIds" + FROM "supplierPart" ps + GROUP BY "itemId", "companyId" +) ps ON ps."itemId" = li."id" AND ps."companyId" = li."companyId" +LEFT JOIN "unitOfMeasure" uom ON uom.code = li."unitOfMeasureCode" AND uom."companyId" = li."companyId" +LEFT JOIN "itemCost" ic ON ic."itemId" = li.id; + +DROP VIEW IF EXISTS "jobs"; +CREATE OR REPLACE VIEW "jobs" WITH(SECURITY_INVOKER=true) AS +WITH job_model AS ( + SELECT + j.id AS job_id, + j."companyId", + COALESCE(j."modelUploadId", i."modelUploadId") AS model_upload_id + FROM "job" j + INNER JOIN "item" i ON j."itemId" = i."id" AND j."companyId" = i."companyId" +) +SELECT + j.*, + CASE + WHEN j.status = 'Completed' THEN 'Completed' + WHEN j."status" = 'Cancelled' THEN 'Cancelled' + WHEN j."dueDate" IS NOT NULL AND j."dueDate" < CURRENT_DATE THEN 'Overdue' + WHEN j."dueDate" IS NOT NULL AND j."dueDate" = CURRENT_DATE THEN 'Due Today' + ELSE j."status" + END as "statusWithDueDate", + i.name, + i."readableIdWithRevision" as "itemReadableIdWithRevision", + i.type as "itemType", + i.name as "description", + i."itemTrackingType", + i.active, + i."replenishmentSystem", + mu.id as "modelId", + mu."autodeskUrn", + mu."modelPath", + CASE + WHEN i."thumbnailPath" IS NULL AND mu."thumbnailPath" IS NOT NULL THEN mu."thumbnailPath" + ELSE i."thumbnailPath" + END as "thumbnailPath", + mu."name" as "modelName", + mu."size" as "modelSize", + so."salesOrderId" as "salesOrderReadableId", + qo."quoteId" as "quoteReadableId", + ic."itemPostingGroupId" +FROM "job" j +INNER JOIN "item" i ON j."itemId" = i."id" AND j."companyId" = i."companyId" +LEFT JOIN job_model jm ON j.id = jm.job_id AND j."companyId" = jm."companyId" +LEFT JOIN "modelUpload" mu ON mu.id = jm.model_upload_id +LEFT JOIN "salesOrder" so on j."salesOrderId" = so.id AND j."companyId" = so."companyId" +LEFT JOIN "quote" qo ON j."quoteId" = qo.id AND j."companyId" = qo."companyId" +LEFT JOIN "itemCost" ic ON ic."itemId" = i.id; \ No newline at end of file diff --git a/packages/database/supabase/migrations/20250913172116_customer-location-unique.sql b/packages/database/supabase/migrations/20250913172116_customer-location-unique.sql new file mode 100644 index 000000000..e04bdba83 --- /dev/null +++ b/packages/database/supabase/migrations/20250913172116_customer-location-unique.sql @@ -0,0 +1,4 @@ +ALTER TABLE "customerLocation" ADD CONSTRAINT "customerLocation_addressId_customerId_name_key" UNIQUE ("addressId", "customerId"); + +ALTER TABLE "supplierLocation" ADD CONSTRAINT "supplierLocation_addressId_supplierId_name_key" UNIQUE ("addressId", "supplierId"); + diff --git a/packages/database/supabase/migrations/20251122152701_materials-view-fix.sql b/packages/database/supabase/migrations/20251122152701_materials-view-fix.sql index 14072d49f..9125fedc6 100644 --- a/packages/database/supabase/migrations/20251122152701_materials-view-fix.sql +++ b/packages/database/supabase/migrations/20251122152701_materials-view-fix.sql @@ -1,3 +1,4 @@ +DROP VIEW IF EXISTS "materials"; CREATE OR REPLACE VIEW "materials" WITH (SECURITY_INVOKER=true) AS WITH latest_items AS ( SELECT DISTINCT ON (i."readableId", i."companyId") diff --git a/packages/ee/package.json b/packages/ee/package.json index 5c7cc675b..d71451568 100644 --- a/packages/ee/package.json +++ b/packages/ee/package.json @@ -4,12 +4,15 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./accounting": "./src/accounting/index.ts", "./exchange-rates.server": "./src/exchange-rates/exchange-rates.server.ts", "./notifications": "./src/notifications/index.ts", "./onshape": "./src/onshape/lib/index.ts", "./paperless-parts": "./src/paperless-parts/lib/index.ts", + "./quickbooks": "./src/accounting/providers/quickbooks.ts", "./slack.server": "./src/slack.server.ts", - "./slack/messages": "./src/slack/lib/messages.ts" + "./slack/messages": "./src/slack/lib/messages.ts", + "./xero": "./src/accounting/providers/xero.ts" }, "jest": { "preset": "@carbon/jest/node" diff --git a/packages/ee/src/accounting/index.ts b/packages/ee/src/accounting/index.ts new file mode 100644 index 000000000..e172e94a6 --- /dev/null +++ b/packages/ee/src/accounting/index.ts @@ -0,0 +1,4 @@ +export * from "./models"; +export * from "./sync"; + +export type { CoreProvider, ProviderConfig } from "./service"; diff --git a/packages/ee/src/accounting/models.ts b/packages/ee/src/accounting/models.ts new file mode 100644 index 000000000..1abb9c208 --- /dev/null +++ b/packages/ee/src/accounting/models.ts @@ -0,0 +1,518 @@ +/* +License: MIT +Author: Pontus Abrahamssons +Repository: https://github.com/midday-ai/zuno +*/ + +import { z } from "zod"; + +// Base schemas +export const BaseEntitySchema = z.object({ + id: z.string(), + externalId: z.string().optional(), // Provider-specific ID + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + lastSyncedAt: z.string().datetime().optional(), + remoteWasDeleted: z.boolean().optional(), +}); + +// Address schema (reusable) +export const AddressSchema = z.object({ + type: z.enum(["billing", "shipping", "mailing"]).optional(), + street: z.string().optional(), + street2: z.string().optional(), + city: z.string().optional(), + state: z.string().optional(), + postalCode: z.string().optional(), + country: z.string().optional(), +}); + +// Phone number schema +export const PhoneNumberSchema = z.object({ + type: z.enum(["home", "work", "mobile", "fax"]).optional(), + number: z.string(), + isPrimary: z.boolean().optional(), +}); + +// Enhanced attachment schema with comprehensive file support +export const AttachmentSchema = BaseEntitySchema.extend({ + filename: z.string(), + originalFilename: z.string().optional(), + mimeType: z.string(), + size: z.number(), + url: z.string().url(), + downloadUrl: z.string().url().optional(), + thumbnailUrl: z.string().url().optional(), + entityType: z.enum([ + "invoice", + "customer", + "transaction", + "expense", + "bill", + "receipt", + "journal_entry", + ]), + entityId: z.string(), + attachmentType: z + .enum(["receipt", "invoice", "contract", "supporting_document", "other"]) + .optional(), + description: z.string().optional(), + isPublic: z.boolean().default(false), + uploadedBy: z.string().optional(), + checksum: z.string().optional(), // For file integrity + metadata: z.record(z.any()).optional(), // Provider-specific metadata +}); + +// Account schema for chart of accounts +export const AccountSchema = BaseEntitySchema.extend({ + name: z.string(), + code: z.string().optional(), + description: z.string().optional(), + accountType: z.enum([ + "asset", + "liability", + "equity", + "income", + "expense", + "accounts_receivable", + "accounts_payable", + "bank", + "credit_card", + "current_asset", + "fixed_asset", + "other_asset", + "current_liability", + "long_term_liability", + "cost_of_goods_sold", + "other_income", + "other_expense", + ]), + accountSubType: z.string().optional(), + parentAccountId: z.string().optional(), + isActive: z.boolean().default(true), + currentBalance: z.number().optional(), + currency: z.string().default("USD"), + taxCode: z.string().optional(), + bankAccountNumber: z.string().optional(), + routingNumber: z.string().optional(), +}); + +// Enhanced customer schema +export const CustomerSchema = BaseEntitySchema.extend({ + name: z.string(), + displayName: z.string().optional(), + email: z.string().email().optional(), + website: z.string().url().optional(), + phone: PhoneNumberSchema.optional(), + phoneNumbers: z.array(PhoneNumberSchema).optional(), + addresses: z.array(AddressSchema).optional(), + billingAddress: AddressSchema.optional(), + shippingAddress: AddressSchema.optional(), + taxNumber: z.string().optional(), + registrationNumber: z.string().optional(), + currency: z.string().default("USD"), + paymentTerms: z.string().optional(), + creditLimit: z.number().optional(), + isActive: z.boolean().default(true), + isArchived: z.boolean().default(false), + balance: z.number().optional(), + notes: z.string().optional(), + tags: z.array(z.string()).optional(), + customFields: z.record(z.any()).optional(), + attachments: z.array(AttachmentSchema).optional(), +}); + +// Vendor/Supplier schema +export const VendorSchema = BaseEntitySchema.extend({ + name: z.string(), + displayName: z.string().optional(), + email: z.string().email().optional(), + website: z.string().url().optional(), + phone: PhoneNumberSchema.optional(), + phoneNumbers: z.array(PhoneNumberSchema).optional(), + addresses: z.array(AddressSchema).optional(), + billingAddress: AddressSchema.optional(), + taxNumber: z.string().optional(), + registrationNumber: z.string().optional(), + currency: z.string().default("USD"), + paymentTerms: z.string().optional(), + isActive: z.boolean().default(true), + isArchived: z.boolean().default(false), + balance: z.number().optional(), + notes: z.string().optional(), + tags: z.array(z.string()).optional(), + customFields: z.record(z.any()).optional(), + attachments: z.array(AttachmentSchema).optional(), +}); + +// Item/Product schema +export const ItemSchema = BaseEntitySchema.extend({ + name: z.string(), + description: z.string().optional(), + sku: z.string().optional(), + type: z.enum(["inventory", "non_inventory", "service", "bundle"]), + unitPrice: z.number().optional(), + unitOfMeasure: z.string().optional(), + quantityOnHand: z.number().optional(), + reorderPoint: z.number().optional(), + assetAccountId: z.string().optional(), + incomeAccountId: z.string().optional(), + expenseAccountId: z.string().optional(), + isActive: z.boolean().default(true), + isTaxable: z.boolean().default(true), + isSold: z.boolean().default(true), + isPurchased: z.boolean().default(false), + taxCode: z.string().optional(), + customFields: z.record(z.any()).optional(), +}); + +// Enhanced invoice schema with attachments +export const InvoiceSchema = BaseEntitySchema.extend({ + number: z.string(), + customerId: z.string(), + customerName: z.string(), + issueDate: z.string().datetime(), + dueDate: z.string().datetime(), + status: z.enum(["draft", "sent", "paid", "overdue", "cancelled", "void"]), + currency: z.string(), + exchangeRate: z.number().optional(), + subtotal: z.number(), + taxAmount: z.number(), + discountAmount: z.number().optional(), + total: z.number(), + amountPaid: z.number().optional(), + amountDue: z.number().optional(), + paymentTerms: z.string().optional(), + reference: z.string().optional(), + poNumber: z.string().optional(), + notes: z.string().optional(), + privateNotes: z.string().optional(), + billingAddress: AddressSchema.optional(), + shippingAddress: AddressSchema.optional(), + lineItems: z.array( + z.object({ + id: z.string(), + itemId: z.string().optional(), + description: z.string(), + quantity: z.number(), + unitPrice: z.number(), + discount: z.number().optional(), + total: z.number(), + taxRate: z.number().optional(), + taxAmount: z.number().optional(), + accountId: z.string().optional(), + trackingCategories: z + .array( + z.object({ + id: z.string(), + name: z.string(), + value: z.string(), + }) + ) + .optional(), + }) + ), + attachments: z.array(AttachmentSchema).optional(), + customFields: z.record(z.any()).optional(), +}); + +// Bill schema (for vendor bills) +export const BillSchema = BaseEntitySchema.extend({ + number: z.string(), + vendorId: z.string(), + vendorName: z.string(), + issueDate: z.string().datetime(), + dueDate: z.string().datetime(), + status: z.enum(["draft", "open", "paid", "overdue", "cancelled", "void"]), + currency: z.string(), + exchangeRate: z.number().optional(), + subtotal: z.number(), + taxAmount: z.number(), + discountAmount: z.number().optional(), + total: z.number(), + amountPaid: z.number().optional(), + amountDue: z.number().optional(), + reference: z.string().optional(), + poNumber: z.string().optional(), + notes: z.string().optional(), + privateNotes: z.string().optional(), + lineItems: z.array( + z.object({ + id: z.string(), + itemId: z.string().optional(), + description: z.string(), + quantity: z.number(), + unitPrice: z.number(), + discount: z.number().optional(), + total: z.number(), + taxRate: z.number().optional(), + taxAmount: z.number().optional(), + accountId: z.string().optional(), + trackingCategories: z + .array( + z.object({ + id: z.string(), + name: z.string(), + value: z.string(), + }) + ) + .optional(), + }) + ), + attachments: z.array(AttachmentSchema).optional(), + customFields: z.record(z.any()).optional(), +}); + +// Enhanced transaction schema with attachments and reconciliation +export const TransactionSchema = BaseEntitySchema.extend({ + type: z.enum([ + "payment", + "receipt", + "transfer", + "adjustment", + "deposit", + "withdrawal", + "charge", + "refund", + ]), + reference: z.string().optional(), + description: z.string(), + amount: z.number(), + currency: z.string(), + exchangeRate: z.number().optional(), + date: z.string().datetime(), + accountId: z.string(), + accountName: z.string(), + toAccountId: z.string().optional(), // For transfers + toAccountName: z.string().optional(), + customerId: z.string().optional(), + vendorId: z.string().optional(), + contactName: z.string().optional(), + invoiceId: z.string().optional(), + billId: z.string().optional(), + status: z.enum(["pending", "cleared", "reconciled", "voided"]), + reconciliationStatus: z + .enum(["unreconciled", "reconciled", "suggested"]) + .optional(), + reconciliationDate: z.string().datetime().optional(), + bankTransactionId: z.string().optional(), + checkNumber: z.string().optional(), + memo: z.string().optional(), + category: z.string().optional(), + tags: z.array(z.string()).optional(), + trackingCategories: z + .array( + z.object({ + id: z.string(), + name: z.string(), + value: z.string(), + }) + ) + .optional(), + attachments: z.array(AttachmentSchema).optional(), + customFields: z.record(z.any()).optional(), +}); + +// Expense schema +export const ExpenseSchema = BaseEntitySchema.extend({ + amount: z.number(), + currency: z.string(), + exchangeRate: z.number().optional(), + date: z.string().datetime(), + description: z.string(), + reference: z.string().optional(), + vendorId: z.string().optional(), + vendorName: z.string().optional(), + employeeId: z.string().optional(), + employeeName: z.string().optional(), + accountId: z.string(), + accountName: z.string(), + category: z.string().optional(), + projectId: z.string().optional(), + projectName: z.string().optional(), + billable: z.boolean().default(false), + reimbursable: z.boolean().default(false), + status: z.enum(["draft", "submitted", "approved", "rejected", "paid"]), + paymentMethod: z + .enum(["cash", "credit_card", "bank_transfer", "check", "other"]) + .optional(), + receiptRequired: z.boolean().default(true), + notes: z.string().optional(), + taxAmount: z.number().optional(), + taxRate: z.number().optional(), + tags: z.array(z.string()).optional(), + trackingCategories: z + .array( + z.object({ + id: z.string(), + name: z.string(), + value: z.string(), + }) + ) + .optional(), + attachments: z.array(AttachmentSchema).optional(), + customFields: z.record(z.any()).optional(), +}); + +// Journal Entry schema +export const JournalEntrySchema = BaseEntitySchema.extend({ + number: z.string(), + date: z.string().datetime(), + description: z.string(), + reference: z.string().optional(), + status: z.enum(["draft", "posted", "void"]), + currency: z.string(), + exchangeRate: z.number().optional(), + lineItems: z.array( + z.object({ + id: z.string(), + accountId: z.string(), + accountName: z.string(), + description: z.string().optional(), + debitAmount: z.number().optional(), + creditAmount: z.number().optional(), + trackingCategories: z + .array( + z.object({ + id: z.string(), + name: z.string(), + value: z.string(), + }) + ) + .optional(), + }) + ), + totalDebit: z.number(), + totalCredit: z.number(), + notes: z.string().optional(), + attachments: z.array(AttachmentSchema).optional(), + customFields: z.record(z.any()).optional(), +}); + +// Payment schema +export const PaymentSchema = BaseEntitySchema.extend({ + amount: z.number(), + currency: z.string(), + exchangeRate: z.number().optional(), + date: z.string().datetime(), + reference: z.string().optional(), + paymentMethod: z.enum([ + "cash", + "check", + "credit_card", + "bank_transfer", + "online", + "other", + ]), + accountId: z.string(), + accountName: z.string(), + customerId: z.string().optional(), + vendorId: z.string().optional(), + contactName: z.string().optional(), + invoiceId: z.string().optional(), + billId: z.string().optional(), + status: z.enum(["pending", "cleared", "bounced", "cancelled"]), + checkNumber: z.string().optional(), + memo: z.string().optional(), + fees: z.number().optional(), + notes: z.string().optional(), + attachments: z.array(AttachmentSchema).optional(), + customFields: z.record(z.any()).optional(), +}); + +// Company info schema +export const CompanyInfoSchema = BaseEntitySchema.extend({ + name: z.string(), + legalName: z.string().optional(), + email: z.string().email().optional(), + phone: PhoneNumberSchema.optional(), + website: z.string().url().optional(), + addresses: z.array(AddressSchema).optional(), + taxNumber: z.string().optional(), + registrationNumber: z.string().optional(), + baseCurrency: z.string().default("USD"), + fiscalYearStart: z.string().optional(), + timeZone: z.string().optional(), + logo: z.string().url().optional(), + industry: z.string().optional(), + employees: z.number().optional(), + customFields: z.record(z.any()).optional(), +}); + +// Enhanced provider-specific metadata +export const ProviderMetadataSchema = z.object({ + provider: z.enum(["xero", "sage", "quickbooks"]), + externalId: z.string(), + lastSyncAt: z.string().datetime().optional(), + syncHash: z.string().optional(), + version: z.string().optional(), + rawData: z.record(z.any()).optional(), // Store original provider data + customFields: z.record(z.any()).optional(), +}); + +// Enhanced API Request/Response schemas +export const ApiRequestSchema = z.object({ + provider: z.enum(["xero", "sage", "quickbooks"]), + includeAttachments: z.boolean().default(false), + includeCustomFields: z.boolean().default(false), + includeRawData: z.boolean().default(false), +}); + +export const PaginationSchema = z.object({ + page: z.number().min(1).default(1), + limit: z.number().min(1).max(100).default(20), + total: z.number().optional(), + hasNext: z.boolean().optional(), + cursor: z.string().optional(), +}); + +// Bulk export request schema +export const BulkExportSchema = z.object({ + provider: z.enum(["xero", "sage", "quickbooks"]), + entityTypes: z.array( + z.enum([ + "customers", + "vendors", + "invoices", + "bills", + "transactions", + "expenses", + "accounts", + "items", + "journal_entries", + "payments", + ]) + ), + dateRange: z + .object({ + startDate: z.string().datetime(), + endDate: z.string().datetime(), + }) + .optional(), + includeAttachments: z.boolean().default(true), + includeCustomFields: z.boolean().default(false), + includeRawData: z.boolean().default(false), + format: z.enum(["json", "csv", "xlsx"]).default("json"), + batchSize: z.number().min(1).max(1000).default(100), +}); + +// Export types +export type Customer = z.infer; +export type Vendor = z.infer; +export type Item = z.infer; +export type Invoice = z.infer; +export type Bill = z.infer; +export type Transaction = z.infer; +export type Expense = z.infer; +export type JournalEntry = z.infer; +export type Payment = z.infer; +export type Account = z.infer; +export type Attachment = z.infer; +export type CompanyInfo = z.infer; +export type ProviderMetadata = z.infer; + +export type ApiRequest = z.infer; +export type Pagination = z.infer; +export type BulkExport = z.infer; +export type Address = z.infer; +export type PhoneNumber = z.infer; diff --git a/packages/ee/src/accounting/providers/quickbooks.ts b/packages/ee/src/accounting/providers/quickbooks.ts new file mode 100644 index 000000000..63c898d42 --- /dev/null +++ b/packages/ee/src/accounting/providers/quickbooks.ts @@ -0,0 +1,2230 @@ +import type { + Account, + Attachment, + Bill, + BulkExport, + CompanyInfo, + Customer, + Expense, + Invoice, + Item, + JournalEntry, + Payment, + Transaction, + Vendor, +} from "../models"; +import type { + ProviderAuth, + ProviderConfig, + RateLimitInfo, + SyncOptions, + SyncResult, +} from "../service"; +import { CoreProvider } from "../service"; + +export class QuickBooksProvider extends CoreProvider { + private baseUrl = "https://sandbox-quickbooks.api.intuit.com"; + private discoveryUrl = "https://appcenter.intuit.com/connect/oauth2"; + + constructor(config: ProviderConfig) { + super(config); + if (config.environment === "production") { + this.baseUrl = "https://quickbooks.api.intuit.com"; + } + } + + getAuthUrl(scopes: string[]): string { + const params = new URLSearchParams({ + client_id: this.config.clientId, + scope: scopes.join(" ") || "com.intuit.quickbooks.accounting", + redirect_uri: this.config.redirectUri || "", + response_type: "code", + access_type: "offline", + state: crypto.randomUUID(), + }); + + return `${this.discoveryUrl}?${params.toString()}`; + } + + async exchangeCodeForToken(code: string): Promise { + const response = await fetch( + "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${btoa( + `${this.config.clientId}:${this.config.clientSecret}` + )}`, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: this.config.redirectUri || "", + }), + } + ); + + if (!response.ok) { + throw new Error(`Auth failed: ${response.statusText}`); + } + + const data = (await response.json()) as any; + + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: new Date(Date.now() + data.expires_in * 1000), + }; + } + + async refreshAccessToken(): Promise { + if (!this.auth?.refreshToken) { + throw new Error("No refresh token available"); + } + + const response = await fetch( + "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${btoa( + `${this.config.clientId}:${this.config.clientSecret}` + )}`, + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: this.auth.refreshToken, + }), + } + ); + + if (!response.ok) { + throw new Error(`Token refresh failed: ${response.statusText}`); + } + + const data = (await response.json()) as any; + + const newAuth = { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: new Date(Date.now() + data.expires_in * 1000), + tenantId: this.auth.tenantId, + }; + + this.setAuth(newAuth); + + // Call the onTokenRefresh callback if provided + if (this.config.onTokenRefresh) { + await this.config.onTokenRefresh(newAuth); + } + + return newAuth; + } + + async validateAuth(): Promise { + if (!this.auth?.accessToken || !this.auth?.tenantId) { + return false; + } + + try { + const response = await this.request("/companyinfo"); + return response.ok; + } catch { + return false; + } + } + + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise { + if (!this.auth?.tenantId) { + throw new Error("No tenant ID available"); + } + + const url = `${this.baseUrl}/v3/company/${this.auth.tenantId}${endpoint}`; + + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json", + ...(options.headers as Record), + }; + + if (this.auth?.accessToken) { + headers["Authorization"] = `Bearer ${this.auth.accessToken}`; + } + + const response = await fetch(url, { + ...options, + headers, + }); + + // Handle rate limiting + if (response.status === 429) { + const retryAfter = response.headers.get("Retry-After"); + if (retryAfter) { + await new Promise((resolve) => + setTimeout(resolve, parseInt(retryAfter) * 1000) + ); + return this.request(endpoint, options); + } + } + + // Handle token refresh + if (response.status === 401 && this.auth?.refreshToken) { + await this.refreshAccessToken(); + const updatedHeaders = { ...headers }; + updatedHeaders["Authorization"] = `Bearer ${this.auth.accessToken}`; + return fetch(url, { ...options, headers: updatedHeaders }); + } + + return response; + } + + // Transform data to unified format + private transformCustomer( + qbCustomer: any + ): Customer & { syncToken?: string } { + return { + id: qbCustomer.Id, + name: qbCustomer.Name, + displayName: qbCustomer.DisplayName || qbCustomer.Name, + email: qbCustomer.PrimaryEmailAddr?.Address, + website: qbCustomer.WebAddr?.URI, + phone: qbCustomer.PrimaryPhone?.FreeFormNumber + ? { + number: qbCustomer.PrimaryPhone.FreeFormNumber, + type: "work" as any, + } + : undefined, + addresses: this.transformAddresses( + qbCustomer.BillAddr, + qbCustomer.ShipAddr + ), + taxNumber: qbCustomer.ResaleNum, + currency: qbCustomer.CurrencyRef?.value || "USD", + paymentTerms: qbCustomer.PaymentMethodRef?.name, + isActive: qbCustomer.Active, + balance: qbCustomer.Balance, + creditLimit: qbCustomer.CreditLimit, + createdAt: qbCustomer.MetaData?.CreateTime || new Date().toISOString(), + updatedAt: + qbCustomer.MetaData?.LastUpdatedTime || new Date().toISOString(), + syncToken: qbCustomer.SyncToken, + }; + } + + private transformVendor(qbVendor: any): Vendor & { syncToken?: string } { + return { + id: qbVendor.Id, + name: qbVendor.Name, + displayName: qbVendor.DisplayName || qbVendor.Name, + email: qbVendor.PrimaryEmailAddr?.Address, + website: qbVendor.WebAddr?.URI, + phone: qbVendor.PrimaryPhone?.FreeFormNumber + ? { + number: qbVendor.PrimaryPhone.FreeFormNumber, + type: "work" as any, + } + : undefined, + addresses: this.transformAddresses(qbVendor.BillAddr), + taxNumber: qbVendor.TaxIdentifier, + currency: qbVendor.CurrencyRef?.value || "USD", + paymentTerms: qbVendor.PaymentMethodRef?.name, + isActive: qbVendor.Active, + balance: qbVendor.Balance, + createdAt: qbVendor.MetaData?.CreateTime || new Date().toISOString(), + updatedAt: qbVendor.MetaData?.LastUpdatedTime || new Date().toISOString(), + syncToken: qbVendor.SyncToken, + }; + } + + private transformAddresses(billAddr?: any, shipAddr?: any): any[] { + const addresses = []; + + if (billAddr) { + addresses.push({ + type: "billing" as any, + street: billAddr.Line1, + street2: billAddr.Line2, + city: billAddr.City, + state: billAddr.CountrySubDivisionCode, + postalCode: billAddr.PostalCode, + country: billAddr.Country, + }); + } + + if (shipAddr && shipAddr.Id !== billAddr?.Id) { + addresses.push({ + type: "shipping" as any, + street: shipAddr.Line1, + street2: shipAddr.Line2, + city: shipAddr.City, + state: shipAddr.CountrySubDivisionCode, + postalCode: shipAddr.PostalCode, + country: shipAddr.Country, + }); + } + + return addresses.filter((addr) => addr.street || addr.city); + } + + private transformInvoice(qbInvoice: any): Invoice & { syncToken?: string } { + return { + id: qbInvoice.Id, + number: qbInvoice.DocNumber, + customerId: qbInvoice.CustomerRef?.value, + customerName: qbInvoice.CustomerRef?.name, + issueDate: qbInvoice.TxnDate, + dueDate: qbInvoice.DueDate, + status: this.mapInvoiceStatus(qbInvoice.EmailStatus, qbInvoice.Balance), + currency: qbInvoice.CurrencyRef?.value || "USD", + subtotal: qbInvoice.TotalAmt - qbInvoice.TxnTaxDetail?.TotalTax || 0, + taxAmount: qbInvoice.TxnTaxDetail?.TotalTax || 0, + total: qbInvoice.TotalAmt, + amountDue: qbInvoice.Balance, + reference: qbInvoice.CustomerMemo?.value, + notes: qbInvoice.PrivateNote, + lineItems: + qbInvoice.Line?.filter( + (line: any) => line.DetailType === "SalesItemLineDetail" + ).map((line: any) => ({ + id: line.Id, + itemId: line.SalesItemLineDetail?.ItemRef?.value, + description: line.Description, + quantity: line.SalesItemLineDetail?.Qty, + unitPrice: line.SalesItemLineDetail?.UnitPrice, + discount: line.DiscountLineDetail?.DiscountPercent, + total: line.Amount, + taxAmount: line.SalesItemLineDetail?.TaxCodeRef?.value, + accountId: line.SalesItemLineDetail?.ItemRef?.value, + })) || [], + createdAt: qbInvoice.MetaData?.CreateTime || new Date().toISOString(), + updatedAt: + qbInvoice.MetaData?.LastUpdatedTime || new Date().toISOString(), + syncToken: qbInvoice.SyncToken, + }; + } + + private mapInvoiceStatus( + emailStatus: string, + balance: number + ): "draft" | "sent" | "paid" | "overdue" | "cancelled" | "void" { + if (balance === 0) return "paid"; + if (emailStatus === "EmailSent") return "sent"; + return "draft"; + } + + private transformBill(qbBill: any): Bill & { syncToken?: string } { + return { + id: qbBill.Id, + number: qbBill.DocNumber, + vendorId: qbBill.VendorRef?.value, + vendorName: qbBill.VendorRef?.name, + issueDate: qbBill.TxnDate, + dueDate: qbBill.DueDate, + status: qbBill.Balance === 0 ? ("paid" as any) : ("sent" as any), + currency: qbBill.CurrencyRef?.value || "USD", + subtotal: qbBill.TotalAmt - qbBill.TxnTaxDetail?.TotalTax || 0, + taxAmount: qbBill.TxnTaxDetail?.TotalTax || 0, + total: qbBill.TotalAmt, + amountDue: qbBill.Balance, + reference: qbBill.MemoRef?.value, + notes: qbBill.PrivateNote, + createdAt: qbBill.MetaData?.CreateTime || new Date().toISOString(), + updatedAt: qbBill.MetaData?.LastUpdatedTime || new Date().toISOString(), + syncToken: qbBill.SyncToken, + }; + } + + private transformItem(qbItem: any): Item & { syncToken?: string } { + return { + id: qbItem.Id, + name: qbItem.Name, + sku: qbItem.Sku, + description: qbItem.Description, + unitPrice: qbItem.UnitPrice, + unitOfMeasure: qbItem.QtyOnHand?.UnitOfMeasure, + isActive: qbItem.Active, + isSold: qbItem.Type === "Service" || qbItem.Type === "Inventory", + isPurchased: qbItem.Type === "Inventory", + quantityOnHand: qbItem.QtyOnHand?.quantity, + createdAt: qbItem.MetaData?.CreateTime || new Date().toISOString(), + updatedAt: qbItem.MetaData?.LastUpdatedTime || new Date().toISOString(), + syncToken: qbItem.SyncToken, + }; + } + + private transformAccount(qbAccount: any): Account & { syncToken?: string } { + return { + id: qbAccount.Id, + name: qbAccount.Name, + code: qbAccount.AcctNum || qbAccount.Id, + description: qbAccount.Description, + accountType: this.mapAccountType(qbAccount.AccountType), + accountSubType: qbAccount.AccountSubType, + isActive: qbAccount.Active, + currency: qbAccount.CurrencyRef?.value || "USD", + currentBalance: qbAccount.CurrentBalance, + createdAt: qbAccount.MetaData?.CreateTime || new Date().toISOString(), + updatedAt: + qbAccount.MetaData?.LastUpdatedTime || new Date().toISOString(), + syncToken: qbAccount.SyncToken, + }; + } + + private mapAccountType( + qbType: string + ): + | "asset" + | "liability" + | "equity" + | "income" + | "expense" + | "accounts_receivable" + | "accounts_payable" + | "bank" + | "credit_card" + | "current_asset" + | "fixed_asset" + | "other_asset" + | "current_liability" + | "long_term_liability" + | "cost_of_goods_sold" + | "other_income" + | "other_expense" { + switch (qbType) { + case "Asset": + case "Other Current Asset": + return "current_asset"; + case "Fixed Asset": + return "fixed_asset"; + case "Bank": + return "bank"; + case "Accounts Receivable": + return "accounts_receivable"; + case "Liability": + case "Other Current Liability": + return "current_liability"; + case "Long Term Liability": + return "long_term_liability"; + case "Accounts Payable": + return "accounts_payable"; + case "Credit Card": + return "credit_card"; + case "Equity": + return "equity"; + case "Income": + return "income"; + case "Other Income": + return "other_income"; + case "Expense": + return "expense"; + case "Other Expense": + return "other_expense"; + case "Cost of Goods Sold": + return "cost_of_goods_sold"; + default: + return "other_asset"; + } + } + + private transformExpense(qbExpense: any): Expense & { syncToken?: string } { + return { + id: qbExpense.Id, + amount: qbExpense.TotalAmt, + currency: qbExpense.CurrencyRef?.value || "USD", + date: qbExpense.TxnDate, + description: qbExpense.PrivateNote || `Expense ${qbExpense.DocNumber}`, + // accountId: + // qbExpense.Line?.[0]?.AccountBasedExpenseLineDetail?.AccountRef?.value, + // accountName: + // qbExpense.Line?.[0]?.AccountBasedExpenseLineDetail?.AccountRef?.name, + vendorId: qbExpense.EntityRef?.value, + vendorName: qbExpense.EntityRef?.name, + reference: qbExpense.DocNumber, + paymentMethod: qbExpense.PaymentMethodRef?.value, + accountId: qbExpense.AccountRef?.value, + accountName: qbExpense.AccountRef?.name, + status: "approved" as any, + createdAt: qbExpense.MetaData?.CreateTime || new Date().toISOString(), + updatedAt: + qbExpense.MetaData?.LastUpdatedTime || new Date().toISOString(), + syncToken: qbExpense.SyncToken, + }; + } + + private transformPayment(qbPayment: any): Payment & { syncToken?: string } { + return { + id: qbPayment.Id, + amount: qbPayment.TotalAmt, + currency: qbPayment.CurrencyRef?.value || "USD", + date: qbPayment.TxnDate, + paymentMethod: qbPayment.PaymentMethodRef?.value, + customerId: qbPayment.CustomerRef?.value, + reference: qbPayment.PaymentRefNum, + accountId: qbPayment.AccountRef?.value, + accountName: qbPayment.AccountRef?.name, + vendorId: qbPayment.EntityRef?.value, + invoiceId: qbPayment.PaymentRef?.value, + billId: qbPayment.BillRef?.value, + contactName: qbPayment.ContactRef?.name, + status: "completed" as any, + createdAt: qbPayment.MetaData?.CreateTime || new Date().toISOString(), + updatedAt: + qbPayment.MetaData?.LastUpdatedTime || new Date().toISOString(), + syncToken: qbPayment.SyncToken, + }; + } + + // Helper methods to transform our data to QuickBooks format + private customerToQB( + customer: Omit + ): any { + return { + Name: customer.name, + DisplayName: customer.displayName || customer.name, + PrimaryEmailAddr: customer.email + ? { Address: customer.email } + : undefined, + WebAddr: customer.website ? { URI: customer.website } : undefined, + PrimaryPhone: customer.phone + ? { FreeFormNumber: customer.phone.number } + : undefined, + BillAddr: customer.addresses?.[0] + ? { + Line1: customer.addresses[0].street, + Line2: customer.addresses[0].street2, + City: customer.addresses[0].city, + CountrySubDivisionCode: customer.addresses[0].state, + PostalCode: customer.addresses[0].postalCode, + Country: customer.addresses[0].country, + } + : undefined, + ResaleNum: customer.taxNumber, + Active: customer.isActive !== false, + }; + } + + private vendorToQB( + vendor: Omit + ): any { + return { + Name: vendor.name, + DisplayName: vendor.displayName || vendor.name, + PrimaryEmailAddr: vendor.email ? { Address: vendor.email } : undefined, + WebAddr: vendor.website ? { URI: vendor.website } : undefined, + PrimaryPhone: vendor.phone + ? { FreeFormNumber: vendor.phone.number } + : undefined, + BillAddr: vendor.addresses?.[0] + ? { + Line1: vendor.addresses[0].street, + Line2: vendor.addresses[0].street2, + City: vendor.addresses[0].city, + CountrySubDivisionCode: vendor.addresses[0].state, + PostalCode: vendor.addresses[0].postalCode, + Country: vendor.addresses[0].country, + } + : undefined, + TaxIdentifier: vendor.taxNumber, + Active: vendor.isActive !== false, + }; + } + + // Company Info + async getCompanyInfo(): Promise { + const response = await this.request("/companyinfo"); + if (!response.ok) { + throw new Error(`Failed to get company info: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const company = data.QueryResponse.CompanyInfo[0]; + + return { + id: company.Id, + name: company.CompanyName, + legalName: company.LegalName || company.CompanyName, + email: company.Email?.Address, + phone: company.PrimaryPhone?.FreeFormNumber, + website: company.WebAddr?.URI, + addresses: company.CompanyAddr + ? [ + { + type: "billing" as any, + street: company.CompanyAddr.Line1, + street2: company.CompanyAddr.Line2, + city: company.CompanyAddr.City, + state: company.CompanyAddr.CountrySubDivisionCode, + postalCode: company.CompanyAddr.PostalCode, + country: company.CompanyAddr.Country, + }, + ] + : [], + taxNumber: company.LegalAddr?.Line1, // QB doesn't have direct tax number + baseCurrency: company.Country === "US" ? "USD" : "USD", + createdAt: company.MetaData?.CreateTime || new Date().toISOString(), + updatedAt: company.MetaData?.LastUpdatedTime || new Date().toISOString(), + }; + } + + // Account operations + async getAccounts(options?: SyncOptions): Promise> { + const response = await this.request("/query?query=SELECT * FROM Account"); + if (!response.ok) { + throw new Error(`Failed to get accounts: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const accounts = + data.QueryResponse?.Account?.map(this.transformAccount.bind(this)) || []; + + return { + data: accounts, + hasMore: false, + pagination: { + page: 1, + limit: accounts.length, + total: accounts.length, + hasNext: false, + }, + }; + } + + async getAccount(id: string): Promise { + const response = await this.request( + `/query?query=SELECT * FROM Account WHERE Id = '${id}'` + ); + if (!response.ok) { + throw new Error(`Failed to get account: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const account = data.QueryResponse?.Account?.[0]; + + if (!account) { + throw new Error("Account not found"); + } + + return this.transformAccount(account); + } + + async createAccount( + account: Omit + ): Promise { + const qbAccount = { + Name: account.name, + AcctNum: account.code, + Description: account.description, + AccountType: account.accountType, + Active: account.isActive !== false, + }; + + const response = await this.request("/account", { + method: "POST", + body: JSON.stringify(qbAccount), + }); + + if (!response.ok) { + throw new Error(`Failed to create account: ${response.statusText}`); + } + + const data = (await response.json()) as any; + return this.transformAccount(data.Account); + } + + async updateAccount(id: string, account: Partial): Promise { + // First get the existing account for sync token + const existing = (await this.getAccount(id)) as Account & { + syncToken?: string; + }; + + const qbAccount: any = { + Id: id, + SyncToken: existing.syncToken || "0", + }; + + if (account.name) qbAccount.Name = account.name; + if (account.code) qbAccount.AcctNum = account.code; + if (account.description) qbAccount.Description = account.description; + if (account.isActive !== undefined) qbAccount.Active = account.isActive; + + const response = await this.request("/account", { + method: "POST", + body: JSON.stringify(qbAccount), + }); + + if (!response.ok) { + throw new Error(`Failed to update account: ${response.statusText}`); + } + + const data = (await response.json()) as any; + return this.transformAccount(data.Account); + } + + async deleteAccount(id: string): Promise { + // QuickBooks doesn't allow deleting accounts, only deactivating + await this.updateAccount(id, { isActive: false }); + } + + // Customer operations + async getCustomers(options?: SyncOptions): Promise> { + let query = "SELECT * FROM Customer"; + if (options?.modifiedSince) { + query += ` WHERE Metadata.LastUpdatedTime > '${options.modifiedSince.toISOString()}'`; + } + + const response = await this.request( + `/query?query=${encodeURIComponent(query)}` + ); + if (!response.ok) { + throw new Error(`Failed to get customers: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const customers = + data.QueryResponse?.Customer?.map(this.transformCustomer.bind(this)) || + []; + + return { + data: customers, + hasMore: false, + pagination: { + page: 1, + limit: customers.length, + total: customers.length, + hasNext: false, + }, + }; + } + + async getCustomer(id: string): Promise { + const response = await this.request( + `/query?query=SELECT * FROM Customer WHERE Id = '${id}'` + ); + + if (!response.ok) { + throw new Error(`Failed to get customer: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const customer = data.QueryResponse?.Customer?.[0]; + + if (!customer) { + throw new Error("Customer not found"); + } + + return this.transformCustomer(customer); + } + + async createCustomer( + customer: Omit + ): Promise { + const qbCustomer = this.customerToQB(customer); + + const response = await this.request("/customer", { + method: "POST", + body: JSON.stringify(qbCustomer), + }); + + if (!response.ok) { + throw new Error(`Failed to create customer: ${response.statusText}`); + } + + const data = (await response.json()) as any; + return this.transformCustomer(data.Customer); + } + + async updateCustomer( + id: string, + customer: Partial + ): Promise { + // First get the existing customer for sync token + const existing = (await this.getCustomer(id)) as Customer & { + syncToken?: string; + }; + + const qbCustomer: any = { + Id: id, + SyncToken: existing.syncToken || "0", + }; + + if (customer.name) qbCustomer.Name = customer.name; + if (customer.displayName) qbCustomer.DisplayName = customer.displayName; + if (customer.email) + qbCustomer.PrimaryEmailAddr = { Address: customer.email }; + if (customer.website) qbCustomer.WebAddr = { URI: customer.website }; + if (customer.phone) + qbCustomer.PrimaryPhone = { FreeFormNumber: customer.phone.number }; + if (customer.addresses?.[0]) { + const addr = customer.addresses[0]; + qbCustomer.BillAddr = { + Line1: addr.street, + Line2: addr.street2, + City: addr.city, + CountrySubDivisionCode: addr.state, + PostalCode: addr.postalCode, + Country: addr.country, + }; + } + if (customer.taxNumber) qbCustomer.ResaleNum = customer.taxNumber; + if (customer.isActive !== undefined) qbCustomer.Active = customer.isActive; + + const response = await this.request("/customer", { + method: "POST", + body: JSON.stringify(qbCustomer), + }); + + if (!response.ok) { + throw new Error(`Failed to update customer: ${response.statusText}`); + } + + const data = (await response.json()) as any; + return this.transformCustomer(data.Customer); + } + + async deleteCustomer(id: string): Promise { + // QuickBooks doesn't allow deleting customers, only deactivating + await this.updateCustomer(id, { isActive: false }); + } + + // Vendor operations + async getVendors(options?: SyncOptions): Promise> { + let query = "SELECT * FROM Vendor"; + if (options?.modifiedSince) { + query += ` WHERE Metadata.LastUpdatedTime > '${options.modifiedSince.toISOString()}'`; + } + + const response = await this.request( + `/query?query=${encodeURIComponent(query)}` + ); + if (!response.ok) { + throw new Error(`Failed to get vendors: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const vendors = + data.QueryResponse?.Vendor?.map(this.transformVendor.bind(this)) || []; + + return { + data: vendors, + hasMore: false, + pagination: { + page: 1, + limit: vendors.length, + total: vendors.length, + hasNext: false, + }, + }; + } + + async getVendor(id: string): Promise { + const response = await this.request( + `/query?query=SELECT * FROM Vendor WHERE Id = '${id}'` + ); + if (!response.ok) { + throw new Error(`Failed to get vendor: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const vendor = data.QueryResponse?.Vendor?.[0]; + + if (!vendor) { + throw new Error("Vendor not found"); + } + + return this.transformVendor(vendor); + } + + async createVendor( + vendor: Omit + ): Promise { + const qbVendor = this.vendorToQB(vendor); + + const response = await this.request("/vendor", { + method: "POST", + body: JSON.stringify(qbVendor), + }); + + if (!response.ok) { + throw new Error(`Failed to create vendor: ${response.statusText}`); + } + + const data = (await response.json()) as any; + return this.transformVendor(data.Vendor); + } + + async updateVendor(id: string, vendor: Partial): Promise { + // First get the existing vendor for sync token + const existing = (await this.getVendor(id)) as Vendor & { + syncToken?: string; + }; + + const qbVendor: any = { + Id: id, + SyncToken: existing.syncToken || "0", + }; + + if (vendor.name) qbVendor.Name = vendor.name; + if (vendor.displayName) qbVendor.DisplayName = vendor.displayName; + if (vendor.email) qbVendor.PrimaryEmailAddr = { Address: vendor.email }; + if (vendor.website) qbVendor.WebAddr = { URI: vendor.website }; + if (vendor.phone) + qbVendor.PrimaryPhone = { FreeFormNumber: vendor.phone.number }; + if (vendor.addresses?.[0]) { + const addr = vendor.addresses[0]; + qbVendor.BillAddr = { + Line1: addr.street, + Line2: addr.street2, + City: addr.city, + CountrySubDivisionCode: addr.state, + PostalCode: addr.postalCode, + Country: addr.country, + }; + } + if (vendor.taxNumber) qbVendor.TaxIdentifier = vendor.taxNumber; + if (vendor.isActive !== undefined) qbVendor.Active = vendor.isActive; + + const response = await this.request("/vendor", { + method: "POST", + body: JSON.stringify(qbVendor), + }); + + if (!response.ok) { + throw new Error(`Failed to update vendor: ${response.statusText}`); + } + + const data = (await response.json()) as any; + return this.transformVendor(data.Vendor); + } + + async deleteVendor(id: string): Promise { + // QuickBooks doesn't allow deleting vendors, only deactivating + await this.updateVendor(id, { isActive: false }); + } + + // Item operations + async getItems(options?: SyncOptions): Promise> { + const response = await this.request("/query?query=SELECT * FROM Item"); + if (!response.ok) { + throw new Error(`Failed to get items: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const items = + data.QueryResponse?.Item?.map(this.transformItem.bind(this)) || []; + + return { + data: items, + hasMore: false, + pagination: { + page: 1, + limit: items.length, + total: items.length, + hasNext: false, + }, + }; + } + + async getItem(id: string): Promise { + const response = await this.request( + `/query?query=SELECT * FROM Item WHERE Id = '${id}'` + ); + if (!response.ok) { + throw new Error(`Failed to get item: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const item = data.QueryResponse?.Item?.[0]; + + if (!item) { + throw new Error("Item not found"); + } + + return this.transformItem(item); + } + + async createItem( + item: Omit + ): Promise { + const qbItem = { + Name: item.name, + Sku: item.sku, + Description: item.description, + UnitPrice: item.unitPrice, + Type: item.isSold ? "Service" : "NonInventory", + Active: item.isActive !== false, + }; + + const response = await this.request("/item", { + method: "POST", + body: JSON.stringify(qbItem), + }); + + if (!response.ok) { + throw new Error(`Failed to create item: ${response.statusText}`); + } + + const data = (await response.json()) as any; + return this.transformItem(data.Item); + } + + async updateItem(id: string, item: Partial): Promise { + // First get the existing item for sync token + const existing = (await this.getItem(id)) as Item & { syncToken?: string }; + + const qbItem: any = { + Id: id, + SyncToken: existing.syncToken || "0", + }; + + if (item.name) qbItem.Name = item.name; + if (item.sku) qbItem.Sku = item.sku; + if (item.description) qbItem.Description = item.description; + if (item.unitPrice) qbItem.UnitPrice = item.unitPrice; + if (item.isActive !== undefined) qbItem.Active = item.isActive; + + const response = await this.request("/item", { + method: "POST", + body: JSON.stringify(qbItem), + }); + + if (!response.ok) { + throw new Error(`Failed to update item: ${response.statusText}`); + } + + const data = (await response.json()) as any; + return this.transformItem(data.Item); + } + + async deleteItem(id: string): Promise { + // QuickBooks doesn't allow deleting items, only deactivating + await this.updateItem(id, { isActive: false }); + } + + // Invoice operations + async getInvoices(options?: SyncOptions): Promise> { + let query = "SELECT * FROM Invoice"; + if (options?.modifiedSince) { + query += ` WHERE Metadata.LastUpdatedTime > '${options.modifiedSince.toISOString()}'`; + } + + const response = await this.request( + `/query?query=${encodeURIComponent(query)}` + ); + if (!response.ok) { + throw new Error(`Failed to get invoices: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const invoices = + data.QueryResponse?.Invoice?.map(this.transformInvoice.bind(this)) || []; + + return { + data: invoices, + hasMore: false, + pagination: { + page: 1, + limit: invoices.length, + total: invoices.length, + hasNext: false, + }, + }; + } + + async getInvoice(id: string): Promise { + const response = await this.request( + `/query?query=SELECT * FROM Invoice WHERE Id = '${id}'` + ); + if (!response.ok) { + throw new Error(`Failed to get invoice: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const invoice = data.QueryResponse?.Invoice?.[0]; + + if (!invoice) { + throw new Error("Invoice not found"); + } + + return this.transformInvoice(invoice); + } + + async createInvoice( + invoice: Omit + ): Promise { + const qbInvoice = { + CustomerRef: { value: invoice.customerId }, + TxnDate: invoice.issueDate, + DueDate: invoice.dueDate, + DocNumber: invoice.number, + CustomerMemo: invoice.reference + ? { value: invoice.reference } + : undefined, + PrivateNote: invoice.notes, + Line: + invoice.lineItems?.map((item) => ({ + DetailType: "SalesItemLineDetail", + Amount: item.total, + Description: item.description, + SalesItemLineDetail: { + ItemRef: { value: item.itemId }, + Qty: item.quantity, + UnitPrice: item.unitPrice, + }, + })) || [], + }; + + const response = await this.request("/invoice", { + method: "POST", + body: JSON.stringify(qbInvoice), + }); + + if (!response.ok) { + throw new Error(`Failed to create invoice: ${response.statusText}`); + } + + const data = (await response.json()) as any; + return this.transformInvoice(data.Invoice); + } + + async updateInvoice(id: string, invoice: Partial): Promise { + // First get the existing invoice for sync token + const existing = (await this.getInvoice(id)) as Invoice & { + syncToken?: string; + }; + + const qbInvoice: any = { + Id: id, + SyncToken: existing.syncToken || "0", + }; + + if (invoice.customerId) + qbInvoice.CustomerRef = { value: invoice.customerId }; + if (invoice.issueDate) qbInvoice.TxnDate = invoice.issueDate; + if (invoice.dueDate) qbInvoice.DueDate = invoice.dueDate; + if (invoice.number) qbInvoice.DocNumber = invoice.number; + if (invoice.reference) + qbInvoice.CustomerMemo = { value: invoice.reference }; + if (invoice.notes) qbInvoice.PrivateNote = invoice.notes; + if (invoice.lineItems) { + qbInvoice.Line = invoice.lineItems.map((item) => ({ + DetailType: "SalesItemLineDetail", + Amount: item.total, + Description: item.description, + SalesItemLineDetail: { + ItemRef: { value: item.itemId }, + Qty: item.quantity, + UnitPrice: item.unitPrice, + }, + })); + } + + const response = await this.request("/invoice", { + method: "POST", + body: JSON.stringify(qbInvoice), + }); + + if (!response.ok) { + throw new Error(`Failed to update invoice: ${response.statusText}`); + } + + const data = (await response.json()) as any; + return this.transformInvoice(data.Invoice); + } + + async deleteInvoice(id: string): Promise { + // First get the existing invoice for sync token + const existing = (await this.getInvoice(id)) as Invoice & { + syncToken?: string; + }; + + const response = await this.request(`/invoice?operation=delete`, { + method: "POST", + body: JSON.stringify({ + Id: id, + SyncToken: existing.syncToken || "0", + }), + }); + + if (!response.ok) { + throw new Error(`Failed to delete invoice: ${response.statusText}`); + } + } + + async sendInvoice( + id: string, + options?: { email?: string; subject?: string; message?: string } + ): Promise { + const response = await this.request( + `/invoice/${id}/send?sendTo=${options?.email || ""}`, + { + method: "POST", + } + ); + + if (!response.ok) { + throw new Error(`Failed to send invoice: ${response.statusText}`); + } + } + + // Bill operations + async getBills(options?: SyncOptions): Promise> { + const response = await this.request("/query?query=SELECT * FROM Bill"); + if (!response.ok) { + throw new Error(`Failed to get bills: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const bills = + data.QueryResponse?.Bill?.map(this.transformBill.bind(this)) || []; + + return { + data: bills, + hasMore: false, + pagination: { + page: 1, + limit: bills.length, + total: bills.length, + hasNext: false, + }, + }; + } + + async getBill(id: string): Promise { + const response = await this.request( + `/query?query=SELECT * FROM Bill WHERE Id = '${id}'` + ); + if (!response.ok) { + throw new Error(`Failed to get bill: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const bill = data.QueryResponse?.Bill?.[0]; + + if (!bill) { + throw new Error("Bill not found"); + } + + return this.transformBill(bill); + } + + async createBill( + bill: Omit + ): Promise { + const qbBill = { + VendorRef: { value: bill.vendorId }, + TxnDate: bill.issueDate, + DueDate: bill.dueDate, + DocNumber: bill.number, + MemoRef: bill.reference ? { value: bill.reference } : undefined, + PrivateNote: bill.notes, + TotalAmt: bill.total, + }; + + const response = await this.request("/bill", { + method: "POST", + body: JSON.stringify(qbBill), + }); + + if (!response.ok) { + throw new Error(`Failed to create bill: ${response.statusText}`); + } + + const data = (await response.json()) as any; + return this.transformBill(data.Bill); + } + + async updateBill(id: string, bill: Partial): Promise { + // First get the existing bill for sync token + const existing = (await this.getBill(id)) as Bill & { syncToken?: string }; + + const qbBill: any = { + Id: id, + SyncToken: existing.syncToken || "0", + }; + + if (bill.vendorId) qbBill.VendorRef = { value: bill.vendorId }; + if (bill.issueDate) qbBill.TxnDate = bill.issueDate; + if (bill.dueDate) qbBill.DueDate = bill.dueDate; + if (bill.number) qbBill.DocNumber = bill.number; + if (bill.reference) qbBill.MemoRef = { value: bill.reference }; + if (bill.notes) qbBill.PrivateNote = bill.notes; + if (bill.total) qbBill.TotalAmt = bill.total; + + const response = await this.request("/bill", { + method: "POST", + body: JSON.stringify(qbBill), + }); + + if (!response.ok) { + throw new Error(`Failed to update bill: ${response.statusText}`); + } + + const data = (await response.json()) as any; + return this.transformBill(data.Bill); + } + + async deleteBill(id: string): Promise { + // First get the existing bill for sync token + const existing = (await this.getBill(id)) as Bill & { syncToken?: string }; + + const response = await this.request(`/bill?operation=delete`, { + method: "POST", + body: JSON.stringify({ + Id: id, + SyncToken: existing.syncToken || "0", + }), + }); + + if (!response.ok) { + throw new Error(`Failed to delete bill: ${response.statusText}`); + } + } + + // Transaction operations (Limited) + async getTransactions( + options?: SyncOptions + ): Promise> { + // QuickBooks doesn't have a direct transactions endpoint + // We can combine multiple entity types if needed + return { + data: [], + hasMore: false, + pagination: { + page: 1, + limit: 0, + total: 0, + hasNext: false, + }, + }; + } + + async getTransaction(id: string): Promise { + throw new Error( + "Direct transaction operations not supported by QuickBooks API" + ); + } + + async createTransaction( + transaction: Omit + ): Promise { + throw new Error( + "Direct transaction operations not supported by QuickBooks API" + ); + } + + async updateTransaction( + id: string, + transaction: Partial + ): Promise { + throw new Error( + "Direct transaction operations not supported by QuickBooks API" + ); + } + + async deleteTransaction(id: string): Promise { + throw new Error( + "Direct transaction operations not supported by QuickBooks API" + ); + } + + async reconcileTransaction( + id: string, + bankTransactionId: string + ): Promise { + throw new Error( + "Direct transaction operations not supported by QuickBooks API" + ); + } + + // Expense operations + async getExpenses(options?: SyncOptions): Promise> { + const response = await this.request( + '/query?query=SELECT * FROM Purchase WHERE PaymentType = "Cash"' + ); + if (!response.ok) { + throw new Error(`Failed to get expenses: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const expenses = + data.QueryResponse?.Purchase?.map(this.transformExpense.bind(this)) || []; + + return { + data: expenses, + hasMore: false, + pagination: { + page: 1, + limit: expenses.length, + total: expenses.length, + hasNext: false, + }, + }; + } + + async getExpense(id: string): Promise { + const response = await this.request( + `/query?query=SELECT * FROM Purchase WHERE Id = '${id}'` + ); + if (!response.ok) { + throw new Error(`Failed to get expense: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const expense = data.QueryResponse?.Purchase?.[0]; + + if (!expense) { + throw new Error("Expense not found"); + } + + return this.transformExpense(expense); + } + + async createExpense( + expense: Omit + ): Promise { + const qbExpense = { + EntityRef: expense.vendorId ? { value: expense.vendorId } : undefined, + TxnDate: expense.date, + TotalAmt: expense.amount, + PaymentType: "Cash", + PrivateNote: expense.description, + Line: [ + { + DetailType: "AccountBasedExpenseLineDetail", + Amount: expense.amount, + AccountBasedExpenseLineDetail: { + AccountRef: { value: expense.accountId || expense.category }, + }, + }, + ], + }; + + const response = await this.request("/purchase", { + method: "POST", + body: JSON.stringify(qbExpense), + }); + + if (!response.ok) { + throw new Error(`Failed to create expense: ${response.statusText}`); + } + + const data = (await response.json()) as any; + return this.transformExpense(data.Purchase); + } + + async updateExpense(id: string, expense: Partial): Promise { + // First get the existing expense for sync token + const existing = (await this.getExpense(id)) as Expense & { + syncToken?: string; + }; + + const qbExpense: any = { + Id: id, + SyncToken: existing.syncToken || "0", + }; + + if (expense.vendorId) qbExpense.EntityRef = { value: expense.vendorId }; + if (expense.date) qbExpense.TxnDate = expense.date; + if (expense.amount) qbExpense.TotalAmt = expense.amount; + if (expense.description) qbExpense.PrivateNote = expense.description; + if (expense.accountId || expense.category) { + qbExpense.Line = [ + { + DetailType: "AccountBasedExpenseLineDetail", + Amount: expense.amount, + AccountBasedExpenseLineDetail: { + AccountRef: { value: expense.accountId || expense.category }, + }, + }, + ]; + } + + const response = await this.request("/purchase", { + method: "POST", + body: JSON.stringify(qbExpense), + }); + + if (!response.ok) { + throw new Error(`Failed to update expense: ${response.statusText}`); + } + + const data = (await response.json()) as any; + return this.transformExpense(data.Purchase); + } + + async deleteExpense(id: string): Promise { + // First get the existing expense for sync token + const existing = (await this.getExpense(id)) as Expense & { + syncToken?: string; + }; + + const response = await this.request(`/purchase?operation=delete`, { + method: "POST", + body: JSON.stringify({ + Id: id, + SyncToken: existing.syncToken || "0", + }), + }); + + if (!response.ok) { + throw new Error(`Failed to delete expense: ${response.statusText}`); + } + } + + async submitExpense(id: string): Promise { + // QuickBooks doesn't have expense submission workflow + return await this.getExpense(id); + } + + async approveExpense(id: string): Promise { + // QuickBooks doesn't have expense approval workflow + return await this.getExpense(id); + } + + async rejectExpense(id: string, reason?: string): Promise { + // QuickBooks doesn't have expense rejection workflow + return await this.getExpense(id); + } + + // Journal Entry operations + async getJournalEntries( + options?: SyncOptions + ): Promise> { + const response = await this.request( + "/query?query=SELECT * FROM JournalEntry" + ); + if (!response.ok) { + throw new Error(`Failed to get journal entries: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const journalEntries = + data.QueryResponse?.JournalEntry?.map((entry: any) => ({ + id: entry.Id, + number: entry.DocNumber, + date: entry.TxnDate, + description: entry.PrivateNote, + reference: entry.DocNumber, + status: "posted" as any, + currency: "USD", + createdAt: entry.MetaData?.CreateTime || new Date().toISOString(), + updatedAt: entry.MetaData?.LastUpdatedTime || new Date().toISOString(), + })) || []; + + return { + data: journalEntries, + hasMore: false, + pagination: { + page: 1, + limit: journalEntries.length, + total: journalEntries.length, + hasNext: false, + }, + }; + } + + async getJournalEntry(id: string): Promise { + const response = await this.request( + `/query?query=SELECT * FROM JournalEntry WHERE Id = '${id}'` + ); + if (!response.ok) { + throw new Error(`Failed to get journal entry: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const entry = data.QueryResponse?.JournalEntry?.[0]; + + if (!entry) { + throw new Error("Journal entry not found"); + } + + return { + id: entry.Id, + number: entry.DocNumber, + date: entry.TxnDate, + description: entry.PrivateNote, + reference: entry.DocNumber, + status: "posted" as any, + currency: "USD", + createdAt: entry.MetaData?.CreateTime || new Date().toISOString(), + updatedAt: entry.MetaData?.LastUpdatedTime || new Date().toISOString(), + }; + } + + async createJournalEntry( + journalEntry: Omit + ): Promise { + const qbJournalEntry = { + TxnDate: journalEntry.date, + DocNumber: journalEntry.number, + PrivateNote: journalEntry.description, + Line: + journalEntry.lineItems?.map((row) => ({ + DetailType: "JournalEntryLineDetail", + Amount: row.debitAmount || row.creditAmount, + Description: row.description, + JournalEntryLineDetail: { + PostingType: row.debitAmount ? "Debit" : "Credit", + AccountRef: { value: row.accountId }, + }, + })) || [], + }; + + const response = await this.request("/journalentry", { + method: "POST", + body: JSON.stringify(qbJournalEntry), + }); + + if (!response.ok) { + throw new Error(`Failed to create journal entry: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const entry = data.JournalEntry; + + return { + id: entry.Id, + number: entry.DocNumber, + date: entry.TxnDate, + description: entry.PrivateNote, + reference: entry.DocNumber, + status: "posted" as any, + currency: "USD", + createdAt: entry.MetaData?.CreateTime || new Date().toISOString(), + updatedAt: entry.MetaData?.LastUpdatedTime || new Date().toISOString(), + }; + } + + async updateJournalEntry( + id: string, + journalEntry: Partial + ): Promise { + // First get the existing entry for sync token + const existing = (await this.getJournalEntry(id)) as JournalEntry & { + syncToken?: string; + }; + + const qbJournalEntry: any = { + Id: id, + SyncToken: existing.syncToken || "0", + }; + + if (journalEntry.date) qbJournalEntry.TxnDate = journalEntry.date; + if (journalEntry.number) qbJournalEntry.DocNumber = journalEntry.number; + if (journalEntry.description) + qbJournalEntry.PrivateNote = journalEntry.description; + if (journalEntry.lineItems) { + qbJournalEntry.Line = journalEntry.lineItems.map((row) => ({ + DetailType: "JournalEntryLineDetail", + Amount: row.debitAmount || row.creditAmount, + Description: row.description, + JournalEntryLineDetail: { + PostingType: row.debitAmount ? "Debit" : "Credit", + AccountRef: { value: row.accountId }, + }, + })); + } + + const response = await this.request("/journalentry", { + method: "POST", + body: JSON.stringify(qbJournalEntry), + }); + + if (!response.ok) { + throw new Error(`Failed to update journal entry: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const entry = data.JournalEntry; + + return { + id: entry.Id, + number: entry.DocNumber, + date: entry.TxnDate, + description: entry.PrivateNote, + reference: entry.DocNumber, + status: "posted" as any, + currency: "USD", + createdAt: entry.MetaData?.CreateTime || new Date().toISOString(), + updatedAt: entry.MetaData?.LastUpdatedTime || new Date().toISOString(), + }; + } + + async deleteJournalEntry(id: string): Promise { + // First get the existing entry for sync token + const existing = (await this.getJournalEntry(id)) as JournalEntry & { + syncToken?: string; + }; + + const response = await this.request(`/journalentry?operation=delete`, { + method: "POST", + body: JSON.stringify({ + Id: id, + SyncToken: existing.syncToken || "0", + }), + }); + + if (!response.ok) { + throw new Error(`Failed to delete journal entry: ${response.statusText}`); + } + } + + async postJournalEntry(id: string): Promise { + // QuickBooks journal entries are automatically posted + return await this.getJournalEntry(id); + } + + // Payment operations + async getPayments(options?: SyncOptions): Promise> { + const response = await this.request("/query?query=SELECT * FROM Payment"); + if (!response.ok) { + throw new Error(`Failed to get payments: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const payments = + data.QueryResponse?.Payment?.map(this.transformPayment.bind(this)) || []; + + return { + data: payments, + hasMore: false, + pagination: { + page: 1, + limit: payments.length, + total: payments.length, + hasNext: false, + }, + }; + } + + async getPayment(id: string): Promise { + const response = await this.request( + `/query?query=SELECT * FROM Payment WHERE Id = '${id}'` + ); + if (!response.ok) { + throw new Error(`Failed to get payment: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const payment = data.QueryResponse?.Payment?.[0]; + + if (!payment) { + throw new Error("Payment not found"); + } + + return this.transformPayment(payment); + } + + async createPayment( + payment: Omit + ): Promise { + const qbPayment = { + CustomerRef: { value: payment.customerId }, + TxnDate: payment.date, + TotalAmt: payment.amount, + PaymentMethodRef: payment.paymentMethod + ? { value: payment.paymentMethod } + : undefined, + PaymentRefNum: payment.reference, + }; + + const response = await this.request("/payment", { + method: "POST", + body: JSON.stringify(qbPayment), + }); + + if (!response.ok) { + throw new Error(`Failed to create payment: ${response.statusText}`); + } + + const data = (await response.json()) as any; + return this.transformPayment(data.Payment); + } + + async updatePayment(id: string, payment: Partial): Promise { + // First get the existing payment for sync token + const existing = (await this.getPayment(id)) as Payment & { + syncToken?: string; + }; + + const qbPayment: any = { + Id: id, + SyncToken: existing.syncToken || "0", + }; + + if (payment.customerId) + qbPayment.CustomerRef = { value: payment.customerId }; + if (payment.date) qbPayment.TxnDate = payment.date; + if (payment.amount) qbPayment.TotalAmt = payment.amount; + if (payment.paymentMethod) + qbPayment.PaymentMethodRef = { value: payment.paymentMethod }; + if (payment.reference) qbPayment.PaymentRefNum = payment.reference; + + const response = await this.request("/payment", { + method: "POST", + body: JSON.stringify(qbPayment), + }); + + if (!response.ok) { + throw new Error(`Failed to update payment: ${response.statusText}`); + } + + const data = (await response.json()) as any; + return this.transformPayment(data.Payment); + } + + async deletePayment(id: string): Promise { + // First get the existing payment for sync token + const existing = (await this.getPayment(id)) as Payment & { + syncToken?: string; + }; + + const response = await this.request(`/payment?operation=delete`, { + method: "POST", + body: JSON.stringify({ + Id: id, + SyncToken: existing.syncToken || "0", + }), + }); + + if (!response.ok) { + throw new Error(`Failed to delete payment: ${response.statusText}`); + } + } + + async processPayment(id: string): Promise { + // QuickBooks payments are automatically processed + return await this.getPayment(id); + } + + // Attachment operations + async getAttachments( + entityType: string, + entityId: string, + attachmentType?: string + ): Promise { + const response = await this.request( + `/query?query=SELECT * FROM Attachable WHERE AttachableRef.EntityRef.value = '${entityId}'` + ); + + if (!response.ok) { + throw new Error(`Failed to get attachments: ${response.statusText}`); + } + + const data = (await response.json()) as any; + return ( + data.QueryResponse?.Attachable?.map((att: any) => ({ + id: att.Id, + filename: att.FileName, + originalFilename: att.FileName, + mimeType: att.ContentType, + size: att.Size, + url: att.TempDownloadUri, + downloadUrl: att.TempDownloadUri, + entityType: entityType as any, + entityId, + description: att.Note, + createdAt: att.MetaData?.CreateTime || new Date().toISOString(), + updatedAt: att.MetaData?.LastUpdatedTime || new Date().toISOString(), + })) || [] + ); + } + + async getAttachment(id: string): Promise { + const response = await this.request( + `/query?query=SELECT * FROM Attachable WHERE Id = '${id}'` + ); + + if (!response.ok) { + throw new Error(`Failed to get attachment: ${response.statusText}`); + } + + const data = (await response.json()) as any; + const att = data.QueryResponse?.Attachable?.[0]; + + if (!att) { + throw new Error("Attachment not found"); + } + + return { + id: att.Id, + filename: att.FileName, + originalFilename: att.FileName, + mimeType: att.ContentType, + size: att.Size, + url: att.TempDownloadUri, + downloadUrl: att.TempDownloadUri, + entityType: "invoice" as any, // Default + entityId: att.AttachableRef?.[0]?.EntityRef?.value, + description: att.Note, + createdAt: att.MetaData?.CreateTime || new Date().toISOString(), + updatedAt: att.MetaData?.LastUpdatedTime || new Date().toISOString(), + }; + } + + async downloadAttachment(id: string): Promise { + const attachment = await this.getAttachment(id); + + if (!attachment.downloadUrl) { + return null; + } + + const response = await fetch(attachment.downloadUrl); + + if (!response.ok) { + return null; + } + + return response.body; + } + + async getAttachmentMetadata(id: string): Promise { + const attachment = await this.getAttachment(id); + return { + id: attachment.id, + filename: attachment.filename, + mimeType: attachment.mimeType, + size: attachment.size, + url: attachment.url, + description: attachment.description, + }; + } + + // Bulk operations + async bulkCreate( + entityType: string, + entities: T[] + ): Promise<{ success: T[]; failed: { entity: T; error: string }[] }> { + const success: T[] = []; + const failed: { entity: T; error: string }[] = []; + + for (const entity of entities) { + try { + let result: any; + switch (entityType) { + case "customers": + result = await this.createCustomer(entity as any); + break; + case "vendors": + result = await this.createVendor(entity as any); + break; + case "invoices": + result = await this.createInvoice(entity as any); + break; + case "bills": + result = await this.createBill(entity as any); + break; + case "items": + result = await this.createItem(entity as any); + break; + case "accounts": + result = await this.createAccount(entity as any); + break; + case "expenses": + result = await this.createExpense(entity as any); + break; + case "payments": + result = await this.createPayment(entity as any); + break; + case "journal_entries": + result = await this.createJournalEntry(entity as any); + break; + default: + throw new Error( + `Bulk creation not supported for entity type: ${entityType}` + ); + } + success.push(result); + } catch (error) { + failed.push({ + entity, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + return { success, failed }; + } + + async bulkUpdate( + entityType: string, + entities: { id: string; data: Partial }[] + ): Promise<{ success: T[]; failed: { id: string; error: string }[] }> { + const success: T[] = []; + const failed: { id: string; error: string }[] = []; + + for (const { id, data } of entities) { + try { + let result: any; + switch (entityType) { + case "customers": + result = await this.updateCustomer(id, data as any); + break; + case "vendors": + result = await this.updateVendor(id, data as any); + break; + case "invoices": + result = await this.updateInvoice(id, data as any); + break; + case "bills": + result = await this.updateBill(id, data as any); + break; + case "items": + result = await this.updateItem(id, data as any); + break; + case "accounts": + result = await this.updateAccount(id, data as any); + break; + case "expenses": + result = await this.updateExpense(id, data as any); + break; + case "payments": + result = await this.updatePayment(id, data as any); + break; + case "journal_entries": + result = await this.updateJournalEntry(id, data as any); + break; + default: + throw new Error( + `Bulk update not supported for entity type: ${entityType}` + ); + } + success.push(result); + } catch (error) { + failed.push({ + id, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + return { success, failed }; + } + + async bulkDelete( + entityType: string, + ids: string[] + ): Promise<{ success: string[]; failed: { id: string; error: string }[] }> { + const success: string[] = []; + const failed: { id: string; error: string }[] = []; + + for (const id of ids) { + try { + switch (entityType) { + case "customers": + await this.deleteCustomer(id); + break; + case "vendors": + await this.deleteVendor(id); + break; + case "invoices": + await this.deleteInvoice(id); + break; + case "bills": + await this.deleteBill(id); + break; + case "items": + await this.deleteItem(id); + break; + case "accounts": + await this.deleteAccount(id); + break; + case "expenses": + await this.deleteExpense(id); + break; + case "payments": + await this.deletePayment(id); + break; + case "journal_entries": + await this.deleteJournalEntry(id); + break; + default: + throw new Error( + `Bulk deletion not supported for entity type: ${entityType}` + ); + } + success.push(id); + } catch (error) { + failed.push({ + id, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + return { success, failed }; + } + + // Export operations + async startBulkExport(request: BulkExport): Promise { + const jobId = crypto.randomUUID(); + // In a real implementation, this would queue a job + return jobId; + } + + async getExportStatus(jobId: string): Promise { + return { + jobId, + status: "completed", + progress: 100, + }; + } + + async getExportResult(jobId: string): Promise { + throw new Error("Export result retrieval not implemented yet"); + } + + async getRateLimitInfo(): Promise { + return { + remaining: 500, // QuickBooks allows 500 requests per minute + reset: new Date(Date.now() + 60000), + limit: 500, + }; + } + + getProviderInfo(): { name: string; version: string; capabilities: string[] } { + return { + name: "QuickBooks Online", + version: "3.0", + capabilities: [ + "customers", + "vendors", + "invoices", + "bills", + "items", + "accounts", + "expenses", + "payments", + "journal_entries", + "attachments", + "full_crud", + ], + }; + } + + // Missing abstract methods implementation + async getBulkExportStatus(jobId: string): Promise { + return { + jobId, + status: "completed", + progress: 100, + }; + } + + async downloadBulkExport(jobId: string): Promise { + throw new Error("Bulk export download not implemented yet"); + } + + async cancelBulkExport(jobId: string): Promise { + throw new Error("Bulk export cancellation not implemented yet"); + } + + async searchEntities( + entityType: string, + query: string, + options?: SyncOptions + ): Promise> { + throw new Error("Entity search not implemented yet"); + } + + async searchAttachments( + query: string, + entityType?: string, + entityId?: string + ): Promise { + throw new Error("Attachment search not implemented yet"); + } + + async createWebhook( + url: string, + events: string[] + ): Promise<{ id: string; secret: string }> { + throw new Error("Webhook creation not supported by QuickBooks API"); + } + + async updateWebhook( + id: string, + url?: string, + events?: string[] + ): Promise { + throw new Error("Webhook updates not supported by QuickBooks API"); + } + + async deleteWebhook(id: string): Promise { + throw new Error("Webhook deletion not supported by QuickBooks API"); + } + + async getWebhooks(): Promise< + { id: string; url: string; events: string[]; active: boolean }[] + > { + throw new Error("Webhook listing not supported by QuickBooks API"); + } + + verifyWebhook(payload: string, signature: string, secret: string): boolean { + throw new Error("Webhook verification not supported by QuickBooks API"); + } + + async processWebhook(payload: any): Promise { + throw new Error("Webhook processing not supported by QuickBooks API"); + } + + async getBalanceSheet( + date?: Date, + options?: { includeComparison?: boolean } + ): Promise { + throw new Error("Balance sheet reports not implemented yet"); + } + + async getIncomeStatement( + startDate?: Date, + endDate?: Date, + options?: { includeComparison?: boolean } + ): Promise { + throw new Error("Income statement reports not implemented yet"); + } + + async getCashFlowStatement(startDate?: Date, endDate?: Date): Promise { + throw new Error("Cash flow reports not implemented yet"); + } + + async getTrialBalance(date?: Date): Promise { + throw new Error("Trial balance reports not implemented yet"); + } + + async getAgingReport( + type: "receivables" | "payables", + date?: Date + ): Promise { + throw new Error("Aging reports not implemented yet"); + } + + async validateEntity( + entityType: string, + data: any + ): Promise<{ valid: boolean; errors: string[] }> { + return { valid: true, errors: [] }; + } + + async transformEntity( + entityType: string, + data: any, + direction: "to" | "from" + ): Promise { + return data; + } + + async getMetadata( + entityType: string + ): Promise<{ fields: any; relationships: any; actions: string[] }> { + return { + fields: {}, + relationships: {}, + actions: ["read", "create", "update", "delete"], + }; + } +} diff --git a/packages/ee/src/accounting/providers/xero.ts b/packages/ee/src/accounting/providers/xero.ts new file mode 100644 index 000000000..0094ee816 --- /dev/null +++ b/packages/ee/src/accounting/providers/xero.ts @@ -0,0 +1,917 @@ +import type { + Account, + Attachment, + Bill, + BulkExport, + CompanyInfo, + Customer, + Expense, + Invoice, + Item, + JournalEntry, + Payment, + Transaction, + Vendor, +} from "../models"; +import type { + ExportJobStatus, + ProviderAuth, + ProviderConfig, + RateLimitInfo, + SyncOptions, + SyncResult, + WebhookEvent, +} from "../service"; +import { CoreProvider } from "../service"; + +export class XeroProvider extends CoreProvider { + private baseUrl = "https://api.xero.com/api.xro/2.0"; + + constructor(config: ProviderConfig) { + super(config); + if (config.baseUrl) { + this.baseUrl = config.baseUrl; + } + } + + getAuthUrl(scopes: string[]): string { + const params = new URLSearchParams({ + response_type: "code", + client_id: this.config.clientId, + redirect_uri: this.config.redirectUri || "", + scope: scopes.join(" "), + state: crypto.randomUUID(), + }); + + return `https://login.xero.com/identity/connect/authorize?${params.toString()}`; + } + + async exchangeCodeForToken(code: string): Promise { + const response = await fetch("https://identity.xero.com/connect/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${btoa( + `${this.config.clientId}:${this.config.clientSecret}` + )}`, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: this.config.redirectUri || "", + }), + }); + + if (!response.ok) { + throw new Error(`Auth failed: ${response.statusText}`); + } + + const data = (await response.json()) as any; + + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: new Date(Date.now() + data.expires_in * 1000), + }; + } + + async refreshAccessToken(): Promise { + if (!this.auth?.refreshToken) { + throw new Error("No refresh token available"); + } + + const response = await fetch("https://identity.xero.com/connect/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${btoa( + `${this.config.clientId}:${this.config.clientSecret}` + )}`, + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: this.auth.refreshToken, + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + }), + }); + + if (!response.ok) { + throw new Error(`Token refresh failed: ${response.statusText}`); + } + + const data = (await response.json()) as any; + + const newAuth = { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: new Date(Date.now() + data.expires_in * 1000), + tenantId: this.auth.tenantId, + }; + + this.setAuth(newAuth); + + // Call the onTokenRefresh callback if provided + if (this.config.onTokenRefresh) { + await this.config.onTokenRefresh(newAuth); + } + + return newAuth; + } + + async validateAuth(): Promise { + if (!this.auth?.accessToken) { + return false; + } + + try { + const response = await this.makeRequest("/Organisation"); + return response.ok; + } catch { + return false; + } + } + + protected async makeRequest( + endpoint: string, + options: RequestInit = {} + ): Promise { + if (!this.auth?.accessToken) { + throw new Error("No access token available"); + } + + const url = `${this.baseUrl}${endpoint}`; + const tenantId = this.auth?.tenantId || this.config.tenantId; + + const baseHeaders: Record = { + Authorization: `Bearer ${this.auth.accessToken}`, + Accept: "application/json", + "Content-Type": "application/json", + ...(options.headers as Record), + }; + + if (tenantId) { + baseHeaders["xero-tenant-id"] = tenantId; + } + + const response = await fetch(url, { + ...options, + headers: baseHeaders, + }); + + if (response.status === 401) { + await this.refreshAccessToken(); + + const retryHeaders: Record = { + ...baseHeaders, + Authorization: `Bearer ${this.auth.accessToken}`, + }; + + if (tenantId) { + retryHeaders["xero-tenant-id"] = tenantId; + } + + return fetch(url, { + ...options, + headers: retryHeaders, + }); + } + + return response; + } + + private extractPrimaryPhone(phones: any[]): string | undefined { + if (!phones || phones.length === 0) return undefined; + + // Prioritize DEFAULT, WORK, MOBILE phone types + const priorityTypes = ["DEFAULT", "WORK", "MOBILE"]; + const priorityPhone = phones.find((p: any) => + priorityTypes.includes(p.PhoneType) + ); + + return priorityPhone?.PhoneNumber || phones[0]?.PhoneNumber; + } + + private transformXeroCustomer( + xeroCustomer: any + ): Customer & { firstName?: string; lastName?: string } { + // Extract first and last name from FirstName, LastName, or parse from Name + let firstName = xeroCustomer.FirstName || ""; + let lastName = xeroCustomer.LastName || ""; + + // If no FirstName/LastName, try to parse from Name + if (!firstName && !lastName) { + const fullName = xeroCustomer.Name || ""; + const nameParts = fullName.split(" "); + firstName = nameParts[0] || ""; + lastName = nameParts.slice(1).join(" ") || ""; + } + + const phone = this.extractPrimaryPhone(xeroCustomer.Phones); + + return { + id: xeroCustomer.ContactID, + name: xeroCustomer.Name, + email: xeroCustomer.EmailAddress, + phone: phone ? { number: phone, type: "work" as any } : undefined, + website: xeroCustomer.Website, + addresses: xeroCustomer.Addresses?.[0] + ? [ + { + street: xeroCustomer.Addresses[0].AddressLine1, + city: xeroCustomer.Addresses[0].City, + state: xeroCustomer.Addresses[0].Region, + postalCode: xeroCustomer.Addresses[0].PostalCode, + country: xeroCustomer.Addresses[0].Country, + }, + ] + : undefined, + taxNumber: xeroCustomer.TaxNumber, + currency: xeroCustomer.DefaultCurrency || "USD", + isActive: xeroCustomer.ContactStatus === "ACTIVE", + createdAt: xeroCustomer.CreatedDateUTC || new Date().toISOString(), + updatedAt: xeroCustomer.UpdatedDateUTC || new Date().toISOString(), + firstName, + lastName, + }; + } + + private transformXeroVendor( + xeroVendor: any + ): Vendor & { firstName?: string; lastName?: string } { + let firstName = xeroVendor.FirstName || ""; + let lastName = xeroVendor.LastName || ""; + + if (!firstName && !lastName) { + const fullName = xeroVendor.Name || ""; + const nameParts = fullName.split(" "); + firstName = nameParts[0] || ""; + lastName = nameParts.slice(1).join(" ") || ""; + } + + const phone = this.extractPrimaryPhone(xeroVendor.Phones); + + return { + id: xeroVendor.ContactID, + name: xeroVendor.Name, + email: xeroVendor.EmailAddress, + phone: phone ? { number: phone, type: "work" as any } : undefined, + website: xeroVendor.Website, + addresses: xeroVendor.Addresses?.[0] + ? [ + { + street: xeroVendor.Addresses[0].AddressLine1, + city: xeroVendor.Addresses[0].City, + state: xeroVendor.Addresses[0].Region, + postalCode: xeroVendor.Addresses[0].PostalCode, + country: xeroVendor.Addresses[0].Country, + }, + ] + : undefined, + taxNumber: xeroVendor.TaxNumber, + currency: xeroVendor.DefaultCurrency || "USD", + isActive: xeroVendor.ContactStatus === "ACTIVE", + createdAt: xeroVendor.CreatedDateUTC || new Date().toISOString(), + updatedAt: xeroVendor.UpdatedDateUTC || new Date().toISOString(), + firstName, + lastName, + }; + } + + async getCustomers(options?: SyncOptions): Promise> { + const params = new URLSearchParams(); + + if (options?.includeArchived) { + params.append("includeArchived", "true"); + } + + const headers: Record = {}; + if (options?.modifiedSince) { + headers["If-Modified-Since"] = options.modifiedSince.toUTCString(); + } + + const response = await this.makeRequest( + `/Contacts${params.toString() ? `?${params.toString()}` : ""}`, + { + headers, + } + ); + + if (!response.ok) { + const errText = await response.text().catch(() => ""); + throw new Error( + `Failed to get customers (${response.status}): ${ + errText || response.statusText + }` + ); + } + + const data = (await response.json()) as any; + const customers = data.Contacts.map(this.transformXeroCustomer); + + return { + data: customers, + hasMore: false, + pagination: { + page: 1, + limit: customers.length, + total: customers.length, + hasNext: false, + }, + }; + } + + async getAccounts(options?: SyncOptions): Promise> { + throw new Error("Not implemented yet"); + } + + async getAccount(id: string): Promise { + throw new Error("Not implemented yet"); + } + + async createAccount( + account: Omit + ): Promise { + throw new Error("Not implemented yet"); + } + + async updateAccount(id: string, account: Partial): Promise { + throw new Error("Not implemented yet"); + } + + async deleteAccount(id: string): Promise { + throw new Error("Not implemented yet"); + } + + async getCompanyInfo(): Promise { + throw new Error("Not implemented yet"); + } + + async getCustomer(id: string): Promise { + const response = await this.makeRequest(`/Contacts/${id}`); + + if (!response.ok) { + const errText = await response.text().catch(() => ""); + throw new Error( + `Failed to get customer (${response.status}): ${ + errText || response.statusText + }` + ); + } + + const data = (await response.json()) as any; + return this.transformXeroCustomer(data.Contacts[0]); + } + + async createCustomer( + customer: Omit + ): Promise { + const xeroCustomer = { + Name: customer.name, + EmailAddress: customer.email, + Phones: customer.phone + ? [{ PhoneType: "DEFAULT", PhoneNumber: customer.phone }] + : [], + Addresses: customer.addresses?.[0] + ? [ + { + AddressType: "STREET", + AddressLine1: customer.addresses[0].street, + City: customer.addresses[0].city, + Region: customer.addresses[0].state, + PostalCode: customer.addresses[0].postalCode, + Country: customer.addresses[0].country, + }, + ] + : [], + }; + + const response = await this.makeRequest("/Contacts", { + method: "POST", + body: JSON.stringify({ Contacts: [xeroCustomer] }), + }); + + if (!response.ok) { + const errText = await response.text().catch(() => ""); + throw new Error( + `Failed to create customer (${response.status}): ${ + errText || response.statusText + }` + ); + } + + const data = (await response.json()) as any; + return this.transformXeroCustomer(data.Contacts[0]); + } + + async getContact(id: string): Promise { + const response = await this.makeRequest(`/Contacts/${id}`); + if (!response.ok) { + throw new Error(`Failed to get contact: ${response.statusText}`); + } + const data = (await response.json()) as any; + return data; + } + + async updateCustomer( + id: string, + customer: Partial + ): Promise { + // Similar implementation to createCustomer but with PUT method + throw new Error("Not implemented yet"); + } + + async deleteCustomer(id: string): Promise { + // Xero doesn't support deletion, only archiving + throw new Error("Xero does not support customer deletion, only archiving"); + } + + // Vendor operations + async getVendors(options?: SyncOptions): Promise> { + throw new Error("Not implemented yet"); + } + + async getVendor(id: string): Promise { + const response = await this.makeRequest(`/Contacts/${id}`); + + if (!response.ok) { + const errText = await response.text().catch(() => ""); + throw new Error( + `Failed to get vendor (${response.status}): ${ + errText || response.statusText + }` + ); + } + + const data = (await response.json()) as any; + return this.transformXeroVendor(data.Contacts[0]); + } + + async createVendor( + vendor: Omit + ): Promise { + throw new Error("Not implemented yet"); + } + + async updateVendor(id: string, vendor: Partial): Promise { + throw new Error("Not implemented yet"); + } + + async deleteVendor(id: string): Promise { + throw new Error("Not implemented yet"); + } + + // Item operations + async getItems(options?: SyncOptions): Promise> { + throw new Error("Not implemented yet"); + } + + async getItem(id: string): Promise { + throw new Error("Not implemented yet"); + } + + async createItem( + item: Omit + ): Promise { + throw new Error("Not implemented yet"); + } + + async updateItem(id: string, item: Partial): Promise { + throw new Error("Not implemented yet"); + } + + async deleteItem(id: string): Promise { + throw new Error("Not implemented yet"); + } + + // Invoice operations + async getInvoices(options?: SyncOptions): Promise> { + throw new Error("Not implemented yet"); + } + + async getInvoice(id: string): Promise { + const response = await this.makeRequest(`/Invoices/${id}`); + + if (!response.ok) { + const errText = await response.text().catch(() => ""); + throw new Error( + `Failed to get invoice (${response.status}): ${ + errText || response.statusText + }` + ); + } + + const data = (await response.json()) as any; + return data; + } + + async createInvoice( + invoice: Omit + ): Promise { + throw new Error("Not implemented yet"); + } + + async updateInvoice(id: string, invoice: Partial): Promise { + throw new Error("Not implemented yet"); + } + + async deleteInvoice(id: string): Promise { + throw new Error("Not implemented yet"); + } + + async sendInvoice( + id: string, + options?: { email?: string; subject?: string; message?: string } + ): Promise { + throw new Error("Not implemented yet"); + } + + // Bill operations + async getBills(options?: SyncOptions): Promise> { + throw new Error("Not implemented yet"); + } + + async getBill(id: string): Promise { + throw new Error("Not implemented yet"); + } + + async createBill( + bill: Omit + ): Promise { + throw new Error("Not implemented yet"); + } + + async updateBill(id: string, bill: Partial): Promise { + throw new Error("Not implemented yet"); + } + + async deleteBill(id: string): Promise { + throw new Error("Not implemented yet"); + } + + async getTransactions( + options?: SyncOptions + ): Promise> { + throw new Error("Not implemented yet"); + } + + async getTransaction(id: string): Promise { + throw new Error("Not implemented yet"); + } + + async createTransaction( + transaction: Omit + ): Promise { + throw new Error("Not implemented yet"); + } + + async updateTransaction( + id: string, + transaction: Partial + ): Promise { + throw new Error("Not implemented yet"); + } + + async deleteTransaction(id: string): Promise { + throw new Error("Not implemented yet"); + } + + async reconcileTransaction( + id: string, + bankTransactionId: string + ): Promise { + throw new Error("Not implemented yet"); + } + + // Expense operations + async getExpenses(options?: SyncOptions): Promise> { + throw new Error("Not implemented yet"); + } + + async getExpense(id: string): Promise { + throw new Error("Not implemented yet"); + } + + async createExpense( + expense: Omit + ): Promise { + throw new Error("Not implemented yet"); + } + + async updateExpense(id: string, expense: Partial): Promise { + throw new Error("Not implemented yet"); + } + + async deleteExpense(id: string): Promise { + throw new Error("Not implemented yet"); + } + + async submitExpense(id: string): Promise { + throw new Error("Not implemented yet"); + } + + async approveExpense(id: string): Promise { + throw new Error("Not implemented yet"); + } + + async rejectExpense(id: string, reason?: string): Promise { + throw new Error("Not implemented yet"); + } + + // Journal Entry operations + async getJournalEntries( + options?: SyncOptions + ): Promise> { + throw new Error("Not implemented yet"); + } + + async getJournalEntry(id: string): Promise { + throw new Error("Not implemented yet"); + } + + async createJournalEntry( + journalEntry: Omit + ): Promise { + throw new Error("Not implemented yet"); + } + + async updateJournalEntry( + id: string, + journalEntry: Partial + ): Promise { + throw new Error("Not implemented yet"); + } + + async deleteJournalEntry(id: string): Promise { + throw new Error("Not implemented yet"); + } + + async postJournalEntry(id: string): Promise { + throw new Error("Not implemented yet"); + } + + // Payment operations + async getPayments(options?: SyncOptions): Promise> { + throw new Error("Not implemented yet"); + } + + async getPayment(id: string): Promise { + throw new Error("Not implemented yet"); + } + + async createPayment( + payment: Omit + ): Promise { + throw new Error("Not implemented yet"); + } + + async updatePayment(id: string, payment: Partial): Promise { + throw new Error("Not implemented yet"); + } + + async deletePayment(id: string): Promise { + throw new Error("Not implemented yet"); + } + + async processPayment(id: string): Promise { + throw new Error("Not implemented yet"); + } + + async getAttachments( + entityType: string, + entityId: string, + attachmentType?: string + ): Promise { + let endpoint = ""; + + switch (entityType) { + case "invoice": + endpoint = `/Invoices/${entityId}/Attachments`; + break; + case "bill": + endpoint = `/Bills/${entityId}/Attachments`; + break; + case "expense": + endpoint = `/ExpenseClaims/${entityId}/Attachments`; + break; + case "transaction": + endpoint = `/BankTransactions/${entityId}/Attachments`; + break; + default: + throw new Error( + `Attachments not supported for entity type: ${entityType}` + ); + } + + const response = await this.makeRequest(endpoint); + + if (!response.ok) { + throw new Error(`Failed to get attachments: ${response.statusText}`); + } + + const data = (await response.json()) as any; + return ( + data.Attachments?.map((att: any) => ({ + id: att.AttachmentID, + filename: att.FileName, + originalFilename: att.FileName, + mimeType: att.MimeType, + size: att.ContentLength, + url: att.Url, + downloadUrl: att.Url, + entityType: entityType as any, + entityId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + })) || [] + ); + } + + async getAttachment(id: string): Promise { + // Xero doesn't have a direct attachment endpoint, need to search through entities + throw new Error("Direct attachment retrieval not supported by Xero API"); + } + + async downloadAttachment(id: string): Promise { + // This would need to be implemented based on Xero's attachment download API + const response = await this.makeRequest(`/Attachments/${id}/Content`); + + if (!response.ok) { + return null; + } + + return response.body; + } + + async getAttachmentMetadata(id: string): Promise { + const response = await this.makeRequest(`/Attachments/${id}`); + + if (!response.ok) { + throw new Error( + `Failed to get attachment metadata: ${response.statusText}` + ); + } + + return await response.json(); + } + + // Bulk operations + async bulkCreate( + entityType: string, + entities: T[] + ): Promise<{ success: T[]; failed: { entity: T; error: string }[] }> { + throw new Error("Not implemented yet"); + } + + async bulkUpdate( + entityType: string, + entities: { id: string; data: Partial }[] + ): Promise<{ success: T[]; failed: { id: string; error: string }[] }> { + throw new Error("Not implemented yet"); + } + + async bulkDelete( + entityType: string, + ids: string[] + ): Promise<{ success: string[]; failed: { id: string; error: string }[] }> { + throw new Error("Not implemented yet"); + } + + // Export operations + async startBulkExport(request: BulkExport): Promise { + throw new Error("Not implemented yet"); + } + + async getBulkExportStatus(jobId: string): Promise { + throw new Error("Not implemented yet"); + } + + async downloadBulkExport(jobId: string): Promise { + throw new Error("Not implemented yet"); + } + + async cancelBulkExport(jobId: string): Promise { + throw new Error("Not implemented yet"); + } + + // Search operations + async searchEntities( + entityType: string, + query: string, + options?: SyncOptions + ): Promise> { + throw new Error("Not implemented yet"); + } + + async searchAttachments( + query: string, + entityType?: string, + entityId?: string + ): Promise { + throw new Error("Not implemented yet"); + } + + // Webhook operations + async createWebhook( + url: string, + events: string[] + ): Promise<{ id: string; secret: string }> { + throw new Error("Not implemented yet"); + } + + async updateWebhook( + id: string, + url?: string, + events?: string[] + ): Promise { + throw new Error("Not implemented yet"); + } + + async deleteWebhook(id: string): Promise { + throw new Error("Not implemented yet"); + } + + async getWebhooks(): Promise< + { id: string; url: string; events: string[]; active: boolean }[] + > { + throw new Error("Not implemented yet"); + } + + verifyWebhook(payload: string, signature: string, secret: string): boolean { + throw new Error("Not implemented yet"); + } + + async processWebhook(payload: WebhookEvent): Promise { + throw new Error("Not implemented yet"); + } + + // Reporting operations + async getBalanceSheet( + date?: Date, + options?: { includeComparison?: boolean } + ): Promise { + throw new Error("Not implemented yet"); + } + + async getIncomeStatement( + startDate?: Date, + endDate?: Date, + options?: { includeComparison?: boolean } + ): Promise { + throw new Error("Not implemented yet"); + } + + async getCashFlowStatement(startDate?: Date, endDate?: Date): Promise { + throw new Error("Not implemented yet"); + } + + async getTrialBalance(date?: Date): Promise { + throw new Error("Not implemented yet"); + } + + async getAgingReport( + type: "receivables" | "payables", + date?: Date + ): Promise { + throw new Error("Not implemented yet"); + } + + // Utility methods + async validateEntity( + entityType: string, + data: any + ): Promise<{ valid: boolean; errors: string[] }> { + throw new Error("Not implemented yet"); + } + + async transformEntity( + entityType: string, + data: any, + direction: "to" | "from" + ): Promise { + throw new Error("Not implemented yet"); + } + + async getMetadata( + entityType: string + ): Promise<{ fields: any; relationships: any; actions: string[] }> { + throw new Error("Not implemented yet"); + } + + async getRateLimitInfo(): Promise { + // Xero rate limits are typically in response headers + return { + remaining: 100, + reset: new Date(Date.now() + 60000), + limit: 100, + }; + } + + getProviderInfo(): { name: string; version: string; capabilities: string[] } { + return { + name: "Xero", + version: "2.0", + capabilities: ["customers", "invoices", "transactions", "attachments"], + }; + } +} diff --git a/packages/ee/src/accounting/service.ts b/packages/ee/src/accounting/service.ts new file mode 100644 index 000000000..7f5c2d739 --- /dev/null +++ b/packages/ee/src/accounting/service.ts @@ -0,0 +1,483 @@ +/* +License: MIT +Author: Pontus Abrahamssons +Repository: https://github.com/midday-ai/zuno +*/ + +import type { + Account, + Attachment, + Bill, + BulkExport, + CompanyInfo, + Customer, + Expense, + Invoice, + Item, + JournalEntry, + Pagination, + Payment, + Transaction, + Vendor, +} from "./models"; + +export interface ProviderConfig { + clientId: string; + clientSecret: string; + redirectUri?: string; + baseUrl?: string; + accessToken?: string; + refreshToken?: string; + tenantId?: string; + apiVersion?: string; + environment?: "production" | "sandbox"; + companyId?: string; + integrationId?: string; + onTokenRefresh?: (auth: ProviderAuth) => Promise; +} + +export interface ProviderAuth { + accessToken: string; + refreshToken?: string; + expiresAt?: Date; + tenantId?: string; + scope?: string[]; +} + +export interface SyncOptions { + modifiedSince?: Date; + includeArchived?: boolean; + includeDeleted?: boolean; + batchSize?: number; + page?: number; + limit?: number; + cursor?: string; + search?: string; + dateFrom?: Date; + dateTo?: Date; + status?: string; + entityId?: string; + entityType?: string; + includeAttachments?: boolean; + includeCustomFields?: boolean; + includeRawData?: boolean; +} + +export interface SyncResult { + data: T[]; + pagination?: Pagination; + hasMore: boolean; + cursor?: string; + total?: number; +} + +export interface RateLimitInfo { + limit: number; + remaining: number; + reset: Date; + retryAfter?: number; +} + +export interface WebhookEvent { + id: string; + type: string; + data: any; + timestamp: Date; + signature?: string; +} + +export interface ExportJobStatus { + id: string; + status: "pending" | "processing" | "completed" | "failed"; + progress: number; + totalRecords?: number; + processedRecords?: number; + downloadUrl?: string; + error?: string; + createdAt: Date; + updatedAt: Date; +} + +export abstract class CoreProvider { + protected config: ProviderConfig; + protected auth?: ProviderAuth; + + constructor(config: ProviderConfig) { + this.config = config; + this.auth = { + accessToken: config.accessToken, + refreshToken: config.refreshToken, + tenantId: config.tenantId, + }; + } + + protected setAuth(auth: ProviderAuth) { + this.auth = auth; + } + + protected revokeAuth(): Promise { + return Promise.resolve(); + } + + // Authentication methods + abstract getAuthUrl(scopes: string[]): string; + abstract exchangeCodeForToken(code: string): Promise; + abstract refreshAccessToken(): Promise; + abstract validateAuth(): Promise; + + // Company information + abstract getCompanyInfo(): Promise; + + // Account operations (Chart of Accounts) + abstract getAccounts(options?: SyncOptions): Promise>; + abstract getAccount(id: string): Promise; + abstract createAccount( + account: Omit + ): Promise; + abstract updateAccount( + id: string, + account: Partial + ): Promise; + abstract deleteAccount(id: string): Promise; + + // Customer operations + abstract getCustomers(options?: SyncOptions): Promise>; + abstract getCustomer(id: string): Promise; + abstract createCustomer( + customer: Omit + ): Promise; + abstract updateCustomer( + id: string, + customer: Partial + ): Promise; + abstract deleteCustomer(id: string): Promise; + + // Vendor operations + abstract getVendors(options?: SyncOptions): Promise>; + abstract getVendor(id: string): Promise; + abstract createVendor( + vendor: Omit + ): Promise; + abstract updateVendor(id: string, vendor: Partial): Promise; + abstract deleteVendor(id: string): Promise; + + // Item operations + abstract getItems(options?: SyncOptions): Promise>; + abstract getItem(id: string): Promise; + abstract createItem( + item: Omit + ): Promise; + abstract updateItem(id: string, item: Partial): Promise; + abstract deleteItem(id: string): Promise; + + // Invoice operations + abstract getInvoices(options?: SyncOptions): Promise>; + abstract getInvoice(id: string): Promise; + abstract createInvoice( + invoice: Omit + ): Promise; + abstract updateInvoice( + id: string, + invoice: Partial + ): Promise; + abstract deleteInvoice(id: string): Promise; + abstract sendInvoice( + id: string, + options?: { email?: string; subject?: string; message?: string } + ): Promise; + + // Bill operations + abstract getBills(options?: SyncOptions): Promise>; + abstract getBill(id: string): Promise; + abstract createBill( + bill: Omit + ): Promise; + abstract updateBill(id: string, bill: Partial): Promise; + abstract deleteBill(id: string): Promise; + + // Transaction operations + abstract getTransactions( + options?: SyncOptions + ): Promise>; + abstract getTransaction(id: string): Promise; + abstract createTransaction( + transaction: Omit + ): Promise; + abstract updateTransaction( + id: string, + transaction: Partial + ): Promise; + abstract deleteTransaction(id: string): Promise; + abstract reconcileTransaction( + id: string, + bankTransactionId: string + ): Promise; + + // Expense operations + abstract getExpenses(options?: SyncOptions): Promise>; + abstract getExpense(id: string): Promise; + abstract createExpense( + expense: Omit + ): Promise; + abstract updateExpense( + id: string, + expense: Partial + ): Promise; + abstract deleteExpense(id: string): Promise; + abstract submitExpense(id: string): Promise; + abstract approveExpense(id: string): Promise; + abstract rejectExpense(id: string, reason?: string): Promise; + + // Journal Entry operations + abstract getJournalEntries( + options?: SyncOptions + ): Promise>; + abstract getJournalEntry(id: string): Promise; + abstract createJournalEntry( + journalEntry: Omit + ): Promise; + abstract updateJournalEntry( + id: string, + journalEntry: Partial + ): Promise; + abstract deleteJournalEntry(id: string): Promise; + abstract postJournalEntry(id: string): Promise; + + // Payment operations + abstract getPayments(options?: SyncOptions): Promise>; + abstract getPayment(id: string): Promise; + abstract createPayment( + payment: Omit + ): Promise; + abstract updatePayment( + id: string, + payment: Partial + ): Promise; + abstract deletePayment(id: string): Promise; + abstract processPayment(id: string): Promise; + + // Attachment operations (read-only) + abstract getAttachments( + entityType: string, + entityId: string, + attachmentType?: string + ): Promise; + abstract getAttachment(id: string): Promise; + abstract downloadAttachment(id: string): Promise; + abstract getAttachmentMetadata(id: string): Promise; + + // Optional attachment methods + generateSignedUrl?(attachmentId: string, expiresIn?: number): Promise; + streamAttachment?(attachmentId: string): Promise; + + // Bulk operations + abstract bulkCreate( + entityType: string, + entities: T[] + ): Promise<{ success: T[]; failed: { entity: T; error: string }[] }>; + abstract bulkUpdate( + entityType: string, + entities: { id: string; data: Partial }[] + ): Promise<{ success: T[]; failed: { id: string; error: string }[] }>; + abstract bulkDelete( + entityType: string, + ids: string[] + ): Promise<{ success: string[]; failed: { id: string; error: string }[] }>; + + // Export operations + abstract startBulkExport(request: BulkExport): Promise; // Returns job ID + abstract getBulkExportStatus(jobId: string): Promise; + abstract downloadBulkExport(jobId: string): Promise; + abstract cancelBulkExport(jobId: string): Promise; + + // Search operations + abstract searchEntities( + entityType: string, + query: string, + options?: SyncOptions + ): Promise>; + abstract searchAttachments( + query: string, + entityType?: string, + entityId?: string + ): Promise; + + // Webhook operations + abstract createWebhook( + url: string, + events: string[] + ): Promise<{ id: string; secret: string }>; + abstract updateWebhook( + id: string, + url?: string, + events?: string[] + ): Promise; + abstract deleteWebhook(id: string): Promise; + abstract getWebhooks(): Promise< + { id: string; url: string; events: string[]; active: boolean }[] + >; + abstract verifyWebhook( + payload: string, + signature: string, + secret: string + ): boolean; + abstract processWebhook(payload: WebhookEvent): Promise; + + // Reporting operations + abstract getBalanceSheet( + date?: Date, + options?: { includeComparison?: boolean } + ): Promise; + abstract getIncomeStatement( + startDate?: Date, + endDate?: Date, + options?: { includeComparison?: boolean } + ): Promise; + abstract getCashFlowStatement(startDate?: Date, endDate?: Date): Promise; + abstract getTrialBalance(date?: Date): Promise; + abstract getAgingReport( + type: "receivables" | "payables", + date?: Date + ): Promise; + + // Utility methods + abstract getRateLimitInfo(): Promise; + abstract getProviderInfo(): { + name: string; + version: string; + capabilities: string[]; + }; + abstract validateEntity( + entityType: string, + data: any + ): Promise<{ valid: boolean; errors: string[] }>; + abstract transformEntity( + entityType: string, + data: any, + direction: "to" | "from" + ): Promise; + abstract getMetadata( + entityType: string + ): Promise<{ fields: any; relationships: any; actions: string[] }>; + + // Helper methods for providers + protected async makeRequest( + endpoint: string, + request: RequestInit = {} + ): Promise { + const url = new URL(endpoint, this.config.baseUrl); + const { method = "GET", headers = {}, body } = request; + + const requestHeaders: Record = { + "Content-Type": "application/json", + ...(headers as Record), + }; + + if (this.auth?.accessToken) { + requestHeaders["Authorization"] = `Bearer ${this.auth.accessToken}`; + } + + const requestOptions: RequestInit = { + method, + headers: requestHeaders, + }; + + if (body && method !== "GET") { + requestOptions.body = JSON.stringify(body); + } + + const response = await fetch(url.toString(), requestOptions); + + // Handle rate limiting + if (response.status === 429) { + const retryAfter = response.headers.get("Retry-After"); + if (retryAfter) { + await new Promise((resolve) => + setTimeout(resolve, parseInt(retryAfter) * 1000) + ); + return this.makeRequest(endpoint, request); + } + } + + // Handle auth errors + if (response.status === 401 && this.auth?.refreshToken) { + try { + const newAuth = await this.refreshAccessToken(); + // Call the onTokenRefresh callback if provided + if (this.config.onTokenRefresh) { + await this.config.onTokenRefresh(newAuth); + } + const updatedHeaders: Record = { ...requestHeaders }; + updatedHeaders["Authorization"] = `Bearer ${this.auth.accessToken}`; + return fetch(url.toString(), { + ...requestOptions, + headers: updatedHeaders, + }); + } catch (error) { + throw new Error("Authentication failed and refresh token is invalid"); + } + } + + return response; + } + + protected handleError(error: any, context?: string): never { + const message = context ? `${context}: ${error.message}` : error.message; + throw new Error(message); + } + + protected validateConfig(): void { + if (!this.config.clientId || !this.config.clientSecret) { + throw new Error("Client ID and Client Secret are required"); + } + } + + protected async retryOperation( + operation: () => Promise, + maxRetries: number = 3, + delay: number = 1000 + ): Promise { + for (let i = 0; i < maxRetries; i++) { + try { + return await operation(); + } catch (error) { + if (i === maxRetries - 1) throw error; + await new Promise((resolve) => + setTimeout(resolve, delay * Math.pow(2, i)) + ); + } + } + throw new Error("Max retries exceeded"); + } + + protected generateChecksum(data: string): string { + // Simple checksum for data integrity + let checksum = 0; + for (let i = 0; i < data.length; i++) { + checksum += data.charCodeAt(i); + } + return checksum.toString(16); + } + + protected formatDate(date: Date): string { + return date.toISOString().split("T")[0]; + } + + protected parseDate(dateString: string): Date { + return new Date(dateString); + } + + protected sanitizeData(data: any): any { + // Remove sensitive information before logging + const sanitized = { ...data }; + const sensitiveFields = ["password", "token", "secret", "key", "auth"]; + + for (const field of sensitiveFields) { + if (sanitized[field]) { + sanitized[field] = "[REDACTED]"; + } + } + + return sanitized; + } +} diff --git a/packages/ee/src/accounting/sync.ts b/packages/ee/src/accounting/sync.ts new file mode 100644 index 000000000..24d16f1eb --- /dev/null +++ b/packages/ee/src/accounting/sync.ts @@ -0,0 +1,767 @@ +/* +License: MIT +Author: Pontus Abrahamssons +Repository: https://github.com/midday-ai/zuno +*/ + +import type { + Account, + Attachment, + BulkExport, + CompanyInfo, + Customer, + Expense, + Invoice, + Transaction, + Vendor, +} from "./models"; +import type { + CoreProvider, + ExportJobStatus, + ProviderConfig, + SyncOptions, +} from "./service"; + +export interface QueueJob { + id: string; + provider: string; + method: string; + args: any[]; + retry: number; + maxRetries: number; + scheduledAt: Date; + tenantId?: string; + priority?: "low" | "normal" | "high"; + metadata?: Record; +} + +export interface ProviderRegistry { + [key: string]: new (config: ProviderConfig) => CoreProvider; +} + +export class ProviderManager { + private providers: Map = new Map(); + private registry: ProviderRegistry = {}; + private queue?: any; // Cloudflare Queue - will be typed properly when CF types are available + private exportJobs: Map = new Map(); + + constructor(private env: any) { + this.queue = env.SYNC_QUEUE; + } + + // Provider registration + registerProvider( + name: string, + providerClass: new (config: ProviderConfig) => CoreProvider + ) { + this.registry[name] = providerClass; + } + + // Provider initialization + async initializeProvider( + name: string, + config: ProviderConfig + ): Promise { + const ProviderClass = this.registry[name]; + if (!ProviderClass) { + throw new Error(`Provider ${name} not registered`); + } + + const provider = new ProviderClass(config); + this.providers.set(name, provider); + return provider; + } + + getProvider(name: string): CoreProvider | undefined { + return this.providers.get(name); + } + + // Queue management + async enqueueJob( + job: Omit + ): Promise { + const queueJob: QueueJob = { + ...job, + id: crypto.randomUUID(), + scheduledAt: new Date(), + retry: 0, + }; + + if (this.queue) { + await this.queue.send(queueJob); + } else { + // Fallback to direct execution in development + await this.processJob(queueJob); + } + + return queueJob.id; + } + + async processJob(job: QueueJob): Promise { + const provider = this.getProvider(job.provider); + if (!provider) { + throw new Error(`Provider ${job.provider} not found`); + } + + try { + const method = (provider as any)[job.method]; + if (typeof method !== "function") { + throw new Error( + `Method ${job.method} not found on provider ${job.provider}` + ); + } + + const result = await method.apply(provider, job.args); + return result; + } catch (error) { + if (job.retry < job.maxRetries) { + // Retry with exponential backoff + const delay = Math.pow(2, job.retry) * 1000; + const retryJob = { + ...job, + retry: job.retry + 1, + scheduledAt: new Date(Date.now() + delay), + }; + + if (this.queue) { + await this.queue.send(retryJob, { delaySeconds: delay / 1000 }); + } + } + throw error; + } + } + + // ====================== + // COMPANY INFO METHODS + // ====================== + + async getCompanyInfo( + provider: string, + useQueue = false + ): Promise { + if (useQueue) { + await this.enqueueJob({ + provider, + method: "getCompanyInfo", + args: [], + maxRetries: 3, + }); + throw new Error("Job queued - use webhook or polling to get result"); + } + + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + throw new Error(`Provider ${provider} not found`); + } + + return providerInstance.getCompanyInfo(); + } + + // ====================== + // ACCOUNT METHODS + // ====================== + + async getAccounts( + provider: string, + options?: SyncOptions, + useQueue = false + ): Promise { + if (useQueue) { + return this.enqueueJob({ + provider, + method: "getAccounts", + args: [options], + maxRetries: 3, + }); + } + + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + throw new Error(`Provider ${provider} not found`); + } + + return providerInstance.getAccounts(options); + } + + async getAccount( + provider: string, + id: string, + useQueue = false + ): Promise { + if (useQueue) { + return this.enqueueJob({ + provider, + method: "getAccount", + args: [id], + maxRetries: 3, + }); + } + + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + throw new Error(`Provider ${provider} not found`); + } + + return providerInstance.getAccount(id); + } + + async createAccount( + provider: string, + account: Omit, + useQueue = false + ): Promise { + if (useQueue) { + return this.enqueueJob({ + provider, + method: "createAccount", + args: [account], + maxRetries: 3, + }); + } + + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + throw new Error(`Provider ${provider} not found`); + } + + return providerInstance.createAccount(account); + } + + // ====================== + // CUSTOMER METHODS + // ====================== + + async getCustomers( + provider: string, + options?: SyncOptions, + useQueue = false + ): Promise { + if (useQueue) { + return this.enqueueJob({ + provider, + method: "getCustomers", + args: [options], + maxRetries: 3, + }); + } + + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + throw new Error(`Provider ${provider} not found`); + } + + return providerInstance.getCustomers(options); + } + + async getCustomer( + provider: string, + id: string, + useQueue = false + ): Promise { + if (useQueue) { + return this.enqueueJob({ + provider, + method: "getCustomer", + args: [id], + maxRetries: 3, + }); + } + + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + throw new Error(`Provider ${provider} not found`); + } + + return providerInstance.getCustomer(id); + } + + async createCustomer( + provider: string, + customer: Omit, + useQueue = false + ): Promise { + if (useQueue) { + return this.enqueueJob({ + provider, + method: "createCustomer", + args: [customer], + maxRetries: 3, + }); + } + + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + throw new Error(`Provider ${provider} not found`); + } + + return providerInstance.createCustomer(customer); + } + + // ====================== + // VENDOR METHODS + // ====================== + + async getVendors( + provider: string, + options?: SyncOptions, + useQueue = false + ): Promise { + if (useQueue) { + return this.enqueueJob({ + provider, + method: "getVendors", + args: [options], + maxRetries: 3, + }); + } + + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + throw new Error(`Provider ${provider} not found`); + } + + return providerInstance.getVendors(options); + } + + async createVendor( + provider: string, + vendor: Omit, + useQueue = false + ): Promise { + if (useQueue) { + return this.enqueueJob({ + provider, + method: "createVendor", + args: [vendor], + maxRetries: 3, + }); + } + + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + throw new Error(`Provider ${provider} not found`); + } + + return providerInstance.createVendor(vendor); + } + + // ====================== + // INVOICE METHODS + // ====================== + + async getInvoices( + provider: string, + options?: SyncOptions, + useQueue = false + ): Promise { + if (useQueue) { + return this.enqueueJob({ + provider, + method: "getInvoices", + args: [options], + maxRetries: 3, + }); + } + + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + throw new Error(`Provider ${provider} not found`); + } + + return providerInstance.getInvoices(options); + } + + async getInvoice( + provider: string, + id: string, + useQueue = false + ): Promise { + if (useQueue) { + return this.enqueueJob({ + provider, + method: "getInvoice", + args: [id], + maxRetries: 3, + }); + } + + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + throw new Error(`Provider ${provider} not found`); + } + + return providerInstance.getInvoice(id); + } + + async createInvoice( + provider: string, + invoice: Omit, + useQueue = false + ): Promise { + if (useQueue) { + return this.enqueueJob({ + provider, + method: "createInvoice", + args: [invoice], + maxRetries: 3, + }); + } + + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + throw new Error(`Provider ${provider} not found`); + } + + return providerInstance.createInvoice(invoice); + } + + // ====================== + // TRANSACTION METHODS + // ====================== + + async getTransactions( + provider: string, + options?: SyncOptions, + useQueue = false + ): Promise { + if (useQueue) { + return this.enqueueJob({ + provider, + method: "getTransactions", + args: [options], + maxRetries: 3, + }); + } + + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + throw new Error(`Provider ${provider} not found`); + } + + return providerInstance.getTransactions(options); + } + + async createTransaction( + provider: string, + transaction: Omit, + useQueue = false + ): Promise { + if (useQueue) { + return this.enqueueJob({ + provider, + method: "createTransaction", + args: [transaction], + maxRetries: 3, + }); + } + + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + throw new Error(`Provider ${provider} not found`); + } + + return providerInstance.createTransaction(transaction); + } + + // ====================== + // EXPENSE METHODS + // ====================== + + async getExpenses( + provider: string, + options?: SyncOptions, + useQueue = false + ): Promise { + if (useQueue) { + return this.enqueueJob({ + provider, + method: "getExpenses", + args: [options], + maxRetries: 3, + }); + } + + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + throw new Error(`Provider ${provider} not found`); + } + + return providerInstance.getExpenses(options); + } + + async createExpense( + provider: string, + expense: Omit, + useQueue = false + ): Promise { + if (useQueue) { + return this.enqueueJob({ + provider, + method: "createExpense", + args: [expense], + maxRetries: 3, + }); + } + + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + throw new Error(`Provider ${provider} not found`); + } + + return providerInstance.createExpense(expense); + } + + // ====================== + // ATTACHMENT METHODS + // ====================== + + async getAttachments( + entityType: string, + entityId: string, + attachmentType?: string, + useQueue = false + ): Promise { + // For now, we'll implement a generic attachment retrieval + // In a real implementation, this would be provider-specific + return []; + } + + // Upload attachment method removed - using proxy pattern instead + + // ====================== + // BULK EXPORT METHODS + // ====================== + + async enqueueBulkExport(request: BulkExport): Promise { + const jobId = crypto.randomUUID(); + + // Create export job status + const exportJob: ExportJobStatus = { + id: jobId, + status: "pending", + progress: 0, + createdAt: new Date(), + updatedAt: new Date(), + }; + + this.exportJobs.set(jobId, exportJob); + + // Queue the export job + await this.enqueueJob({ + provider: request.provider, + method: "startBulkExport", + args: [request], + maxRetries: 1, // Don't retry bulk exports + priority: "low", // Bulk exports are low priority + }); + + return jobId; + } + + async getBulkExportStatus(jobId: string): Promise { + const job = this.exportJobs.get(jobId); + if (!job) { + throw new Error(`Export job ${jobId} not found`); + } + + return job; + } + + async downloadBulkExport(jobId: string): Promise { + const job = this.exportJobs.get(jobId); + if (!job) { + throw new Error(`Export job ${jobId} not found`); + } + + if (job.status !== "completed") { + throw new Error(`Export job ${jobId} is not completed`); + } + + // In a real implementation, this would download from storage + throw new Error("Download not implemented yet"); + } + + // ====================== + // BATCH OPERATIONS + // ====================== + + async batchSync( + provider: string, + entityTypes: string[], + options?: SyncOptions + ): Promise<{ [entityType: string]: any }> { + const results: { [entityType: string]: any } = {}; + + const jobs = entityTypes.map((entityType) => ({ + provider, + method: `get${entityType.charAt(0).toUpperCase() + entityType.slice(1)}`, + args: [options], + maxRetries: 3, + })); + + // Execute all jobs in parallel + const promises = jobs.map((job) => this.enqueueJob(job)); + const jobIds = await Promise.all(promises); + + // For now, return the job IDs + // In a real implementation, you'd wait for completion or use webhooks + entityTypes.forEach((entityType, index) => { + results[entityType] = { jobId: jobIds[index] }; + }); + + return results; + } + + async batchCreate( + provider: string, + entityType: string, + entities: T[], + useQueue = false + ): Promise { + if (useQueue) { + return this.enqueueJob({ + provider, + method: "bulkCreate", + args: [entityType, entities], + maxRetries: 3, + }); + } + + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + throw new Error(`Provider ${provider} not found`); + } + + return providerInstance.bulkCreate(entityType, entities); + } + + // ====================== + // UTILITY METHODS + // ====================== + + async validateProvider(provider: string): Promise { + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + return false; + } + + try { + return await providerInstance.validateAuth(); + } catch (error) { + return false; + } + } + + async getProviderCapabilities(provider: string): Promise { + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + throw new Error(`Provider ${provider} not found`); + } + + return providerInstance.getProviderInfo().capabilities; + } + + async healthCheck(): Promise<{ [provider: string]: boolean }> { + const results: { [provider: string]: boolean } = {}; + + for (const [name, provider] of this.providers.entries()) { + try { + results[name] = await provider.validateAuth(); + } catch (error) { + results[name] = false; + } + } + + return results; + } + + // ====================== + // SEARCH METHODS + // ====================== + + async searchEntities( + provider: string, + entityType: string, + query: string, + options?: SyncOptions + ): Promise { + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + throw new Error(`Provider ${provider} not found`); + } + + return providerInstance.searchEntities(entityType, query, options); + } + + // ====================== + // WEBHOOK METHODS + // ====================== + + async createWebhook( + provider: string, + url: string, + events: string[] + ): Promise<{ id: string; secret: string }> { + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + throw new Error(`Provider ${provider} not found`); + } + + return providerInstance.createWebhook(url, events); + } + + async processWebhook( + provider: string, + payload: any, + signature: string + ): Promise { + const providerInstance = this.getProvider(provider); + if (!providerInstance) { + throw new Error(`Provider ${provider} not found`); + } + + // In a real implementation, you'd validate the signature and process the webhook + await providerInstance.processWebhook(payload); + } + + // ====================== + // CLEANUP METHODS + // ====================== + + async cleanup(): Promise { + // Clear expired export jobs + const now = new Date(); + for (const [jobId, job] of this.exportJobs.entries()) { + const ageInHours = + (now.getTime() - job.createdAt.getTime()) / (1000 * 60 * 60); + if (ageInHours > 24) { + // Remove jobs older than 24 hours + this.exportJobs.delete(jobId); + } + } + + // Clear provider instances (they'll be recreated as needed) + this.providers.clear(); + } + + getStats(): { + providers: number; + activeExportJobs: number; + completedExportJobs: number; + failedExportJobs: number; + } { + const exportJobsArray = Array.from(this.exportJobs.values()); + + return { + providers: this.providers.size, + activeExportJobs: exportJobsArray.filter( + (job) => job.status === "processing" + ).length, + completedExportJobs: exportJobsArray.filter( + (job) => job.status === "completed" + ).length, + failedExportJobs: exportJobsArray.filter((job) => job.status === "failed") + .length, + }; + } +} diff --git a/packages/ee/src/index.ts b/packages/ee/src/index.ts index 8a6f26b78..73b7d044d 100644 --- a/packages/ee/src/index.ts +++ b/packages/ee/src/index.ts @@ -26,8 +26,7 @@ export const integrations = [ ]; export { Onshape, Logo as OnshapeLogo } from "./onshape/config"; +export { QuickBooks } from "./quickbooks/config"; export { Slack } from "./slack/config"; export * from "./slack/lib/messages"; - -// TODO: export as @carbon/ee/paperless -export { PaperlessPartsClient } from "./paperless-parts/lib/client"; +export { Xero } from "./xero/config"; diff --git a/packages/ee/src/quickbooks/config.tsx b/packages/ee/src/quickbooks/config.tsx index dbc1046c3..02ca08a25 100644 --- a/packages/ee/src/quickbooks/config.tsx +++ b/packages/ee/src/quickbooks/config.tsx @@ -1,11 +1,12 @@ +import { QUICKBOOKS_CLIENT_ID } from "@carbon/auth"; import type { ComponentProps } from "react"; import { z } from 'zod/v3'; import type { IntegrationConfig } from "../types"; export const QuickBooks: IntegrationConfig = { name: "QuickBooks", - id: "quick-books", - active: false, + id: "quickbooks", + active: true, category: "Accounting", logo: Logo, description: @@ -15,6 +16,16 @@ export const QuickBooks: IntegrationConfig = { images: [], settings: [], schema: z.object({}), + oauth: { + authUrl: "https://appcenter.intuit.com/connect/oauth2", + clientId: QUICKBOOKS_CLIENT_ID, + redirectUri: "/api/integrations/quickbooks/oauth", + scopes: [ + "com.intuit.quickbooks.accounting", + "com.intuit.quickbooks.payment", + ], + tokenUrl: "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer", + }, }; function Logo(props: ComponentProps<"svg">) { diff --git a/packages/ee/src/slack/lib/client.ts b/packages/ee/src/slack/lib/client.ts index dc95f5b1a..2d9e891dc 100644 --- a/packages/ee/src/slack/lib/client.ts +++ b/packages/ee/src/slack/lib/client.ts @@ -14,11 +14,6 @@ import { const { App } = Bolt; -export const slackOAuthCallbackSchema = z.object({ - code: z.string(), - state: z.string(), -}); - export const slackOAuthTokenResponseSchema = z.object({ ok: z.literal(true), app_id: z.string(), @@ -53,9 +48,6 @@ export const slackOAuthTokenResponseSchema = z.object({ .optional(), }); -// Legacy export for backward compatibility - should be removed eventually -export const slackAuthResponseSchema = slackOAuthTokenResponseSchema; - let slackInstaller: InstallProvider | null = null; export const createSlackApp = ({ diff --git a/packages/ee/src/slack/lib/service.ts b/packages/ee/src/slack/lib/service.ts index dee787b64..1ca8b3e4c 100644 --- a/packages/ee/src/slack/lib/service.ts +++ b/packages/ee/src/slack/lib/service.ts @@ -112,8 +112,6 @@ export async function createIssueSlackThread( : []), ]; - console.log({ blocks, data }); - const threadMessage = await slackClient.chat.postMessage({ channel: auth.channelId, unfurl_links: false, diff --git a/packages/ee/src/types.ts b/packages/ee/src/types.ts index 4235a6f47..8e4a1bc79 100644 --- a/packages/ee/src/types.ts +++ b/packages/ee/src/types.ts @@ -19,6 +19,13 @@ export type IntegrationConfig = { value: unknown; }[]; schema: ZodType; + oauth?: { + authUrl: string; + clientId: string; + redirectUri: string; + scopes: string[]; + tokenUrl: string; + }; onInitialize?: () => void | Promise; onUninstall?: () => void | Promise; }; diff --git a/packages/ee/src/xero/config.tsx b/packages/ee/src/xero/config.tsx index c75963e49..2db594293 100644 --- a/packages/ee/src/xero/config.tsx +++ b/packages/ee/src/xero/config.tsx @@ -1,3 +1,4 @@ +import { XERO_CLIENT_ID } from "@carbon/auth"; import type { ComponentProps } from "react"; import { z } from 'zod/v3'; import type { IntegrationConfig } from "../types"; @@ -5,7 +6,7 @@ import type { IntegrationConfig } from "../types"; export const Xero: IntegrationConfig = { name: "Xero", id: "xero", - active: false, + active: true, category: "Accounting", logo: Logo, description: @@ -15,6 +16,18 @@ export const Xero: IntegrationConfig = { images: [], settings: [], schema: z.object({}), + oauth: { + authUrl: "https://login.xero.com/identity/connect/authorize", + clientId: XERO_CLIENT_ID, + redirectUri: "/api/integrations/xero/oauth", + scopes: [ + "offline_access", + "accounting.contacts", + "accounting.transactions", + "accounting.settings", + ], + tokenUrl: "https://login.xero.com/identity/connect/token", + }, }; function Logo(props: ComponentProps<"svg">) { diff --git a/packages/jobs/client.ts b/packages/jobs/client.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/jobs/trigger/accounting-sync.ts b/packages/jobs/trigger/accounting-sync.ts new file mode 100644 index 000000000..43dd1d610 --- /dev/null +++ b/packages/jobs/trigger/accounting-sync.ts @@ -0,0 +1,1096 @@ +import { getCarbonServiceRole } from "@carbon/auth"; +import type { Database } from "@carbon/database"; +import type { CoreProvider, ProviderConfig } from "@carbon/ee/accounting"; +import { QuickBooksProvider } from "@carbon/ee/quickbooks"; +import { XeroProvider } from "@carbon/ee/xero"; + +import type { SupabaseClient } from "@supabase/supabase-js"; +import { task } from "@trigger.dev/sdk/v3"; +import z from "zod"; + +export interface AccountingSyncPayload { + companyId: string; + provider: "quickbooks" | "xero" | "sage"; + syncType: "webhook" | "scheduled" | "trigger"; + syncDirection: "fromAccounting" | "toAccounting" | "bidirectional"; + entities: AccountingEntity[]; + metadata?: { + tenantId?: string; + webhookId?: string; + userId?: string; + [key: string]: any; + }; +} + +export interface AccountingEntity { + entityType: "customer" | "vendor" | "invoice" | "bill" | "payment" | "item"; + entityId: string; + operation: "create" | "update" | "delete" | "sync"; + externalId?: string; + syncToken?: string; + data?: any; +} + +const metadataSchema = z.object({ + refreshToken: z.string().optional(), + accessToken: z.string(), + expiresAt: z.string().datetime(), + tenantId: z.string().optional(), +}); + +export const accountingSyncTask = task({ + id: "accounting-sync", + run: async (payload: AccountingSyncPayload) => { + const { companyId, provider, syncDirection, entities, metadata } = payload; + + const client = getCarbonServiceRole(); + + // Get the company integration details + const companyIntegration = await client + .from("companyIntegration") + .select("*") + .eq("companyId", companyId) + .eq("id", provider) + .single(); + + if (companyIntegration.error || !companyIntegration.data) { + throw new Error( + `No ${provider} integration found for company ${companyId}` + ); + } + + const providerConfig = metadataSchema.safeParse( + companyIntegration.data.metadata + ); + + if (!providerConfig.success) { + console.error(providerConfig.error); + throw new Error("Invalid provider config"); + } + + const accountingProvider = await initializeProvider( + client, + companyId, + provider, + providerConfig.data + ); + + const results = { + success: [] as any[], + failed: [] as { entity: AccountingEntity; error: string }[], + }; + + for (const entity of entities) { + try { + const result = await processEntity( + client, + accountingProvider, + entity, + syncDirection, + companyId, + metadata + ); + results.success.push(result); + } catch (error) { + results.failed.push({ + entity, + error: error instanceof Error ? error.message : "Unknown error", + }); + console.error( + `Failed to process ${entity.entityType} ${entity.entityId}:`, + error + ); + } + } + + return results; + }, +}); + +async function initializeProvider( + client: SupabaseClient, + companyId: string, + provider: string, + config: z.infer +): Promise { + const { accessToken, refreshToken, tenantId } = config; + + // Create a callback function to update the integration metadata when tokens are refreshed + const onTokenRefresh = async (auth) => { + try { + const newMetadata = { + accessToken: auth.accessToken, + refreshToken: auth.refreshToken, + expiresAt: + auth.expiresAt?.toISOString() || + new Date(Date.now() + 3600000).toISOString(), // Default to 1 hour if not provided + tenantId: auth.tenantId || tenantId, + }; + + await client + .from("companyIntegration") + .update({ metadata: newMetadata }) + .eq("companyId", companyId) + .eq("id", provider); + + console.log( + `Updated ${provider} integration metadata for company ${companyId}` + ); + } catch (error) { + console.error( + `Failed to update ${provider} integration metadata:`, + error + ); + } + }; + + switch (provider) { + case "quickbooks": { + const config: ProviderConfig = { + clientId: process.env.QUICKBOOKS_CLIENT_ID!, + clientSecret: process.env.QUICKBOOKS_CLIENT_SECRET!, + redirectUri: process.env.QUICKBOOKS_REDIRECT_URI, + environment: + (process.env.QUICKBOOKS_ENVIRONMENT as "production" | "sandbox") || + "sandbox", + accessToken, + refreshToken, + tenantId, + companyId, + integrationId: provider, + onTokenRefresh, + }; + + const qbProvider = new QuickBooksProvider(config); + return qbProvider; + } + case "xero": { + const config: ProviderConfig = { + clientId: process.env.XERO_CLIENT_ID!, + clientSecret: process.env.XERO_CLIENT_SECRET!, + redirectUri: process.env.XERO_REDIRECT_URI, + accessToken, + refreshToken, + tenantId, + companyId, + integrationId: provider, + onTokenRefresh, + }; + + const xeroProvider = new XeroProvider(config); + return xeroProvider; + } + // Add other providers as needed + // case "sage": + // return new SageProvider(config); + default: + throw new Error(`Unsupported provider: ${provider}`); + } +} + +async function processEntity( + client: SupabaseClient, + provider: CoreProvider, + entity: AccountingEntity, + syncDirection: "fromAccounting" | "toAccounting" | "bidirectional", + companyId: string, + metadata?: any +): Promise { + const { entityType, entityId, operation, externalId } = entity; + + if (syncDirection === "fromAccounting" || syncDirection === "bidirectional") { + if ( + operation === "create" || + operation === "update" || + operation === "sync" + ) { + switch (entityType) { + case "customer": + return await syncCustomerFromAccounting( + client, + provider, + entityId, + companyId, + operation + ); + case "vendor": + return await syncVendorFromAccounting( + client, + provider, + entityId, + companyId, + operation + ); + case "invoice": + return await syncInvoiceFromAccounting( + client, + provider, + entityId, + companyId, + operation + ); + } + } else if (operation === "delete") { + switch (entityType) { + case "customer": + return await deactivateCustomer(client, companyId, externalId); + case "vendor": + return await deactivateSupplier(client, companyId, externalId); + } + } + } + + if (syncDirection === "toAccounting" || syncDirection === "bidirectional") { + if (operation === "create" || operation === "update") { + switch (entityType) { + case "customer": + return await syncCustomerToAccounting( + client, + provider, + entityId, + companyId, + externalId + ); + case "vendor": + return await syncVendorToAccounting( + client, + provider, + entityId, + companyId, + externalId + ); + } + } + } + + throw new Error( + `Unsupported operation ${operation} for ${entityType} in direction ${syncDirection}` + ); +} + +async function syncCustomerFromAccounting( + client: SupabaseClient, + provider: CoreProvider, + externalId: string, + companyId: string, + operation: "create" | "update" | "sync" +): Promise { + const accountingCustomer = await provider.getCustomer(externalId); + + const existingCustomer = await client + .from("customer") + .select("*") + .eq("companyId", companyId) + .eq("externalId->>externalId", externalId) + .single(); + + const providerName = provider.getProviderInfo().name.toLowerCase(); + + const customerData: Database["public"]["Tables"]["customer"]["Insert"] = { + companyId, + name: accountingCustomer.name ?? accountingCustomer.displayName, + phone: accountingCustomer.phone?.number || null, + website: accountingCustomer.website || null, + taxId: accountingCustomer.taxNumber || null, + currencyCode: accountingCustomer.currency || "USD", + externalId: { + externalId, + syncToken: (accountingCustomer as any).syncToken, + accountingProvider: provider.getProviderInfo().name, + lastSyncedAt: new Date().toISOString(), + accountingData: { + displayName: accountingCustomer.displayName, + balance: accountingCustomer.balance, + creditLimit: accountingCustomer.creditLimit, + paymentTerms: accountingCustomer.paymentTerms, + }, + }, + updatedAt: new Date().toISOString(), + }; + + let customerId: string; + + if (existingCustomer.data) { + // Update existing customer + const result = await client + .from("customer") + .update(customerData) + .eq("id", existingCustomer.data.id) + .select() + .single(); + + if (result.error) throw result.error; + customerId = result.data.id; + } else { + // Create new customer + const result = await client + .from("customer") + .insert({ + ...customerData, + createdAt: new Date().toISOString(), + }) + .select() + .single(); + + if (result.error) throw result.error; + customerId = result.data.id; + } + + // Sync contact information if email is available + if (accountingCustomer.email) { + try { + // Check if contact already exists by external ID + const existingContactQuery = await client + .from("contact") + .select( + ` + id, + customerContact!inner( + id, + customerId + ) + ` + ) + .eq("companyId", companyId) + .eq(`externalId->>${providerName}Id`, externalId) + .single(); + + let contactId: string; + + if (!existingContactQuery.data) { + // Try to find contact by email + const contactByEmail = await client + .from("contact") + .select("id") + .eq("companyId", companyId) + .eq("email", accountingCustomer.email) + .eq("isCustomer", true) + .maybeSingle(); + + if (contactByEmail.data) { + contactId = contactByEmail.data.id; + + // Update the contact with external ID + await client + .from("contact") + .update({ + externalId: { + [`${providerName}Id`]: externalId, + }, + }) + .eq("id", contactId); + } else { + // Create new contact + const firstName = accountingCustomer.firstName; + const lastName = accountingCustomer.lastName; + + const newContact = await client + .from("contact") + .insert({ + companyId, + firstName, + lastName, + email: accountingCustomer.email, + isCustomer: true, + workPhone: accountingCustomer.phone?.number || null, + externalId: { + [`${providerName}Id`]: externalId, + }, + }) + .select() + .single(); + + if (newContact.error) { + console.error("Failed to create contact:", newContact.error); + } else { + contactId = newContact.data.id; + } + } + + // Create customerContact relationship if contact was created/found + if (contactId) { + // Check if customerContact already exists + const existingCustomerContact = await client + .from("customerContact") + .select("id") + .eq("customerId", customerId) + .eq("contactId", contactId) + .maybeSingle(); + + if (!existingCustomerContact.data) { + const newCustomerContact = await client + .from("customerContact") + .insert({ + customerId, + contactId, + }) + .select() + .single(); + + if (newCustomerContact.error) { + console.error( + "Failed to create customerContact:", + newCustomerContact.error + ); + } + } + } + } else { + // Contact exists, check if linked to this customer + const customerContact = existingContactQuery.data.customerContact?.find( + (cc: any) => cc.customerId === customerId + ); + + if (!customerContact) { + // Link existing contact to customer + const newCustomerContact = await client + .from("customerContact") + .insert({ + customerId, + contactId: existingContactQuery.data.id, + }) + .select() + .single(); + + if (newCustomerContact.error) { + console.error( + "Failed to link contact to customer:", + newCustomerContact.error + ); + } + } + } + } catch (error) { + console.error("Error syncing customer contact:", error); + } + } + + // Sync addresses if available + if (accountingCustomer.addresses && accountingCustomer.addresses.length > 0) { + for (const address of accountingCustomer.addresses) { + // Skip if no valid address data (need at least street and city or state) + if (!address.street || (!address.city && !address.state)) { + console.log( + "Skipping address creation - missing required fields (street and city/state)" + ); + continue; + } + + if (address.country && address.country.length === 3) { + const country = await client + .from("country") + .select("alpha2") + .eq("alpha3", address.country) + .maybeSingle(); + if (country.data) { + address.country = country.data?.alpha2; + } + } + + if (address.country && address.country.length > 3) { + const country = await client + .from("country") + .select("alpha2") + .eq("name", address.country) + .maybeSingle(); + if (country.data) { + address.country = country.data?.alpha2; + } + } + + const addressData = { + companyId, + addressLine1: address.street, + addressLine2: address.street2 || null, + city: address.city || "", + stateProvince: address.state || null, + postalCode: address.postalCode || null, + countryCode: address.country || null, + }; + + // Check if address exists + const existingAddress = await client + .from("address") + .select("*") + .eq("companyId", companyId) + .eq("addressLine1", addressData.addressLine1) + .eq("city", addressData.city) + .maybeSingle(); + + let addressId: string; + + if (existingAddress?.data) { + addressId = existingAddress.data.id; + } else { + const newAddress = await client + .from("address") + .insert(addressData) + .select() + .single(); + + if (newAddress.error) throw newAddress.error; + addressId = newAddress.data.id; + } + + // Link address to customer + const locationType = address.type === "billing" ? "Billing" : "Shipping"; + + const customerLocation = await client.from("customerLocation").upsert( + { + customerId, + addressId, + name: locationType, + }, + { + onConflict: "addressId,customerId", + ignoreDuplicates: true, + } + ); + + if (customerLocation.error) console.error(customerLocation.error); + } + } + + return { + customerId, + externalId, + operation, + status: "success", + }; +} + +async function syncVendorFromAccounting( + client: SupabaseClient, + provider: CoreProvider, + externalId: string, + companyId: string, + operation: "create" | "update" | "sync" +): Promise { + // Fetch vendor from accounting system + const accountingVendor = await provider.getVendor(externalId); + + // Check if supplier exists in Carbon (by external ID) + const existingSupplier = await client + .from("supplier") + .select("*") + .eq("companyId", companyId) + .eq("externalId->>externalId", externalId) + .single(); + + const providerName = provider.getProviderInfo().name.toLowerCase(); + + const supplierData: Database["public"]["Tables"]["supplier"]["Insert"] = { + companyId, + name: accountingVendor.name ?? accountingVendor.displayName, + phone: accountingVendor.phone?.number || null, + website: accountingVendor.website || null, + taxId: accountingVendor.taxNumber || null, + currencyCode: accountingVendor.currency || "USD", + externalId: { + externalId, + syncToken: (accountingVendor as any).syncToken, + accountingProvider: provider.getProviderInfo().name, + lastSyncedAt: new Date().toISOString(), + accountingData: { + displayName: accountingVendor.displayName, + balance: accountingVendor.balance, + paymentTerms: accountingVendor.paymentTerms, + }, + }, + updatedAt: new Date().toISOString(), + }; + + let supplierId: string; + + if (existingSupplier.data) { + // Update existing supplier + const result = await client + .from("supplier") + .update(supplierData) + .eq("id", existingSupplier.data.id) + .select() + .single(); + + if (result.error) throw result.error; + supplierId = result.data.id; + } else { + // Create new supplier + const result = await client + .from("supplier") + .insert({ + ...supplierData, + createdAt: new Date().toISOString(), + }) + .select() + .single(); + + if (result.error) throw result.error; + supplierId = result.data.id; + } + + // Sync contact information if email is available + if (accountingVendor.email) { + try { + // Check if contact already exists by external ID + const existingContactQuery = await client + .from("contact") + .select( + ` + id, + supplierContact!inner( + id, + supplierId + ) + ` + ) + .eq("companyId", companyId) + .eq(`externalId->>${providerName}Id`, externalId) + .single(); + + let contactId: string; + + if (!existingContactQuery.data) { + // Try to find contact by email + const contactByEmail = await client + .from("contact") + .select("id") + .eq("companyId", companyId) + .eq("email", accountingVendor.email) + .eq("isCustomer", false) + .maybeSingle(); + + if (contactByEmail.data) { + contactId = contactByEmail.data.id; + + // Update the contact with external ID + await client + .from("contact") + .update({ + externalId: { + [`${providerName}Id`]: externalId, + }, + }) + .eq("id", contactId); + } else { + // Create new contact + const firstName = accountingVendor.firstName; + const lastName = accountingVendor.lastName; + + const newContact = await client + .from("contact") + .insert({ + companyId, + firstName, + lastName, + email: accountingVendor.email, + isCustomer: false, + workPhone: accountingVendor.phone?.number || null, + externalId: { + [`${providerName}Id`]: externalId, + }, + }) + .select() + .single(); + + if (newContact.error) { + console.error("Failed to create contact:", newContact.error); + } else { + contactId = newContact.data.id; + } + } + + // Create supplierContact relationship if contact was created/found + if (contactId) { + // Check if supplierContact already exists + const existingSupplierContact = await client + .from("supplierContact") + .select("id") + .eq("supplierId", supplierId) + .eq("contactId", contactId) + .maybeSingle(); + + if (!existingSupplierContact.data) { + const newSupplierContact = await client + .from("supplierContact") + .insert({ + supplierId, + contactId, + }) + .select() + .single(); + + if (newSupplierContact.error) { + console.error( + "Failed to create supplierContact:", + newSupplierContact.error + ); + } + } + } + } else { + // Contact exists, check if linked to this supplier + const supplierContact = existingContactQuery.data.supplierContact?.find( + (sc: any) => sc.supplierId === supplierId + ); + + if (!supplierContact) { + // Link existing contact to supplier + const newSupplierContact = await client + .from("supplierContact") + .insert({ + supplierId, + contactId: existingContactQuery.data.id, + }) + .select() + .single(); + + if (newSupplierContact.error) { + console.error( + "Failed to link contact to supplier:", + newSupplierContact.error + ); + } + } + } + } catch (error) { + console.error("Error syncing supplier contact:", error); + } + } + + if (accountingVendor.addresses && accountingVendor.addresses.length > 0) { + for (const address of accountingVendor.addresses) { + // Skip if no valid address data (need at least street and city or state) + if (!address.street || (!address.city && !address.state)) { + console.log( + "Skipping address creation - missing required fields (street and city/state)" + ); + continue; + } + + if (address.country && address.country.length === 3) { + const country = await client + .from("country") + .select("alpha2") + .eq("alpha3", address.country) + .maybeSingle(); + if (country.data) { + address.country = country.data?.alpha2; + } + } + + if (address.country && address.country.length > 3) { + const country = await client + .from("country") + .select("alpha2") + .eq("name", address.country) + .maybeSingle(); + if (country.data) { + address.country = country.data?.alpha2; + } + } + const addressData = { + companyId, + addressLine1: address.street, + addressLine2: address.street2 || null, + city: address.city || "", + stateProvince: address.state || null, + postalCode: address.postalCode || null, + countryCode: address.country || null, + }; + + // Check if address exists + const existingAddress = await client + .from("address") + .select("*") + .eq("companyId", companyId) + .eq("addressLine1", addressData.addressLine1) + .eq("city", addressData.city) + .maybeSingle(); + + let addressId: string; + + if (existingAddress?.data) { + addressId = existingAddress.data.id; + } else { + const newAddress = await client + .from("address") + .insert(addressData) + .select() + .single(); + + if (newAddress.error) throw newAddress.error; + addressId = newAddress.data.id; + } + + // Link address to supplier + const locationType = address.type === "billing" ? "Billing" : "Shipping"; + + const supplierLocation = await client.from("supplierLocation").upsert( + { + supplierId, + addressId, + name: locationType, + }, + { + onConflict: "addressId,supplierId", + } + ); + + if (supplierLocation.error) console.error(supplierLocation.error); + } + } + + return { + supplierId, + externalId, + operation, + status: "success", + }; +} + +async function syncCustomerToAccounting( + client: SupabaseClient, + provider: CoreProvider, + customerId: string, + companyId: string, + externalId?: string +): Promise { + // Fetch customer from Carbon + const customer = await client + .from("customer") + .select("*, customerLocation(*, address(*))") + .eq("id", customerId) + .eq("companyId", companyId) + .single(); + + if (customer.error || !customer.data) { + throw new Error(`Customer ${customerId} not found`); + } + + // Transform Carbon customer to accounting format + const accountingCustomerData = { + name: customer.data.name, + phone: customer.data.phone + ? { number: customer.data.phone, type: "work" as any } + : undefined, + website: customer.data.website || undefined, + taxNumber: customer.data.taxId || undefined, + currency: customer.data.currencyCode || "USD", + isActive: true, + addresses: (customer.data.customerLocation || []) + .map((loc: any) => ({ + type: loc.name === "Billing" ? "billing" : "shipping", + street: loc.address?.addressLine1, + street2: loc.address?.addressLine2, + city: loc.address?.city, + state: loc.address?.state, + postalCode: loc.address?.postalCode, + country: loc.address?.country, + })) + .filter((addr: any) => addr.street), + }; + + let result; + if (externalId) { + // Update existing customer in accounting system + result = await provider.updateCustomer(externalId, accountingCustomerData); + } else { + // Create new customer in accounting system + result = await provider.createCustomer(accountingCustomerData); + } + + // Update Carbon customer with external ID + await client + .from("customer") + .update({ + externalId: { + ...((customer.data.externalId as any) || {}), + externalId: result.id, + syncToken: (result as any).syncToken, + accountingProvider: provider.getProviderInfo().name, + lastSyncedAt: new Date().toISOString(), + }, + updatedAt: new Date().toISOString(), + }) + .eq("id", customerId); + + return { + customerId, + externalId: result.id, + operation: externalId ? "update" : "create", + status: "success", + }; +} + +async function syncVendorToAccounting( + client: SupabaseClient, + provider: CoreProvider, + supplierId: string, + companyId: string, + externalId?: string +): Promise { + // Fetch supplier from Carbon + const supplier = await client + .from("supplier") + .select("*, supplierLocation(*, address(*))") + .eq("id", supplierId) + .eq("companyId", companyId) + .single(); + + if (supplier.error || !supplier.data) { + throw new Error(`Supplier ${supplierId} not found`); + } + + // Transform Carbon supplier to accounting vendor format + const accountingVendorData = { + name: supplier.data.name, + email: undefined, + phone: supplier.data.phone + ? { number: supplier.data.phone, type: "work" as any } + : undefined, + website: supplier.data.website || undefined, + taxNumber: supplier.data.taxId || undefined, + currency: supplier.data.currencyCode || "USD", + isActive: true, + addresses: (supplier.data.supplierLocation || []) + .map((loc: any) => ({ + type: "billing" as any, + street: loc.address?.addressLine1, + street2: loc.address?.addressLine2, + city: loc.address?.city, + state: loc.address?.state, + postalCode: loc.address?.postalCode, + country: loc.address?.country, + })) + .filter((addr: any) => addr.street), + }; + + let result; + if (externalId) { + // Update existing vendor in accounting system + result = await provider.updateVendor(externalId, accountingVendorData); + } else { + // Create new vendor in accounting system + result = await provider.createVendor(accountingVendorData); + } + + // Update Carbon supplier with external ID + await client + .from("supplier") + .update({ + externalId: { + ...((supplier.data.externalId as any) || {}), + externalId: result.id, + syncToken: (result as any).syncToken, + accountingProvider: provider.getProviderInfo().name, + lastSyncedAt: new Date().toISOString(), + }, + updatedAt: new Date().toISOString(), + }) + .eq("id", supplierId); + + return { + supplierId, + externalId: result.id, + operation: externalId ? "update" : "create", + status: "success", + }; +} + +async function deactivateCustomer( + client: SupabaseClient, + companyId: string, + externalId?: string +): Promise { + if (!externalId) { + throw new Error("External ID required for deactivation"); + } + + const result = await client + .from("customer") + .update({ + active: false, + updatedAt: new Date().toISOString(), + }) + .eq("companyId", companyId) + .eq("externalId->>externalId", externalId) + .select() + .single(); + + if (result.error) throw result.error; + + return { + customerId: result.data.id, + externalId, + operation: "delete", + status: "deactivated", + }; +} + +async function deactivateSupplier( + client: SupabaseClient, + companyId: string, + externalId?: string +): Promise { + if (!externalId) { + throw new Error("External ID required for deactivation"); + } + + const result = await client + .from("supplier") + .update({ + active: false, + updatedAt: new Date().toISOString(), + }) + .eq("companyId", companyId) + .eq("externalId->>externalId", externalId) + .select() + .single(); + + if (result.error) throw result.error; + + return { + supplierId: result.data.id, + externalId, + operation: "delete", + status: "deactivated", + }; +} + +async function syncInvoiceFromAccounting( + client: SupabaseClient, + provider: CoreProvider, + externalId: string, + companyId: string, + operation: "create" | "update" | "sync" +): Promise { + // Fetch invoice from accounting system + const invoiceData = await provider.getInvoice(externalId); + const accountingInvoice = invoiceData.Invoices?.[0]; + + if (!accountingInvoice) { + throw new Error(`Invoice ${externalId} not found`); + } + + console.log( + `Processing invoice ${externalId} of type ${accountingInvoice.Type} for contact ${accountingInvoice.Contact?.ContactID}` + ); + + // Invoice processing would typically include: + // 1. Creating/updating the invoice record + // 2. Processing line items + // 3. Handling payments and status updates + + // For now, we'll focus on ensuring the related contact is synced + // The actual invoice sync logic would be implemented based on your invoice table schema + + return { + invoiceId: externalId, + contactId: accountingInvoice.Contact?.ContactID, + invoiceType: accountingInvoice.Type, + operation, + status: "processed", + message: + "Invoice processed, related contact should be synced via webhook handler", + }; +} diff --git a/packages/react/src/Select.tsx b/packages/react/src/Select.tsx index 80df7f859..f0251cedf 100644 --- a/packages/react/src/Select.tsx +++ b/packages/react/src/Select.tsx @@ -27,7 +27,7 @@ const selectTriggerVariants = cva( size: { lg: "h-12 px-4 py-3 rounded-lg text-base space-x-4", md: "h-10 px-3 py-2 rounded-md text-sm space-x-3", - sm: "h-8 px-3 py-2 rounded text-sm text-xs space-x-2", + sm: "h-8 px-3 py-2 rounded text-sm text-sm space-x-2", }, }, defaultVariants: { diff --git a/sst.config.ts b/sst.config.ts index 10d31f774..554fea4a5 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -57,6 +57,9 @@ export default $config({ OPENAI_API_KEY: process.env.OPENAI_API_KEY, POSTHOG_API_HOST: process.env.POSTHOG_API_HOST, POSTHOG_PROJECT_PUBLIC_KEY: process.env.POSTHOG_PROJECT_PUBLIC_KEY, + QUICKBOOKS_CLIENT_ID: process.env.QUICKBOOKS_CLIENT_ID, + QUICKBOOKS_CLIENT_SECRET: process.env.QUICKBOOKS_CLIENT_SECRET, + QUICKBOOKS_WEBHOOK_SECRET: process.env.QUICKBOOKS_WEBHOOK_SECRET, RESEND_API_KEY: process.env.RESEND_API_KEY, SESSION_SECRET: process.env.SESSION_SECRET, SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN, @@ -81,6 +84,9 @@ export default $config({ UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL, VERCEL_ENV: "production", VERCEL_URL: process.env.URL_ERP ?? "itar.carbon.ms", + XERO_CLIENT_ID: process.env.XERO_CLIENT_ID, + XERO_CLIENT_SECRET: process.env.XERO_CLIENT_SECRET, + XERO_WEBHOOK_SECRET: process.env.XERO_WEBHOOK_SECRET, }, transform: { loadBalancer: {