Skip to content

Commit 0f22fae

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat(quickbooks): add document and attachment tools
1 parent 0ad0fe4 commit 0f22fae

8 files changed

Lines changed: 951 additions & 0 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import type {
2+
QuickBooksAddAttachmentParams,
3+
QuickBooksAddAttachmentResponse,
4+
} from '@/tools/quickbooks/types'
5+
import { QUICKBOOKS_ATTACHABLE_PROPERTIES } from '@/tools/quickbooks/types'
6+
import type { ToolConfig } from '@/tools/types'
7+
8+
export const quickbooksAddAttachmentTool: ToolConfig<
9+
QuickBooksAddAttachmentParams,
10+
QuickBooksAddAttachmentResponse
11+
> = {
12+
id: 'quickbooks_add_attachment',
13+
name: 'QuickBooks Add Attachment',
14+
description: 'Attach one supported file or one note to a fixed QuickBooks entity',
15+
version: '1.0.0',
16+
params: {
17+
accessToken: {
18+
type: 'string',
19+
required: true,
20+
visibility: 'hidden',
21+
description: 'QuickBooks OAuth access token',
22+
},
23+
realmId: {
24+
type: 'string',
25+
required: true,
26+
visibility: 'hidden',
27+
description: 'QuickBooks company ID derived from the connected credential',
28+
},
29+
attachmentKind: {
30+
type: 'string',
31+
required: true,
32+
visibility: 'user-or-llm',
33+
description: 'Attachment kind: file or note',
34+
},
35+
targetType: {
36+
type: 'string',
37+
required: true,
38+
visibility: 'user-or-llm',
39+
description: 'Fixed QuickBooks entity type to attach to',
40+
},
41+
targetId: {
42+
type: 'string',
43+
required: true,
44+
visibility: 'user-or-llm',
45+
description: 'QuickBooks target entity ID',
46+
},
47+
file: {
48+
type: 'file',
49+
required: false,
50+
visibility: 'user-only',
51+
description: 'Single Sim file to upload',
52+
},
53+
fileName: {
54+
type: 'string',
55+
required: false,
56+
visibility: 'user-or-llm',
57+
description: 'Optional safe filename override',
58+
},
59+
contentType: {
60+
type: 'string',
61+
required: false,
62+
visibility: 'user-or-llm',
63+
description: 'Optional compatible QuickBooks MIME type override',
64+
},
65+
description: {
66+
type: 'string',
67+
required: false,
68+
visibility: 'user-or-llm',
69+
description: 'Optional file attachment description',
70+
},
71+
note: {
72+
type: 'string',
73+
required: false,
74+
visibility: 'user-or-llm',
75+
description: 'Required nonempty note text in Note mode',
76+
},
77+
},
78+
oauth: {
79+
required: true,
80+
provider: 'quickbooks',
81+
requiredScopes: ['com.intuit.quickbooks.accounting'],
82+
},
83+
request: {
84+
url: '/api/tools/quickbooks/add-attachment',
85+
method: 'POST',
86+
headers: () => ({ 'Content-Type': 'application/json' }),
87+
body: (params) => params,
88+
},
89+
outputs: {
90+
attachment: {
91+
type: 'json',
92+
description: 'Created native QuickBooks attachment metadata',
93+
properties: QUICKBOOKS_ATTACHABLE_PROPERTIES,
94+
},
95+
attachmentId: { type: 'string', description: 'Created QuickBooks attachment ID' },
96+
attachmentKind: { type: 'string', description: 'Created attachment kind' },
97+
targetType: { type: 'string', description: 'QuickBooks target entity type' },
98+
targetId: { type: 'string', description: 'QuickBooks target entity ID' },
99+
time: {
100+
type: 'string',
101+
description: 'QuickBooks response timestamp',
102+
optional: true,
103+
nullable: true,
104+
},
105+
},
106+
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits'
2+
import type { RawFileInput } from '@/lib/uploads/utils/file-schemas'
3+
import { QUICKBOOKS_MAX_RESPONSE_BYTES } from '@/tools/quickbooks/client'
4+
import { formatQuickBooksFaultDetail, sanitizeQuickBooksFaultData } from '@/tools/quickbooks/fault'
5+
import type {
6+
QuickBooksAttachable,
7+
QuickBooksAttachmentTargetType,
8+
QuickBooksDocumentTransactionType,
9+
} from '@/tools/quickbooks/types'
10+
import { parseQuickBooksJson, requiredQuickBooksString } from '@/tools/quickbooks/utils'
11+
12+
export const QUICKBOOKS_DOCUMENT_TRANSACTIONS = {
13+
credit_memo: { entity: 'CreditMemo', resource: 'creditmemo' },
14+
estimate: { entity: 'Estimate', resource: 'estimate' },
15+
invoice: { entity: 'Invoice', resource: 'invoice' },
16+
purchase_order: { entity: 'PurchaseOrder', resource: 'purchaseorder' },
17+
refund_receipt: { entity: 'RefundReceipt', resource: 'refundreceipt' },
18+
sales_receipt: { entity: 'SalesReceipt', resource: 'salesreceipt' },
19+
} as const satisfies Record<QuickBooksDocumentTransactionType, { entity: string; resource: string }>
20+
21+
export const QUICKBOOKS_ATTACHMENT_TARGETS = {
22+
bill: { entityType: 'Bill' },
23+
bill_payment: { entityType: 'BillPayment' },
24+
credit_memo: { entityType: 'CreditMemo' },
25+
deposit: { entityType: 'Deposit' },
26+
estimate: { entityType: 'Estimate' },
27+
invoice: { entityType: 'Invoice' },
28+
item: { entityType: 'Item' },
29+
journal_entry: { entityType: 'JournalEntry' },
30+
payment: { entityType: 'Payment' },
31+
purchase: { entityType: 'Purchase' },
32+
purchase_order: { entityType: 'PurchaseOrder' },
33+
refund_receipt: { entityType: 'RefundReceipt' },
34+
sales_receipt: { entityType: 'SalesReceipt' },
35+
vendor_credit: { entityType: 'VendorCredit' },
36+
} as const satisfies Record<QuickBooksAttachmentTargetType, { entityType: string }>
37+
38+
const QUICKBOOKS_FILE_TYPES: Record<string, readonly string[]> = {
39+
ai: ['application/postscript'],
40+
csv: ['text/csv'],
41+
doc: ['application/msword'],
42+
docx: ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
43+
eps: ['application/postscript'],
44+
gif: ['image/gif'],
45+
jpeg: ['image/jpeg'],
46+
jpg: ['image/jpeg', 'image/jpg'],
47+
ods: ['application/vnd.oasis.opendocument.spreadsheet'],
48+
pdf: ['application/pdf'],
49+
png: ['image/png'],
50+
rtf: ['application/rtf', 'text/rtf'],
51+
tif: ['image/tiff'],
52+
tiff: ['image/tiff'],
53+
txt: ['text/plain'],
54+
xls: ['application/vnd.ms-excel'],
55+
xlsx: ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
56+
xml: ['application/xml', 'text/xml'],
57+
}
58+
59+
export function getQuickBooksDocumentTransaction(type: QuickBooksDocumentTransactionType) {
60+
const config = QUICKBOOKS_DOCUMENT_TRANSACTIONS[type]
61+
if (!config) throw new Error(`Unsupported QuickBooks document transaction type: ${String(type)}`)
62+
return config
63+
}
64+
65+
export function getQuickBooksAttachmentTarget(type: QuickBooksAttachmentTargetType) {
66+
const config = QUICKBOOKS_ATTACHMENT_TARGETS[type]
67+
if (!config) throw new Error(`Unsupported QuickBooks attachment target type: ${String(type)}`)
68+
return config
69+
}
70+
71+
export function validateQuickBooksRecipient(recipient?: string): string | undefined {
72+
if (recipient === undefined) return undefined
73+
const normalized = recipient.trim()
74+
if (!normalized) return undefined
75+
if (/[,;\r\n]/.test(normalized) || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) {
76+
throw new Error('recipient must be one valid email address')
77+
}
78+
return normalized
79+
}
80+
81+
export function sanitizeQuickBooksFileName(value: string | undefined, fallback: string): string {
82+
const raw = value?.trim() || fallback
83+
const leaf = raw.split(/[\\/]/).pop() || fallback
84+
const sanitized = leaf
85+
.replace(/[\u0000-\u001f\u007f]/g, '')
86+
.replace(/[^\w.() -]/g, '_')
87+
.trim()
88+
const bounded = sanitized.slice(0, 180)
89+
if (!bounded || bounded === '.' || bounded === '..') return fallback
90+
return bounded
91+
}
92+
93+
export function validateQuickBooksAttachmentFileType(fileName: string, mimeType: string): string {
94+
const extension = fileName.split('.').pop()?.toLowerCase() ?? ''
95+
const normalizedMime = mimeType.split(';', 1)[0].trim().toLowerCase()
96+
const accepted = QUICKBOOKS_FILE_TYPES[extension]
97+
if (!accepted || !accepted.includes(normalizedMime)) {
98+
throw new Error(
99+
`QuickBooks does not support the ${extension || 'extensionless'} / ${normalizedMime || 'unknown'} file type combination`
100+
)
101+
}
102+
return normalizedMime
103+
}
104+
105+
export function escapeQuickBooksQueryLiteral(value: string, fieldName: string): string {
106+
return requiredQuickBooksString(value, fieldName).replace(/\\/g, '\\\\').replace(/'/g, "\\'")
107+
}
108+
109+
export interface QuickBooksAttachableEnvelope {
110+
Attachable?: QuickBooksAttachable
111+
AttachableResponse?: Array<{
112+
Attachable?: QuickBooksAttachable
113+
Fault?: unknown
114+
time?: string
115+
}>
116+
time?: string
117+
}
118+
119+
export async function parseQuickBooksAttachableResponse(
120+
response: Response
121+
): Promise<{ attachment: QuickBooksAttachable; time: string | null }> {
122+
const data = await parseQuickBooksJson<QuickBooksAttachableEnvelope>(
123+
response,
124+
'QuickBooks Attachable response'
125+
)
126+
const attachment = data.Attachable ?? data.AttachableResponse?.[0]?.Attachable
127+
if (!attachment || typeof attachment !== 'object' || Array.isArray(attachment)) {
128+
throw new Error('QuickBooks Attachable response is missing a valid attachment')
129+
}
130+
if (typeof attachment.Id !== 'string' || !attachment.Id.trim()) {
131+
throw new Error('QuickBooks Attachable response is missing a valid attachment ID')
132+
}
133+
const responseTime = data.time ?? data.AttachableResponse?.[0]?.time
134+
return { attachment, time: typeof responseTime === 'string' ? responseTime : null }
135+
}
136+
137+
export function buildQuickBooksAttachableMetadata(
138+
targetType: QuickBooksAttachmentTargetType,
139+
targetId: string,
140+
options: { fileName?: string; contentType?: string; description?: string; note?: string }
141+
) {
142+
const target = getQuickBooksAttachmentTarget(targetType)
143+
return {
144+
AttachableRef: [
145+
{
146+
EntityRef: {
147+
type: target.entityType,
148+
value: requiredQuickBooksString(targetId, 'targetId'),
149+
},
150+
},
151+
],
152+
...(options.fileName ? { FileName: options.fileName } : {}),
153+
...(options.contentType ? { ContentType: options.contentType } : {}),
154+
...(options.description ? { Note: options.description } : {}),
155+
...(options.note ? { Note: options.note } : {}),
156+
}
157+
}
158+
159+
export function assertSingleQuickBooksFile(file: RawFileInput | undefined): RawFileInput {
160+
if (!file || typeof file !== 'object' || Array.isArray(file)) {
161+
throw new Error('Exactly one file is required for a QuickBooks file attachment')
162+
}
163+
return file
164+
}
165+
166+
export const QUICKBOOKS_TEMP_URL_MAX_BYTES = 64 * 1024
167+
export const QUICKBOOKS_DOCUMENT_JSON_MAX_BYTES = QUICKBOOKS_MAX_RESPONSE_BYTES
168+
169+
export async function getQuickBooksDocumentError(response: Response): Promise<Error> {
170+
let detail = ''
171+
try {
172+
const text = await readResponseTextWithLimit(response, {
173+
maxBytes: QUICKBOOKS_TEMP_URL_MAX_BYTES,
174+
label: 'QuickBooks document error response',
175+
})
176+
if (text) {
177+
try {
178+
const fault = sanitizeQuickBooksFaultData(JSON.parse(text))
179+
if (fault) detail = formatQuickBooksFaultDetail(fault)
180+
} catch {
181+
// Empty, plain-text, and HTML gateway errors intentionally remain opaque.
182+
}
183+
}
184+
} catch {
185+
detail = 'The error response exceeded the safe size limit.'
186+
}
187+
188+
const guidance =
189+
response.status === 401
190+
? 'Reconnect the QuickBooks credential.'
191+
: response.status === 403
192+
? 'Confirm the QuickBooks accounting scope and access to this company.'
193+
: response.status === 429
194+
? 'QuickBooks rate limit reached; retry after the indicated delay.'
195+
: ''
196+
const trackingId =
197+
response.headers.get('intuit_tid') ??
198+
response.headers.get('intuit-tid') ??
199+
response.headers.get('x-request-id')
200+
const retryAfter = response.status === 429 ? response.headers.get('retry-after') : null
201+
return new Error(
202+
[
203+
`QuickBooks request failed with HTTP ${response.status}.`,
204+
guidance,
205+
detail,
206+
trackingId ? `(Intuit tracking ID: ${trackingId})` : '',
207+
retryAfter ? `(Retry-After: ${retryAfter})` : '',
208+
]
209+
.filter(Boolean)
210+
.join(' ')
211+
)
212+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type {
2+
QuickBooksDownloadAttachmentParams,
3+
QuickBooksFileResponse,
4+
} from '@/tools/quickbooks/types'
5+
import { QUICKBOOKS_FILE_OUTPUTS } from '@/tools/quickbooks/types'
6+
import type { ToolConfig } from '@/tools/types'
7+
8+
export const quickbooksDownloadAttachmentTool: ToolConfig<
9+
QuickBooksDownloadAttachmentParams,
10+
QuickBooksFileResponse
11+
> = {
12+
id: 'quickbooks_download_attachment',
13+
name: 'QuickBooks Download Attachment',
14+
description: 'Download a QuickBooks file attachment through its short-lived URL',
15+
version: '1.0.0',
16+
params: {
17+
accessToken: {
18+
type: 'string',
19+
required: true,
20+
visibility: 'hidden',
21+
description: 'QuickBooks OAuth access token',
22+
},
23+
realmId: {
24+
type: 'string',
25+
required: true,
26+
visibility: 'hidden',
27+
description: 'QuickBooks company ID derived from the connected credential',
28+
},
29+
attachmentId: {
30+
type: 'string',
31+
required: true,
32+
visibility: 'user-or-llm',
33+
description: 'QuickBooks attachment ID',
34+
},
35+
fileName: {
36+
type: 'string',
37+
required: false,
38+
visibility: 'user-or-llm',
39+
description: 'Optional safe filename override',
40+
},
41+
},
42+
oauth: {
43+
required: true,
44+
provider: 'quickbooks',
45+
requiredScopes: ['com.intuit.quickbooks.accounting'],
46+
},
47+
request: {
48+
url: '/api/tools/quickbooks/download-attachment',
49+
method: 'POST',
50+
headers: () => ({ 'Content-Type': 'application/json' }),
51+
body: (params) => params,
52+
},
53+
outputs: {
54+
...QUICKBOOKS_FILE_OUTPUTS,
55+
attachmentId: { type: 'string', description: 'Downloaded QuickBooks attachment ID' },
56+
},
57+
}

0 commit comments

Comments
 (0)