← Public packages
@kentcdodds/paypal
PayPal helpers for invoices, sending invoices, transactions, and reimbursements.
src/invoices.ts
319 lines · 9.2 KB · TypeScriptimport {
includesText,
normalizeInvoiceSummary,
paypalCreateInvoice,
paypalGetInvoice,
paypalListInvoices,
paypalSearchInvoices,
paypalSendInvoice,
type PayPalAuthInput,
} from './paypal-core.ts'
import { searchTransactions } from './transactions.ts'
export type ListInvoicesInput = PayPalAuthInput & {
page?: number
pageSize?: number
totalRequired?: boolean
fields?: string
status?: string
includeRaw?: boolean
}
export type SearchInvoicesInput = PayPalAuthInput & {
page?: number
pageSize?: number
totalRequired?: boolean
invoiceNumber?: string
recipientEmail?: string
status?: string
reference?: string
currencyCode?: string
amount?: string | number
rawCriteria?: Record<string, unknown>
includeRaw?: boolean
query?: string
}
export type GetInvoiceInput = PayPalAuthInput & {
id: string
includeRaw?: boolean
}
export type ReimbursementSearchInput = PayPalAuthInput & {
invoiceId?: string
invoiceNumber?: string
recipientEmail?: string
amount?: string | number
currencyCode?: string
query?: string
startDate?: string | Date
endDate?: string | Date
includeRaw?: boolean
}
function invoiceMatches(
summary: any,
input: {
amount?: string | number
status?: string
recipientEmail?: string
query?: string
currencyCode?: string
},
) {
if (input.status && summary.status !== input.status) return false
if (input.recipientEmail && !includesText(summary.recipient_email, input.recipientEmail)) return false
if (input.currencyCode && summary.currency_code !== input.currencyCode) return false
if (input.amount != null) {
const expected = Number(input.amount)
const amount = Number(summary.amount)
if (!Number.isFinite(expected) || !Number.isFinite(amount) || Math.abs(Math.abs(amount) - Math.abs(expected)) > 0.005) {
return false
}
}
if (input.query) {
const haystack = [
summary.id,
summary.invoice_number,
summary.status,
summary.recipient_email,
summary.recipient_name?.given_name,
summary.recipient_name?.surname,
summary.merchant_name,
summary.reference,
summary.memo,
summary.amount,
].join(' ')
if (!includesText(haystack, input.query)) return false
}
return true
}
function mapInvoiceItem(item: any, includeRaw?: boolean) {
const summary = normalizeInvoiceSummary(item)
return includeRaw ? { summary, raw: item } : summary
}
/**
* List recent PayPal invoices (OpenAPI `invoices_list`).
*/
export async function listInvoices(input: ListInvoicesInput = {}) {
const raw = await paypalListInvoices({
...input,
query: {
page: input.page ?? 1,
page_size: input.pageSize ?? 20,
total_required: input.totalRequired ?? true,
fields: input.fields,
},
})
const items = Array.isArray((raw as any)?.items) ? (raw as any).items : []
const mapped = items.map((item: any) => mapInvoiceItem(item, input.includeRaw))
const filtered = input.status
? mapped.filter((item: any) => (input.includeRaw ? item.summary?.status : item?.status) === input.status)
: mapped
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: filtered,
}
}
/**
* Read one PayPal invoice by invoice id (OpenAPI `invoices_get`).
*/
export async function getInvoice(input: GetInvoiceInput) {
if (!input.id) throw new Error('getInvoice requires id.')
const raw = await paypalGetInvoice({ ...input, invoiceId: input.id })
const summary = normalizeInvoiceSummary(raw)
return input.includeRaw ? { summary, raw } : summary
}
/**
* Search PayPal invoices by invoice number, recipient email, status, reference, currency, or amount
* (OpenAPI `invoices_search_invoices`).
*/
export async function searchInvoices(input: SearchInvoicesInput = {}) {
const body: Record<string, unknown> = { ...(input.rawCriteria ?? {}) }
if (input.invoiceNumber) body.invoice_number = input.invoiceNumber
if (input.recipientEmail) body.recipient_email = input.recipientEmail
if (input.status) body.status = input.status
if (input.reference) body.reference = input.reference
if (input.currencyCode) body.currency_code = input.currencyCode
const raw = await paypalSearchInvoices({
...input,
query: {
page: input.page ?? 1,
page_size: input.pageSize ?? 20,
total_required: input.totalRequired ?? true,
},
body,
})
const items = Array.isArray((raw as any)?.items) ? (raw as any).items : []
const mapped = items.map((item: any) => mapInvoiceItem(item, input.includeRaw))
const filtered = mapped.filter((item: any) => invoiceMatches(input.includeRaw ? item.summary : item, input))
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: filtered,
}
}
/**
* Find a single PayPal invoice. If id is provided, this reads that invoice directly; otherwise it searches.
*/
export async function findInvoice(input: SearchInvoicesInput & { id?: string } = {}) {
if (input.id) {
const invoice = await getInvoice({ ...input, id: input.id })
return { match: invoice, source: 'id' }
}
const result = await searchInvoices(input)
return {
...result,
match: result.items[0] ?? null,
source: 'search',
}
}
/**
* Return a compact invoice summary by id or search criteria.
*/
export async function summarizeInvoice(input: SearchInvoicesInput & { id?: string } = {}) {
const result = await findInvoice(input)
return result.match
}
/**
* Search PayPal invoices and transaction activity together for reimbursement investigations.
*/
export async function searchInvoicesForReimbursement(input: ReimbursementSearchInput = {}) {
const invoiceResults = input.invoiceId
? { items: [await getInvoice({ ...input, id: input.invoiceId })].filter(Boolean), match: null }
: await searchInvoices({
...input,
invoiceNumber: input.invoiceNumber,
recipientEmail: input.recipientEmail,
amount: input.amount,
currencyCode: input.currencyCode,
pageSize: 20,
})
const invoices = (invoiceResults.items as Array<any>).filter((item) =>
invoiceMatches(input.includeRaw ? item.summary : item, input),
)
const shouldSearchTransactions = Boolean(
input.startDate || input.endDate || input.amount || input.recipientEmail || input.query,
)
const transactions = shouldSearchTransactions
? await searchTransactions({
...input,
email: input.recipientEmail,
amount: input.amount,
currencyCode: input.currencyCode,
query: input.query,
direction: 'incoming',
})
: null
return {
criteria: {
invoiceId: input.invoiceId ?? null,
invoiceNumber: input.invoiceNumber ?? null,
recipientEmail: input.recipientEmail ?? null,
amount: input.amount ?? null,
currencyCode: input.currencyCode ?? null,
query: input.query ?? null,
},
invoices,
transactions,
likely_matches: {
invoice: invoices[0] ?? null,
transaction: transactions?.matches?.[0] ?? null,
},
}
}
export type CreateInvoiceInput = PayPalAuthInput & {
invoiceNumber?: string
invoiceDate?: string
currencyCode?: string
memo?: string
note?: string
termType?: string
recipientEmail: string
recipientGivenName?: string
recipientSurname?: string
itemName: string
itemDescription?: string
amount: string | number
send?: boolean
includeRaw?: boolean
}
/** Create a PayPal invoice (draft unless `send` is true). */
export async function createInvoice(input: CreateInvoiceInput) {
if (!input.recipientEmail) throw new Error('createInvoice requires recipientEmail.')
if (!input.itemName) throw new Error('createInvoice requires itemName.')
if (input.amount == null || input.amount === '') throw new Error('createInvoice requires amount.')
const currency = input.currencyCode ?? 'USD'
const value = Number(input.amount).toFixed(2)
const body = {
detail: {
invoice_number: input.invoiceNumber,
invoice_date: input.invoiceDate,
currency_code: currency,
memo: input.memo,
note: input.note,
payment_term: { term_type: input.termType ?? 'DUE_ON_RECEIPT' },
},
primary_recipients: [
{
billing_info: {
email_address: input.recipientEmail,
name: {
given_name: input.recipientGivenName,
surname: input.recipientSurname,
},
},
},
],
items: [
{
name: input.itemName,
description: input.itemDescription,
quantity: '1',
unit_amount: { currency_code: currency, value },
},
],
}
const raw = await paypalCreateInvoice({ ...input, body })
const id = (raw as any)?.id
if (input.send) {
if (!id) throw new Error('PayPal create did not return an invoice id.')
await paypalSendInvoice({ ...input, invoiceId: id })
return await getInvoice({ ...input, id, includeRaw: input.includeRaw })
}
const summary = normalizeInvoiceSummary(raw)
return input.includeRaw ? { summary, raw } : summary
}
export type SendInvoiceInput = PayPalAuthInput & {
id: string
includeRaw?: boolean
}
/** Send an existing PayPal draft invoice to the recipient. */
export async function sendInvoice(input: SendInvoiceInput) {
if (!input.id) throw new Error('sendInvoice requires id.')
await paypalSendInvoice({ ...input, invoiceId: input.id })
return await getInvoice({ ...input, id: input.id, includeRaw: input.includeRaw })
}
export default async function invoices(input: SearchInvoicesInput = {}) {
return await searchInvoices(input)
}