-
Notifications
You must be signed in to change notification settings - Fork 17
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 all commits
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,216 @@ | ||
| 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 | ||
|
|
||
| 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 | ||
|
|
||
| while (true) { | ||
| 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}` | ||
|
|
||
| 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) | ||
| cursor = result.next_cursor | ||
|
|
||
| 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 (cursor == null) { | ||
| break | ||
| } | ||
| } | ||
|
|
||
| startTime = endTime | ||
| if (endTime > now) { | ||
| break | ||
| } | ||
| retry = 0 | ||
| } catch (e) { | ||
| datelog(e) | ||
| retry++ | ||
| if (retry <= MAX_RETRIES) { | ||
| datelog(`Snoozing ${60 * retry}s`) | ||
| await snooze(60000 * retry) | ||
| } else { | ||
| break | ||
| } | ||
| } | ||
| 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, | ||
| 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
+158
to
+195
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}` | ||
| ) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Issue: The inner pagination loop only exits when
cursor == null. If the API returns a repeated, empty-string, or non-advancing cursor, this loop runs indefinitely until the outerpromiseTimeoutkills it—delaying progress and producing noisy retries.Recommendation: Add a max-pages guard and track the previous cursor to break on non-advancing values: