Skip to content
Open
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
90 changes: 90 additions & 0 deletions components/Account/ChangeCsvCollapseThreshold.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React, { ReactElement, useState } from 'react'
import style from './account.module.css'
import { DEFAULT_CSV_COLLAPSE_THRESHOLD } from 'constants/index'

interface IProps {
csvCollapseThreshold: number
}

export default function ChangeCsvCollapseThreshold ({ csvCollapseThreshold }: IProps): ReactElement {
const [threshold, setThreshold] = useState(csvCollapseThreshold ?? DEFAULT_CSV_COLLAPSE_THRESHOLD)
const [inputValue, setInputValue] = useState(String(csvCollapseThreshold ?? DEFAULT_CSV_COLLAPSE_THRESHOLD))
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [disabled, setDisabled] = useState(true)

const onSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault()
const newThreshold = parseInt(inputValue, 10)
if (isNaN(newThreshold) || newThreshold < 0) {
Comment on lines +18 to +19
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

git ls-files | grep -i "ChangeCsvCollapseThreshold"

Repository: PayButton/paybutton-server

Length of output: 119


🏁 Script executed:

fd -i "changecsvcollapsethreshold" --type f

Repository: PayButton/paybutton-server

Length of output: 119


🏁 Script executed:

cat -n components/Account/ChangeCsvCollapseThreshold.tsx

Repository: PayButton/paybutton-server

Length of output: 3610


🏁 Script executed:

node <<'EOF'
// Verify parseInt behavior
console.log("parseInt('1.5', 10):", parseInt('1.5', 10));
console.log("parseInt('12abc', 10):", parseInt('12abc', 10));
console.log("parseInt('abc', 10):", parseInt('abc', 10));
console.log("isNaN(parseInt('abc', 10)):", isNaN(parseInt('abc', 10)));
console.log("isNaN(parseInt('1.5', 10)):", isNaN(parseInt('1.5', 10)));
console.log("Number.isInteger(Number('1.5')):", Number.isInteger(Number('1.5')));
console.log("Number.isInteger(Number('12')):", Number.isInteger(Number('12')));
EOF

Repository: PayButton/paybutton-server

Length of output: 286


parseInt silently truncates decimal input; use strict integer validation instead.

Values like 1.5 are accepted by isNaN() and truncated to 1 without warning. Use Number() with Number.isInteger() to enforce strict integer validation and reject decimal input explicitly.

Proposed fix
-    const newThreshold = parseInt(inputValue, 10)
-    if (isNaN(newThreshold) || newThreshold < 0) {
+    const normalized = inputValue.trim()
+    const newThreshold = Number(normalized)
+    if (normalized === '' || !Number.isInteger(newThreshold) || newThreshold < 0) {
       setError('Please enter a valid non-negative number')
       return
     }
@@
-    const numValue = parseInt(value, 10)
-    if (!isNaN(numValue) && numValue >= 0 && numValue !== threshold) {
+    const normalized = value.trim()
+    const numValue = Number(normalized)
+    if (normalized !== '' && Number.isInteger(numValue) && numValue >= 0 && numValue !== threshold) {
       setDisabled(false)
       setError('')

Also applies to: lines 56-57

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/Account/ChangeCsvCollapseThreshold.tsx` around lines 18 - 19, The
input parsing currently uses parseInt (e.g., in ChangeCsvCollapseThreshold’s
handling of inputValue) which silently truncates decimals; replace parseInt
usage with Number() to parse the value and then validate using
Number.isInteger(newThreshold) and newThreshold >= 0, rejecting NaN or
non-integer (decimal) values explicitly (apply the same change for the other
occurrence referenced at the second spot around the lines corresponding to the
existing parseInt usage), and update any validation/error paths to reflect "must
be a non-negative integer" instead of relying on parseInt truncation.

setError('Please enter a valid non-negative number')
return
}

const oldThreshold = threshold
setThreshold(newThreshold)
setDisabled(true)

try {
const res = await fetch('/api/user/csvCollapseThreshold', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ csvCollapseThreshold: newThreshold })
})
if (res.status === 200) {
setError('')
setSuccess('Updated successfully.')
setTimeout(() => {
setSuccess('')
}, 3000)
} else {
throw new Error('Failed to update threshold')
}
} catch (err: any) {
setSuccess('')
setError(err.message ?? 'Failed to update threshold')
setThreshold(oldThreshold)
setInputValue(String(oldThreshold))
}
}

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const value = e.target.value
setInputValue(value)
const numValue = parseInt(value, 10)
if (!isNaN(numValue) && numValue >= 0 && numValue !== threshold) {
setDisabled(false)
setError('')
} else if (numValue === threshold) {
setDisabled(true)
} else {
setDisabled(true)
if (value !== '') {
setError('Please enter a valid non-negative number')
}
}
}

return (
<div className={style.threshold_ctn}>
<form onSubmit={(e) => { void onSubmit(e) }}>
<div className={style.threshold_row}>
<input
id="csvCollapseThreshold"
type="text"
min="0"
required
value={inputValue}
onChange={handleInputChange}
Comment on lines +74 to +80
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd "ChangeCsvCollapseThreshold.tsx" --type f

Repository: PayButton/paybutton-server

Length of output: 119


🏁 Script executed:

wc -l components/Account/ChangeCsvCollapseThreshold.tsx

Repository: PayButton/paybutton-server

Length of output: 122


🏁 Script executed:

cat -n components/Account/ChangeCsvCollapseThreshold.tsx

Repository: PayButton/paybutton-server

Length of output: 3610


Add an accessible label and use a numeric input type.

The input currently lacks an accessible label and type="text" makes the min="0" attribute ineffective. With manual validation expecting an integer, type="number" is more semantically appropriate and enables proper HTML5 validation.

Proposed fix
         <div className={style.threshold_row}>
+          <label htmlFor="csvCollapseThreshold">CSV collapse threshold</label>
           <input
             id="csvCollapseThreshold"
-            type="text"
+            type="number"
             min="0"
+            step="1"
+            inputMode="numeric"
             required
             value={inputValue}
             onChange={handleInputChange}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/Account/ChangeCsvCollapseThreshold.tsx` around lines 74 - 80,
Change the input in the ChangeCsvCollapseThreshold component to be an accessible
numeric control: replace type="text" with type="number", add step="1" and keep
min="0" so HTML5 numeric validation works, and provide an associated label
element referencing id="csvCollapseThreshold" (or add aria-label if a visible
label isn't desired) to ensure accessibility; update any related handlers like
handleInputChange to handle numeric values (parseInt/Number) from the inputValue
before using it.

className={style.threshold_input}
/>
<button disabled={disabled} className='button_main' type='submit'>Update</button>
</div>
{error !== '' && <div className={style.error_message}>{error}</div>}
{success !== '' && <div className={style.success_message}>{success}</div>}
</form>
</div>
)
}
31 changes: 31 additions & 0 deletions components/Account/account.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,34 @@ body[data-theme="dark"] .pro_ctn input {
margin: 0 0 10px 0;
font-weight: 600;
}

.threshold_ctn {
width: 100%;
margin-top: 10px;
}

.threshold_row {
display: flex;
gap: 10px;
align-items: center;
}

.threshold_input {
width: 80px;
padding: 5px 10px;
border: 1px solid #ccc;
border-radius: 5px;
font-size: 14px;
}

body[data-theme='dark'] .threshold_input {
border-color: #a5a5a5;
background-color: var(--primary-bg-color);
color: var(--primary-text-color);
}

.threshold_ctn button {
padding: 5px 15px;
border-radius: 5px;
font-size: 14px;
}
10 changes: 10 additions & 0 deletions pages/account/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { GetServerSideProps } from 'next'
import Page from 'components/Page'
import ChangePassword from 'components/Account/ChangePassword'
import ChangeFiatCurrency from 'components/Account/ChangeFiatCurrency'
import ChangeCsvCollapseThreshold from 'components/Account/ChangeCsvCollapseThreshold'
import style from './account.module.css'
import { fetchUserProfileFromId, fetchUserWithSupertokens, getUserPublicKeyHex, UserWithSupertokens } from 'services/userService'
import CopyIcon from '../../assets/copy-black.png'
Expand Down Expand Up @@ -147,6 +148,15 @@ export default function Account ({ user, userPublicKey, organization, orgMembers
/>
</div>

<div className={style.account_row}>
<div className={style.label}>CSV Collapse Threshold</div>
<div className={style.value}>
<ChangeCsvCollapseThreshold
csvCollapseThreshold={userProfile.csvCollapseThreshold}
/>
</div>
</div>

<div className={style.account_row}>
<div className={style.label}>
Public Key{' '}
Expand Down
2 changes: 1 addition & 1 deletion pages/api/paybutton/download/transactions/[paybuttonId].ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export default async (req: any, res: any): Promise<void> => {
};
const transactions = await fetchTransactionsByPaybuttonId(paybutton.id, networkIdArray)
res.setHeader('Content-Type', 'text/csv')
await downloadTxsFile(res, quoteSlug, timezone, transactions, userId, paybuttonId)
await downloadTxsFile(res, quoteSlug, timezone, transactions, userId, paybuttonId, true, user.csvCollapseThreshold)
} catch (error: any) {
switch (error.message) {
case RESPONSE_MESSAGES.PAYBUTTON_ID_NOT_PROVIDED_400.message:
Expand Down
2 changes: 1 addition & 1 deletion pages/api/payments/download/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export default async (req: any, res: any): Promise<void> => {

const transactions = await fetchAllPaymentsByUserId(userId, networkIdArray, buttonIds, years, startDate, endDate, timezone)

await downloadTxsFile(res, quoteSlug, timezone, transactions, userId)
await downloadTxsFile(res, quoteSlug, timezone, transactions, userId, undefined, true, user.csvCollapseThreshold)
} catch (error: any) {
switch (error.message) {
case RESPONSE_MESSAGES.METHOD_NOT_ALLOWED_405.message:
Expand Down
32 changes: 32 additions & 0 deletions pages/api/user/csvCollapseThreshold/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { setSession } from 'utils/setSession'
import * as userService from 'services/userService'
import { RESPONSE_MESSAGES } from 'constants/index'

export default async (
req: any,
res: any
): Promise<void> => {
await setSession(req, res, true)

if (req.method === 'PUT') {
const session = req.session
const { csvCollapseThreshold } = req.body

if (csvCollapseThreshold === undefined || csvCollapseThreshold === null) {
res.status(400).json({ message: 'csvCollapseThreshold is required' })
return
}

const threshold = Number(csvCollapseThreshold)
if (isNaN(threshold) || threshold < 0) {
res.status(400).json({ message: 'csvCollapseThreshold must be a non-negative number' })
return
Comment on lines +15 to +23
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify JS coercion edge-cases that affect current validation logic.
node - <<'NODE'
const inputs = ['', '   ', '1.5', 'Infinity', null, undefined, '12abc'];
for (const v of inputs) {
  console.log(JSON.stringify(v), '=> Number:', Number(v), 'isFinite:', Number.isFinite(Number(v)));
}
NODE

Repository: PayButton/paybutton-server

Length of output: 330


🏁 Script executed:

find . -type f -name "*.ts" -path "*/pages/api/user/csvCollapseThreshold*" | head -20

Repository: PayButton/paybutton-server

Length of output: 116


🏁 Script executed:

cat -n pages/api/user/csvCollapseThreshold/index.ts

Repository: PayButton/paybutton-server

Length of output: 1301


🏁 Script executed:

# Check the userService to understand what updateCsvCollapseThreshold expects
find . -type f -name "userService.ts" | head -5

Repository: PayButton/paybutton-server

Length of output: 95


🏁 Script executed:

cat -n services/userService.ts | head -100

Repository: PayButton/paybutton-server

Length of output: 4017


🏁 Script executed:

# Search for the updateCsvCollapseThreshold function specifically
rg -A 15 "updateCsvCollapseThreshold" services/userService.ts

Repository: PayButton/paybutton-server

Length of output: 520


🏁 Script executed:

# Check if there's a Prisma schema that defines csvCollapseThreshold type
find . -type f \( -name "schema.prisma" -o -name "*.prisma" \)

Repository: PayButton/paybutton-server

Length of output: 98


🏁 Script executed:

rg -A 3 -B 3 "csvCollapseThreshold" ./prisma-local/schema.prisma

Repository: PayButton/paybutton-server

Length of output: 275


🏁 Script executed:

# Also check the UserProfile model in the schema
rg -A 50 "model UserProfile" ./prisma-local/schema.prisma | head -80

Repository: PayButton/paybutton-server

Length of output: 1930


Harden threshold validation to reject blank, non-finite, and non-integer values.

Current coercion accepts invalid inputs like empty strings, whitespace, decimals, and Infinity. Since csvCollapseThreshold is defined as an Int in the database schema, these invalid inputs could cause unintended behavior or persist incorrect values.

Proposed fix
-    if (csvCollapseThreshold === undefined || csvCollapseThreshold === null) {
+    if (
+      csvCollapseThreshold === undefined ||
+      csvCollapseThreshold === null ||
+      (typeof csvCollapseThreshold === 'string' && csvCollapseThreshold.trim() === '')
+    ) {
       res.status(400).json({ message: 'csvCollapseThreshold is required' })
       return
     }

-    const threshold = Number(csvCollapseThreshold)
-    if (isNaN(threshold) || threshold < 0) {
-      res.status(400).json({ message: 'csvCollapseThreshold must be a non-negative number' })
+    const threshold = Number(csvCollapseThreshold)
+    if (!Number.isFinite(threshold) || !Number.isInteger(threshold) || threshold < 0) {
+      res.status(400).json({ message: 'csvCollapseThreshold must be a non-negative integer' })
       return
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (csvCollapseThreshold === undefined || csvCollapseThreshold === null) {
res.status(400).json({ message: 'csvCollapseThreshold is required' })
return
}
const threshold = Number(csvCollapseThreshold)
if (isNaN(threshold) || threshold < 0) {
res.status(400).json({ message: 'csvCollapseThreshold must be a non-negative number' })
return
if (
csvCollapseThreshold === undefined ||
csvCollapseThreshold === null ||
(typeof csvCollapseThreshold === 'string' && csvCollapseThreshold.trim() === '')
) {
res.status(400).json({ message: 'csvCollapseThreshold is required' })
return
}
const threshold = Number(csvCollapseThreshold)
if (!Number.isFinite(threshold) || !Number.isInteger(threshold) || threshold < 0) {
res.status(400).json({ message: 'csvCollapseThreshold must be a non-negative integer' })
return
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pages/api/user/csvCollapseThreshold/index.ts` around lines 15 - 23, The
current validation for csvCollapseThreshold (variable csvCollapseThreshold and
computed threshold) is too permissive; change it to first reject empty or
whitespace-only string inputs (if typeof csvCollapseThreshold === 'string' check
trim().length === 0) then parse to a number and validate using
Number.isFinite(threshold) and Number.isInteger(threshold) and threshold >= 0;
return 400 with an appropriate message if any of these checks fail so decimals,
Infinity/NaN, and blank inputs are rejected before persisting the Int.

}

await userService.updateCsvCollapseThreshold(session.userId, threshold)
res.status(200).json({ success: true })
} else {
res.status(RESPONSE_MESSAGES.METHOD_NOT_ALLOWED_405.statusCode)
.json(RESPONSE_MESSAGES.METHOD_NOT_ALLOWED_405)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `UserProfile` ADD COLUMN `csvCollapseThreshold` INTEGER NOT NULL DEFAULT 1;
1 change: 1 addition & 0 deletions prisma-local/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ model UserProfile {
invoices Invoice[]
preferredCurrencyId Int @default(1)
preferredTimezone String @db.VarChar(255)@default("")
csvCollapseThreshold Int @default(1)
proUntil DateTime?
paybuttons Paybutton[]

Expand Down
9 changes: 9 additions & 0 deletions services/userService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,15 @@ export async function updatePreferredTimezone (id: string, preferredTimezone: st
})
}

export async function updateCsvCollapseThreshold (id: string, csvCollapseThreshold: number): Promise<void> {
await prisma.userProfile.update({
where: { id },
data: {
csvCollapseThreshold
}
})
}

export async function userRemainingProTime (id: string): Promise<number | null> {
const today = new Date()
const proUntil = (await prisma.userProfile.findUniqueOrThrow({
Expand Down
6 changes: 4 additions & 2 deletions tests/mockedObjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,8 @@ export const mockedUserProfile: UserProfile = {
preferredTimezone: '',
emailCredits: 15,
postCredits: 15,
proUntil: null
proUntil: null,
csvCollapseThreshold: 0
}

export const mockedUserProfileWithPublicKey: UserProfile = {
Expand All @@ -543,7 +544,8 @@ export const mockedUserProfileWithPublicKey: UserProfile = {
preferredTimezone: '',
emailCredits: 15,
postCredits: 15,
proUntil: null
proUntil: null,
csvCollapseThreshold: 0
}

export const mockedAddressesOnButtons: AddressesOnButtons[] = [
Expand Down
Loading