|
| 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 | +} |
0 commit comments