Skip to content

Commit e563a9b

Browse files
committed
feat(integrations): add Brex integration with expenses, receipts, transactions, team, budgets, and payments tools
1 parent b465a3c commit e563a9b

44 files changed

Lines changed: 5488 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/components/icons.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2261,6 +2261,17 @@ export function BrandfetchIcon(props: SVGProps<SVGSVGElement>) {
22612261
)
22622262
}
22632263

2264+
export function BrexIcon(props: SVGProps<SVGSVGElement>) {
2265+
return (
2266+
<svg {...props} viewBox='0 0 223 179.3' fill='none' xmlns='http://www.w3.org/2000/svg'>
2267+
<path
2268+
fill='#FFFFFF'
2269+
d='M144.9,14.3c-8.7,11.6-10.8,15.5-19.2,15.5H0v149.4h49.3c11.1,0,21.9-5.4,28.9-14.3c9-12,10.2-15.5,18.9-15.5 H223V0h-49.6C162.3,0,151.5,5.4,144.9,14.3L144.9,14.3z M183.9,110.9h-52.6c-11.4,0-21.9,4.8-28.9,14c-9,12-10.8,15.5-19.2,15.5 H38.8V68.7h52.6c11.4,0,21.9-5.4,28.9-14.3c9-11.6,11.4-15.2,19.5-15.2h44.2V110.9z'
2270+
/>
2271+
</svg>
2272+
)
2273+
}
2274+
22642275
export function BrightDataIcon(props: SVGProps<SVGSVGElement>) {
22652276
return (
22662277
<svg

apps/docs/components/ui/icon-mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
BoxCompanyIcon,
2525
BrainIcon,
2626
BrandfetchIcon,
27+
BrexIcon,
2728
BrightDataIcon,
2829
BrowserUseIcon,
2930
CalComIcon,
@@ -243,6 +244,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
243244
azure_devops: AzureIcon,
244245
box: BoxCompanyIcon,
245246
brandfetch: BrandfetchIcon,
247+
brex: BrexIcon,
246248
brightdata: BrightDataIcon,
247249
browser_use: BrowserUseIcon,
248250
calcom: CalComIcon,

apps/docs/content/docs/en/integrations/brex.mdx

Lines changed: 879 additions & 0 deletions
Large diffs are not rendered by default.

apps/docs/content/docs/en/integrations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"azure_devops",
2222
"box",
2323
"brandfetch",
24+
"brex",
2425
"brightdata",
2526
"browser_use",
2627
"calcom",
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest, hybridAuthMockFns } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockProcessFilesToUserFiles, mockDownloadFileFromStorage, mockAssertToolFileAccess } =
8+
vi.hoisted(() => ({
9+
mockProcessFilesToUserFiles: vi.fn(),
10+
mockDownloadFileFromStorage: vi.fn(),
11+
mockAssertToolFileAccess: vi.fn(),
12+
}))
13+
14+
vi.mock('@/lib/uploads/utils/file-utils', () => ({
15+
processFilesToUserFiles: mockProcessFilesToUserFiles,
16+
}))
17+
vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
18+
downloadFileFromStorage: mockDownloadFileFromStorage,
19+
}))
20+
vi.mock('@/app/api/files/authorization', () => ({
21+
assertToolFileAccess: mockAssertToolFileAccess,
22+
}))
23+
24+
import { POST } from '@/app/api/tools/brex/upload-receipt/route'
25+
26+
const mockFetch = vi.fn()
27+
28+
const baseBody = {
29+
apiKey: 'bxt_test_token',
30+
expenseId: 'expense_123',
31+
file: { key: 'uploads/receipt.pdf', name: 'receipt.pdf', size: 5, type: 'application/pdf' },
32+
}
33+
34+
function jsonResponse(body: unknown, status = 200) {
35+
return {
36+
ok: status >= 200 && status < 300,
37+
status,
38+
text: async () => JSON.stringify(body),
39+
json: async () => body,
40+
}
41+
}
42+
43+
beforeEach(() => {
44+
vi.clearAllMocks()
45+
vi.stubGlobal('fetch', mockFetch)
46+
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
47+
success: true,
48+
userId: 'user-1',
49+
authType: 'internal_jwt',
50+
})
51+
mockProcessFilesToUserFiles.mockReturnValue([
52+
{ key: 'uploads/receipt.pdf', name: 'receipt.pdf', size: 5, type: 'application/pdf' },
53+
])
54+
mockAssertToolFileAccess.mockResolvedValue(null)
55+
mockDownloadFileFromStorage.mockResolvedValue(Buffer.from('receipt-bytes'))
56+
})
57+
58+
describe('POST /api/tools/brex/upload-receipt', () => {
59+
it('rejects unauthenticated requests', async () => {
60+
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValueOnce({
61+
success: false,
62+
error: 'unauthorized',
63+
})
64+
65+
const response = await POST(createMockRequest('POST', baseBody))
66+
expect(response.status).toBe(401)
67+
expect(mockFetch).not.toHaveBeenCalled()
68+
})
69+
70+
it('creates a receipt upload for an expense and PUTs the file to the pre-signed URL', async () => {
71+
mockFetch
72+
.mockResolvedValueOnce(
73+
jsonResponse({ id: 'receipt_1', uri: 'https://s3.example.com/presigned' })
74+
)
75+
.mockResolvedValueOnce(jsonResponse({}))
76+
77+
const response = await POST(createMockRequest('POST', baseBody))
78+
expect(response.status).toBe(200)
79+
const data = await response.json()
80+
expect(data).toEqual({
81+
success: true,
82+
output: { receiptId: 'receipt_1', receiptName: 'receipt.pdf', expenseId: 'expense_123' },
83+
})
84+
85+
expect(mockFetch).toHaveBeenCalledTimes(2)
86+
const [createUrl, createInit] = mockFetch.mock.calls[0]
87+
expect(createUrl).toBe('https://api.brex.com/v1/expenses/card/expense_123/receipt_upload')
88+
expect(createInit.method).toBe('POST')
89+
expect(createInit.headers.Authorization).toBe('Bearer bxt_test_token')
90+
expect(JSON.parse(createInit.body)).toEqual({ receipt_name: 'receipt.pdf' })
91+
92+
const [uploadUrl, uploadInit] = mockFetch.mock.calls[1]
93+
expect(uploadUrl).toBe('https://s3.example.com/presigned')
94+
expect(uploadInit.method).toBe('PUT')
95+
})
96+
97+
it('uses receipt match when no expense ID is provided', async () => {
98+
mockFetch
99+
.mockResolvedValueOnce(
100+
jsonResponse({ id: 'receipt_2', uri: 'https://s3.example.com/presigned' })
101+
)
102+
.mockResolvedValueOnce(jsonResponse({}))
103+
104+
const response = await POST(
105+
createMockRequest('POST', { apiKey: 'bxt_test_token', file: baseBody.file })
106+
)
107+
expect(response.status).toBe(200)
108+
const data = await response.json()
109+
expect(data.output).toEqual({
110+
receiptId: 'receipt_2',
111+
receiptName: 'receipt.pdf',
112+
expenseId: null,
113+
})
114+
115+
const [createUrl] = mockFetch.mock.calls[0]
116+
expect(createUrl).toBe('https://api.brex.com/v1/expenses/card/receipt_match')
117+
})
118+
119+
it('honors a receipt name override', async () => {
120+
mockFetch
121+
.mockResolvedValueOnce(
122+
jsonResponse({ id: 'receipt_3', uri: 'https://s3.example.com/presigned' })
123+
)
124+
.mockResolvedValueOnce(jsonResponse({}))
125+
126+
const response = await POST(
127+
createMockRequest('POST', { ...baseBody, receiptName: 'march-dinner.pdf' })
128+
)
129+
expect(response.status).toBe(200)
130+
const [, createInit] = mockFetch.mock.calls[0]
131+
expect(JSON.parse(createInit.body)).toEqual({ receipt_name: 'march-dinner.pdf' })
132+
})
133+
134+
it('propagates Brex API errors', async () => {
135+
mockFetch.mockResolvedValueOnce(jsonResponse({ message: 'Expense not found' }, 404))
136+
137+
const response = await POST(createMockRequest('POST', baseBody))
138+
expect(response.status).toBe(404)
139+
const data = await response.json()
140+
expect(data.success).toBe(false)
141+
expect(data.error).toContain('Expense not found')
142+
expect(mockFetch).toHaveBeenCalledTimes(1)
143+
})
144+
145+
it('rejects files over the 50 MB limit', async () => {
146+
mockDownloadFileFromStorage.mockResolvedValueOnce(Buffer.alloc(50 * 1024 * 1024 + 1))
147+
148+
const response = await POST(createMockRequest('POST', baseBody))
149+
expect(response.status).toBe(400)
150+
const data = await response.json()
151+
expect(data.error).toContain('50 MB')
152+
expect(mockFetch).not.toHaveBeenCalled()
153+
})
154+
155+
it('fails when the pre-signed upload fails', async () => {
156+
mockFetch
157+
.mockResolvedValueOnce(
158+
jsonResponse({ id: 'receipt_4', uri: 'https://s3.example.com/presigned' })
159+
)
160+
.mockResolvedValueOnce(jsonResponse({}, 403))
161+
162+
const response = await POST(createMockRequest('POST', baseBody))
163+
expect(response.status).toBe(502)
164+
const data = await response.json()
165+
expect(data.success).toBe(false)
166+
})
167+
168+
it('denies access to files the caller cannot read', async () => {
169+
const deniedResponse = new Response(
170+
JSON.stringify({ success: false, error: 'File not found' }),
171+
{
172+
status: 404,
173+
}
174+
)
175+
mockAssertToolFileAccess.mockResolvedValueOnce(deniedResponse)
176+
177+
const response = await POST(createMockRequest('POST', baseBody))
178+
expect(response.status).toBe(404)
179+
expect(mockFetch).not.toHaveBeenCalled()
180+
})
181+
})
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { brexUploadReceiptContract } from '@/lib/api/contracts/tools/brex'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { checkInternalAuth } from '@/lib/auth/hybrid'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
10+
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
11+
import { assertToolFileAccess } from '@/app/api/files/authorization'
12+
import { BREX_API_BASE, buildBrexHeaders } from '@/tools/brex/utils'
13+
14+
export const dynamic = 'force-dynamic'
15+
16+
const logger = createLogger('BrexUploadReceiptAPI')
17+
18+
const MAX_RECEIPT_SIZE_BYTES = 50 * 1024 * 1024
19+
20+
export const POST = withRouteHandler(async (request: NextRequest) => {
21+
const requestId = generateRequestId()
22+
23+
try {
24+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
25+
26+
if (!authResult.success || !authResult.userId) {
27+
logger.warn(`[${requestId}] Unauthorized Brex receipt upload attempt: ${authResult.error}`)
28+
return NextResponse.json(
29+
{ success: false, error: authResult.error || 'Authentication required' },
30+
{ status: 401 }
31+
)
32+
}
33+
34+
const parsed = await parseRequest(brexUploadReceiptContract, request, {})
35+
if (!parsed.success) return parsed.response
36+
const { apiKey, expenseId, file, receiptName } = parsed.data.body
37+
38+
const userFiles = processFilesToUserFiles([file as RawFileInput], requestId, logger)
39+
if (userFiles.length === 0) {
40+
return NextResponse.json({ success: false, error: 'Invalid file input' }, { status: 400 })
41+
}
42+
43+
const userFile = userFiles[0]
44+
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
45+
if (denied) return denied
46+
47+
const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
48+
if (fileBuffer.length > MAX_RECEIPT_SIZE_BYTES) {
49+
return NextResponse.json(
50+
{ success: false, error: 'Receipt file exceeds the 50 MB limit' },
51+
{ status: 400 }
52+
)
53+
}
54+
55+
const effectiveReceiptName = receiptName?.trim() || userFile.name
56+
const trimmedExpenseId = expenseId?.trim() || undefined
57+
const endpoint = trimmedExpenseId
58+
? `${BREX_API_BASE}/v1/expenses/card/${encodeURIComponent(trimmedExpenseId)}/receipt_upload`
59+
: `${BREX_API_BASE}/v1/expenses/card/receipt_match`
60+
61+
logger.info(
62+
`[${requestId}] Creating Brex ${trimmedExpenseId ? 'receipt upload' : 'receipt match'}: ${effectiveReceiptName} (${fileBuffer.length} bytes)`
63+
)
64+
65+
const createResponse = await fetch(endpoint, {
66+
method: 'POST',
67+
headers: buildBrexHeaders(apiKey),
68+
body: JSON.stringify({ receipt_name: effectiveReceiptName }),
69+
})
70+
71+
if (!createResponse.ok) {
72+
const errorText = await createResponse.text()
73+
logger.error(`[${requestId}] Brex API error:`, {
74+
status: createResponse.status,
75+
error: errorText,
76+
})
77+
let message = errorText
78+
try {
79+
message = JSON.parse(errorText).message ?? errorText
80+
} catch {
81+
message = errorText
82+
}
83+
return NextResponse.json(
84+
{ success: false, error: `Brex API error (${createResponse.status}): ${message}` },
85+
{ status: createResponse.status }
86+
)
87+
}
88+
89+
const createData = await createResponse.json()
90+
if (!createData.uri || !createData.id) {
91+
return NextResponse.json(
92+
{ success: false, error: 'Brex did not return an upload URL' },
93+
{ status: 502 }
94+
)
95+
}
96+
97+
const uploadResponse = await fetch(createData.uri, {
98+
method: 'PUT',
99+
body: new Uint8Array(fileBuffer),
100+
})
101+
102+
if (!uploadResponse.ok) {
103+
logger.error(`[${requestId}] Receipt upload to pre-signed URL failed:`, {
104+
status: uploadResponse.status,
105+
})
106+
return NextResponse.json(
107+
{ success: false, error: `Failed to upload receipt file (${uploadResponse.status})` },
108+
{ status: 502 }
109+
)
110+
}
111+
112+
logger.info(`[${requestId}] Receipt uploaded successfully (ID: ${createData.id})`)
113+
114+
return NextResponse.json({
115+
success: true,
116+
output: {
117+
receiptId: createData.id,
118+
receiptName: effectiveReceiptName,
119+
expenseId: trimmedExpenseId ?? null,
120+
},
121+
})
122+
} catch (error) {
123+
logger.error(`[${requestId}] Unexpected error:`, error)
124+
return NextResponse.json(
125+
{ success: false, error: getErrorMessage(error, 'Unknown error') },
126+
{ status: 500 }
127+
)
128+
}
129+
})

0 commit comments

Comments
 (0)