Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/paypal

src/transactions.ts

192 lines · 6.0 KB · TypeScript
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)
}