diff --git a/pages/api/paybutton/download/transactions/[paybuttonId].ts b/pages/api/paybutton/download/transactions/[paybuttonId].ts index 8c465a070..d87aca52d 100644 --- a/pages/api/paybutton/download/transactions/[paybuttonId].ts +++ b/pages/api/paybutton/download/transactions/[paybuttonId].ts @@ -1,11 +1,8 @@ import moment from 'moment-timezone' import { - PRICE_API_DATE_FORMAT, RESPONSE_MESSAGES, DEFAULT_PAYBUTTON_CSV_FILE_DELIMITER, PAYBUTTON_TRANSACTIONS_FILE_HEADERS, - DECIMALS, - SUPPORTED_QUOTES, SupportedQuotesType, NetworkTickersType, NETWORK_TICKERS, @@ -14,38 +11,11 @@ import { } from 'constants/index' import { TransactionWithAddressAndPrices, fetchTransactionsByPaybuttonId, getTransactionValueInCurrency } from 'services/transactionService' import { PaybuttonWithAddresses, fetchPaybuttonById } from 'services/paybuttonService' -import { streamToCSV } from 'utils/files' +import { TransactionFileData, formatNumberHeaders, formatPaybuttonTransactionsFileData, isNetworkValid, streamToCSV } from 'utils/files' import { setSession } from 'utils/setSession' import { NextApiResponse } from 'next' import { getNetworkIdFromSlug } from 'services/networkService' import { fetchUserProfileFromId } from 'services/userService' -import { Prisma } from '@prisma/client' - -export interface TransactionFileData { - amount: Prisma.Decimal - date: moment.Moment - value: number - rate: number - transactionId: string - currency: string - address: string -} - -export interface FormattedTransactionFileData { - amount: string - date: string - value: string - rate: string - transactionId: string - address: string -} -export function isCurrencyValid (currency: SupportedQuotesType): boolean { - return SUPPORTED_QUOTES.includes(currency) -} - -function isNetworkValid (slug: NetworkTickersType): boolean { - return Object.values(NETWORK_TICKERS).includes(slug) -} const getPaybuttonTransactionsFileData = (transaction: TransactionWithAddressAndPrices, currency: SupportedQuotesType, timezone: string): TransactionFileData => { const { amount, hash, address, timestamp } = transaction @@ -64,27 +34,6 @@ const getPaybuttonTransactionsFileData = (transaction: TransactionWithAddressAnd } } -const formatPaybuttonTransactionsFileData = (data: TransactionFileData): FormattedTransactionFileData => { - const { - amount, - date, - value, - rate, - currency - } = data - return { - ...data, - amount: amount.toFixed(DECIMALS[currency]), - date: date.format(PRICE_API_DATE_FORMAT), - value: value.toFixed(2), - rate: rate.toFixed(14) - } -} - -const formatNumberHeaders = (headers: string[], currency: string): string[] => { - return headers.map(h => h === PAYBUTTON_TRANSACTIONS_FILE_HEADERS.value ? h + ` (${currency.toUpperCase()})` : h) -} - const sortTransactionsByNetworkId = async (transactions: TransactionWithAddressAndPrices[]): Promise => { const groupedByNetworkIdTransactions = transactions.reduce>((acc, transaction) => { const networkId = transaction.address.networkId diff --git a/pages/api/payments/download/index.ts b/pages/api/payments/download/index.ts new file mode 100644 index 000000000..4136aa899 --- /dev/null +++ b/pages/api/payments/download/index.ts @@ -0,0 +1,124 @@ +import moment from 'moment-timezone' +import { + RESPONSE_MESSAGES, + DEFAULT_PAYBUTTON_CSV_FILE_DELIMITER, + SupportedQuotesType, + SUPPORTED_QUOTES_FROM_ID, + PAYBUTTON_TRANSACTIONS_FILE_HEADERS, + NETWORK_TICKERS, + NetworkTickersType, + NETWORK_IDS +} from 'constants/index' +import { fetchAllPaymentsByUserId } from 'services/transactionService' +import { TransactionFileData, formatNumberHeaders, formatPaybuttonTransactionsFileData, isNetworkValid, streamToCSV } from 'utils/files' +import { setSession } from 'utils/setSession' +import { NextApiResponse } from 'next' +import { fetchUserProfileFromId } from 'services/userService' +import { Payment } from 'redis/types' +import { getNetworkIdFromSlug } from 'services/networkService' + +const getPaymentsFileData = (payment: Payment, currency: SupportedQuotesType, timezone: string): TransactionFileData => { + const { values, hash, timestamp, address } = payment + const amount = values.amount + const value = Number(values.values[currency]) + const date = moment.tz(timestamp * 1000, timezone) + const rate = value / Number(amount) + + return { + amount, + date, + transactionId: hash, + value, + rate, + currency, + address + } +} + +const sortPaymentsByNetworkId = (payments: Payment[]): Payment[] => { + const groupedByNetworkIdPayments = payments.reduce>((acc, payment) => { + const networkId = payment.networkId + if (acc[networkId] === undefined || acc[networkId] === null) { + acc[networkId] = [] + } + acc[networkId].push(payment) + return acc + }, {}) + + return Object.values(groupedByNetworkIdPayments).reduce( + (acc, curr) => acc.concat(curr), + [] + ) +} + +const downloadPaymentsFileByUserId = async ( + userId: string, + res: NextApiResponse, + currency: SupportedQuotesType, + timezone: string, + networkTicker?: NetworkTickersType): Promise => { + let networkIdArray = Object.values(NETWORK_IDS) + if (networkTicker !== undefined) { + const slug = Object.keys(NETWORK_TICKERS).find(key => NETWORK_TICKERS[key] === networkTicker) + const networkId = getNetworkIdFromSlug(slug ?? NETWORK_TICKERS.ecash) + networkIdArray = [networkId] + } + const payments = await fetchAllPaymentsByUserId(userId, networkIdArray) + const sortedPayments = await sortPaymentsByNetworkId(payments) + const mappedPaymentsData = sortedPayments.map(payment => { + const data = getPaymentsFileData(payment, currency, timezone) + return formatPaybuttonTransactionsFileData(data) + }) + const headers = Object.keys(PAYBUTTON_TRANSACTIONS_FILE_HEADERS) + const humanReadableHeaders = formatNumberHeaders(Object.values(PAYBUTTON_TRANSACTIONS_FILE_HEADERS), currency) + + streamToCSV( + mappedPaymentsData, + headers, + DEFAULT_PAYBUTTON_CSV_FILE_DELIMITER, + res, + humanReadableHeaders + ) +} + +export default async (req: any, res: any): Promise => { + try { + if (req.method !== 'GET') { + throw new Error(RESPONSE_MESSAGES.METHOD_NOT_ALLOWED.message) + } + + await setSession(req, res) + + const userId = req.session.userId + const user = await fetchUserProfileFromId(userId) + + let quoteId: number + if (req.query.currency === undefined || req.query.currency === '' || Number.isNaN(req.query.currency)) { + quoteId = user.preferredCurrencyId + } else { + quoteId = req.query.currency as number + } + const quoteSlug = SUPPORTED_QUOTES_FROM_ID[quoteId] + const userReqTimezone = req.headers.timezone as string + const userPreferredTimezone = user?.preferredTimezone + const timezone = userPreferredTimezone !== '' ? userPreferredTimezone : userReqTimezone + const networkTickerReq = req.query.network as string + + const networkTicker = (networkTickerReq !== '' && isNetworkValid(networkTickerReq as NetworkTickersType)) ? networkTickerReq.toUpperCase() as NetworkTickersType : undefined + res.setHeader('Content-Type', 'text/csv') + await downloadPaymentsFileByUserId(userId, res, quoteSlug, timezone, networkTicker) + } catch (error: any) { + switch (error.message) { + case RESPONSE_MESSAGES.METHOD_NOT_ALLOWED.message: + res.status(RESPONSE_MESSAGES.METHOD_NOT_ALLOWED.statusCode) + .json(RESPONSE_MESSAGES.METHOD_NOT_ALLOWED) + break + case RESPONSE_MESSAGES.MISSING_PRICE_FOR_TRANSACTION_400.message: + res.status(RESPONSE_MESSAGES.MISSING_PRICE_FOR_TRANSACTION_400.statusCode) + .json(RESPONSE_MESSAGES.MISSING_PRICE_FOR_TRANSACTION_400) + break + default: + res.status(500).json({ message: error.message }) + } + } +} diff --git a/pages/payments/index.tsx b/pages/payments/index.tsx index 155bb75ce..8f28ebf72 100644 --- a/pages/payments/index.tsx +++ b/pages/payments/index.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import supertokensNode from 'supertokens-node' import * as SuperTokensConfig from '../../config/backendConfig' import Session from 'supertokens-node/recipe/session' @@ -11,10 +11,11 @@ import XECIcon from 'assets/xec-logo.png' import BCHIcon from 'assets/bch-logo.png' import EyeIcon from 'assets/eye-icon.png' import { formatQuoteValue, compareNumericString, removeUnserializableFields } from 'utils/index' -import { XEC_NETWORK_ID, BCH_TX_EXPLORER_URL, XEC_TX_EXPLORER_URL } from 'constants/index' +import { XEC_NETWORK_ID, BCH_TX_EXPLORER_URL, XEC_TX_EXPLORER_URL, NETWORK_TICKERS_FROM_ID } from 'constants/index' import moment from 'moment-timezone' import TopBar from 'components/TopBar' import { fetchUserWithSupertokens, UserWithSupertokens } from 'services/userService' +import { UserProfile } from '@prisma/client' export const getServerSideProps: GetServerSideProps = async (context) => { // this runs on the backend, so we must call init on supertokens-node SDK @@ -52,6 +53,31 @@ interface PaybuttonsProps { export default function Payments ({ user, userId }: PaybuttonsProps): React.ReactElement { const timezone = user?.userProfile.preferredTimezone === '' ? moment.tz.guess() : user?.userProfile?.preferredTimezone + const [selectedCurrencyCSV, setSelectedCurrencyCSV] = useState('') + const [paybuttonNetworks, setPaybuttonNetworks] = useState>(new Set()) + + const fetchPaybuttons = async (): Promise => { + const res = await fetch(`/api/paybuttons?userId=${user?.userProfile.id}`, { + method: 'GET' + }) + if (res.status === 200) { + return await res.json() + } + } + const getDataAndSetUpCurrencyCSV = async (): Promise => { + const paybuttons = await fetchPaybuttons() + const networkIds: Set = new Set() + + paybuttons.forEach((p: { addresses: any[] }) => { + return p.addresses.forEach((c: { address: { networkId: number } }) => networkIds.add(c.address.networkId)) + }) + + setPaybuttonNetworks(networkIds) + } + + useEffect(() => { + void getDataAndSetUpCurrencyCSV() + }, []) function fetchData (): Function { return async (page: number, pageSize: number, orderBy: string, orderDesc: boolean) => { @@ -138,9 +164,84 @@ export default function Payments ({ user, userId }: PaybuttonsProps): React.Reac [] ) + const downloadCSV = async (userId: string, userProfile: UserProfile, currency: string): Promise => { + try { + const preferredCurrencyId = userProfile?.preferredCurrencyId ?? '' + let url = `/api/payments/download/?currency=${preferredCurrencyId}` + const isCurrencyEmptyOrUndefined = (value: string): boolean => (value === '' || value === undefined) + + if (!isCurrencyEmptyOrUndefined(currency)) { + url += `&network=${currency}` + } + + const response = await fetch(url, { + headers: { + Timezone: moment.tz.guess() + } + }) + + if (!response.ok) { + throw new Error('Failed to download CSV') + } + + const fileName = `${isCurrencyEmptyOrUndefined(currency) ? 'all' : `${currency.toLowerCase()}`}-transactions` + const blob = await response.blob() + const downloadUrl = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = downloadUrl + link.download = `${fileName}.csv` + + document.body.appendChild(link) + link.click() + link.remove() + } catch (error) { + console.error('An error occurred while downloading the CSV:', error) + } finally { + setSelectedCurrencyCSV('') + } + } + + const handleExport = (event: React.ChangeEvent): void => { + const currencyParam = event.target.value !== 'all' ? event.target.value : '' + setSelectedCurrencyCSV(currencyParam) + void downloadCSV(userId, user?.userProfile, currencyParam) + } + return ( <> +
+ {paybuttonNetworks.size > 1 + ? ( + + ) + : ( +
+ Export as CSV +
)} +
{ + const transactions = await prisma.transaction.findMany({ + where: { + address: { + userProfiles: { + some: { + userId + } + }, + networkId: { + in: networkIds ?? Object.values(NETWORK_IDS) + } + }, + amount: { + gt: 0 + } + }, + include: includePaybuttonsAndPrices + }) + + const transformedData: Payment[] = [] + for (let index = 0; index < transactions.length; index++) { + const tx = transactions[index] + if (Number(tx.amount) > 0) { + const payment = await generatePaymentFromTx(tx) + transformedData.push(payment) + } + } + return transformedData +} diff --git a/utils/files.ts b/utils/files.ts index 3294667f7..a5325fe67 100644 --- a/utils/files.ts +++ b/utils/files.ts @@ -1,7 +1,57 @@ -import { MAX_RECORDS_PER_FILE, RESPONSE_MESSAGES } from '../constants/index' +import { Prisma } from '@prisma/client' +import { DECIMALS, MAX_RECORDS_PER_FILE, NETWORK_TICKERS, NetworkTickersType, PAYBUTTON_TRANSACTIONS_FILE_HEADERS, PRICE_API_DATE_FORMAT, RESPONSE_MESSAGES, SUPPORTED_QUOTES, SupportedQuotesType } from '../constants/index' import { NextApiResponse } from 'next' import { Transform } from 'stream' +export interface TransactionFileData { + amount: Prisma.Decimal + date: moment.Moment + value: number + rate: number + transactionId: string + currency: string + address?: string +} + +export interface FormattedTransactionFileData { + amount: string + date: string + value: string + rate: string + transactionId: string + address?: string +} + +export function isCurrencyValid (currency: SupportedQuotesType): boolean { + return SUPPORTED_QUOTES.includes(currency) +} + +export function isNetworkValid (slug: NetworkTickersType): boolean { + return Object.values(NETWORK_TICKERS).includes(slug) +} + +export const formatNumberHeaders = (headers: string[], currency: string): string[] => { + return headers.map(h => h === PAYBUTTON_TRANSACTIONS_FILE_HEADERS.value ? h + ` (${currency.toUpperCase()})` : h) +} + +export const formatPaybuttonTransactionsFileData = (data: TransactionFileData): FormattedTransactionFileData => { + const { + amount, + date, + value, + rate, + currency + } = data + + return { + ...data, + amount: amount.toFixed(DECIMALS[currency]), + date: date.format(PRICE_API_DATE_FORMAT), + value: value.toFixed(2), + rate: rate.toFixed(14) + } +} + export function valuesToCsvLine (values: string[], delimiter: string): string { return values.join(delimiter) + '\n' }