Skip to content

Commit aeb6fb8

Browse files
committed
fix(quickbooks): address final integration review
1 parent 9dff389 commit aeb6fb8

34 files changed

Lines changed: 1299 additions & 235 deletions

‎apps/docs/content/docs/integrations/quickbooks.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1198,7 +1198,7 @@ Read, merge, and full-update a customer payment using its current sync token
11981198
| `paymentMethodId` | string | No | Replacement payment method ID |
11991199
| `depositAccountId` | string | No | Replacement deposit account ID |
12001200
| `invoiceAllocations` | json | No | Bounded invoice allocations to apply. Each entry sets the amount applied to that invoice; invoices already applied on the payment and not listed here keep their current amounts |
1201-
| `unapplyOmittedInvoices` | boolean | No | Replace the payment allocations outright. Every invoice not listed in invoiceAllocations is UNAPPLIED and returns to open |
1201+
| `unapplyOmittedInvoices` | boolean | No | Replace the payment allocations outright. Requires a non-empty invoiceAllocations list; every invoice not listed is UNAPPLIED and returns to open |
12021202

12031203
#### Output
12041204

@@ -1472,7 +1472,7 @@ Create a customer refund receipt against a required deposit account
14721472

14731473
### QuickBooks Update Refund Receipt
14741474

1475-
Sparse-update a refund receipt using its current sync token
1475+
Read, merge, and full-update a refund receipt using its current sync token
14761476

14771477
#### Input
14781478

‎apps/sim/app/desktop/connect/page.test.tsx‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ describe('DesktopConnectPage', () => {
6060
beforeEach(() => {
6161
vi.clearAllMocks()
6262
baseUrl.value = 'https://sim.test'
63-
mockGetSession.mockResolvedValue({ user: { id: 'user-1', email: 'user@example.com' } })
63+
mockGetSession.mockResolvedValue({
64+
user: { id: 'user-1', email: 'user@example.com' },
65+
})
6466
})
6567

6668
it('hands the launcher an absolute complete URL so the callback can read the draft back', async () => {
@@ -143,6 +145,27 @@ describe('DesktopConnectPage', () => {
143145
)
144146
})
145147

148+
it('starts a draft-scoped QuickBooks connect through the server authorize route', async () => {
149+
await expect(
150+
DesktopConnectPage(
151+
pageProps({
152+
provider: 'quickbooks',
153+
state: VALID_STATE,
154+
port: PORT,
155+
draftId: 'draft-1',
156+
})
157+
)
158+
).rejects.toThrow('NEXT_REDIRECT:')
159+
160+
const authorize = new URL(mockRedirect.mock.calls[0][0])
161+
expect(authorize.pathname).toBe('/api/auth/oauth2/authorize')
162+
expect(authorize.searchParams.get('draftId')).toBe('draft-1')
163+
expect(authorize.searchParams.get('providerId')).toBeNull()
164+
expect(authorize.searchParams.get('callbackURL')).toBe(
165+
`https://sim.test/desktop/connect/complete?state=${VALID_STATE}&port=${PORT}`
166+
)
167+
})
168+
146169
it.each(['trello', 'instagram', 'shopify'])(
147170
'starts %s through its dedicated authorize route in the system browser',
148171
async (provider) => {
@@ -171,7 +194,12 @@ describe('DesktopConnectPage', () => {
171194
{ provider: 'Google', state: VALID_STATE, port: PORT },
172195
{ provider: 'google-email', state: 'short', port: PORT },
173196
{ provider: 'google-email', state: VALID_STATE },
174-
{ provider: 'google-email', state: VALID_STATE, port: PORT, draftId: 'bad draft' },
197+
{
198+
provider: 'google-email',
199+
state: VALID_STATE,
200+
port: PORT,
201+
draftId: 'bad draft',
202+
},
175203
]
176204

177205
for (const params of invalid) {

‎apps/sim/app/desktop/connect/page.tsx‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec
7777
// Force a DB-backed session read (bypass the cookie cache) so a revoked
7878
// browser session goes to login instead of starting a doomed link flow.
7979
const hdrs = await headers()
80-
const session = await auth.api.getSession({ headers: hdrs, query: { disableCookieCache: true } })
80+
const session = await auth.api.getSession({
81+
headers: hdrs,
82+
query: { disableCookieCache: true },
83+
})
8184
if (!session?.user) {
8285
redirect(
8386
`/login?callbackUrl=${encodeURIComponent(
@@ -130,6 +133,13 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec
130133
redirect(authorize.toString())
131134
}
132135

136+
if (providerId === 'quickbooks' && draftId) {
137+
const authorize = new URL('/api/auth/oauth2/authorize', getBaseUrl())
138+
authorize.searchParams.set('draftId', draftId)
139+
authorize.searchParams.set('callbackURL', buildConnectCompleteUrl(state, port))
140+
redirect(authorize.toString())
141+
}
142+
133143
if (providerId === 'trello' || providerId === 'instagram' || providerId === 'shopify') {
134144
const authorize = new URL(`/api/auth/${providerId}/authorize`, getBaseUrl())
135145
authorize.searchParams.set('returnUrl', buildConnectCompleteUrl(state, port))

‎apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx‎

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ const mocks = vi.hoisted(() => ({
1010
connectOAuthService: vi.fn(),
1111
getServiceConfigByProviderId: vi.fn(),
1212
onConnect: vi.fn(),
13+
clearOAuthReturnContext: vi.fn(),
14+
workspaceCredentials: vi.fn(),
15+
writeOAuthReturnContext: vi.fn(),
1316
}))
1417

1518
interface MockChipModalFieldProps {
@@ -90,7 +93,8 @@ vi.mock('@/lib/auth/auth-client', () => ({
9093

9194
vi.mock('@/lib/credentials/client-state', () => ({
9295
ADD_CONNECTOR_SEARCH_PARAM: 'addConnector',
93-
writeOAuthReturnContext: vi.fn(),
96+
clearOAuthReturnContext: mocks.clearOAuthReturnContext,
97+
writeOAuthReturnContext: mocks.writeOAuthReturnContext,
9498
}))
9599

96100
vi.mock('@/lib/credentials/display-name', () => ({
@@ -123,10 +127,7 @@ vi.mock('@/hooks/queries/credentials', () => ({
123127
mutateAsync: mocks.createDraft,
124128
isPending: false,
125129
}),
126-
useWorkspaceCredentials: () => ({
127-
data: [],
128-
isPending: false,
129-
}),
130+
useWorkspaceCredentials: mocks.workspaceCredentials,
130131
}))
131132

132133
vi.mock('@/hooks/queries/oauth/oauth-connections', () => ({
@@ -188,17 +189,23 @@ function setFormControlValue(control: HTMLInputElement | HTMLSelectElement, valu
188189
const valueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
189190
valueSetter?.call(control, value)
190191
control.dispatchEvent(
191-
new Event(control instanceof HTMLSelectElement ? 'change' : 'input', { bubbles: true })
192+
new Event(control instanceof HTMLSelectElement ? 'change' : 'input', {
193+
bubbles: true,
194+
})
192195
)
193196
}
194197

195198
describe('ConnectOAuthModal reauthorization', () => {
196199
beforeEach(() => {
197200
vi.clearAllMocks()
198-
mocks.createDraft.mockResolvedValue({ success: true, draftId: 'draft-exact' })
201+
mocks.createDraft.mockResolvedValue({
202+
success: true,
203+
draftId: 'draft-exact',
204+
})
199205
mocks.connectOAuthService.mockResolvedValue({ success: true })
200206
mocks.onConnect.mockResolvedValue(undefined)
201207
mocks.getServiceConfigByProviderId.mockReturnValue(null)
208+
mocks.workspaceCredentials.mockReturnValue({ data: [], isPending: false })
202209
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
203210
container = document.createElement('div')
204211
document.body.appendChild(container)
@@ -235,6 +242,15 @@ describe('ConnectOAuthModal reauthorization', () => {
235242
expect(mocks.createDraft.mock.invocationCallOrder[0]).toBeLessThan(
236243
mocks.connectOAuthService.mock.invocationCallOrder[0]
237244
)
245+
expect(mocks.writeOAuthReturnContext).toHaveBeenCalledWith(
246+
expect.objectContaining({
247+
origin: 'integrations',
248+
displayName: 'Team Slack',
249+
providerId: 'slack',
250+
workspaceId: 'workspace-1',
251+
reconnect: true,
252+
})
253+
)
238254
})
239255

240256
it('does not launch OAuth when the reconnect draft cannot be created', async () => {
@@ -251,6 +267,24 @@ describe('ConnectOAuthModal reauthorization', () => {
251267

252268
expect(mocks.connectOAuthService).not.toHaveBeenCalled()
253269
expect(container).toHaveTextContent('Draft creation failed')
270+
expect(mocks.clearOAuthReturnContext).not.toHaveBeenCalled()
271+
})
272+
273+
it('clears reconnect context when the provider handoff cannot start', async () => {
274+
mocks.connectOAuthService.mockRejectedValue(new Error('Provider launch failed'))
275+
renderReauthorizeModal({
276+
reconnectTarget: {
277+
workspaceId: 'workspace-1',
278+
credentialId: 'credential-slack',
279+
displayName: 'Team Slack',
280+
},
281+
})
282+
283+
await clickConnect()
284+
285+
expect(mocks.writeOAuthReturnContext).toHaveBeenCalledOnce()
286+
expect(mocks.clearOAuthReturnContext).toHaveBeenCalledOnce()
287+
expect(container).toHaveTextContent('Provider launch failed')
254288
})
255289

256290
it('preserves provider-only reauthorization without creating a draft', async () => {
@@ -289,7 +323,12 @@ describe('ConnectOAuthModal reauthorization', () => {
289323
redirectPath: '/api/auth/oauth2/callback/quickbooks',
290324
fields: [
291325
{ id: 'clientId', label: 'Client ID', type: 'text' },
292-
{ id: 'clientSecret', label: 'Client secret', type: 'secret', secret: true },
326+
{
327+
id: 'clientSecret',
328+
label: 'Client secret',
329+
type: 'secret',
330+
secret: true,
331+
},
293332
{
294333
id: 'webhookVerifierToken',
295334
label: 'Webhook verifier token',

‎apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx‎

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ import { createLogger } from '@sim/logger'
1717
import { getErrorMessage } from '@sim/utils/errors'
1818
import { useSession } from '@/lib/auth/auth-client'
1919
import type { OAuthReturnContext } from '@/lib/credentials/client-state'
20-
import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state'
20+
import {
21+
ADD_CONNECTOR_SEARCH_PARAM,
22+
clearOAuthReturnContext,
23+
writeOAuthReturnContext,
24+
} from '@/lib/credentials/client-state'
2125
import { defaultCredentialDisplayName } from '@/lib/credentials/display-name'
2226
import {
2327
getProviderIdFromServiceId,
@@ -121,7 +125,11 @@ type ConnectOAuthModalConnectProps = ConnectOAuthModalBaseProps & {
121125
requiredScopes: readonly string[]
122126
} & (
123127
| { origin: 'workflow'; workflowId: string }
124-
| { origin: 'kb-connectors'; knowledgeBaseId: string; connectorType?: string }
128+
| {
129+
origin: 'kb-connectors'
130+
knowledgeBaseId: string
131+
connectorType?: string
132+
}
125133
| { origin: 'integrations' }
126134
)
127135

@@ -199,21 +207,24 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
199207

200208
const { providerName, ProviderIcon } = useMemo(() => {
201209
if (props.serviceName && props.serviceIcon) {
202-
return { providerName: props.serviceName, ProviderIcon: props.serviceIcon }
210+
return {
211+
providerName: props.serviceName,
212+
ProviderIcon: props.serviceIcon,
213+
}
203214
}
204215
const provider = (props.provider ?? providerId) as OAuthProvider
205216
return resolveService(provider, props.serviceId ?? providerId)
206217
}, [props.serviceName, props.serviceIcon, props.provider, props.serviceId, providerId])
207218

208-
const workspaceId = isConnect ? props.workspaceId : ''
219+
const workspaceId = isConnect ? props.workspaceId : (props.reconnectTarget?.workspaceId ?? '')
209220
const clientConfiguration = getServiceConfigByProviderId(providerId)?.clientConfiguration
210221
const oauthClientRedirectUri =
211222
clientConfiguration?.redirectPath && typeof window !== 'undefined'
212223
? new URL(clientConfiguration.redirectPath, window.location.origin).toString()
213224
: null
214225
const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
215226
workspaceId,
216-
enabled: isConnect && Boolean(workspaceId) && open,
227+
enabled: Boolean(workspaceId) && open,
217228
})
218229
const createDraft = useCreateCredentialDraft()
219230
const connectOAuthService = useConnectOAuthService()
@@ -301,6 +312,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
301312
const handleConnect = async () => {
302313
setValidationError(null)
303314
setSubmitError(null)
315+
let returnContextWritten = false
304316
try {
305317
const environmentUrl = dataverseEnvironmentForm.validate()
306318
if (dataverseEnvironmentForm.enabled && !environmentUrl) return
@@ -373,12 +385,17 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
373385
connectorType: props.connectorType,
374386
}
375387
} else if (props.origin === 'workflow') {
376-
returnContext = { ...baseContext, origin: 'workflow', workflowId: props.workflowId }
388+
returnContext = {
389+
...baseContext,
390+
origin: 'workflow',
391+
workflowId: props.workflowId,
392+
}
377393
} else {
378394
returnContext = { ...baseContext, origin: 'integrations' }
379395
}
380396

381397
writeOAuthReturnContext(returnContext)
398+
returnContextWritten = true
382399
} else if (props.onConnect) {
383400
await props.onConnect()
384401
handleClose()
@@ -393,6 +410,25 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
393410
oauthClientConfig: quickBooksOAuthClientConfig,
394411
})
395412
draftId = draft.draftId
413+
414+
const providerCredentials = credentials.filter(
415+
(credential) => credential.type === 'oauth' && credential.providerId === providerId
416+
)
417+
writeOAuthReturnContext({
418+
origin: 'integrations',
419+
displayName: props.reconnectTarget.displayName,
420+
providerId,
421+
preCount: providerCredentials.length,
422+
baselineCredentials: providerCredentials.map((credential) => ({
423+
id: credential.id,
424+
accountId: credential.accountId,
425+
updatedAt: credential.updatedAt,
426+
})),
427+
workspaceId: props.reconnectTarget.workspaceId,
428+
reconnect: true,
429+
requestedAt: Date.now(),
430+
})
431+
returnContextWritten = true
396432
}
397433

398434
logger.info('Reauthorizing OAuth2', {
@@ -422,6 +458,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
422458
}
423459
handleClose()
424460
} catch (err: unknown) {
461+
if (returnContextWritten) clearOAuthReturnContext()
425462
const message = getErrorMessage(err, 'Failed to start OAuth connection')
426463
setSubmitError(message)
427464
logger.error('Failed to connect OAuth service', err)
@@ -514,7 +551,10 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
514551
title={field.label}
515552
value={oauthClientFields[field.id] ?? ''}
516553
onChange={(value) =>
517-
setOAuthClientFields((current) => ({ ...current, [field.id]: value }))
554+
setOAuthClientFields((current) => ({
555+
...current,
556+
[field.id]: value,
557+
}))
518558
}
519559
options={[...field.options]}
520560
placeholder={field.placeholder}
@@ -529,7 +569,10 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
529569
title={field.label}
530570
value={oauthClientFields[field.id] ?? ''}
531571
onChange={(value) =>
532-
setOAuthClientFields((current) => ({ ...current, [field.id]: value }))
572+
setOAuthClientFields((current) => ({
573+
...current,
574+
[field.id]: value,
575+
}))
533576
}
534577
placeholder={field.placeholder}
535578
inputType={field.secret ? 'password' : 'text'}

0 commit comments

Comments
 (0)