Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 1 addition & 52 deletions pages/api/paybutton/download/transactions/[paybuttonId].ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -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<TransactionWithAddressAndPrices[]> => {
const groupedByNetworkIdTransactions = transactions.reduce<Record<number, TransactionWithAddressAndPrices[]>>((acc, transaction) => {
const networkId = transaction.address.networkId
Expand Down
124 changes: 124 additions & 0 deletions pages/api/payments/download/index.ts
Original file line number Diff line number Diff line change
@@ -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<Record<number, Payment[]>>((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<void> => {
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<void> => {
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 })
}
}
}
105 changes: 103 additions & 2 deletions pages/payments/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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<string>('')
const [paybuttonNetworks, setPaybuttonNetworks] = useState<Set<number>>(new Set())

const fetchPaybuttons = async (): Promise<any> => {
const res = await fetch(`/api/paybuttons?userId=${user?.userProfile.id}`, {
method: 'GET'
})
if (res.status === 200) {
return await res.json()
}
}
const getDataAndSetUpCurrencyCSV = async (): Promise<void> => {
const paybuttons = await fetchPaybuttons()
const networkIds: Set<number> = new Set()

paybuttons.forEach((p: { addresses: any[] }) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Better to use new Set here already and down where this array is used with .includes just use the Set method .has which does the same

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) => {
Expand Down Expand Up @@ -138,9 +164,84 @@ export default function Payments ({ user, userId }: PaybuttonsProps): React.Reac
[]
)

const downloadCSV = async (userId: string, userProfile: UserProfile, currency: string): Promise<void> => {
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<HTMLSelectElement>): void => {
const currencyParam = event.target.value !== 'all' ? event.target.value : ''
setSelectedCurrencyCSV(currencyParam)
void downloadCSV(userId, user?.userProfile, currencyParam)
}

return (
<>
<TopBar title="Payments" user={user?.stUser?.email} />
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', justifyContent: 'right' }}>
{paybuttonNetworks.size > 1
? (
<select
id='export-btn'
value={selectedCurrencyCSV}
onChange={handleExport}
className="button_outline button_small"
style={{ marginBottom: '0', cursor: 'pointer' }}
>
<option value='' disabled> Export as CSV</option>
<option key="all" value="all">
All Currencies
</option>
{Object.entries(NETWORK_TICKERS_FROM_ID)
.filter(([id]) => paybuttonNetworks.has(Number(id)))
.map(([id, ticker]) => (
<option key={id} value={ticker}>
{ticker.toUpperCase()}
</option>
))}
</select>
)
: (
<div
onClick={handleExport}
className="button_outline button_small"
style={{ marginBottom: '0', cursor: 'pointer' }}
>
Export as CSV
</div>)}
</div>
<TableContainerGetter
columns={columns}
dataGetter={fetchData()}
Expand Down
3 changes: 2 additions & 1 deletion redis/paymentCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ export const generatePaymentFromTx = async (tx: TransactionsWithPaybuttonsAndPri
},
networkId: tx.address.networkId,
hash: tx.hash,
buttonDisplayDataList
buttonDisplayDataList,
address: tx.address.address
}
}

Expand Down
1 change: 1 addition & 0 deletions redis/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export interface Payment {
networkId: number
hash: string
buttonDisplayDataList: ButtonDisplayData[]
address?: string
}

export interface ButtonData {
Expand Down
34 changes: 34 additions & 0 deletions services/transactionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -801,3 +801,37 @@ export async function fetchAllPaymentsByUserIdWithPagination (
}
return transformedData
}

export async function fetchAllPaymentsByUserId (
userId: string,
networkIds?: number[]
): Promise<Payment[]> {
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
}
Loading
Loading