Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/app/api/quickbooks/accounts/accounts.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import authenticate from '@/app/api/core/utils/authenticate'
import { NextRequest, NextResponse } from 'next/server'
import httpStatus from 'http-status'
import { AccountService } from '@/app/api/quickbooks/accounts/accounts.service'
import { TokenService } from '@/app/api/quickbooks/token/token.service'
import { AccountRefsUpdateSchema } from '@/type/common'
import { QBPortalConnectionSelectSchema } from '@/db/schema/qbPortalConnections'

// Explicit allowlist of what is safe to return — anything else (especially
// token/secret fields) is dropped. Adding a new column to QBPortalConnection
// does NOT widen this surface unless the column is added here too.
const SafePortalConnectionSchema = QBPortalConnectionSelectSchema.pick({
id: true,
portalId: true,
incomeAccountRef: true,
expenseAccountRef: true,
assetAccountRef: true,
})

export async function listAccounts(req: NextRequest) {
const user = await authenticate(req)
const service = new AccountService(user)
const accountsResponse = await service.listAccountsForProductMapping()
return NextResponse.json(accountsResponse)
}

export async function updateAccountRefs(req: NextRequest) {
const user = await authenticate(req)
const body = await req.json()
const accountRefs = AccountRefsUpdateSchema.parse(body)

const service = new TokenService(user)
const updatedConnection = await service.updateAccountRefs(accountRefs)

return NextResponse.json(
{ portalConnection: SafePortalConnectionSchema.parse(updatedConnection) },
{ status: httpStatus.OK },
)
}
27 changes: 27 additions & 0 deletions src/app/api/quickbooks/accounts/accounts.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { BaseService } from '@/app/api/core/services/base.service'
import { getPortalTokens } from '@/db/service/token.service'
import IntuitAPI from '@/utils/intuitAPI'
import { AccountsListResponse } from '@/type/common'

export class AccountService extends BaseService {
async listAccountsForProductMapping(): Promise<AccountsListResponse> {
const tokens = await getPortalTokens(this.user.workspaceId)

const intuitApi = new IntuitAPI(tokens)
const { income, expense, asset } =
await intuitApi.getAccountsForProductMapping()

return {
options: {
income: income.map((a) => ({ id: a.Id, name: a.Name })),
expense: expense.map((a) => ({ id: a.Id, name: a.Name })),
asset: asset.map((a) => ({ id: a.Id, name: a.Name })),
},
selected: {
incomeAccountRef: tokens.incomeAccountRef,
expenseAccountRef: tokens.expenseAccountRef,
assetAccountRef: tokens.assetAccountRef,
},
Comment thread
SandipBajracharya marked this conversation as resolved.
}
}
}
10 changes: 10 additions & 0 deletions src/app/api/quickbooks/accounts/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler'
import {
listAccounts,
updateAccountRefs,
} from '@/app/api/quickbooks/accounts/accounts.controller'

export const maxDuration = 300 // 5 minutes

export const GET = withErrorHandler(listAccounts)
export const PATCH = withErrorHandler(updateAccountRefs)
23 changes: 22 additions & 1 deletion src/app/api/quickbooks/token/token.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import {
} from '@/db/schema/qbPortalConnections'
import { QBSetting, QBSettingsUpdateSchemaType } from '@/db/schema/qbSettings'
import { getPortalConnection } from '@/db/service/token.service'
import { AccountType, ChangeEnableStatusRequestType } from '@/type/common'
import {
AccountRefsUpdateSchema,
AccountRefsUpdateType,
AccountType,
ChangeEnableStatusRequestType,
} from '@/type/common'
import IntuitAPI from '@/utils/intuitAPI'
import CustomLogger from '@/utils/logger'
import dayjs from 'dayjs'
Expand Down Expand Up @@ -90,6 +95,22 @@ export class TokenService extends BaseService {
return token
}

async updateAccountRefs(payload: AccountRefsUpdateType) {
const accountRefs = AccountRefsUpdateSchema.parse(payload)

const updatedConnection = await this.updateQBPortalConnection(
accountRefs,
eq(QBPortalConnection.portalId, this.user.workspaceId),
)
Comment thread
SandipBajracharya marked this conversation as resolved.
if (!updatedConnection) {
throw new APIError(
httpStatus.INTERNAL_SERVER_ERROR,
'TokenService#updateAccountRefs | portal connection row not found during update',
)
}
return updatedConnection
}

async turnOffSync(intuitRealmId: string) {
const portalId = this.user.workspaceId
// update db sync status for the defined portal
Expand Down
44 changes: 44 additions & 0 deletions src/components/dashboard/settings/SettingAccordion.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useApp } from '@/app/context/AppContext'
import InvoiceDetail from '@/components/dashboard/settings/sections/invoice/InvoiceDetail'
import AccountMapping from '@/components/dashboard/settings/sections/account/AccountMapping'
import ProductMapping from '@/components/dashboard/settings/sections/product/ProductMapping'
import Accordion from '@/components/ui/Accordion'
import Divider from '@/components/ui/Divider'
import {
useInvoiceDetailSettings,
useAccountMapping,
useProductMappingSettings,
useSettings,
} from '@/hook/useSettings'
Expand Down Expand Up @@ -44,6 +46,18 @@ export default function SettingAccordion({
showButton: showInvoiceButton,
} = useInvoiceDetailSettings()

const {
options: accountOptions,
settingState: accountMappingState,
changeSettings: changeAccountMapping,
submitAccountMapping,
cancelAccountMapping,
isLoading: accountMappingIsLoading,
error: accountMappingError,
showButton: showAccountMappingButton,
isDisconnected: accountMappingIsDisconnected,
} = useAccountMapping()

const accordionItems = [
{
id: 'product-mapping',
Expand Down Expand Up @@ -75,6 +89,20 @@ export default function SettingAccordion({
/>
),
},
{
id: 'account-mapping',
header: 'Account Mapping',
content: (
<AccountMapping
options={accountOptions}
settingState={accountMappingState}
changeSettings={changeAccountMapping}
isLoading={accountMappingIsLoading}
error={accountMappingError}
isDisconnected={accountMappingIsDisconnected}
/>
),
},
]
const { openItems, setOpenItems } = useSettings()

Expand Down Expand Up @@ -142,6 +170,22 @@ export default function SettingAccordion({
/>
</>
)}
{index === 2 && syncFlag && showAccountMappingButton && (
<>
<Button
label="Cancel"
variant="text"
className="me-2"
onClick={cancelAccountMapping}
/>
<Button
label="Update Setting"
variant="primary"
prefixIcon="Check"
onClick={submitAccountMapping}
/>
</>
)}
</div>
<Accordion
item={item}
Expand Down
168 changes: 168 additions & 0 deletions src/components/dashboard/settings/sections/account/AccountMapping.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { AccountsListResponseUi, AccountMappingState } from '@/hook/useSettings'
import useClickOutside from '@/hook/useClickOutside'
import { AccountOption } from '@/type/common'
import { Icon, Spinner } from 'copilot-design-system'
import { useRef, useState } from 'react'

type AccountMappingProps = {
options: AccountsListResponseUi['options'] | undefined
settingState: AccountMappingState
changeSettings: (field: keyof AccountMappingState, value: string) => void
isLoading: boolean
error: unknown
isDisconnected: boolean
}

function AccountSelect({
label,
description,
value,
options,
placeholder,
onChange,
}: {
label: string
description: string
value: string
options: AccountOption[] | undefined
placeholder: string
onChange: (id: string) => void
}) {
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)

useClickOutside(dropdownRef, () => setIsOpen(false), [buttonRef])

const disabled = !options || options.length === 0
const selected = options?.find((o) => o.id === value)
// Defends against an account being deleted in QBO between load and save —
// surfaces the stale id with a hint instead of silently snapping to empty.
const optionMissing = !!value && !selected

const labelId = `account-select-label-${label.replace(/\s+/g, '-').toLowerCase()}`
return (
<div className="mb-5">
<label id={labelId} className="block text-sm font-medium mb-1">
{label}
</label>
<p className="text-xs text-gray-500 mb-2">{description}</p>
<div className="relative">
<button
ref={buttonRef}
type="button"
disabled={disabled}
onClick={() => setIsOpen((v) => !v)}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-labelledby={labelId}
className="w-full bg-gray-100 hover:bg-gray-150 grid grid-cols-6 md:grid-cols-14 py-2 pl-4 pr-3 border border-gray-200 rounded text-left disabled:opacity-50 focus:outline-none focus:border-gray-200"
>
<div className="col-span-5 md:col-span-13 text-sm text-gray-600 break-all lg:break-normal">
{selected ? (
selected.name
) : optionMissing ? (
<span className="text-gray-500">
Please select {label.toLowerCase()}
</span>
) : (
<span className="text-gray-400">
{disabled ? 'No matching accounts in QuickBooks' : placeholder}
</span>
)}
</div>
<div className="col-span-1 ml-auto my-auto">
<Icon
icon="ChevronDown"
width={16}
height={16}
className="text-gray-500"
/>
</div>
</button>
{isOpen && !disabled && (
<div
ref={dropdownRef}
role="listbox"
aria-labelledby={labelId}
className="absolute right-0 left-0 top-full mt-[-1px] bg-white border border-gray-150 !shadow-popover-050 rounded-sm z-100"
>
<div className="max-h-56 overflow-y-auto">
{options?.map((o) => (
<button
key={o.id}
type="button"
role="option"
aria-selected={o.id === value}
onClick={() => {
onChange(o.id)
setIsOpen(false)
}}
className="w-full px-3 py-1.5 text-sm hover:bg-gray-100 focus:outline-none transition-colors cursor-pointer text-left text-gray-600 line-clamp-1 break-all lg:break-normal"
>
{o.name}
</button>
))}
</div>
</div>
)}
</div>
</div>
)
}

export default function AccountMapping({
options,
settingState,
changeSettings,
isLoading,
error,
isDisconnected,
}: AccountMappingProps) {
if (isDisconnected) {
return (
<div className="mt-2 mb-6 text-sm text-gray-600">
Connect to QuickBooks to manage account settings.
</div>
)
}

if (isLoading) return <Spinner size={5} />

if (error) {
return (
<div className="mt-2 mb-6 text-sm text-red-600">
Could not load accounts. Reload to retry.
</div>
)
}

return (
<div className="mt-2 mb-6">
<AccountSelect
label="Income account"
description="Default income account assigned to services synced from Assembly to QuickBooks."
value={settingState.incomeAccountRef}
options={options?.income}
placeholder="Select an income account"
onChange={(id) => changeSettings('incomeAccountRef', id)}
/>
<AccountSelect
label="Expense account"
description="Account where absorbed invoice payment fees are recorded as expenses in QuickBooks."
value={settingState.expenseAccountRef}
options={options?.expense}
placeholder="Select an expense account"
onChange={(id) => changeSettings('expenseAccountRef', id)}
/>
<AccountSelect
label="Bank account"
description="Account the absorbed invoice payment fees are paid out of, paired with the expense account above."
value={settingState.assetAccountRef}
options={options?.asset}
placeholder="Select a bank account"
onChange={(id) => changeSettings('assetAccountRef', id)}
/>
</div>
)
}
5 changes: 4 additions & 1 deletion src/db/service/token.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
'use server'
import APIError from '@/app/api/core/exceptions/api'
import { db } from '@/db'
import {
PortalConnectionWithSettingType,
Expand All @@ -10,6 +11,7 @@ import { WorkspaceResponse } from '@/type/common'
import { CopilotAPI } from '@/utils/copilotAPI'
import { IntuitAPITokensType } from '@/utils/intuitAPI'
import { and, asc, eq, isNotNull, isNull, sql } from 'drizzle-orm'
import httpStatus from 'http-status'

export const getPortalConnection = async (
portalId: string,
Expand Down Expand Up @@ -108,7 +110,8 @@ export const getPortalTokens = async (
portalId: string,
): Promise<IntuitAPITokensType> => {
const portalConnection = await getPortalConnection(portalId)
if (!portalConnection) throw new Error('Portal connection not found')
if (!portalConnection)
throw new APIError(httpStatus.NOT_FOUND, 'Portal connection not found')

return {
accessToken: portalConnection.accessToken,
Expand Down
Loading
Loading