diff --git a/pages/api/payments/count/index.ts b/pages/api/payments/count/index.ts index c60cd187..44b5839d 100644 --- a/pages/api/payments/count/index.ts +++ b/pages/api/payments/count/index.ts @@ -20,9 +20,18 @@ export default async (req: any, res: any): Promise => { if (typeof req.query.years === 'string' && req.query.years !== '') { years = (req.query.years as string).split(',') } + let startDate: string | undefined + if (typeof req.query.startDate === 'string' && req.query.startDate !== '') { + startDate = req.query.startDate as string + } + let endDate: string | undefined + if (typeof req.query.endDate === 'string' && req.query.endDate !== '') { + endDate = req.query.endDate as string + } if (((buttonIds !== undefined) && buttonIds.length > 0) || - ((years !== undefined) && years.length > 0)) { - const totalCount = await getFilteredTransactionCount(userId, buttonIds, years) + ((years !== undefined) && years.length > 0) || + (startDate !== undefined && endDate !== undefined && startDate !== '' && endDate !== '')) { + const totalCount = await getFilteredTransactionCount(userId, buttonIds, years, timezone, startDate, endDate) res.status(200).json(totalCount) } else { const totalCount = await CacheGet.paymentsCount(userId, timezone) diff --git a/pages/api/payments/download/index.ts b/pages/api/payments/download/index.ts index e753fd02..2d3e406b 100644 --- a/pages/api/payments/download/index.ts +++ b/pages/api/payments/download/index.ts @@ -51,8 +51,16 @@ export default async (req: any, res: any): Promise => { if (typeof req.query.years === 'string' && req.query.years !== '') { years = (req.query.years as string).split(',') } + let startDate: string | undefined + if (typeof req.query.startDate === 'string' && req.query.startDate !== '') { + startDate = req.query.startDate as string + } + let endDate: string | undefined + if (typeof req.query.endDate === 'string' && req.query.endDate !== '') { + endDate = req.query.endDate as string + } - const transactions = await fetchAllPaymentsByUserId(userId, networkIdArray, buttonIds, years) + const transactions = await fetchAllPaymentsByUserId(userId, networkIdArray, buttonIds, years, startDate, endDate, timezone) await downloadTxsFile(res, quoteSlug, timezone, transactions, userId) } catch (error: any) { diff --git a/pages/api/payments/index.ts b/pages/api/payments/index.ts index 43a5d66f..bf475bf4 100644 --- a/pages/api/payments/index.ts +++ b/pages/api/payments/index.ts @@ -6,6 +6,7 @@ export default async (req: any, res: any): Promise => { if (req.method === 'GET') { await setSession(req, res) const userId = req.session.userId + const user = await fetchUserProfileFromId(userId) const page = req.query.page as number const pageSize = req.query.pageSize as number const orderDesc: boolean = !!(req.query.orderDesc === '' || req.query.orderDesc === undefined || req.query.orderDesc === 'true') @@ -19,19 +20,34 @@ export default async (req: any, res: any): Promise => { if (typeof req.query.years === 'string' && req.query.years !== '') { years = (req.query.years as string).split(',') } + let startDate: string | undefined + if (typeof req.query.startDate === 'string' && req.query.startDate !== '') { + startDate = req.query.startDate as string + } + let endDate: string | undefined + if (typeof req.query.endDate === 'string' && req.query.endDate !== '') { + endDate = req.query.endDate as string + } const userReqTimezone = req.headers.timezone as string - const userProfile = await fetchUserProfileFromId(userId) - const userPreferredTimezone = userProfile?.preferredTimezone + const userPreferredTimezone = user?.preferredTimezone + let timezone = userPreferredTimezone !== '' ? userPreferredTimezone : userReqTimezone + if (timezone === '' || timezone === undefined || timezone === null) { + const timezoneValue = timezone === '' ? 'an empty string' : (timezone === undefined ? 'undefined' : 'null') + console.warn(`WARN: Payments API got timezone as ${timezoneValue}, defaulting to UTC`) + timezone = 'UTC' + } const resJSON = await fetchAllPaymentsByUserIdWithPagination( userId, page, pageSize, + timezone, orderBy, orderDesc, buttonIds, years, - userPreferredTimezone ?? userReqTimezone + startDate, + endDate ) res.status(200).json(resJSON) } diff --git a/pages/payments/index.tsx b/pages/payments/index.tsx index 05fd021e..39ae08c9 100644 --- a/pages/payments/index.tsx +++ b/pages/payments/index.tsx @@ -11,7 +11,7 @@ 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, removeDateFields } from 'utils/index' -import { XEC_NETWORK_ID, BCH_TX_EXPLORER_URL, XEC_TX_EXPLORER_URL, NETWORK_TICKERS_FROM_ID, DECIMALS } from 'constants/index' +import { XEC_NETWORK_ID, BCH_TX_EXPLORER_URL, XEC_TX_EXPLORER_URL, NETWORK_TICKERS_FROM_ID, DECIMALS, HUMAN_READABLE_DATE_FORMAT } from 'constants/index' import moment from 'moment-timezone' import TopBar from 'components/TopBar' import { fetchUserWithSupertokens, UserWithSupertokens } from 'services/userService' @@ -86,10 +86,13 @@ export default function Payments ({ user, userId, organization }: PaybuttonsProp const [invoiceMode, setInvoiceMode] = useState<'create' | 'edit' | 'view'>('create') const [isModalOpen, setIsModalOpen] = useState(false) + const [startDate, setStartDate] = useState('') + const [endDate, setEndDate] = useState('') + const fetchNextInvoiceNumberByUserId = async (): Promise => { const response = await fetch('/api/invoices/invoiceNumber/', { headers: { - Timezone: moment.tz.guess() + Timezone: timezone } }) const result = await response?.json() @@ -100,8 +103,12 @@ export default function Payments ({ user, userId, organization }: PaybuttonsProp setInvoiceData(null) setRefreshCount(prev => prev + 1) } + + const todayDateString = moment().format(HUMAN_READABLE_DATE_FORMAT) + const onCreateInvoice = async (transaction: TransactionWithAddressAndPricesAndInvoices): Promise => { const nextInvoiceNumber = await fetchNextInvoiceNumberByUserId() + const now = new Date() const invoiceData = { invoiceNumber: nextInvoiceNumber ?? '', amount: transaction.amount, @@ -113,8 +120,8 @@ export default function Payments ({ user, userId, organization }: PaybuttonsProp userId: '', transaction, transactionId: transaction.id, - createdAt: new Date(), - updatedAt: new Date(), + createdAt: now, + updatedAt: now, id: '' } setInvoiceDataTransaction(transaction) @@ -136,7 +143,7 @@ export default function Payments ({ user, userId, organization }: PaybuttonsProp } useEffect(() => { setRefreshCount(prev => prev + 1) - }, [selectedButtonIds, selectedTransactionYears]) + }, [selectedButtonIds, selectedTransactionYears, endDate]) const fetchPaybuttons = async (): Promise => { const res = await fetch(`/api/paybuttons?userId=${user?.userProfile.id}`, { @@ -193,19 +200,33 @@ export default function Payments ({ user, userId, organization }: PaybuttonsProp if (selectedTransactionYears.length > 0) { url += `&years=${selectedTransactionYears.join(',')}` } + if (startDate !== '') { + url += `&startDate=${startDate}` + } + if (endDate !== '') { + url += `&endDate=${endDate}` + } - const paymentsResponse = await fetch(url, { - headers: { - Timezone: moment.tz.guess() - } - }) let paymentsCountUrl = '/api/payments/count' if (selectedButtonIds.length > 0) { paymentsCountUrl += `?buttonIds=${selectedButtonIds.join(',')}` } if (selectedTransactionYears.length > 0) { - paymentsCountUrl += `${selectedButtonIds.length > 0 ? '&' : '?'}years=${selectedTransactionYears.join(',')}` + paymentsCountUrl += `${paymentsCountUrl.includes('?') ? '&' : '?'}years=${selectedTransactionYears.join(',')}` + } + if (startDate !== '') { + paymentsCountUrl += `${paymentsCountUrl.includes('?') ? '&' : '?'}startDate=${startDate}` } + if (endDate !== '') { + paymentsCountUrl += `${paymentsCountUrl.includes('?') ? '&' : '?'}endDate=${endDate}` + } + + const paymentsResponse = await fetch(url, { + headers: { + Timezone: timezone + } + }) + const paymentsCountResponse = await fetch( paymentsCountUrl, { headers: { Timezone: timezone } } @@ -390,15 +411,21 @@ export default function Payments ({ user, userId, organization }: PaybuttonsProp if (selectedTransactionYears.length > 0) { url += `&years=${selectedTransactionYears.join(',')}` } - const isCurrencyEmptyOrUndefined = (value: string): boolean => (value === '' || value === undefined) + if (startDate !== '') { + url += `&startDate=${startDate}` + } + if (endDate !== '') { + url += `&endDate=${endDate}` + } + const isCurrencyEmptyOrUndefined = (value: string): boolean => (value === '' || value === undefined) if (!isCurrencyEmptyOrUndefined(currency)) { url += `&network=${currency}` } const response = await fetch(url, { headers: { - Timezone: moment.tz.guess() + Timezone: timezone } }) @@ -443,10 +470,13 @@ export default function Payments ({ user, userId, organization }: PaybuttonsProp const handleClearFilters = (): void => { setSelectedButtonIds([]) setSelectedTransactionYears([]) + setStartDate('') + setEndDate('') } + return ( <> - +
filtersFilters
- {(selectedButtonIds.length > 0 || selectedTransactionYears.length > 0) && + {(selectedButtonIds.length > 0 || selectedTransactionYears.length > 0 || startDate !== '' || endDate !== '') &&
handleClearFilters()} className={style.show_filters_button} @@ -524,11 +554,17 @@ export default function Payments ({ user, userId, organization }: PaybuttonsProp
{ - setSelectedTransactionYears(prev => - prev.includes(y) + setSelectedTransactionYears(prev => { + const newYears = prev.includes(y) ? prev.filter(year => year !== y) : [...prev, y] - ) + + if (newYears.length > 0) { + setStartDate('') + setEndDate('') + } + return newYears + }) }} className={`${style.filter_button} ${selectedTransactionYears.includes(y) ? style.active : ''}`} > @@ -537,6 +573,40 @@ export default function Payments ({ user, userId, organization }: PaybuttonsProp ))}
+
+ Filter by date range +
+ { + const newStartDate = e.target.value + setStartDate(newStartDate) + if (newStartDate !== '') { + setSelectedTransactionYears([]) + } + setEndDate('') + }} + className={style.filter_input} + max={todayDateString} + /> + to + { + const newEndDate = e.target.value + setEndDate(newEndDate) + if (newEndDate !== '') { + setSelectedTransactionYears([]) + } + }} + min={startDate !== '' ? startDate : undefined} + max={todayDateString} + className={style.filter_input} + /> +
+
)} { const orderDescString: Prisma.SortOrder = orderDesc ? 'desc' : 'asc' @@ -884,18 +886,15 @@ export async function fetchAllPaymentsByUserIdWithPagination ( const where: Prisma.TransactionWhereInput = { address: { - userProfiles: { - some: { userId } - } + userProfiles: { some: { userId } } }, - amount: { - gt: 0 - } + amount: { gt: 0 } } - if (years !== undefined && years.length > 0) { - const yearFilters = getYearFilters(years, timezone) - where.OR = yearFilters + if (startDate !== undefined && endDate !== undefined && startDate !== '' && endDate !== '') { + Object.assign(where, getDateRangeFilter(new Date(startDate), new Date(endDate), timezone)) + } else if (years !== undefined && years.length > 0) { + where.OR = getYearFilters(years, timezone) } if ((buttonIds !== undefined) && buttonIds.length > 0) { @@ -926,29 +925,65 @@ export async function fetchAllPaymentsByUserIdWithPagination ( } return transformedData } -const getYearFilters = (years: string[], timezone?: string): Prisma.TransactionWhereInput[] => { - return years.map((year) => { - let start: number - let end: number - if (timezone !== undefined && timezone !== null && timezone !== '') { - const startDate = new Date(`${year}-01-01T00:00:00`) - const endDate = new Date(`${Number(year) + 1}-01-01T00:00:00`) - const startInTimezone = new Date(startDate.toLocaleString('en-US', { timeZone: timezone })) - const endInTimezone = new Date(endDate.toLocaleString('en-US', { timeZone: timezone })) - const startOffset = startDate.getTime() - startInTimezone.getTime() - const endOffset = endDate.getTime() - endInTimezone.getTime() - start = (startDate.getTime() + startOffset) / 1000 - end = (endDate.getTime() + endOffset) / 1000 - } else { - start = new Date(`${year}-01-01T00:00:00Z`).getTime() / 1000 - end = new Date(`${Number(year) + 1}-01-01T00:00:00Z`).getTime() / 1000 - } +const buildDateRange = ( + startDate: Date, + endDate: Date, + timezone?: string +): { gte: number, lte: number } => { + let start: number + let end: number + + if (timezone !== undefined && timezone !== null && timezone !== '') { + const startMoment = moment.tz( + { + year: startDate.getUTCFullYear(), + month: startDate.getUTCMonth(), + day: startDate.getUTCDate() + }, + timezone + ).startOf('day') + + const endMoment = moment.tz( + { + year: endDate.getUTCFullYear(), + month: endDate.getUTCMonth(), + day: endDate.getUTCDate() + }, + timezone + ).endOf('day') + + start = startMoment.unix() + end = endMoment.unix() + } else { + start = moment.utc(startDate).unix() + end = moment.utc(endDate).endOf('day').unix() + } + + return { + gte: Math.round(start), + lte: Math.round(end) + } +} + +const getDateRangeFilter = ( + startDate: Date, + endDate: Date, + timezone?: string +): Prisma.TransactionWhereInput => ({ + timestamp: buildDateRange(startDate, endDate, timezone) +}) + +const getYearFilters = ( + years: string[], + timezone?: string +): Prisma.TransactionWhereInput[] => { + return years.map((year) => { + const y = Number(year) + const startDate = new Date(Date.UTC(y, 0, 1, 0, 0, 0)) // Jan 1, 00:00:00 + const endDate = new Date(Date.UTC(y, 11, 31, 23, 59, 59)) // Dec 31, 23:59:59 return { - timestamp: { - gte: Math.floor(start), - lt: Math.floor(end) - } + timestamp: buildDateRange(startDate, endDate, timezone) } }) } @@ -958,6 +993,8 @@ export async function fetchAllPaymentsByUserId ( networkIds?: number[], buttonIds?: string[], years?: string[], + startDate?: string, + endDate?: string, timezone?: string ): Promise { const where: Prisma.TransactionWhereInput = { @@ -984,10 +1021,10 @@ export async function fetchAllPaymentsByUserId ( } } - if (years !== undefined && years.length > 0) { - const yearFilters = getYearFilters(years, timezone) - - where.OR = yearFilters + if (startDate !== undefined && endDate !== undefined && startDate !== '' && endDate !== '') { + Object.assign(where, getDateRangeFilter(new Date(startDate), new Date(endDate), timezone)) + } else if (years !== undefined && years.length > 0) { + where.OR = getYearFilters(years, timezone) } return await prisma.transaction.findMany({ @@ -1015,7 +1052,9 @@ export const getFilteredTransactionCount = async ( userId: string, buttonIds?: string[], years?: string[], - timezone?: string + timezone?: string, + startDate?: string, + endDate?: string ): Promise => { const where: Prisma.TransactionWhereInput = { address: { @@ -1034,10 +1073,11 @@ export const getFilteredTransactionCount = async ( } } } - if (years !== undefined && years.length > 0) { - const yearFilters = getYearFilters(years, timezone) - where.OR = yearFilters + if (startDate !== undefined && endDate !== undefined && startDate !== '' && endDate !== '') { + Object.assign(where, getDateRangeFilter(new Date(startDate), new Date(endDate), timezone)) + } else if (years !== undefined && years.length > 0) { + where.OR = getYearFilters(years, timezone) } return await prisma.transaction.count({ where }) diff --git a/tests/unittests/transactionService.test.ts b/tests/unittests/transactionService.test.ts index cc3580e4..65376f64 100644 --- a/tests/unittests/transactionService.test.ts +++ b/tests/unittests/transactionService.test.ts @@ -6,6 +6,7 @@ import { CacheSet } from 'redis/index' import { Prisma } from '@prisma/client' import * as addressService from 'services/addressService' import { RESPONSE_MESSAGES } from 'constants/index' +import moment from 'moment-timezone' const includePrices = { prices: { @@ -194,3 +195,233 @@ describe('Address object arrays (input/output) integration', () => { expect(simplified.outputAddresses).toEqual(outputs) }) }) + +describe('Date and timezone filters for transactions', () => { + const startDate = '2025-11-05' + const endDate = '2025-11-10' + + const timezones = [ + { label: 'UTC', timezone: 'UTC' }, + { label: 'positive offset (China)', timezone: 'Asia/Shanghai' }, + { label: 'negative offset (Canada)', timezone: 'America/Toronto' } + ] + + const computeExpectedRange = (tz: string) => { + const start = new Date(startDate) + const end = new Date(endDate) + + const startMoment = moment.tz( + { + year: start.getUTCFullYear(), + month: start.getUTCMonth(), + day: start.getUTCDate() + }, + tz + ).startOf('day') + + const endMoment = moment.tz( + { + year: end.getUTCFullYear(), + month: end.getUTCMonth(), + day: end.getUTCDate() + }, + tz + ).endOf('day') + + return { + gte: Math.round(startMoment.unix()), + lte: Math.round(endMoment.unix()) + } + } + + const computeYearFilter = (year: number, tz: string) => { + const startDateObj = new Date(year, 0, 1, 0, 0, 0) + const endDateObj = new Date(year, 11, 31, 23, 59, 59) + + const startMoment = moment.tz( + { + year: startDateObj.getUTCFullYear(), + month: startDateObj.getUTCMonth(), + day: startDateObj.getUTCDate() + }, + tz + ).startOf('day') + + const endMoment = moment.tz( + { + year: endDateObj.getUTCFullYear(), + month: endDateObj.getUTCMonth(), + day: endDateObj.getUTCDate() + }, + tz + ).endOf('day') + + return { + timestamp: { + gte: Math.round(startMoment.unix()), + lte: Math.round(endMoment.unix()) + } + } + } + + test.each(timezones)( + 'fetchAllPaymentsByUserIdWithPagination uses local day boundaries for %s', + async ({ timezone }) => { + prismaMock.transaction.findMany.mockResolvedValue([]) + prisma.transaction.findMany = prismaMock.transaction.findMany + + const expected = computeExpectedRange(timezone) + + await transactionService.fetchAllPaymentsByUserIdWithPagination( + 'user-1', + 0, + 10, + timezone, + 'timestamp', + true, + undefined, + undefined, + startDate, + endDate + ) + + expect(prismaMock.transaction.findMany).toHaveBeenCalledTimes(1) + const callArgs = prismaMock.transaction.findMany.mock.calls[0][0] as any + + expect(callArgs.where.timestamp).toEqual(expected) + expect(callArgs.where.OR).toBeUndefined() + } + ) + + test.each(timezones)( + 'getFilteredTransactionCount uses local day boundaries for %s', + async ({ timezone }) => { + prismaMock.transaction.count.mockResolvedValue(7) + prisma.transaction.count = prismaMock.transaction.count + + const expected = computeExpectedRange(timezone) + + const result = await transactionService.getFilteredTransactionCount( + 'user-1', + undefined, + undefined, + timezone, + startDate, + endDate + ) + + expect(result).toBe(7) + expect(prismaMock.transaction.count).toHaveBeenCalledTimes(1) + const callArgs = prismaMock.transaction.count.mock.calls[0][0] as any + + expect(callArgs.where.timestamp).toEqual(expected) + expect(callArgs.where.OR).toBeUndefined() + } + ) + + it('uses year filters when only years are provided (no start/end)', async () => { + const timezone = 'America/Sao_Paulo' + const years = ['2025'] + + prismaMock.transaction.findMany.mockResolvedValue([]) + prisma.transaction.findMany = prismaMock.transaction.findMany + + await transactionService.fetchAllPaymentsByUserId( + 'user-1', + undefined, + undefined, + years, + undefined, + undefined, + timezone + ) + + expect(prismaMock.transaction.findMany).toHaveBeenCalledTimes(1) + const callArgs = prismaMock.transaction.findMany.mock.calls[0][0] as any + + const expectedFilter = computeYearFilter(2025, timezone) + expect(callArgs.where.timestamp).toBeUndefined() + expect(callArgs.where.OR).toHaveLength(1) + expect(callArgs.where.OR[0]).toEqual(expectedFilter) + }) + + it('date range takes precedence over year filters (pagination)', async () => { + const timezone = 'America/Sao_Paulo' + const years = ['2024'] + + prismaMock.transaction.findMany.mockResolvedValue([]) + prisma.transaction.findMany = prismaMock.transaction.findMany + + const expected = computeExpectedRange(timezone) + + await transactionService.fetchAllPaymentsByUserIdWithPagination( + 'user-1', + 0, + 10, + timezone, + 'timestamp', + true, + undefined, + years, + startDate, + endDate + ) + + const callArgs = prismaMock.transaction.findMany.mock.calls[0][0] as any + + expect(callArgs.where.timestamp).toEqual(expected) + expect(callArgs.where.OR).toBeUndefined() + }) + + it('date range takes precedence over year filters (count)', async () => { + const timezone = 'America/Sao_Paulo' + const years = ['2024'] + + prismaMock.transaction.count.mockResolvedValue(13) + prisma.transaction.count = prismaMock.transaction.count + + const expected = computeExpectedRange(timezone) + + const result = await transactionService.getFilteredTransactionCount( + 'user-1', + undefined, + years, + timezone, + startDate, + endDate + ) + + expect(result).toBe(13) + expect(prismaMock.transaction.count).toHaveBeenCalledTimes(1) + const callArgs = prismaMock.transaction.count.mock.calls[0][0] as any + + expect(callArgs.where.timestamp).toEqual(expected) + expect(callArgs.where.OR).toBeUndefined() + }) + + it('does not add timestamp or OR when no years and no date range are provided', async () => { + const timezone = 'America/Sao_Paulo' + + prismaMock.transaction.findMany.mockResolvedValue([]) + prisma.transaction.findMany = prismaMock.transaction.findMany + + await transactionService.fetchAllPaymentsByUserIdWithPagination( + 'user-1', + 0, + 10, + timezone, + 'timestamp', + true, + undefined, + undefined, + undefined, + undefined + ) + + const callArgs = prismaMock.transaction.findMany.mock.calls[0][0] as any + + expect(callArgs.where.timestamp).toBeUndefined() + expect(callArgs.where.OR).toBeUndefined() + }) +}) +