Skip to content

Add Revolut fiat payment provider#211

Open
claudinethelobster wants to merge 4 commits into
EdgeApp:masterfrom
claudinethelobster:claudine/add-revolut-plugin
Open

Add Revolut fiat payment provider#211
claudinethelobster wants to merge 4 commits into
EdgeApp:masterfrom
claudinethelobster:claudine/add-revolut-plugin

Conversation

@claudinethelobster

@claudinethelobster claudinethelobster commented Feb 12, 2026

Copy link
Copy Markdown

Summary

Implements Revolut fiat payment provider following the moonpay.ts pattern.

Changes

  • Created src/partners/revolut.ts - Partner plugin for Revolut API integration
  • Created src/routes/v1/revolut.ts - REST endpoint for Revolut transactions
  • Updated src/indexApi.ts - Registered revolut router
  • Updated src/demo/partners.ts - Added revolut entry (type: fiat, color: #191C33)
  • Updated src/queryEngine.ts - Registered revolut plugin

Implementation Details

  • Follows moonpay.ts architecture
  • Uses cleaners for all external data
  • Types derived from cleaners
  • No unused code
  • Debug logging guarded

Testing

  • Implementation follows edge-reports-server conventions
  • Pattern verified against moonpay.ts reference


Note

Medium Risk
New third-party API ingestion affects transaction data and progress watermarks, though it follows existing partner patterns and includes targeted tests for pagination/retry behavior.

Overview
Adds Revolut as a new fiat partner so the query engine can pull completed buy/sell transactions from Revolut’s partner API and store them as standard report transactions.

The new queryRevolut flow walks time windows with a 7-day lookback, paginates with cursor guards (malformed/repeated cursors, max pages), retries on failures, and only advances latestIsoDate when pagination completes—otherwise it keeps fetched rows but does not move the watermark. processRevolutTx maps buy/sell orders, payment methods (card, bank transfer, Apple/Google Pay, etc.), and optional fields into StandardTx. Revolut is registered in queryEngine and listed in demo partner metadata (fiat, #191C33).

Tests cover mapping for buy/sell and payment types, plus query behavior for cursor bugs, retries without duplicate appends, and advancing progress on empty responses.

Reviewed by Cursor Bugbot for commit 134bdb3. Bugbot is set up for automated code reviews on this repo. Configure here.

@samholmes

Copy link
Copy Markdown
Contributor

The fixup commit should have been squashed. If the fixup was apart of review feedback then it should be prefixed with fixup!.

@claudinethelobster

Copy link
Copy Markdown
Author

Done — the fixup was squashed into the original commit (now 515be6a). The PR shows a single commit.

@samholmes samholmes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See inline comments for specific issues.

Comment thread src/partners/revolut.ts
Comment on lines +84 to +126
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
}
}

Copy link
Copy Markdown
Contributor

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 outer promiseTimeout kills it—delaying progress and producing noisy retries.

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
  }
}

Comment thread src/partners/revolut.ts
Comment on lines +158 to +195
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 test/partners/revolut.test.ts:

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
  })
})

@j0ntz j0ntz mentioned this pull request Jul 23, 2026
2 tasks
- 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
@claudinethelobster
claudinethelobster force-pushed the claudine/add-revolut-plugin branch from 515be6a to 727da5d Compare July 24, 2026 17:55
@claudinethelobster

Copy link
Copy Markdown
Author

Updated for Sam’s review feedback in 727da5d:

  • Added pagination protection for Revolut queries with a max-page guard and non-advancing cursor break.
  • Added focused Revolut transaction mapping tests for buy/sell direction, currency assignment, txid placement, country/payment fields, missing optionals, and payment-method mapping.

I could not run the repo checks locally because dependencies are unavailable in this workspace (mocha/tsc missing, and yarn install --frozen-lockfile fails on the private changelly/api-changelly SSH dependency).

Comment thread src/partners/revolut.ts Outdated
Comment thread src/partners/revolut.ts Outdated
Comment thread src/partners/revolut.ts
@claudinethelobster

Copy link
Copy Markdown
Author

Updated for the Cursor Bugbot follow-up in c091121:

  • Made optional Revolut API fields tolerate null / malformed values without dropping the whole query window.
  • Treat malformed or unknown payment_method as missing.
  • Avoid setting payoutAddress from wallet_address on sell orders.
  • Prevent repeated/non-advancing pagination from advancing latestIsoDate, so unfetched transactions are not silently skipped.
  • Added focused tests for optional fields, sell payout address behavior, and repeated cursor pagination.

Verification:

  • git diff --check passed.
  • npm test -- --grep Revolut is still blocked locally because mocha is not installed.
  • yarn install --frozen-lockfile is still blocked by the private SSH dependency changelly/api-changelly (Permission denied (publickey)).

Comment thread src/partners/revolut.ts
Comment thread src/partners/revolut.ts
Comment thread src/partners/revolut.ts
@claudinethelobster

claudinethelobster commented Jul 24, 2026

Copy link
Copy Markdown
Author

Updated for the latest Cursor Bugbot follow-up in f150c8d:

  • Detect repeated/non-advancing cursor pages before ingestion, avoiding duplicate orders in the same run.
  • Stage page results per window/retry and only append them after pagination completes, avoiding retry duplicate inserts.
  • Advance the Revolut watermark for completed empty-history windows so future runs do not keep rewalking from the plugin start date.
  • Added focused tests for repeated cursor ingestion, retry duplicate prevention, and empty-history watermark advancement.

Verification:

  • git diff --check passed.
  • npm test -- --grep Revolut is still blocked locally because mocha is not installed.
  • npm run build.types -- --pretty false is still blocked locally because tsc is not installed.
  • yarn install --frozen-lockfile is still blocked by the private SSH dependency changelly/api-changelly (Permission denied (publickey)).

Comment thread src/partners/revolut.ts
Comment thread src/partners/revolut.ts Outdated
@claudinethelobster

Copy link
Copy Markdown
Author

Updated for the latest Cursor Bugbot follow-up in 134bdb3:

  • Treat malformed non-string next_cursor as unsafe/incomplete so it does not advance the Revolut watermark.
  • Preserve transactions already fetched in a window when repeated-cursor / unsafe pagination stops, while keeping the watermark unchanged for a future retry.
  • Added focused tests for repeated cursor and malformed cursor pagination behavior.

Verification:

  • git diff --check passed.
  • npm test -- --grep Revolut is still blocked locally because mocha is not installed.
  • npm run build.types -- --pretty false is still blocked locally because tsc is not installed.
  • Focused lint is also blocked locally because eslint is not installed.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 134bdb3. Configure here.

Comment thread src/partners/revolut.ts
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}`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor left unencoded in URL

Medium Severity

Pagination appends cursor with raw string concatenation, so reserved characters in a cursor token are not escaped. That can alter the query string, fetch the wrong page, or break pagination after the first page. paybis builds the same params through a query helper that encodes values.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 134bdb3. Configure here.

Comment thread test/revolut.test.ts
} finally {
;(util as any).retryFetch = oldRetryFetch
;(util as any).snooze = oldSnooze
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retry test misses frozen time

Medium Severity

The retry test never stubs Date.now, so after the first window succeeds queryRevolut keeps walking later windows until it reaches the real current time. That adds extra fetches and can append the same mocked orderId again, so the callCount === 3 and single-transaction assertions do not match the implementation under today’s date.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 134bdb3. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants