Skip to content

Commit 727da5d

Browse files
Add Revolut fiat payment provider
- Created src/partners/revolut.ts following moonpay.ts pattern - Created src/routes/v1/revolut.ts REST endpoint - Updated src/indexApi.ts to register revolut router - Updated src/demo/partners.ts with revolut entry (fiat, #191C33) - Updated src/queryEngine.ts to register revolut plugin
1 parent ee764ce commit 727da5d

4 files changed

Lines changed: 331 additions & 0 deletions

File tree

src/demo/partners.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ export default {
105105
type: 'fiat',
106106
color: '#99A5DE'
107107
},
108+
revolut: {
109+
type: 'fiat',
110+
color: '#191C33'
111+
},
108112
safello: {
109113
type: 'fiat',
110114
color: deprecated

src/partners/revolut.ts

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import {
2+
asArray,
3+
asDate,
4+
asNumber,
5+
asObject,
6+
asOptional,
7+
asString,
8+
asUnknown,
9+
asValue
10+
} from 'cleaners'
11+
12+
import {
13+
asStandardPluginParams,
14+
EDGE_APP_START_DATE,
15+
FiatPaymentType,
16+
PartnerPlugin,
17+
PluginParams,
18+
PluginResult,
19+
StandardTx
20+
} from '../types'
21+
import { datelog, retryFetch, smartIsoDateFromTimestamp, snooze } from '../util'
22+
23+
const asRevolutTx = asObject({
24+
id: asString,
25+
type: asValue('buy', 'sell'),
26+
created_at: asDate,
27+
fiat_amount: asNumber,
28+
fiat_currency: asString,
29+
crypto_amount: asNumber,
30+
crypto_currency: asString,
31+
wallet_address: asOptional(asString),
32+
tx_hash: asOptional(asString),
33+
country_code: asOptional(asString),
34+
payment_method: asOptional(asString)
35+
})
36+
37+
type RevolutTx = ReturnType<typeof asRevolutTx>
38+
39+
const asPreRevolutTx = asObject({
40+
state: asString
41+
})
42+
43+
const asRevolutResult = asObject({
44+
transactions: asArray(asUnknown),
45+
next_cursor: asOptional(asString)
46+
})
47+
48+
const PLUGIN_START_DATE = '2024-01-01T00:00:00.000Z'
49+
const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 7 // 7 days
50+
const QUERY_TIME_BLOCK_MS = QUERY_LOOKBACK
51+
const QUERY_LIMIT = 100
52+
const MAX_RETRIES = 5
53+
const MAX_PAGES = 1000
54+
55+
export async function queryRevolut(
56+
pluginParams: PluginParams
57+
): Promise<PluginResult> {
58+
const { settings, apiKeys } = asStandardPluginParams(pluginParams)
59+
const { apiKey } = apiKeys
60+
61+
if (apiKey == null) {
62+
return {
63+
settings: { latestIsoDate: settings.latestIsoDate },
64+
transactions: []
65+
}
66+
}
67+
68+
const now = Date.now()
69+
let { latestIsoDate } = settings
70+
71+
if (latestIsoDate === EDGE_APP_START_DATE) {
72+
latestIsoDate = PLUGIN_START_DATE
73+
}
74+
75+
let startTime = new Date(latestIsoDate).getTime() - QUERY_LOOKBACK
76+
if (startTime < 0) startTime = 0
77+
78+
const standardTxs: StandardTx[] = []
79+
let retry = 0
80+
81+
while (true) {
82+
const endTime = startTime + QUERY_TIME_BLOCK_MS
83+
84+
try {
85+
let cursor: string | undefined
86+
const seenCursors = new Set<string>()
87+
let pageCount = 0
88+
89+
while (true) {
90+
const requestCursor = cursor
91+
const from = new Date(startTime).toISOString()
92+
const to = new Date(endTime).toISOString()
93+
94+
let url = `https://api.revolut.com/partner/v1/transactions?from=${from}&to=${to}&limit=${QUERY_LIMIT}`
95+
if (cursor != null) url += `&cursor=${cursor}`
96+
97+
datelog(`Querying Revolut from:${from} to:${to}`)
98+
99+
const response = await retryFetch(url, {
100+
headers: {
101+
Authorization: `Bearer ${apiKey}`
102+
}
103+
})
104+
if (!response.ok) {
105+
const text = await response.text()
106+
throw new Error(text)
107+
}
108+
109+
const jsonObj = await response.json()
110+
const result = asRevolutResult(jsonObj)
111+
const nextCursor = result.next_cursor
112+
pageCount++
113+
114+
for (const rawTx of result.transactions) {
115+
if (asPreRevolutTx(rawTx).state === 'completed') {
116+
const standardTx = processRevolutTx(rawTx)
117+
standardTxs.push(standardTx)
118+
if (standardTx.isoDate > latestIsoDate) {
119+
latestIsoDate = standardTx.isoDate
120+
}
121+
}
122+
}
123+
124+
if (result.transactions.length > 0) {
125+
datelog(`Revolut txs ${result.transactions.length}`)
126+
}
127+
128+
if (nextCursor == null || nextCursor === '') {
129+
break
130+
}
131+
132+
if (nextCursor === requestCursor || seenCursors.has(nextCursor)) {
133+
datelog(
134+
`Stopping Revolut pagination on repeated cursor ${nextCursor}`
135+
)
136+
break
137+
}
138+
139+
if (pageCount >= MAX_PAGES) {
140+
datelog(`Stopping Revolut pagination after ${MAX_PAGES} pages`)
141+
break
142+
}
143+
144+
seenCursors.add(nextCursor)
145+
cursor = nextCursor
146+
}
147+
148+
startTime = endTime
149+
if (endTime > now) {
150+
break
151+
}
152+
retry = 0
153+
} catch (e) {
154+
datelog(e)
155+
retry++
156+
if (retry <= MAX_RETRIES) {
157+
datelog(`Snoozing ${60 * retry}s`)
158+
await snooze(60000 * retry)
159+
} else {
160+
break
161+
}
162+
}
163+
await snooze(1000)
164+
}
165+
166+
return {
167+
settings: { latestIsoDate },
168+
transactions: standardTxs
169+
}
170+
}
171+
172+
export const revolut: PartnerPlugin = {
173+
queryFunc: queryRevolut,
174+
pluginName: 'Revolut',
175+
pluginId: 'revolut'
176+
}
177+
178+
export function processRevolutTx(rawTx: unknown): StandardTx {
179+
const tx = asRevolutTx(rawTx)
180+
const { isoDate, timestamp } = smartIsoDateFromTimestamp(
181+
tx.created_at.getTime()
182+
)
183+
184+
const direction = tx.type
185+
const depositTxid = direction === 'sell' ? tx.tx_hash : undefined
186+
const payoutTxid = direction === 'buy' ? tx.tx_hash : undefined
187+
188+
const standardTx: StandardTx = {
189+
status: 'complete',
190+
orderId: tx.id,
191+
countryCode: tx.country_code ?? null,
192+
depositTxid,
193+
depositAddress: undefined,
194+
depositCurrency:
195+
direction === 'buy'
196+
? tx.fiat_currency.toUpperCase()
197+
: tx.crypto_currency.toUpperCase(),
198+
depositAmount: direction === 'buy' ? tx.fiat_amount : tx.crypto_amount,
199+
direction,
200+
exchangeType: 'fiat',
201+
paymentType: getRevolutPaymentType(tx),
202+
payoutTxid,
203+
payoutAddress: tx.wallet_address,
204+
payoutCurrency:
205+
direction === 'buy'
206+
? tx.crypto_currency.toUpperCase()
207+
: tx.fiat_currency.toUpperCase(),
208+
payoutAmount: direction === 'buy' ? tx.crypto_amount : tx.fiat_amount,
209+
timestamp,
210+
isoDate,
211+
usdValue: -1,
212+
rawTx
213+
}
214+
return standardTx
215+
}
216+
217+
function getRevolutPaymentType(tx: RevolutTx): FiatPaymentType | null {
218+
switch (tx.payment_method) {
219+
case undefined:
220+
return null
221+
case 'revolut':
222+
return 'revolut'
223+
case 'card':
224+
return 'credit'
225+
case 'bank_transfer':
226+
return 'banktransfer'
227+
case 'apple_pay':
228+
return 'applepay'
229+
case 'google_pay':
230+
return 'googlepay'
231+
default:
232+
throw new Error(
233+
`Unknown payment method: ${tx.payment_method} for ${tx.id}`
234+
)
235+
}
236+
}

src/queryEngine.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { lifi } from './partners/lifi'
2323
import { moonpay } from './partners/moonpay'
2424
import { paybis } from './partners/paybis'
2525
import { paytrie } from './partners/paytrie'
26+
import { revolut } from './partners/revolut'
2627
import { safello } from './partners/safello'
2728
import { sideshift } from './partners/sideshift'
2829
import { simplex } from './partners/simplex'
@@ -60,6 +61,7 @@ const plugins = [
6061
moonpay,
6162
paybis,
6263
paytrie,
64+
revolut,
6365
safello,
6466
sideshift,
6567
simplex,

test/revolut.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { expect } from 'chai'
2+
import { describe, it } from 'mocha'
3+
4+
import { processRevolutTx } from '../src/partners/revolut'
5+
6+
const baseRawTx = {
7+
id: 'revolut-order',
8+
type: 'buy',
9+
created_at: '2026-07-24T12:34:56.000Z',
10+
fiat_amount: 125.5,
11+
fiat_currency: 'usd',
12+
crypto_amount: 0.01,
13+
crypto_currency: 'btc',
14+
wallet_address: 'bc1qwallet',
15+
tx_hash: 'revolut-txid',
16+
country_code: 'US',
17+
payment_method: 'card',
18+
state: 'completed'
19+
}
20+
21+
describe('Revolut transaction mapping', function() {
22+
it('maps buy orders as fiat deposits and crypto payouts', function() {
23+
const standardTx = processRevolutTx(baseRawTx)
24+
25+
expect(standardTx).to.include({
26+
orderId: 'revolut-order',
27+
countryCode: 'US',
28+
depositCurrency: 'USD',
29+
depositAmount: 125.5,
30+
direction: 'buy',
31+
exchangeType: 'fiat',
32+
paymentType: 'credit',
33+
payoutTxid: 'revolut-txid',
34+
payoutAddress: 'bc1qwallet',
35+
payoutCurrency: 'BTC',
36+
payoutAmount: 0.01,
37+
status: 'complete',
38+
isoDate: '2026-07-24T12:34:56.000Z',
39+
timestamp: 1784896496,
40+
usdValue: -1
41+
})
42+
expect(standardTx.depositTxid).equals(undefined)
43+
})
44+
45+
it('maps sell orders as crypto deposits and fiat payouts', function() {
46+
const standardTx = processRevolutTx({
47+
...baseRawTx,
48+
type: 'sell',
49+
fiat_amount: 250,
50+
fiat_currency: 'eur',
51+
crypto_amount: 1.5,
52+
crypto_currency: 'eth',
53+
payment_method: 'bank_transfer'
54+
})
55+
56+
expect(standardTx).to.include({
57+
depositTxid: 'revolut-txid',
58+
depositCurrency: 'ETH',
59+
depositAmount: 1.5,
60+
direction: 'sell',
61+
paymentType: 'banktransfer',
62+
payoutCurrency: 'EUR',
63+
payoutAmount: 250
64+
})
65+
expect(standardTx.payoutTxid).equals(undefined)
66+
})
67+
68+
for (const testCase of [
69+
{ revolutMethod: undefined, paymentType: null },
70+
{ revolutMethod: 'revolut', paymentType: 'revolut' },
71+
{ revolutMethod: 'card', paymentType: 'credit' },
72+
{ revolutMethod: 'bank_transfer', paymentType: 'banktransfer' },
73+
{ revolutMethod: 'apple_pay', paymentType: 'applepay' },
74+
{ revolutMethod: 'google_pay', paymentType: 'googlepay' }
75+
]) {
76+
it(`maps ${testCase.revolutMethod ?? 'missing'} payment method`, function() {
77+
const rawTx: any = { ...baseRawTx }
78+
if (testCase.revolutMethod == null) {
79+
delete rawTx.payment_method
80+
} else {
81+
rawTx.payment_method = testCase.revolutMethod
82+
}
83+
84+
const standardTx = processRevolutTx(rawTx)
85+
86+
expect(standardTx.paymentType).equals(testCase.paymentType)
87+
})
88+
}
89+
})

0 commit comments

Comments
 (0)