Skip to content

Commit 755f8e1

Browse files
committed
Add Revolut fiat payment provider
Recreate the Revolut partner plugin following the moonpay pattern: - src/partners/revolut.ts queries the Revolut partner transactions API, cleans results, and maps completed buy/sell txs to StandardTx. - Register the plugin in queryEngine.ts. - Add the revolut demo entry (fiat, #191C33).
1 parent d9c6f34 commit 755f8e1

4 files changed

Lines changed: 252 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- added: Add Revolut fiat payment provider
56
- changed: Update sideshift plugin with new optional API fields
67
- changed: Add signature header support to Exolix
78
- changed: Add index for orderId

src/demo/partners.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@ export default {
109109
type: 'swap',
110110
color: '#5891EE'
111111
},
112+
revolut: {
113+
type: 'fiat',
114+
color: '#191C33'
115+
},
112116
safello: {
113117
type: 'fiat',
114118
color: deprecated

src/partners/revolut.ts

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import {
2+
asArray,
3+
asDate,
4+
asMaybe,
5+
asNumber,
6+
asObject,
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 { 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: asMaybe(asString),
32+
tx_hash: asMaybe(asString),
33+
country_code: asMaybe(asString),
34+
payment_method: asMaybe(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: asMaybe(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+
54+
export async function queryRevolut(
55+
pluginParams: PluginParams
56+
): Promise<PluginResult> {
57+
const { log } = pluginParams
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+
// Buffer this window's results so a mid-window failure can be retried
87+
// idempotently: the buffer is discarded on error, so already-paged txs
88+
// are never appended twice (which would create duplicate orderIds and
89+
// Couch _id conflicts on bulk insert).
90+
const windowTxs: StandardTx[] = []
91+
let windowLatestIsoDate = latestIsoDate
92+
93+
while (true) {
94+
const from = new Date(startTime).toISOString()
95+
const to = new Date(endTime).toISOString()
96+
97+
let url = `https://api.revolut.com/partner/v1/transactions?from=${from}&to=${to}&limit=${QUERY_LIMIT}`
98+
if (cursor != null) url += `&cursor=${cursor}`
99+
100+
log(`Querying Revolut from:${from} to:${to}`)
101+
102+
const response = await retryFetch(url, {
103+
headers: {
104+
Authorization: `Bearer ${apiKey}`
105+
}
106+
})
107+
if (!response.ok) {
108+
const text = await response.text()
109+
throw new Error(text)
110+
}
111+
112+
const jsonObj = await response.json()
113+
const result = asRevolutResult(jsonObj)
114+
cursor = result.next_cursor
115+
116+
for (const rawTx of result.transactions) {
117+
if (asPreRevolutTx(rawTx).state === 'completed') {
118+
const standardTx = processRevolutTx(rawTx)
119+
windowTxs.push(standardTx)
120+
if (standardTx.isoDate > windowLatestIsoDate) {
121+
windowLatestIsoDate = standardTx.isoDate
122+
}
123+
}
124+
}
125+
126+
if (result.transactions.length > 0) {
127+
log(`Revolut txs ${result.transactions.length}`)
128+
}
129+
130+
if (cursor == null) {
131+
break
132+
}
133+
}
134+
135+
// The window completed cleanly: commit its txs and advance progress.
136+
// Persisting progress on empty windows too keeps the next invocation
137+
// from re-walking the full range from PLUGIN_START_DATE; the
138+
// QUERY_LOOKBACK overlap still re-checks the most recent window for
139+
// late-completing orders.
140+
standardTxs.push(...windowTxs)
141+
latestIsoDate = windowLatestIsoDate
142+
const scannedThroughIso = new Date(Math.min(endTime, now)).toISOString()
143+
if (scannedThroughIso > latestIsoDate) {
144+
latestIsoDate = scannedThroughIso
145+
}
146+
147+
startTime = endTime
148+
if (endTime > now) {
149+
break
150+
}
151+
retry = 0
152+
} catch (e) {
153+
log.error(String(e))
154+
retry++
155+
if (retry <= MAX_RETRIES) {
156+
log(`Snoozing ${60 * retry}s`)
157+
await snooze(60000 * retry)
158+
} else {
159+
break
160+
}
161+
}
162+
await snooze(1000)
163+
}
164+
165+
return {
166+
settings: { latestIsoDate },
167+
transactions: standardTxs
168+
}
169+
}
170+
171+
export const revolut: PartnerPlugin = {
172+
queryFunc: queryRevolut,
173+
pluginName: 'Revolut',
174+
pluginId: 'revolut'
175+
}
176+
177+
export function processRevolutTx(rawTx: unknown): StandardTx {
178+
const tx = asRevolutTx(rawTx)
179+
const { isoDate, timestamp } = smartIsoDateFromTimestamp(
180+
tx.created_at.getTime()
181+
)
182+
183+
const direction = tx.type
184+
const depositTxid = direction === 'sell' ? tx.tx_hash : undefined
185+
const payoutTxid = direction === 'buy' ? tx.tx_hash : undefined
186+
// The wallet_address is the user's crypto wallet, so it belongs on
187+
// whichever side holds crypto: payout for a buy, deposit for a sell.
188+
const depositAddress = direction === 'sell' ? tx.wallet_address : undefined
189+
const payoutAddress = direction === 'buy' ? tx.wallet_address : undefined
190+
191+
const standardTx: StandardTx = {
192+
status: 'complete',
193+
orderId: tx.id,
194+
countryCode: tx.country_code ?? null,
195+
depositTxid,
196+
depositAddress,
197+
depositCurrency:
198+
direction === 'buy'
199+
? tx.fiat_currency.toUpperCase()
200+
: tx.crypto_currency.toUpperCase(),
201+
depositChainPluginId: undefined,
202+
depositEvmChainId: undefined,
203+
depositTokenId: undefined,
204+
depositAmount: direction === 'buy' ? tx.fiat_amount : tx.crypto_amount,
205+
direction,
206+
exchangeType: 'fiat',
207+
paymentType: getRevolutPaymentType(tx),
208+
payoutTxid,
209+
payoutAddress,
210+
payoutCurrency:
211+
direction === 'buy'
212+
? tx.crypto_currency.toUpperCase()
213+
: tx.fiat_currency.toUpperCase(),
214+
payoutChainPluginId: undefined,
215+
payoutEvmChainId: undefined,
216+
payoutTokenId: undefined,
217+
payoutAmount: direction === 'buy' ? tx.crypto_amount : tx.fiat_amount,
218+
timestamp,
219+
isoDate,
220+
usdValue: -1,
221+
rawTx
222+
}
223+
return standardTx
224+
}
225+
226+
function getRevolutPaymentType(tx: RevolutTx): FiatPaymentType | null {
227+
switch (tx.payment_method) {
228+
case undefined:
229+
return null
230+
case 'revolut':
231+
return 'revolut'
232+
case 'card':
233+
return 'credit'
234+
case 'bank_transfer':
235+
return 'banktransfer'
236+
case 'apple_pay':
237+
return 'applepay'
238+
case 'google_pay':
239+
return 'googlepay'
240+
default:
241+
throw new Error(
242+
`Unknown payment method: ${tx.payment_method} for ${tx.id}`
243+
)
244+
}
245+
}

src/queryEngine.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { moonpay } from './partners/moonpay'
2525
import { paybis } from './partners/paybis'
2626
import { paytrie } from './partners/paytrie'
2727
import { rango } from './partners/rango'
28+
import { revolut } from './partners/revolut'
2829
import { safello } from './partners/safello'
2930
import { sideshift } from './partners/sideshift'
3031
import { simplex } from './partners/simplex'
@@ -77,6 +78,7 @@ const plugins = [
7778
paybis,
7879
paytrie,
7980
rango,
81+
revolut,
8082
safello,
8183
sideshift,
8284
simplex,

0 commit comments

Comments
 (0)