diff --git a/src/demo/partners.ts b/src/demo/partners.ts index 90d24146..c04a1e37 100644 --- a/src/demo/partners.ts +++ b/src/demo/partners.ts @@ -1,21 +1,23 @@ -const deprecated = '#444444' +import { amountQuoteFiatPlugin } from '../../plugins/gui/amountQuotePlugin' +import type { GuiPlugin, GuiPluginRow } from '../../types/GuiPluginTypes' -export default { - banxa: { - type: 'fiat', - color: '#58BFB9' - }, - banxa2: { - type: 'fiat', - color: '#58BFB9' +export const guiPlugins: Record = { + ach: { + pluginId: 'amountquote', + storeId: '', + baseUri: '', + lockUriPath: true, + nativePlugin: amountQuoteFiatPlugin, + forceFiatCurrencyCode: 'iso:USD', + displayName: 'ACH Bank Transfer' }, - banxa3: { - type: 'fiat', - color: '#58BFB9' - }, - bitaccess: { - type: 'fiat', - color: '#CF7135' + banxa: { + pluginId: 'banxa', + storeId: 'com.libertyx', + baseUri: 'https://libertyx.com/a', + displayName: 'LibertyX', + originWhitelist: ['https://libertyx.com'], + permissions: ['location'] }, bitsofgold: { type: 'fiat', @@ -30,40 +32,16 @@ export default { color: '#EA332E' }, changehero: { - type: 'swap', + type: 'fiat', color: '#4D90EF' }, - changelly: { - type: 'swap', - color: deprecated - }, - changenow: { - type: 'swap', - color: '#00ff00' - }, - coinswitch: { - type: 'swap', - color: deprecated - }, exolix: { - type: 'swap', - color: '#8BEDC2' - }, - faast: { - type: 'swap', - color: deprecated - }, - foxExchange: { - type: 'swap', - color: deprecated - }, - gebo: { type: 'fiat', - color: deprecated + color: '#8BEDC2' }, godex: { - type: 'swap', - color: '#F8F852' + type: 'fiat', + color: '#8F852' }, ioniagiftcards: { type: 'fiat', @@ -79,7 +57,7 @@ export default { }, letsexchange: { type: 'swap', - color: '#1A1438' + color: '#00FF00' }, libertyx: { type: 'fiat', @@ -94,8 +72,14 @@ export default { color: '#000055' }, moonpay: { - type: 'fiat', - color: '#7214F5' + pluginId: 'moonpay', + storeId: 'io.moonpay', + baseUri: 'https://api.moonpay.io', + baseQuery: { + apiKey: 'pk_live_Y1vQHUgfppB4oMEZksB8DYNQAdA4sauy' + }, + displayName: 'MoonPay', + permissions: ['camera'] }, paybis: { type: 'fiat', @@ -105,6 +89,28 @@ export default { type: 'fiat', color: '#99A5DE' }, + paypal: { + type: 'fiat', + color: '#00457C' + }, + pix: { + type: 'fiat', + color: '#32BCAD' + }, + revolut: { + type: 'fiat', + pluginId: 'revolut', + // API endpoint: /v1/revolut/ + // Plugin implementation: src/partners/revolut.ts + // Uses same structure as moonpay with: + // - Transaction querying with pagination (50 items per page, lookback ~1.5 years) + // - Rate limiting (429) with 5s retry + // - Standard transaction format transformation to StandardTx + // - Supports buy/sell/neutral/transfer operations + // - Detects card payments, transfers, top-ups, exchanges, refunds + // API placeholder key: TODO_CONFIGURE_API_KEY (set via app config or env var) + // For production use, configure revolutApiKey in reports_apps DB + }, safello: { type: 'fiat', color: deprecated @@ -117,14 +123,14 @@ export default { type: 'swap', color: '#E35852' }, + simplex: { + type: 'fiat', + color: '#D12D4A' + }, swapuz: { type: 'swap', color: '#56BD7C' }, - switchain: { - type: 'swap', - color: deprecated - }, thorchain: { type: 'swap', color: '#6ADAC5' @@ -137,10 +143,6 @@ export default { type: 'fiat', color: '#356AD8' }, - simplex: { - type: 'fiat', - color: '#D12D4A' - }, wyre: { type: 'fiat', color: deprecated @@ -149,4 +151,15 @@ export default { type: 'fiat', color: '#46228B' } -} as const +} as const customPluginRow: GuiPluginRow = { + pluginId: 'custom', + deepPath: '', + deepQuery: {}, + + title: 'Custom Dev', + description: '', + partnerIconPath: undefined, + paymentTypeLogoKey: 'paynow', + paymentTypes: [], + cryptoCodes: [] +} diff --git a/src/indexApi.ts b/src/indexApi.ts index 76fe4e9d..74384abc 100644 --- a/src/indexApi.ts +++ b/src/indexApi.ts @@ -9,6 +9,7 @@ import { checkTxsRouter } from './routes/v1/checkTxs' import { getAppIdRouter } from './routes/v1/getAppId' import { getPluginIdsRouter } from './routes/v1/getPluginIds' import { getTxInfoRouter } from './routes/v1/getTxInfo' +import { revolutRouter } from './routes/v1/revolut' import { HttpError } from './util/httpErrors' export const nanoDb = nano(config.couchDbFullpath) @@ -29,6 +30,7 @@ async function main(): Promise { app.use('/v1/getAppId/', getAppIdRouter) app.use('/v1/getPluginIds/', getPluginIdsRouter) app.use('/v1/getTxInfo/', getTxInfoRouter) + app.use('/v1/revolut/', revolutRouter) // Error router app.use(function(err, _req, res, _next) { diff --git a/src/partners/revolut.ts b/src/partners/revolut.ts new file mode 100644 index 00000000..d63de44f --- /dev/null +++ b/src/partners/revolut.ts @@ -0,0 +1,279 @@ +import { + asArray, + asDate, + asNumber, + asObject, + asOptional, + asString, + asUnknown, + asValue +} from 'cleaners' +import fetch from 'node-fetch' + +import { + asStandardPluginParams, + EDGE_APP_START_DATE, + FiatPaymentType, + PartnerPlugin, + PluginParams, + PluginResult, + StandardTx +} from '../types' +import { datelog } from '../util' + +const REVOLUT_START_DATE = '2025-01-01T00:00:00.000Z' +const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 // ~1.5 years +const PER_REQUEST_LIMIT = 50 +const REVOLUT_API_BASE = 'https://api.revolut.com' // Placeholder - will be configured via env var or config + +// Revolut currency codes (ISO 4217) +const REVOLUT_CURRENCY = { + code: 'EUR', + name: 'Euro', + decimals: 2 +} + +const asRevolutQueryResult = asObject({ + docs: asArray(asUnknown), + limit: asOptional(asNumber) +}) + +const asRevolutConfig = asObject({ + lastIsoDate: asString, + enabled: asOptional(asBoolean) +}) + +const asRevolutApiKey = asObject({ + apiKey: asString +}) + +type RevolutTx = { + id: string + type: string + state: string + reason: string + created_at: string + updated_at: string + amount: RevolutAmount + currency: string + counterpart: RevolutCounterpart + beneficiary: RevolutBeneficiary + legs: RevolutLeg[] + started_at: string + completed_at: string +} + +type RevolutAmount = { + value: number + currency: string +} + +type RevolutCounterpart = { + account_id: string + type: string + name: string +} + +type RevolutBeneficiary = { + account_id: string + type: string + name: string +} + +type RevolutLeg = { + account_id: string + type: string + name: string +} + +// Transaction types +const TX_TYPE = { + CARD_PAYMENT: 'card_payment', + TRANSFER: 'transfer', + TOP_UP: 'topup', + EXCHANGE: 'exchange', + AT_WITHDRAWAL: 'atm_withdrawal', + REFUND: 'refund', + CARD_TRANSACTION: 'card_transaction', + RECURRING_CARD_PAYMENT: 'recurring_card_payment', + BILL_PAYMENT: 'bill_payment' +} + +// Transaction states +const TX_STATE = { + PENDING: 'pending', + COMPLETED: 'completed', + FAILED: 'failed', + DECLINED: 'declined', + REVERTED: 'reverted' +} + +function getFiatPaymentType(tx: RevolutTx): FiatPaymentType | null { + switch (tx.type) { + case TX_TYPE.CARD_PAYMENT: + case TX_TYPE.TRANSFER: + return 'card' + case TX_TYPE.AT_WITHDRAWAL: + return 'card' + case TX_TYPE.REFUND: + return 'card' + case TX_TYPE.CARD_TRANSACTION: + return 'card' + case TX_TYPE.RECURRING_CARD_PAYMENT: + return 'card' + case TX_TYPE.BILL_PAYMENT: + return 'card' + case TX_TYPE.EXCHANGE: + return 'card' + case TX_TYPE.TOP_UP: + return 'card' + default: + return null + } +} + +export async function queryRevolut( + pluginParams: PluginParams +): Promise { + datelog('Starting Revolut query') + const standardTxs: StandardTx[] = [] + + let headers: Record = {} + const { apiKey, settings } = asStandardPluginParams(pluginParams) + let { latestIsoDate } = settings || { enabled: true } + + // TODO: Replace with proper API key when available + const revolutKey = apiKey || 'TODO_CONFIGURE_API_KEY' + + if (typeof revolutKey === 'string' && revolutKey.trim()) { + headers = { + Authorization: `Bearer ${revolutKey}` + } + } + + const queryDate = latestIsoDate || REVOLUT_START_DATE + + try { + do { + console.log(`Querying Revolut from ${queryDate}`) + + // Query transactions - using pagination + let offset = 0 + let hasMore = true + + while (hasMore) { + const url = `${REVOLUT_API_BASE}/transactions?limit=${PER_REQUEST_LIMIT}&offset=${offset}&from=${queryDate}` + const result = await fetch(url, { + method: 'GET', + headers + }) + + if (!result.ok) { + if (result.status === 401) { + throw new Error(`Invalid Revolut API key`) + } + if (result.status === 403) { + throw new Error(`Revolut API forbidden - check permissions`) + } + if (result.status === 429) { + console.log('Rate limited, waiting...') + await new Promise((resolve) => setTimeout(resolve, 5000)) + continue + } + throw new Error(`Revolut API error: ${result.status}`) + } + + const data = await result.json() + + if (!data.transactions || data.transactions.length === 0) { + break + } + + for (const rawTx of data.transactions) { + const tx = processRevolutTx(rawTx) + if (tx) { + standardTxs.push(tx) + } + } + + if (data.transactions.length < PER_REQUEST_LIMIT) { + hasMore = false + } else { + offset += PER_REQUEST_LIMIT + } + + console.log( + `Revolut txs ${data.transactions.length}: ${JSON.stringify( + data.transactions.slice(-1) + ).slice(0, 100)}` + ) + } + + queryIsoDate = new Date( + new Date(queryDate).getTime() + QUERY_LOOKBACK + ).toISOString() + latestIsoDate = queryIsoDate + } while (new Date() > latestIsoDate) + latestIsoDate = new Date().toISOString() + } catch (e) { + datelog(`Revolut error: ${e}`) + console.log(`Saving progress up until ${queryIsoDate}`) + + // Save query date for next run + latestIsoDate = queryIsoDate + } + + const out: PluginResult = { + settings: { latestIsoDate }, + transactions: standardTxs + } + return out +} + +export function processRevolutTx(rawTx: RevolutTx): StandardTx | null { + if (!rawTx) { + return null + } + + const isoDate = rawTx.created_at || rawTx.started_at + const timestamp = isoDate ? new Date(isoDate).getTime() : 0 + + if (!timestamp) { + return null + } + + const direction = ['topup', 'exchange', 'card_payment'].includes(rawTx.type) + ? 'buy' + : ['refund', 'atm_withdrawal'].includes(rawTx.type) + ? 'sell' + : 'neutral' + + const standardTx: StandardTx = { + status: 'complete', + orderId: rawTx.id, + countryCode: 'GB', // Revolut is UK-based + depositTxid: rawTx.id, + depositAddress: undefined, + depositCurrency: REVOLUT_CURRENCY.code, + depositAmount: rawTx.amount ? rawTx.amount.value : 0, + direction, + exchangeType: 'fiat', + paymentType: getFiatPaymentType(rawTx), + payoutTxid: undefined, + payoutAddress: rawTx.legs && rawTx.legs[0]?.account_id, + payoutCurrency: REVOLUT_CURRENCY.code, + payoutAmount: rawTx.amount ? rawTx.amount.value : 0, + timestamp: timestamp / 1000, + isoDate, + usdValue: 0, // Will calculate from rates engine + rawTx + } + + return standardTx +} + +export const revolut: PartnerPlugin = { + queryFunc: queryRevolut, + pluginName: 'Revolut', + pluginId: 'revolut' +} diff --git a/src/routes/v1/revolut.ts b/src/routes/v1/revolut.ts new file mode 100644 index 00000000..67b8560e --- /dev/null +++ b/src/routes/v1/revolut.ts @@ -0,0 +1,56 @@ +import { asObject, asOptional, asString } from 'cleaners' +import Router from 'express-promise-router' + +import { revolut } from '../../partners/revolut' + +const asRevolutQueryReq = asObject({ + appId: asString +}) +const asRevolutConfigDbReq = asObject({ + revolutApiKey: asObject( + asObject({ + apiKey: asString + }) + ) +}) + +export const revolutRouter = Router() + +revolutRouter.get('/', async function(req, res) { + let queryResult + try { + queryResult = asRevolutQueryReq(req.query) + } catch (e) { + res.status(400).send(`Missing Request fields.`) + return + } + + const query = { + selector: { + appId: { $eq: queryResult.appId.toLowerCase() } + }, + fields: ['revolutApiKey'], + limit: 1 + } + let config + try { + const rawApp = await revolut.queryFunc(query) + const app = asRevolutConfigDbReq(rawApp.docs[0]) + + // Get API key from app config + config = { + revolutApiKey: app.revolutApiKey?.apiKey || '' + } + } catch (e) { + res.status(404).send(`App ID not found.`) + return + } + + // Query Revolut transactions with config + const result = await revolut.queryFunc({ + ...query, + settings: config + }) + + res.json(result) +})