import {
includesText,
normalizeTransactionSummary,
numericMoneyValue,
toPayPalDate,
type PayPalAuthInput,
} from './paypal-helpers.ts'
import { paypalListTransactions } from './paypal-core.ts'
export type ListTransactionsInput = PayPalAuthInput & {
startDate: string | Date
endDate: string | Date
page?: number
pageSize?: number
fields?: string
transactionId?: string
transactionType?: string
transactionStatus?: string
includeRaw?: boolean
}
export type SearchTransactionsInput = PayPalAuthInput & {
startDate?: string | Date
endDate?: string | Date
pageSize?: number
maxPagesPerChunk?: number
query?: string
email?: string
amount?: string | number
currencyCode?: string
invoiceId?: string
transactionStatus?: string
direction?: 'incoming' | 'outgoing'
includeRaw?: boolean
}
function defaultDateRange() {
const end = new Date()
const start = new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000)
return { start, end }
}
export function splitDateRange(startInput: string | Date, endInput: string | Date) {
const start = startInput instanceof Date ? startInput : new Date(startInput)
const end = endInput instanceof Date ? endInput : new Date(endInput)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
throw new Error('Invalid PayPal transaction date range.')
}
if (start > end) {
throw new Error('startDate must be before endDate.')
}
const chunks: Array<{ start: Date; end: Date }> = []
let cursor = new Date(start)
const chunkMs = 30 * 24 * 60 * 60 * 1000
while (cursor <= end) {
const chunkEnd = new Date(Math.min(end.getTime(), cursor.getTime() + chunkMs))
chunks.push({ start: new Date(cursor), end: chunkEnd })
cursor = new Date(chunkEnd.getTime() + 1000)
}
return chunks
}
export function transactionMatches(summary: any, input: SearchTransactionsInput) {
if (input.email && !includesText(summary.payer_email, input.email)) return false
if (input.invoiceId && !includesText(summary.invoice_id, input.invoiceId)) return false
if (input.currencyCode && summary.transaction_amount?.currency_code !== input.currencyCode) return false
if (input.transactionStatus && summary.transaction_status !== input.transactionStatus) return false
const amount = numericMoneyValue(summary.transaction_amount)
if (input.amount != null) {
const expected = Number(input.amount)
if (!Number.isFinite(expected) || amount == null || Math.abs(Math.abs(amount) - Math.abs(expected)) > 0.005) {
return false
}
}
if (input.direction === 'incoming' && (amount == null || amount <= 0)) return false
if (input.direction === 'outgoing' && (amount == null || amount >= 0)) return false
if (input.query) {
const haystack = [
summary.transaction_id,
summary.invoice_id,
summary.transaction_subject,
summary.transaction_note,
summary.payer_email,
summary.payer_name,
summary.transaction_amount?.value,
].join(' ')
if (!includesText(haystack, input.query)) return false
}
return true
}
/**
* List PayPal account transactions for a single PayPal reporting date range
* (OpenAPI `search_get`). PayPal rejects ranges over 31 days; use searchTransactions for longer ranges.
*/
export async function listTransactions(input: ListTransactionsInput) {
if (!input.startDate || !input.endDate) {
throw new Error('listTransactions requires startDate and endDate.')
}
const raw = await paypalListTransactions({
...input,
query: {
start_date: toPayPalDate(input.startDate),
end_date: toPayPalDate(input.endDate),
fields: input.fields ?? 'all',
page: input.page ?? 1,
page_size: input.pageSize ?? 100,
transaction_id: input.transactionId,
transaction_type: input.transactionType,
transaction_status: input.transactionStatus,
},
})
const details = Array.isArray((raw as any)?.transaction_details) ? (raw as any).transaction_details : []
return {
total_items: (raw as any)?.total_items ?? null,
total_pages: (raw as any)?.total_pages ?? null,
page: (raw as any)?.page ?? input.page ?? 1,
items: details.map((detail: any) =>
input.includeRaw
? { summary: normalizeTransactionSummary(detail), raw: detail }
: normalizeTransactionSummary(detail),
),
}
}
/**
* Search PayPal transactions across a date range and filter by text, email, amount, currency, invoice id, or direction.
*/
export async function searchTransactions(input: SearchTransactionsInput = {}) {
const defaults = defaultDateRange()
const startDate = input.startDate ?? defaults.start
const endDate = input.endDate ?? defaults.end
const chunks = splitDateRange(startDate, endDate)
const matches: Array<unknown> = []
let scanned = 0
for (const chunk of chunks) {
const maxPages = input.maxPagesPerChunk ?? 10
for (let page = 1; page <= maxPages; page += 1) {
const listed = await listTransactions({
...input,
startDate: chunk.start,
endDate: chunk.end,
page,
pageSize: input.pageSize ?? 100,
})
scanned += listed.items.length
for (const item of listed.items as Array<any>) {
const summary = input.includeRaw ? item.summary : item
if (transactionMatches(summary, input)) matches.push(item)
}
if (!listed.total_pages || page >= Number(listed.total_pages)) break
}
}
return {
criteria: {
startDate: toPayPalDate(startDate),
endDate: toPayPalDate(endDate),
query: input.query ?? null,
email: input.email ?? null,
amount: input.amount ?? null,
currencyCode: input.currencyCode ?? null,
invoiceId: input.invoiceId ?? null,
direction: input.direction ?? null,
},
chunks: chunks.map((chunk) => ({
startDate: toPayPalDate(chunk.start),
endDate: toPayPalDate(chunk.end),
})),
scanned,
matches,
}
}
/**
* Return the first transaction matching searchTransactions criteria.
*/
export async function findTransaction(input: SearchTransactionsInput = {}) {
const result = await searchTransactions(input)
return {
...result,
match: result.matches[0] ?? null,
}
}
export default async function transactions(input: SearchTransactionsInput = {}) {
return await searchTransactions(input)
}