Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a3a3cd3
improvement(OUT-1958): Hide the action menu when the app is disconnec…
SandipBajracharya Jul 10, 2025
baa798b
enhancement(OUT-1958): hide the 'Disconnect app' menu when sync fails
SandipBajracharya Jul 10, 2025
9628527
fix(OUT-2025): QB item's price is not mapping for the product
SandipBajracharya Jul 15, 2025
c290db0
improvement(OUT-2028): UI improvements for initial connection and re-…
SandipBajracharya Jul 16, 2025
f932633
Merged branch preview to OUT-2025. Resolved conflict
SandipBajracharya Jul 16, 2025
f6bc4e2
fix(OUT-2025): update zod validation
SandipBajracharya Jul 16, 2025
f55e889
update(OUT-2025): removed ternary condition
SandipBajracharya Jul 16, 2025
bb5309c
fix(OUT-1932): checkmark icon size
SandipBajracharya Jul 18, 2025
3991a94
fix(OUT-2025): fix tax amount value in logs
SandipBajracharya Jul 18, 2025
5f3ab37
improvement(OUT-2027): Close dropdown popover when clicking away
SandipBajracharya Jul 18, 2025
e06457b
update(OUT-2038): Change email notification subject and header
SandipBajracharya Jul 21, 2025
3ca7a1b
Merge branch 'preview' of github.com:copilot-platforms/quickbooks-syn…
SandipBajracharya Jul 21, 2025
77aeab2
refactor(OUT-1958): removed multiple portal connection flags
SandipBajracharya Jul 21, 2025
47ef26b
Merge pull request #77 from copilot-platforms/OUT-2038
SandipBajracharya Jul 21, 2025
89bc2e5
Merge pull request #76 from copilot-platforms/OUT-2027
SandipBajracharya Jul 21, 2025
2574721
Merge pull request #71 from copilot-platforms/OUT-2025
SandipBajracharya Jul 21, 2025
8a95bb2
improvement(OUT-1798): Sort product list by price in ascending order,…
SandipBajracharya Jul 21, 2025
bf7c207
add(preview): logs to track the notification party and token
SandipBajracharya Jul 22, 2025
8a86c22
Merge branch 'OUT-1958' of github.com:copilot-platforms/quickbooks-sy…
SandipBajracharya Jul 22, 2025
728c569
Merge branch 'OUT-2028' of github.com:copilot-platforms/quickbooks-sy…
SandipBajracharya Jul 22, 2025
5481dc3
Merge branch 'OUT-1932' of github.com:copilot-platforms/quickbooks-sy…
SandipBajracharya Jul 22, 2025
0683af0
Merge branch 'OUT-1798' of github.com:copilot-platforms/quickbooks-sy…
SandipBajracharya Jul 22, 2025
7eb3228
fix(OUT-2052): Amount-related issues
SandipBajracharya Jul 22, 2025
4f8fb83
Merge pull request #78 from copilot-platforms/OUT-1798
SandipBajracharya Jul 22, 2025
88e308a
Merge pull request #75 from copilot-platforms/OUT-1932
SandipBajracharya Jul 22, 2025
26bc29f
Merge pull request #68 from copilot-platforms/OUT-1958
SandipBajracharya Jul 22, 2025
cf9da2e
Merge pull request #73 from copilot-platforms/OUT-2028
SandipBajracharya Jul 23, 2025
4cf1aa4
Merge branch 'preview' of github.com:copilot-platforms/quickbooks-syn…
SandipBajracharya Jul 23, 2025
27db547
Merge pull request #79 from copilot-platforms/OUT-2052
SandipBajracharya Jul 23, 2025
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
33 changes: 24 additions & 9 deletions src/app/(home)/HomeClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,46 @@ import { useAppBridge, useQuickbooks } from '@/hook/useQuickbooks'
import { Button, Spinner } from 'copilot-design-system'

export default function HomeClient() {
const { token, tokenPayload, reconnect, portalConnectionStatus, isEnabled } =
useApp()
const {
token,
tokenPayload,
reconnect,
portalConnectionStatus,
isEnabled,
syncFlag,
} = useApp()

const { loading, handleConnect, hasConnection, isReconnecting } =
useQuickbooks(token, tokenPayload, reconnect)
const { loading, handleConnect, isReconnecting } = useQuickbooks(
token,
tokenPayload,
reconnect,
)

// bridge related logics like disconnect app and download sync log csv
useAppBridge(token, isEnabled)
useAppBridge({
token,
isEnabled,
syncFlag,
connectionStatus: portalConnectionStatus || false,
})

if (hasConnection === null) {
if (portalConnectionStatus === null) {
return (
<SilentError
message="Error connecting to QuickBooks"
message="Something went wrong while connecting to QuickBooks"
resetFn={handleConnect}
/>
)
}

return (
<div className="home-client-wrapper w-full h-full">
{hasConnection || portalConnectionStatus ? (
{portalConnectionStatus ? (
<>
{isReconnecting && (
<div>
Reconnecting to QuickBooks <Spinner size={5} />
<span className="me-2">Reconnecting to QuickBooks</span>{' '}
<Spinner size={5} />
</div>
)}
<DashboardMain />
Expand Down
8 changes: 4 additions & 4 deletions src/app/api/notification/notification.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ export const getInProductNotificationDetail = (): {
} => {
return {
[NotificationActions.AUTH_RECONNECT]: {
title: 'QuickBooks Integration Has Stopped Working',
body: 'Your QuickBooks integration encountered an error and has stopped syncing. Please reconnect to avoid any disruptions.',
title: 'QuickBooks Sync has disconnected',
body: 'Your QuickBooks Sync encountered an error and has stopped syncing. Please reconnect to avoid any disruptions.',
ctaParams: { type: AuthStatus.RECONNECT },
},
}
Expand All @@ -29,8 +29,8 @@ export const getIEmailNotificationDetail = (): {
return {
[NotificationActions.AUTH_RECONNECT]: {
title: 'Reconnect QuickBooks',
subject: 'Your QuickBooks Sync has stopped working',
header: 'Regarding QuickBooks sync off',
subject: 'Your QuickBooks Sync has disconnected',
header: 'QuickBooks Sync has disconnected',
body: 'Your QuickBooks integration encountered an error and has stopped syncing. Please reconnect to avoid any disruptions.',
ctaParams: { type: AuthStatus.RECONNECT }, // TODO: CTA params not working for email
},
Expand Down
5 changes: 5 additions & 0 deletions src/app/api/notification/notification.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export class NotificationService extends BaseService {
senderId: string
},
): Promise<void> {
console.info(
'NotificationService#createBulkNotification | Token: ',
this.user.token,
)
try {
const copilot = new CopilotAPI(this.user.token)
// 1. get all parties that gets notification
Expand All @@ -35,6 +39,7 @@ export class NotificationService extends BaseService {
: getIEmailNotificationDetail()[action]

for (const party of parties.data) {
console.info({ party })
const triggerNotification = await copilot.createNotification({
recipientId: party.id,
senderId,
Expand Down
7 changes: 7 additions & 0 deletions src/app/api/quickbooks/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,10 @@ export const handleTokenExchange = async (req: NextRequest) => {
const response = await authService.handleTokenExchange(body, user.workspaceId)
return NextResponse.json({ response })
}

export const handleConnectionError = async (req: NextRequest) => {
const user = await authenticate(req)
const authService = new AuthService(user)
const response = await authService.handleConnectionError(user.workspaceId)
return NextResponse.json({ response })
}
18 changes: 18 additions & 0 deletions src/app/api/quickbooks/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,24 @@ export class AuthService extends BaseService {
}
}

async handleConnectionError(portalId: string) {
const logService = new LogService(this.user)

try {
// store error connection log
await logService.upsertLatestPendingConnectionLog({
portalId,
connectionStatus: ConnectionStatus.ERROR,
})
} catch (error: unknown) {
console.error('AuthService#handleConnectionError | Error =', error)
throw new APIError(
httpStatus.INTERNAL_SERVER_ERROR,
`Something went wrong while handling connection error for portal ${portalId}.`,
)
}
}

async getQBPortalConnection(
portalId: string,
manualSyncEnable: boolean = false,
Expand Down
4 changes: 4 additions & 0 deletions src/app/api/quickbooks/auth/connection-error/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler'
import { handleConnectionError } from '@/app/api/quickbooks/auth/auth.controller'

export const POST = withErrorHandler(handleConnectionError)
95 changes: 70 additions & 25 deletions src/app/api/quickbooks/invoice/invoice.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
QBInvoiceUpdateSchema,
QBInvoiceUpdateSchemaType,
} from '@/db/schema/qbInvoiceSync'
import { QBProductSync } from '@/db/schema/qbProductSync'
import { TransactionType, WhereClause } from '@/type/common'
import {
QBCustomerSparseUpdatePayloadType,
Expand Down Expand Up @@ -47,6 +46,7 @@ const oneOffItem = {

type InvoiceItemRefAndDescriptionType = {
ref: QBNameValueSchemaType
amount?: number
productDescription?: string
}

Expand Down Expand Up @@ -140,6 +140,9 @@ export class InvoiceService extends BaseService {
console.info('InvoiceService#getInvoiceItemRef | Product map found')
return {
ref: { value: mapping.qbItemId },
amount: mapping.unitPrice
? parseFloat(mapping.unitPrice) / 100
: undefined,
productDescription: mapping.description || '',
}
}
Expand Down Expand Up @@ -236,11 +239,11 @@ export class InvoiceService extends BaseService {
}
return {
DetailType: 'SalesItemLineDetail',
Amount: actualAmount * lineItem.quantity,
Amount: (itemRef.amount ?? actualAmount) * lineItem.quantity,
SalesItemLineDetail: {
ItemRef: itemRef.ref,
Qty: lineItem.quantity,
UnitPrice: actualAmount,
UnitPrice: itemRef.amount ?? actualAmount,
TaxCodeRef: {
// required to enable tax for the product.
// Doc reference: https://developer.intuit.com/app/developer/qbo/docs/workflows/manage-sales-tax-for-us-locales#specifying-sales-tax
Expand Down Expand Up @@ -402,6 +405,13 @@ export class InvoiceService extends BaseService {
}

const lineItems = await Promise.all(lineItemPromises)
const actualTotalAmount = lineItems.reduce((acc, item) => {
// calculate the actual tax amount from the lineItems. Not using invoiceResource amount directly as the amount for mapped items can be different (mapped QB amount).
return acc + item.Amount
}, 0)
const totalTax = parseFloat(
((actualTotalAmount * invoiceResource.taxPercentage) / 100).toFixed(2),
)

// 5. create invoice in QB
const customerRefValue: string =
Expand All @@ -414,9 +424,7 @@ export class InvoiceService extends BaseService {
DocNumber: invoiceResource.number, // copilot invoice number as DocNumber
// include tax and dates
TxnTaxDetail: {
TotalTax: invoiceResource?.taxAmount
? Number((invoiceResource.taxAmount / 100).toFixed(2))
: 0,
TotalTax: totalTax,
},
...(invoiceResource?.sentDate && {
TxnDate: dayjs(invoiceResource.sentDate).format('YYYY/MM/DD'), // Valid date format for TxnDate is YYYY/MM/DD. For more info: https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/invoice#the-invoice-object
Expand All @@ -443,6 +451,7 @@ export class InvoiceService extends BaseService {
await this.createQBInvoice(invoicePayload)

// update/ create the record in sync log table
const totalWithTax = actualTotalAmount + totalTax
await this.logSync(
invoiceResource.id,
{
Expand All @@ -451,8 +460,8 @@ export class InvoiceService extends BaseService {
},
EventType.CREATED,
{
amount: invoiceResource.total.toFixed(2),
taxAmount: invoiceResource.taxAmount?.toFixed(2),
amount: (totalWithTax * 100).toFixed(2),
taxAmount: (totalTax * 100).toFixed(2), // convert to cents for logs
customerName: recipientInfo.displayName,
customerEmail: recipientInfo.email,
},
Expand All @@ -464,15 +473,14 @@ export class InvoiceService extends BaseService {
*/
if (invoiceResource.status === InvoiceStatus.PAID) {
const paymentService = new PaymentService(this.user)
const totalActualAmount = invoiceResource.total / 100
const qbPaymentPayload = {
TotalAmt: totalActualAmount,
TotalAmt: totalWithTax,
CustomerRef: {
value: customerRefValue,
},
Line: [
{
Amount: totalActualAmount,
Amount: totalWithTax,
LinkedTxn: [
{
TxnId: invoiceRes.Invoice.Id,
Expand All @@ -488,7 +496,7 @@ export class InvoiceService extends BaseService {
{
invoiceNumber: invoiceResource.number,
invoiceId: invoiceResource.id,
taxAmount: invoiceResource.taxAmount?.toFixed(2),
taxAmount: (totalTax * 100).toFixed(2),
},
{
displayName: recipientInfo.displayName,
Expand Down Expand Up @@ -547,15 +555,28 @@ export class InvoiceService extends BaseService {
)
}

const actualAmount = payload.data.total / 100 // Convert to dollar. amount received in cents.
// get invoice sync log
const invoiceLog = await this.syncLogService.getOneByCopilotIdAndEventType(
payload.data.id,
EventType.CREATED,
)

if (!invoiceLog) {
throw new APIError(
httpStatus.INTERNAL_SERVER_ERROR,
'WebhookService#webhookInvoicePaid | Invoice sync log not found',
)
}

const invoiceAmount = Number(z.string().parse(invoiceLog.amount)) / 100
const qbPaymentPayload = {
TotalAmt: actualAmount,
TotalAmt: invoiceAmount,
CustomerRef: {
value: existingCustomer.qbCustomerId,
},
Line: [
{
Amount: actualAmount,
Amount: invoiceAmount,
LinkedTxn: [
{
TxnId: z.string().parse(invoiceSync.qbInvoiceId), // this links payment to invoice docs reference: https://help.developer.intuit.com/s/question/0D54R00007Ot7ZXSAZ/linking-payment-to-invoice-through-api
Expand All @@ -579,7 +600,7 @@ export class InvoiceService extends BaseService {
{
invoiceNumber: payload.data.number,
invoiceId: payload.data.id,
taxAmount: payload.data.taxAmount?.toFixed(2),
taxAmount: z.string().parse(invoiceLog.taxAmount),
},
{
displayName: customerDisplayName,
Expand Down Expand Up @@ -618,14 +639,23 @@ export class InvoiceService extends BaseService {
)
}

const syncLog = await this.syncLogService.getOneByCopilotIdAndEventType(
if (invoiceSync.status !== InvoiceStatus.OPEN) {
console.error(
'WebhookService#handleInvoiceVoided | Invoices void was requested for non-open record',
)
return // return early if invoice is not open
}

// get invoice sync log
const invoiceLog = await this.syncLogService.getOneByCopilotIdAndEventType(
payload.data.id,
EventType.VOIDED,
EventType.CREATED,
)

if (syncLog?.status === LogStatus.SUCCESS) {
console.info(
'WebhookService#webhookInvoiceVoided | Invoice already voided',
if (!invoiceLog) {
throw new APIError(
httpStatus.INTERNAL_SERVER_ERROR,
'WebhookService#webhookInvoicePaid | Invoice sync log not found',
)
}

Expand Down Expand Up @@ -660,8 +690,8 @@ export class InvoiceService extends BaseService {
['id'],
),
this.logSync(payload.data.id, invoiceSync, EventType.VOIDED, {
amount: payload.data.total.toFixed(2),
taxAmount: payload.data.taxAmount?.toFixed(2),
amount: z.string().parse(invoiceLog.amount),
taxAmount: z.string().parse(invoiceLog.taxAmount),
customerName: recipientInfo.displayName,
customerEmail: recipientInfo.email,
}),
Expand All @@ -677,6 +707,7 @@ export class InvoiceService extends BaseService {
'qbInvoiceId',
'status',
'qbSyncToken',
'invoiceNumber',
])

if (!syncedInvoice) {
Expand All @@ -692,6 +723,20 @@ export class InvoiceService extends BaseService {
console.error(
'WebhookService#handleInvoiceDeleted | Invoices delete was requested for non-voided record',
)
return // return early if invoice is not voided
}

// get invoice sync log
const invoiceLog = await this.syncLogService.getOneByCopilotIdAndEventType(
payload.id,
EventType.CREATED,
)

if (!invoiceLog) {
throw new APIError(
httpStatus.INTERNAL_SERVER_ERROR,
'WebhookService#webhookInvoicePaid | Invoice sync log not found',
)
}

const intuitApi = new IntuitAPI(qbTokenInfo)
Expand Down Expand Up @@ -722,8 +767,8 @@ export class InvoiceService extends BaseService {
['id'],
),
this.logSync(payload.id, syncedInvoice, EventType.DELETED, {
amount: payload.total.toFixed(2),
taxAmount: payload.taxAmount?.toFixed(2),
amount: z.string().parse(invoiceLog.amount),
taxAmount: z.string().parse(invoiceLog.taxAmount),
customerName: recipientInfo.displayName,
customerEmail: recipientInfo.email,
}),
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/quickbooks/product/product.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export async function getItemsFromQB(req: NextRequest) {
const items = await productService.queryItemsFromQB(
qbTokenInfo,
MAX_PRODUCT_LIST_LIMIT,
['Id', 'Name', 'UnitPrice', 'SyncToken'],
['Id', 'Name', 'UnitPrice', 'SyncToken', 'Description'],
)
return NextResponse.json(items)
}
Loading