Skip to content

Commit d72df32

Browse files
committed
feat(integrations): add Ramp integration with spend, receipts, and bill tools
1 parent f4d22ff commit d72df32

46 files changed

Lines changed: 4692 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
@@ -1625,6 +1625,17 @@ export function RB2BIcon(props: SVGProps<SVGSVGElement>) {
16251625
)
16261626
}
16271627

1628+
export function RampIcon(props: SVGProps<SVGSVGElement>) {
1629+
return (
1630+
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 69.5 59'>
1631+
<path
1632+
d='M69.5,58.7V59l-37.9,0v-0.3c5.5-3.1,9.2-6.2,12.6-9.5h15.6L69.5,58.7z M60.2,9.4L50.6,0h-0.3c0,0,0.2,17.5-16,33.5 C18.5,49.1,0,49.2,0,49.2v0.3L9.8,59c0,0,18.3,0.2,34.4-15.7C60.3,27.6,60.2,9.4,60.2,9.4z'
1633+
fill='currentColor'
1634+
/>
1635+
</svg>
1636+
)
1637+
}
1638+
16281639
export function RedditIcon(props: SVGProps<SVGSVGElement>) {
16291640
return (
16301641
<svg

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ import {
156156
QdrantIcon,
157157
QuiverIcon,
158158
RailwayIcon,
159+
RampIcon,
159160
RB2BIcon,
160161
RDSIcon,
161162
RedditIcon,
@@ -391,6 +392,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
391392
qdrant: QdrantIcon,
392393
quiver: QuiverIcon,
393394
railway: RailwayIcon,
395+
ramp: RampIcon,
394396
rb2b: RB2BIcon,
395397
rds: RDSIcon,
396398
reddit: RedditIcon,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@
156156
"qdrant",
157157
"quiver",
158158
"railway",
159+
"ramp",
159160
"rb2b",
160161
"rds",
161162
"reddit",

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

Lines changed: 649 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { generateId } from '@sim/utils/id'
4+
import { type NextRequest, NextResponse } from 'next/server'
5+
import { rampUploadReceiptContract } from '@/lib/api/contracts/tools/ramp'
6+
import { parseRequest } from '@/lib/api/server'
7+
import { checkInternalAuth } from '@/lib/auth/hybrid'
8+
import { generateRequestId } from '@/lib/core/utils/request'
9+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
11+
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
12+
import { assertToolFileAccess } from '@/app/api/files/authorization'
13+
import { extractRampError } from '@/tools/ramp/utils'
14+
15+
export const dynamic = 'force-dynamic'
16+
17+
const logger = createLogger('RampUploadReceiptAPI')
18+
19+
const RAMP_RECEIPTS_URL = 'https://api.ramp.com/developer/v1/receipts'
20+
21+
/**
22+
* Builds the multipart body for Ramp's receipt upload endpoint. Ramp expects
23+
* metadata parts with `Content-Disposition: form-data` and the receipt image
24+
* as a part named `receipt` with `Content-Disposition: attachment`.
25+
*/
26+
function buildReceiptMultipartBody(
27+
boundary: string,
28+
fields: Record<string, string>,
29+
file: { name: string; type: string; buffer: Buffer }
30+
): Buffer {
31+
const parts: Buffer[] = []
32+
33+
for (const [name, value] of Object.entries(fields)) {
34+
parts.push(
35+
Buffer.from(
36+
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`
37+
)
38+
)
39+
}
40+
41+
const safeFileName = file.name.replace(/[\r\n"]/g, '_')
42+
parts.push(
43+
Buffer.from(
44+
`--${boundary}\r\nContent-Disposition: attachment; name="receipt"; filename="${safeFileName}"\r\nContent-Type: ${file.type}\r\n\r\n`
45+
)
46+
)
47+
parts.push(file.buffer)
48+
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`))
49+
50+
return Buffer.concat(parts)
51+
}
52+
53+
export const POST = withRouteHandler(async (request: NextRequest) => {
54+
const requestId = generateRequestId()
55+
56+
try {
57+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
58+
59+
if (!authResult.success || !authResult.userId) {
60+
logger.warn(`[${requestId}] Unauthorized Ramp receipt upload attempt: ${authResult.error}`)
61+
return NextResponse.json(
62+
{ success: false, error: authResult.error || 'Authentication required' },
63+
{ status: 401 }
64+
)
65+
}
66+
67+
const parsed = await parseRequest(rampUploadReceiptContract, request, {})
68+
if (!parsed.success) return parsed.response
69+
const validatedData = parsed.data.body
70+
71+
const userFiles = processFilesToUserFiles(
72+
[validatedData.file as RawFileInput],
73+
requestId,
74+
logger
75+
)
76+
77+
if (userFiles.length === 0) {
78+
return NextResponse.json({ success: false, error: 'Invalid file input' }, { status: 400 })
79+
}
80+
81+
const userFile = userFiles[0]
82+
logger.info(
83+
`[${requestId}] Downloading receipt file: ${userFile.name} (${userFile.size} bytes)`
84+
)
85+
86+
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
87+
if (denied) return denied
88+
const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
89+
90+
const fields: Record<string, string> = {
91+
idempotency_key: generateId(),
92+
user_id: validatedData.userId,
93+
}
94+
if (validatedData.transactionId) {
95+
fields.transaction_id = validatedData.transactionId
96+
}
97+
98+
const boundary = `----sim-ramp-receipt-${generateId()}`
99+
const body = buildReceiptMultipartBody(boundary, fields, {
100+
name: userFile.name,
101+
type: userFile.type || 'application/octet-stream',
102+
buffer: fileBuffer,
103+
})
104+
105+
logger.info(`[${requestId}] Uploading receipt to Ramp (${fileBuffer.length} bytes)`)
106+
107+
const response = await fetch(RAMP_RECEIPTS_URL, {
108+
method: 'POST',
109+
headers: {
110+
Authorization: `Bearer ${validatedData.accessToken}`,
111+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
112+
},
113+
body: new Uint8Array(body),
114+
})
115+
116+
const data = await response.json().catch(() => ({}))
117+
118+
if (!response.ok) {
119+
const errorMessage = extractRampError(data, 'Failed to upload receipt to Ramp')
120+
logger.error(`[${requestId}] Ramp API error:`, { status: response.status, data })
121+
return NextResponse.json({ success: false, error: errorMessage }, { status: response.status })
122+
}
123+
124+
logger.info(`[${requestId}] Receipt uploaded successfully: ${data.id}`)
125+
126+
return NextResponse.json({
127+
success: true,
128+
output: {
129+
receiptId: data.id,
130+
},
131+
})
132+
} catch (error) {
133+
logger.error(`[${requestId}] Unexpected error:`, error)
134+
return NextResponse.json(
135+
{ success: false, error: getErrorMessage(error, 'Unknown error') },
136+
{ status: 500 }
137+
)
138+
}
139+
})

0 commit comments

Comments
 (0)