-
Notifications
You must be signed in to change notification settings - Fork 18
Add Revolut fiat payment provider #211
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 1 commit
727da5d
c091121
f150c8d
134bdb3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,236 @@ | ||
| import { | ||
| asArray, | ||
| asDate, | ||
| asNumber, | ||
| asObject, | ||
| asOptional, | ||
| asString, | ||
| asUnknown, | ||
| asValue | ||
| } from 'cleaners' | ||
|
|
||
| import { | ||
| asStandardPluginParams, | ||
| EDGE_APP_START_DATE, | ||
| FiatPaymentType, | ||
| PartnerPlugin, | ||
| PluginParams, | ||
| PluginResult, | ||
| StandardTx | ||
| } from '../types' | ||
| import { datelog, retryFetch, smartIsoDateFromTimestamp, snooze } from '../util' | ||
|
|
||
| const asRevolutTx = asObject({ | ||
| id: asString, | ||
| type: asValue('buy', 'sell'), | ||
| created_at: asDate, | ||
| fiat_amount: asNumber, | ||
| fiat_currency: asString, | ||
| crypto_amount: asNumber, | ||
| crypto_currency: asString, | ||
| wallet_address: asOptional(asString), | ||
| tx_hash: asOptional(asString), | ||
| country_code: asOptional(asString), | ||
| payment_method: asOptional(asString) | ||
| }) | ||
|
|
||
| type RevolutTx = ReturnType<typeof asRevolutTx> | ||
|
|
||
| const asPreRevolutTx = asObject({ | ||
| state: asString | ||
| }) | ||
|
|
||
| const asRevolutResult = asObject({ | ||
| transactions: asArray(asUnknown), | ||
| next_cursor: asOptional(asString) | ||
| }) | ||
|
|
||
| const PLUGIN_START_DATE = '2024-01-01T00:00:00.000Z' | ||
| const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 7 // 7 days | ||
| const QUERY_TIME_BLOCK_MS = QUERY_LOOKBACK | ||
| const QUERY_LIMIT = 100 | ||
| const MAX_RETRIES = 5 | ||
| const MAX_PAGES = 1000 | ||
|
|
||
| export async function queryRevolut( | ||
| pluginParams: PluginParams | ||
| ): Promise<PluginResult> { | ||
| const { settings, apiKeys } = asStandardPluginParams(pluginParams) | ||
| const { apiKey } = apiKeys | ||
|
|
||
| if (apiKey == null) { | ||
| return { | ||
| settings: { latestIsoDate: settings.latestIsoDate }, | ||
| transactions: [] | ||
| } | ||
| } | ||
|
|
||
| const now = Date.now() | ||
| let { latestIsoDate } = settings | ||
|
|
||
| if (latestIsoDate === EDGE_APP_START_DATE) { | ||
| latestIsoDate = PLUGIN_START_DATE | ||
| } | ||
|
|
||
| let startTime = new Date(latestIsoDate).getTime() - QUERY_LOOKBACK | ||
| if (startTime < 0) startTime = 0 | ||
|
|
||
| const standardTxs: StandardTx[] = [] | ||
| let retry = 0 | ||
|
|
||
| while (true) { | ||
| const endTime = startTime + QUERY_TIME_BLOCK_MS | ||
|
|
||
| try { | ||
| let cursor: string | undefined | ||
| const seenCursors = new Set<string>() | ||
| let pageCount = 0 | ||
|
|
||
| while (true) { | ||
| const requestCursor = cursor | ||
| const from = new Date(startTime).toISOString() | ||
| const to = new Date(endTime).toISOString() | ||
|
|
||
| let url = `https://api.revolut.com/partner/v1/transactions?from=${from}&to=${to}&limit=${QUERY_LIMIT}` | ||
| if (cursor != null) url += `&cursor=${cursor}` | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cursor left unencoded in URLMedium Severity Pagination appends Reviewed by Cursor Bugbot for commit 134bdb3. Configure here. |
||
|
|
||
| datelog(`Querying Revolut from:${from} to:${to}`) | ||
|
|
||
| const response = await retryFetch(url, { | ||
| headers: { | ||
| Authorization: `Bearer ${apiKey}` | ||
| } | ||
| }) | ||
| if (!response.ok) { | ||
| const text = await response.text() | ||
| throw new Error(text) | ||
| } | ||
|
|
||
| const jsonObj = await response.json() | ||
| const result = asRevolutResult(jsonObj) | ||
| const nextCursor = result.next_cursor | ||
| pageCount++ | ||
|
|
||
| for (const rawTx of result.transactions) { | ||
| if (asPreRevolutTx(rawTx).state === 'completed') { | ||
| const standardTx = processRevolutTx(rawTx) | ||
| standardTxs.push(standardTx) | ||
| if (standardTx.isoDate > latestIsoDate) { | ||
| latestIsoDate = standardTx.isoDate | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (result.transactions.length > 0) { | ||
| datelog(`Revolut txs ${result.transactions.length}`) | ||
| } | ||
|
|
||
| if (nextCursor == null || nextCursor === '') { | ||
| break | ||
| } | ||
|
|
||
| if (nextCursor === requestCursor || seenCursors.has(nextCursor)) { | ||
| datelog( | ||
| `Stopping Revolut pagination on repeated cursor ${nextCursor}` | ||
| ) | ||
| break | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| if (pageCount >= MAX_PAGES) { | ||
| datelog(`Stopping Revolut pagination after ${MAX_PAGES} pages`) | ||
| break | ||
| } | ||
|
|
||
| seenCursors.add(nextCursor) | ||
| cursor = nextCursor | ||
| } | ||
|
Comment on lines
+91
to
+167
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Issue: The inner pagination loop only exits when Recommendation: Add a max-pages guard and track the previous cursor to break on non-advancing values: const MAX_PAGES = 1000
let cursor: string | undefined
let pages = 0
while (true) {
// ... existing fetch logic ...
const prevCursor = cursor
cursor = result.next_cursor
// ... existing tx processing ...
pages++
if (cursor == null || cursor === prevCursor || pages >= MAX_PAGES) {
break
}
} |
||
|
|
||
| startTime = endTime | ||
| if (endTime > now) { | ||
| break | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| retry = 0 | ||
| } catch (e) { | ||
| datelog(e) | ||
| retry++ | ||
| if (retry <= MAX_RETRIES) { | ||
| datelog(`Snoozing ${60 * retry}s`) | ||
| await snooze(60000 * retry) | ||
| } else { | ||
| break | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
| await snooze(1000) | ||
| } | ||
|
|
||
| return { | ||
| settings: { latestIsoDate }, | ||
| transactions: standardTxs | ||
| } | ||
| } | ||
|
|
||
| export const revolut: PartnerPlugin = { | ||
| queryFunc: queryRevolut, | ||
| pluginName: 'Revolut', | ||
| pluginId: 'revolut' | ||
| } | ||
|
|
||
| export function processRevolutTx(rawTx: unknown): StandardTx { | ||
| const tx = asRevolutTx(rawTx) | ||
| const { isoDate, timestamp } = smartIsoDateFromTimestamp( | ||
| tx.created_at.getTime() | ||
| ) | ||
|
|
||
| const direction = tx.type | ||
| const depositTxid = direction === 'sell' ? tx.tx_hash : undefined | ||
| const payoutTxid = direction === 'buy' ? tx.tx_hash : undefined | ||
|
|
||
| const standardTx: StandardTx = { | ||
| status: 'complete', | ||
| orderId: tx.id, | ||
| countryCode: tx.country_code ?? null, | ||
| depositTxid, | ||
| depositAddress: undefined, | ||
| depositCurrency: | ||
| direction === 'buy' | ||
| ? tx.fiat_currency.toUpperCase() | ||
| : tx.crypto_currency.toUpperCase(), | ||
| depositAmount: direction === 'buy' ? tx.fiat_amount : tx.crypto_amount, | ||
| direction, | ||
| exchangeType: 'fiat', | ||
| paymentType: getRevolutPaymentType(tx), | ||
| payoutTxid, | ||
| payoutAddress: tx.wallet_address, | ||
|
cursor[bot] marked this conversation as resolved.
Outdated
|
||
| payoutCurrency: | ||
| direction === 'buy' | ||
| ? tx.crypto_currency.toUpperCase() | ||
| : tx.fiat_currency.toUpperCase(), | ||
| payoutAmount: direction === 'buy' ? tx.crypto_amount : tx.fiat_amount, | ||
| timestamp, | ||
| isoDate, | ||
| usdValue: -1, | ||
| rawTx | ||
| } | ||
| return standardTx | ||
| } | ||
|
Comment on lines
+212
to
+249
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: This mapping logic is high-impact for reporting accuracy and currently untested. Consider adding focused unit tests covering buy/sell direction, deposit/payout currency assignment, txid placement, and payment-method conversion. Recommendation: Add a test file such as import { expect } from 'chai'
import { processRevolutTx, getRevolutPaymentType } from '../../src/partners/revolut'
describe('processRevolutTx', () => {
const baseTx = {
id: 'order-1',
type: 'buy',
state: 'completed',
created_at: '2024-06-01T12:00:00.000Z',
fiat_amount: 100,
fiat_currency: 'usd',
crypto_amount: 0.005,
crypto_currency: 'btc',
wallet_address: 'bc1abc',
tx_hash: '0xabc',
country_code: 'US',
payment_method: 'card'
}
it('should map a buy tx with fiat deposit and crypto payout', () => {
const result = processRevolutTx(baseTx)
expect(result.direction).to.equal('buy')
expect(result.depositCurrency).to.equal('USD')
expect(result.payoutCurrency).to.equal('BTC')
expect(result.payoutTxid).to.equal('0xabc')
expect(result.depositTxid).to.be.undefined
})
it('should map a sell tx with crypto deposit and fiat payout', () => {
const result = processRevolutTx({ ...baseTx, type: 'sell' })
expect(result.direction).to.equal('sell')
expect(result.depositCurrency).to.equal('BTC')
expect(result.payoutCurrency).to.equal('USD')
expect(result.depositTxid).to.equal('0xabc')
expect(result.payoutTxid).to.be.undefined
})
}) |
||
|
|
||
| function getRevolutPaymentType(tx: RevolutTx): FiatPaymentType | null { | ||
| switch (tx.payment_method) { | ||
| case undefined: | ||
| return null | ||
| case 'revolut': | ||
| return 'revolut' | ||
| case 'card': | ||
| return 'credit' | ||
| case 'bank_transfer': | ||
| return 'banktransfer' | ||
| case 'apple_pay': | ||
| return 'applepay' | ||
| case 'google_pay': | ||
| return 'googlepay' | ||
| default: | ||
| throw new Error( | ||
| `Unknown payment method: ${tx.payment_method} for ${tx.id}` | ||
| ) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import { expect } from 'chai' | ||
| import { describe, it } from 'mocha' | ||
|
|
||
| import { processRevolutTx } from '../src/partners/revolut' | ||
|
|
||
| const baseRawTx = { | ||
| id: 'revolut-order', | ||
| type: 'buy', | ||
| created_at: '2026-07-24T12:34:56.000Z', | ||
| fiat_amount: 125.5, | ||
| fiat_currency: 'usd', | ||
| crypto_amount: 0.01, | ||
| crypto_currency: 'btc', | ||
| wallet_address: 'bc1qwallet', | ||
| tx_hash: 'revolut-txid', | ||
| country_code: 'US', | ||
| payment_method: 'card', | ||
| state: 'completed' | ||
| } | ||
|
|
||
| describe('Revolut transaction mapping', function() { | ||
| it('maps buy orders as fiat deposits and crypto payouts', function() { | ||
| const standardTx = processRevolutTx(baseRawTx) | ||
|
|
||
| expect(standardTx).to.include({ | ||
| orderId: 'revolut-order', | ||
| countryCode: 'US', | ||
| depositCurrency: 'USD', | ||
| depositAmount: 125.5, | ||
| direction: 'buy', | ||
| exchangeType: 'fiat', | ||
| paymentType: 'credit', | ||
| payoutTxid: 'revolut-txid', | ||
| payoutAddress: 'bc1qwallet', | ||
| payoutCurrency: 'BTC', | ||
| payoutAmount: 0.01, | ||
| status: 'complete', | ||
| isoDate: '2026-07-24T12:34:56.000Z', | ||
| timestamp: 1784896496, | ||
| usdValue: -1 | ||
| }) | ||
| expect(standardTx.depositTxid).equals(undefined) | ||
| }) | ||
|
|
||
| it('maps sell orders as crypto deposits and fiat payouts', function() { | ||
| const standardTx = processRevolutTx({ | ||
| ...baseRawTx, | ||
| type: 'sell', | ||
| fiat_amount: 250, | ||
| fiat_currency: 'eur', | ||
| crypto_amount: 1.5, | ||
| crypto_currency: 'eth', | ||
| payment_method: 'bank_transfer' | ||
| }) | ||
|
|
||
| expect(standardTx).to.include({ | ||
| depositTxid: 'revolut-txid', | ||
| depositCurrency: 'ETH', | ||
| depositAmount: 1.5, | ||
| direction: 'sell', | ||
| paymentType: 'banktransfer', | ||
| payoutCurrency: 'EUR', | ||
| payoutAmount: 250 | ||
| }) | ||
| expect(standardTx.payoutTxid).equals(undefined) | ||
| }) | ||
|
|
||
| for (const testCase of [ | ||
| { revolutMethod: undefined, paymentType: null }, | ||
| { revolutMethod: 'revolut', paymentType: 'revolut' }, | ||
| { revolutMethod: 'card', paymentType: 'credit' }, | ||
| { revolutMethod: 'bank_transfer', paymentType: 'banktransfer' }, | ||
| { revolutMethod: 'apple_pay', paymentType: 'applepay' }, | ||
| { revolutMethod: 'google_pay', paymentType: 'googlepay' } | ||
| ]) { | ||
| it(`maps ${testCase.revolutMethod ?? 'missing'} payment method`, function() { | ||
| const rawTx: any = { ...baseRawTx } | ||
| if (testCase.revolutMethod == null) { | ||
| delete rawTx.payment_method | ||
| } else { | ||
| rawTx.payment_method = testCase.revolutMethod | ||
| } | ||
|
|
||
| const standardTx = processRevolutTx(rawTx) | ||
|
|
||
| expect(standardTx.paymentType).equals(testCase.paymentType) | ||
| }) | ||
| } | ||
| }) |


Uh oh!
There was an error while loading. Please reload this page.