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
3 changes: 2 additions & 1 deletion constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ export const NETWORK_TICKERS: KeyValueT<string> = {
bitcoincash: 'BCH'
}

export type NetworkTickersType = 'XEC' | 'BCH'

export const NETWORK_TICKERS_FROM_ID: KeyValueT<string> = {
1: 'XEC',
2: 'BCH'
Expand Down Expand Up @@ -220,7 +222,6 @@ export const PAYBUTTON_TRANSACTIONS_FILE_HEADERS = {
rate: 'Rate',
transactionId: 'Transaction Id',
address: 'Address'

}

export const DEFAULT_PAYBUTTON_CSV_FILE_DELIMITER = ','
Expand Down
58 changes: 43 additions & 15 deletions pages/api/paybutton/download/transactions/[paybuttonId].ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@ import {
DECIMALS,
SUPPORTED_QUOTES,
DEFAULT_QUOTE_SLUG,
SupportedQuotesType
SupportedQuotesType,
NetworkTickersType,
NETWORK_TICKERS,
NETWORK_IDS
} from 'constants/index'
import { TransactionWithAddressAndPrices, fetchTransactionsByPaybuttonId, getTransactionValueInCurrency } from 'services/transactionService'
import { PaybuttonWithAddresses, fetchPaybuttonById } from 'services/paybuttonService'
import { streamToCSV } from 'utils/files'
import { setSession } from 'utils/setSession'
import { NextApiResponse } from 'next'
import { Decimal } from '@prisma/client/runtime'
import { getNetworkIdFromSlug } from 'services/networkService'

export interface TransactionFileData {
amount: Decimal
Expand All @@ -34,11 +38,14 @@ export interface FormattedTransactionFileData {
transactionId: string
address: string
}

function isCurrencyValid (currency: SupportedQuotesType): boolean {
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): TransactionFileData => {
const { amount, hash, address, timestamp } = transaction
const value = getTransactionValueInCurrency(transaction, currency)
Expand Down Expand Up @@ -78,27 +85,49 @@ 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
if (acc[networkId] === undefined || acc[networkId] === null) {
acc[networkId] = []
}
acc[networkId].push(transaction)
return acc
}, {})

return Object.values(groupedByNetworkIdTransactions).reduce(
(acc, curr) => acc.concat(curr),
[]
)
}

const downloadPaybuttonTransactionsFile = async (
res: NextApiResponse,
paybutton: PaybuttonWithAddresses,
currency: SupportedQuotesType): Promise<void> => {
const transactions = await fetchTransactionsByPaybuttonId(paybutton.id)
currency: SupportedQuotesType,
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 transactions = await fetchTransactionsByPaybuttonId(paybutton.id, networkIdArray)
const sortedTransactions = await sortTransactionsByNetworkId(transactions)

const mappedTransactionsData = transactions.sort((a, b) => {
return (a.timestamp - b.timestamp)
}).map(tx => {
const mappedTransactionsData = sortedTransactions.map(tx => {
const data = getPaybuttonTransactionsFileData(tx, currency)
return formatPaybuttonTransactionsFileData(data)
})
const headers = Object.keys(PAYBUTTON_TRANSACTIONS_FILE_HEADERS)
const humanReadableHeaders = Object.values(PAYBUTTON_TRANSACTIONS_FILE_HEADERS)
const humanReadableHeaders = formatNumberHeaders(Object.values(PAYBUTTON_TRANSACTIONS_FILE_HEADERS), currency)

streamToCSV(
mappedTransactionsData,
headers,
DEFAULT_PAYBUTTON_CSV_FILE_DELIMITER,
res,
formatNumberHeaders(humanReadableHeaders, currency)
humanReadableHeaders
)
}

Expand All @@ -115,18 +144,17 @@ export default async (req: any, res: any): Promise<void> => {

const userId = req.session.userId
const paybuttonId = req.query.paybuttonId as string
const currency = isCurrencyValid(req.query.currency) ? req.query.currency as SupportedQuotesType : DEFAULT_QUOTE_SLUG
const networkTickerReq = req.query.network as string

const networkTicker = (networkTickerReq !== '' && isNetworkValid(networkTickerReq as NetworkTickersType)) ? networkTickerReq.toUpperCase() as NetworkTickersType : undefined

const paybutton = await fetchPaybuttonById(paybuttonId)
if (paybutton.providerUserId !== userId) {
throw new Error(RESPONSE_MESSAGES.RESOURCE_DOES_NOT_BELONG_TO_USER_400.message)
}

const fileName = `${paybutton.name}-${paybutton.id}-transactions.csv`
res.setHeader('Content-Type', 'text/csv')
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`)

await downloadPaybuttonTransactionsFile(res, paybutton, currency)
await downloadPaybuttonTransactionsFile(res, paybutton, DEFAULT_QUOTE_SLUG, networkTicker)
} catch (error: any) {
switch (error.message) {
case RESPONSE_MESSAGES.PAYBUTTON_ID_NOT_PROVIDED_400.message:
Expand Down
72 changes: 60 additions & 12 deletions pages/button/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,12 @@ import Session from 'supertokens-node/recipe/session'
import { GetServerSideProps } from 'next'
import { useRouter } from 'next/router'
import { BroadcastTxData } from 'ws-service/types'
import { KeyValueT, ResponseMessage, SOCKET_MESSAGES } from 'constants/index'
import { KeyValueT, NETWORK_TICKERS_FROM_ID, ResponseMessage, SOCKET_MESSAGES } from 'constants/index'
import config from 'config'
import io from 'socket.io-client'
import PaybuttonTrigger from 'components/Paybutton/PaybuttonTrigger'

export const getServerSideProps: GetServerSideProps = async (context) => {
// this runs on the backend, so we must call init on supertokens-node SDK
supertokensNode.init(SuperTokensConfig.backendConfig())
let session
try {
Expand Down Expand Up @@ -46,6 +45,9 @@ export default function Button (props: PaybuttonProps): React.ReactElement {
const [paybutton, setPaybutton] = useState(undefined as PaybuttonWithAddresses | undefined)
const [isSyncing, setIsSyncing] = useState<KeyValueT<boolean>>({})
const [tableRefreshCount, setTableRefreshCount] = useState<number>(0)
const [paybuttonNetworks, setPaybuttonNetworks] = useState<number[]>([])

const [selectedCurrency, setSelectedCurrency] = useState<string>('')
const router = useRouter()

const updateIsSyncing = (addressStringList: string[]): void => {
Expand Down Expand Up @@ -94,36 +96,53 @@ export default function Button (props: PaybuttonProps): React.ReactElement {
const getDataAndSetUpSocket = async (): Promise<void> => {
const paybuttonData = await fetchPaybutton()
const addresses: string[] = paybuttonData.addresses.map(c => c.address.address)
const networkIds: number[] = paybuttonData.addresses.map(c => c.address.networkId)

setPaybuttonNetworks(networkIds)
await setUpSocket(addresses)
}

useEffect(() => {
void getDataAndSetUpSocket()
}, [])

const downloadCSV = async (paybutton: { id: string }): Promise<void> => {
const downloadCSV = async (paybutton: { id: string, name: string }, currency: string): Promise<void> => {
try {
const response = await fetch(`/api/paybutton/download/transactions/${paybutton.id}`)
let url = `/api/paybutton/download/transactions/${paybutton.id}`
const isCurrencyEmptyOrUndefined = (value: string): boolean => (value === '' || value === undefined)
if (!isCurrencyEmptyOrUndefined(currency)) {
url += `?network=${currency}`
}
const response = await fetch(url)

if (!response.ok) {
throw new Error('Failed to download CSV')
}

const blob = await response.blob()
const fileName = `${paybutton.name}-${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 = `${paybutton.id}-transactions.csv`
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 {
setSelectedCurrency('')
}
}

const handleExport = (event: React.ChangeEvent<HTMLSelectElement>): void => {
const currencyParam = event.target.value !== 'all' ? event.target.value : ''
setSelectedCurrency(currencyParam)
void downloadCSV(paybutton!, currencyParam)
}

if (paybutton != null) {
return (
<>
Expand All @@ -132,18 +151,47 @@ export default function Button (props: PaybuttonProps): React.ReactElement {
<div style={{ display: 'flex', alignItems: 'center', alignContent: 'center', justifyContent: 'space-between' }}>
<h4>Transactions</h4>

<div
onClick={() => { void downloadCSV(paybutton) }}
className="button_outline button_small export_btn"

> Export as CSV</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{new Set(paybuttonNetworks).size > 1
? (
<select
id='export-btn'
value={selectedCurrency}
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.includes(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>
</div>

<AddressTransactions addressSyncing={isSyncing} tableRefreshCount={tableRefreshCount}/>
<PaybuttonTrigger paybuttonId={paybutton.id}/>
</>
)
}

return (
<Page />
)
Expand Down
21 changes: 16 additions & 5 deletions services/transactionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Address, Prisma, Transaction } from '@prisma/client'
import { syncTransactionsForAddress, subscribeAddresses } from 'services/blockchainService'
import { fetchAddressBySubstring, fetchAddressById, fetchAddressesByPaybuttonId } from 'services/addressService'
import { QuoteValues, fetchPricesForNetworkAndTimestamp } from 'services/priceService'
import { RESPONSE_MESSAGES, USD_QUOTE_ID, CAD_QUOTE_ID, N_OF_QUOTES, KeyValueT, UPSERT_TRANSACTION_PRICES_ON_DB_TIMEOUT, SupportedQuotesType } from 'constants/index'
import { RESPONSE_MESSAGES, USD_QUOTE_ID, CAD_QUOTE_ID, N_OF_QUOTES, KeyValueT, UPSERT_TRANSACTION_PRICES_ON_DB_TIMEOUT, SupportedQuotesType, NETWORK_IDS } from 'constants/index'
import { productionAddresses } from 'prisma/seeds/addresses'
import { appendTxsToFile } from 'prisma/seeds/transactions'
import _ from 'lodash'
Expand Down Expand Up @@ -102,14 +102,25 @@ const transactionWithAddressAndPrices = Prisma.validator<Prisma.TransactionArgs>

export type TransactionWithAddressAndPrices = Prisma.TransactionGetPayload<typeof transactionWithAddressAndPrices>

export async function fetchAddressListTransactions (addressIdList: string[]): Promise<TransactionWithAddressAndPrices[]> {
export async function fetchTransactionsByAddressList (
addressIdList: string[],
networkIdsListFilter?: number[]
): Promise<TransactionWithAddressAndPrices[]> {
return await prisma.transaction.findMany({
where: {
addressId: {
in: addressIdList
},
address: {
networkId: {
in: networkIdsListFilter ?? Object.values(NETWORK_IDS)
}
}
},
include: includeAddressAndPrices
include: includeAddressAndPrices,
orderBy: {
timestamp: 'asc'
}
})
}

Expand Down Expand Up @@ -478,9 +489,9 @@ export async function fetchAllTransactionsWithIrregularPrices (): Promise<Transa
return txs.filter(t => t.prices.length !== 2)
}

export async function fetchTransactionsByPaybuttonId (paybuttonId: string): Promise<TransactionWithAddressAndPrices[]> {
export async function fetchTransactionsByPaybuttonId (paybuttonId: string, networkIds?: number[]): Promise<TransactionWithAddressAndPrices[]> {
const addressIdList = await fetchAddressesByPaybuttonId(paybuttonId)
const transactions = await fetchAddressListTransactions(addressIdList)
const transactions = await fetchTransactionsByAddressList(addressIdList, networkIds)

if (transactions.length === 0) {
throw new Error(RESPONSE_MESSAGES.NO_TRANSACTION_FOUND_404.message)
Expand Down