Skip to content

Commit ef7d549

Browse files
committed
feat(suite-native): add trade history CSV export
1 parent 6ad6e1c commit ef7d549

12 files changed

Lines changed: 851 additions & 1 deletion

File tree

suite-common/trading/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export * from './utils/tradingAccountUtils';
4242
export * from './utils/buy/buyUtils';
4343
export * from './utils/receiveAccountUtils';
4444
export * from './utils/tradeOperationUtils';
45+
export * from './utils/tradeHistoryExportUtils';
4546
export * from './utils/exchange/composeDexTxSimulationAction';
4647
export * from './utils/exchange/exchangeUtils';
4748
export * from './utils/exchange/getExchangeIssue';
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import { type BuyTrade, type Coins, type CryptoId, type SellFiatTrade } from 'invity-api';
2+
3+
import coins from '../__fixtures__/coins.json';
4+
import { type TradingRootState, initialState } from '../reducers/tradingCommonReducer';
5+
import {
6+
type TradingTransaction,
7+
type TradingTransactionBuy,
8+
type TradingTransactionExchange,
9+
type TradingTransactionSell,
10+
} from '../types';
11+
import {
12+
TRADING_HISTORY_CSV_COLUMNS,
13+
type TradingHistoryCsvColumnLabels,
14+
buildTradingHistoryCsv,
15+
getTradingHistoryCsvRow,
16+
getTradingHistoryCsvType,
17+
prepareTradingHistoryCsv,
18+
sanitizeTradingCsvValue,
19+
} from './tradeHistoryExportUtils';
20+
21+
const labels: TradingHistoryCsvColumnLabels = {
22+
orderId: 'Trade ID',
23+
date: 'Date and time',
24+
type: 'Type',
25+
spentAmount: 'Spent amount',
26+
spendTicker: 'Spend ticker',
27+
spendNetwork: 'Spend network',
28+
spendTransactionId: 'Spend transaction ID',
29+
receiveAmount: 'Receive amount',
30+
receiveTicker: 'Receive ticker',
31+
receiveNetwork: 'Receive network',
32+
provider: 'Provider',
33+
status: 'Status',
34+
receiveTransactionId: 'Receive transaction ID',
35+
paymentId: 'Payment ID',
36+
};
37+
38+
const resolvers = {
39+
getCoinSymbol: (cryptoId: CryptoId) =>
40+
(
41+
({
42+
bitcoin: 'BTC',
43+
ethereum: 'ETH',
44+
'ethereum--0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': 'USDC',
45+
}) as Record<string, string>
46+
)[cryptoId],
47+
getProviderName: (name: string | undefined) =>
48+
name === 'btcdirect-sell' ? 'BTC Direct' : name,
49+
};
50+
51+
const buyTrade: TradingTransactionBuy = {
52+
tradeType: 'buy',
53+
date: '2025-04-10T20:21:25.042Z',
54+
key: 'buy-key',
55+
data: {
56+
orderId: 'buy-order',
57+
exchange: 'mercuryo',
58+
fiatCurrency: 'USD',
59+
fiatStringAmount: '1234',
60+
receiveCurrency: 'ethereum' as CryptoId,
61+
receiveStringAmount: '0.462586',
62+
receiveTxHash: 'buy-receive-hash',
63+
paymentId: 'buy-payment',
64+
status: 'SUCCESS',
65+
} as BuyTrade,
66+
receiveAccountKey: undefined,
67+
selectedAccountKey: undefined,
68+
};
69+
70+
const sellTrade: TradingTransactionSell = {
71+
tradeType: 'sell',
72+
date: '2025-01-01T20:12:25.042Z',
73+
key: 'sell-key',
74+
data: {
75+
orderId: 'sell-order',
76+
exchange: 'btcdirect-sell',
77+
cryptoCurrency: 'bitcoin' as CryptoId,
78+
cryptoStringAmount: '1.22',
79+
fiatCurrency: 'USD',
80+
fiatStringAmount: '100',
81+
txid: 'sell-send-hash',
82+
paymentId: 'sell-payment',
83+
status: 'SUCCESS',
84+
} as SellFiatTrade,
85+
sendAccountKey: undefined,
86+
};
87+
88+
const exchangeTrade: TradingTransactionExchange = {
89+
tradeType: 'exchange',
90+
date: '2025-02-12T20:11:03.042Z',
91+
key: 'exchange-key',
92+
data: {
93+
orderId: 'exchange-order',
94+
exchange: 'changelly',
95+
send: 'ethereum--0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CryptoId,
96+
sendStringAmount: '10.1232',
97+
receive: 'bitcoin' as CryptoId,
98+
receiveStringAmount: '0.462586',
99+
receiveTxHash: 'exchange-receive-hash',
100+
status: 'SUCCESS',
101+
},
102+
sendAccountKey: undefined,
103+
receiveAccountKey: undefined,
104+
};
105+
106+
describe('tradeHistoryExportUtils', () => {
107+
describe('sanitizeTradingCsvValue', () => {
108+
it('returns plain values unchanged', () => {
109+
expect(sanitizeTradingCsvValue('abc')).toBe('abc');
110+
});
111+
112+
it('wraps values containing the separator', () => {
113+
expect(sanitizeTradingCsvValue('a,b')).toBe('"a,b"');
114+
});
115+
116+
it('escapes and wraps values containing quotes', () => {
117+
expect(sanitizeTradingCsvValue('a"b')).toBe('"a""b"');
118+
});
119+
120+
it('wraps values containing newlines', () => {
121+
expect(sanitizeTradingCsvValue('a\nb')).toBe('"a\nb"');
122+
});
123+
124+
it('neutralizes formula-injection prefixes', () => {
125+
expect(sanitizeTradingCsvValue('=cmd')).toBe("'=cmd");
126+
expect(sanitizeTradingCsvValue('@SUM(1)')).toBe("'@SUM(1)");
127+
});
128+
129+
it('neutralizes and wraps a formula prefix that also contains a separator', () => {
130+
expect(sanitizeTradingCsvValue('=a,b')).toBe('"\'=a,b"');
131+
});
132+
});
133+
134+
describe('getTradingHistoryCsvType', () => {
135+
it.each([
136+
['buy', 'buy'],
137+
['sell', 'sell'],
138+
['exchange', 'swap'],
139+
] as const)('maps %s to %s', (tradeType, expected) => {
140+
expect(getTradingHistoryCsvType(tradeType)).toBe(expected);
141+
});
142+
});
143+
144+
describe('getTradingHistoryCsvRow', () => {
145+
it('maps a buy trade (fiat spent, crypto received, no spend tx)', () => {
146+
expect(getTradingHistoryCsvRow(buyTrade, resolvers)).toEqual({
147+
orderId: 'buy-order',
148+
date: '2025-04-10T20:21:25.042Z',
149+
type: 'buy',
150+
spentAmount: '1234',
151+
spendTicker: 'USD',
152+
spendNetwork: '',
153+
spendTransactionId: '',
154+
receiveAmount: '0.462586',
155+
receiveTicker: 'ETH',
156+
receiveNetwork: 'Ethereum',
157+
provider: 'mercuryo',
158+
status: 'SUCCESS',
159+
receiveTransactionId: 'buy-receive-hash',
160+
paymentId: 'buy-payment',
161+
});
162+
});
163+
164+
it('maps a sell trade (crypto spent, fiat received, no receive tx)', () => {
165+
expect(getTradingHistoryCsvRow(sellTrade, resolvers)).toEqual({
166+
orderId: 'sell-order',
167+
date: '2025-01-01T20:12:25.042Z',
168+
type: 'sell',
169+
spentAmount: '1.22',
170+
spendTicker: 'BTC',
171+
spendNetwork: 'Bitcoin',
172+
spendTransactionId: 'sell-send-hash',
173+
receiveAmount: '100',
174+
receiveTicker: 'USD',
175+
receiveNetwork: '',
176+
provider: 'BTC Direct',
177+
status: 'SUCCESS',
178+
receiveTransactionId: '',
179+
paymentId: 'sell-payment',
180+
});
181+
});
182+
183+
it('maps an exchange trade to swap (crypto-to-crypto, no payment id)', () => {
184+
expect(getTradingHistoryCsvRow(exchangeTrade, resolvers)).toEqual({
185+
orderId: 'exchange-order',
186+
date: '2025-02-12T20:11:03.042Z',
187+
type: 'swap',
188+
spentAmount: '10.1232',
189+
spendTicker: 'USDC',
190+
spendNetwork: 'Ethereum',
191+
spendTransactionId: '',
192+
receiveAmount: '0.462586',
193+
receiveTicker: 'BTC',
194+
receiveNetwork: 'Bitcoin',
195+
provider: 'changelly',
196+
status: 'SUCCESS',
197+
receiveTransactionId: 'exchange-receive-hash',
198+
paymentId: '',
199+
});
200+
});
201+
202+
it('falls back to the raw crypto id when the ticker cannot be resolved', () => {
203+
const trade: TradingTransactionExchange = {
204+
...exchangeTrade,
205+
data: { ...exchangeTrade.data, send: 'unknown-coin' as CryptoId },
206+
};
207+
208+
expect(getTradingHistoryCsvRow(trade, resolvers).spendTicker).toBe('unknown-coin');
209+
});
210+
211+
it('uses empty strings for missing optional values', () => {
212+
const trade: TradingTransactionExchange = {
213+
tradeType: 'exchange',
214+
date: '2025-02-12T20:11:03.042Z',
215+
key: 'exchange-key',
216+
data: {
217+
orderId: 'only-order',
218+
sendStringAmount: undefined,
219+
receiveStringAmount: undefined,
220+
},
221+
sendAccountKey: undefined,
222+
receiveAccountKey: undefined,
223+
};
224+
225+
expect(getTradingHistoryCsvRow(trade, resolvers)).toEqual({
226+
orderId: 'only-order',
227+
date: '2025-02-12T20:11:03.042Z',
228+
type: 'swap',
229+
spentAmount: '',
230+
spendTicker: '',
231+
spendNetwork: '',
232+
spendTransactionId: '',
233+
receiveAmount: '',
234+
receiveTicker: '',
235+
receiveNetwork: '',
236+
provider: '',
237+
status: '',
238+
receiveTransactionId: '',
239+
paymentId: '',
240+
});
241+
});
242+
});
243+
244+
describe('buildTradingHistoryCsv', () => {
245+
const header = TRADING_HISTORY_CSV_COLUMNS.map(column => labels[column]).join(',');
246+
247+
it('returns only the header for an empty trade list', () => {
248+
expect(buildTradingHistoryCsv(labels)([], resolvers)).toBe(header);
249+
});
250+
251+
it('builds a header plus one line per trade', () => {
252+
const trades: TradingTransaction[] = [buyTrade, sellTrade, exchangeTrade];
253+
const csv = buildTradingHistoryCsv(labels)(trades, resolvers);
254+
const lines = csv.split('\n');
255+
256+
expect(lines).toHaveLength(4);
257+
expect(lines[0]).toBe(header);
258+
expect(lines[1]).toBe(
259+
'buy-order,2025-04-10T20:21:25.042Z,buy,1234,USD,,,0.462586,ETH,Ethereum,mercuryo,SUCCESS,buy-receive-hash,buy-payment',
260+
);
261+
});
262+
263+
it('uses the injected translated labels for the header row', () => {
264+
const czLabels = Object.fromEntries(
265+
TRADING_HISTORY_CSV_COLUMNS.map(column => [column, `cs:${column}`]),
266+
) as TradingHistoryCsvColumnLabels;
267+
268+
const [headerRow] = buildTradingHistoryCsv(czLabels)([], resolvers).split('\n');
269+
270+
expect(headerRow).toBe(
271+
TRADING_HISTORY_CSV_COLUMNS.map(column => `cs:${column}`).join(','),
272+
);
273+
});
274+
275+
it('sanitizes values that contain the separator', () => {
276+
const trade: TradingTransactionBuy = {
277+
...buyTrade,
278+
data: { ...buyTrade.data, orderId: 'a,b' } as BuyTrade,
279+
};
280+
const csv = buildTradingHistoryCsv(labels)([trade], resolvers);
281+
const [, row] = csv.split('\n');
282+
283+
expect(row?.startsWith('"a,b",')).toBe(true);
284+
});
285+
});
286+
287+
describe('prepareTradingHistoryCsv', () => {
288+
const state: TradingRootState = {
289+
wallet: {
290+
trading: {
291+
...initialState,
292+
info: { ...initialState.info, coins: coins as unknown as Coins },
293+
},
294+
},
295+
};
296+
297+
it('resolves coin tickers from state and falls back to the raw provider name', () => {
298+
const csv = prepareTradingHistoryCsv(labels)(state, [sellTrade]);
299+
const [, row] = csv.split('\n');
300+
301+
// BTC ticker resolved from state coins, provider name falls back to the raw exchange id.
302+
expect(row).toBe(
303+
'sell-order,2025-01-01T20:12:25.042Z,sell,1.22,BTC,Bitcoin,sell-send-hash,100,USD,,btcdirect-sell,SUCCESS,,sell-payment',
304+
);
305+
});
306+
});
307+
});

0 commit comments

Comments
 (0)