← Public packages
@kentcdodds/paypal
PayPal helpers for invoices, sending invoices, transactions, and reimbursements.
src/paypal-core.ts
280 lines · 8.5 KB · TypeScriptimport { oauthClientCredentials } from 'kody:runtime'
import {
balancesGet,
invoicesCreate,
invoicesGet,
invoicesList,
invoicesSearchInvoices,
invoicesSend,
searchGet,
type QueryInput,
type ScaffoldInput,
type ScaffoldOptions,
} from './openapi-client.ts'
export type PayPalEnvironment = 'live' | 'sandbox'
export type PayPalAuthInput = {
environment?: PayPalEnvironment
apiBaseUrl?: string
clientIdSecret?: string
clientSecretSecret?: string
secretScope?: 'user' | 'app' | 'session'
}
const liveApiBaseUrl = 'https://api-m.paypal.com'
const sandboxApiBaseUrl = 'https://api-m.sandbox.paypal.com'
export class PayPalApiError extends Error {
status: number
statusText: string
details: unknown
constructor(message: string, input: { status: number; statusText: string; details: unknown }) {
super(message)
this.name = 'PayPalApiError'
this.status = input.status
this.statusText = input.statusText
this.details = input.details
}
}
export function getPayPalApiBaseUrl(input: PayPalAuthInput = {}) {
const baseUrl = input.apiBaseUrl ?? (input.environment === 'sandbox' ? sandboxApiBaseUrl : liveApiBaseUrl)
return String(baseUrl).replace(/\/+$/, '')
}
/**
* Mint a short-lived PayPal access token via oauthClientCredentials.
* Token stays in-memory for the call; never log or return it.
*/
async function getAccessToken(input: PayPalAuthInput = {}) {
const apiBaseUrl = getPayPalApiBaseUrl(input)
const payload = await oauthClientCredentials({
tokenUrl: apiBaseUrl + '/v1/oauth2/token',
clientIdSecret: input.clientIdSecret ?? 'paypalClientId',
clientSecretSecret: input.clientSecretSecret ?? 'paypalClientSecret',
scope: input.secretScope ?? 'user',
authStyle: 'basic',
})
const accessToken = payload && typeof payload.access_token === 'string' ? payload.access_token : null
if (!accessToken) {
throw new Error('PayPal token response did not include an access_token string.')
}
return accessToken
}
async function parseResponse(response: Response) {
const text = await response.text()
if (!text) return null
try {
return JSON.parse(text)
} catch {
return { raw: text }
}
}
async function parseOkJson(response: Response) {
const data = await parseResponse(response)
if (!response.ok) {
const errorName =
data && typeof data === 'object' && 'name' in data
? String((data as Record<string, unknown>).name)
: 'PayPal API request failed'
const message =
data && typeof data === 'object' && 'message' in data
? String((data as Record<string, unknown>).message)
: errorName
throw new PayPalApiError(message, {
status: response.status,
statusText: response.statusText,
details: data,
})
}
return data
}
type AuthCallOptions = PayPalAuthInput & {
headers?: Record<string, string>
}
async function callAuthenticated(
fn: (input?: ScaffoldInput, options?: ScaffoldOptions) => Promise<Response>,
scaffoldInput: ScaffoldInput,
auth: AuthCallOptions = {},
) {
const accessToken = await getAccessToken(auth)
const response = await fn(
{
...scaffoldInput,
headers: {
Accept: 'application/json',
Authorization: 'Bearer ' + accessToken,
...(scaffoldInput.headers ?? {}),
...(auth.headers ?? {}),
},
},
{ apiBaseUrl: getPayPalApiBaseUrl(auth) },
)
return parseOkJson(response)
}
/** List invoices via OpenAPI slug `invoices_list`. */
export async function paypalListInvoices(
input: PayPalAuthInput & { query?: QueryInput } = {},
) {
return callAuthenticated(invoicesList, { query: input.query }, input)
}
/** Get one invoice via OpenAPI slug `invoices_get`. */
export async function paypalGetInvoice(
input: PayPalAuthInput & { invoiceId: string; query?: QueryInput },
) {
return callAuthenticated(
invoicesGet,
{ params: { invoice_id: input.invoiceId }, query: input.query },
input,
)
}
/** Search invoices via OpenAPI slug `invoices_search_invoices` (read-only POST). */
export async function paypalSearchInvoices(
input: PayPalAuthInput & { query?: QueryInput; body?: unknown } = {},
) {
return callAuthenticated(
invoicesSearchInvoices,
{ query: input.query, body: input.body ?? {} },
input,
)
}
/** Create a draft invoice via OpenAPI slug `invoices_create`. */
export async function paypalCreateInvoice(
input: PayPalAuthInput & { body: unknown; query?: QueryInput },
) {
return callAuthenticated(
invoicesCreate,
{
query: input.query,
body: input.body,
headers: { Prefer: 'return=representation' },
},
input,
)
}
/** Send an invoice via OpenAPI slug `invoices_send`. */
export async function paypalSendInvoice(
input: PayPalAuthInput & {
invoiceId: string
body?: unknown
query?: QueryInput
},
) {
return callAuthenticated(
invoicesSend,
{
params: { invoice_id: input.invoiceId },
query: input.query,
body: input.body ?? { send_to_recipient: true, send_to_invoicer: true },
},
input,
)
}
/** List transactions via OpenAPI slug `search_get`. */
export async function paypalListTransactions(
input: PayPalAuthInput & { query?: QueryInput } = {},
) {
return callAuthenticated(searchGet, { query: input.query }, input)
}
/** List balances via OpenAPI slug `balances_get`. */
export async function paypalListBalances(
input: PayPalAuthInput & { query?: QueryInput } = {},
) {
return callAuthenticated(balancesGet, { query: input.query }, input)
}
export function appendDefined(params: URLSearchParams, key: string, value: unknown) {
if (value == null || value === '') return
params.set(key, String(value))
}
export function normalizeMoney(money: unknown) {
if (!money || typeof money !== 'object') return null
const record = money as Record<string, unknown>
return {
currency_code: typeof record.currency_code === 'string' ? record.currency_code : null,
value: typeof record.value === 'string' ? record.value : null,
}
}
export function numericMoneyValue(money: unknown) {
const normalized = normalizeMoney(money)
if (!normalized?.value) return null
const value = Number(normalized.value)
return Number.isFinite(value) ? value : null
}
export function normalizeInvoiceSummary(invoice: any) {
if (!invoice || typeof invoice !== 'object') return null
return {
id: invoice.id ?? null,
status: invoice.status ?? null,
invoice_number: invoice.detail?.invoice_number ?? null,
invoice_date: invoice.detail?.invoice_date ?? null,
due_date: invoice.detail?.payment_term?.due_date ?? null,
currency_code: invoice.amount?.currency_code ?? invoice.detail?.currency_code ?? null,
amount: invoice.amount?.value ?? invoice.amount?.breakdown?.item_total?.value ?? null,
amount_money: invoice.amount ?? null,
recipient_email: invoice.primary_recipients?.[0]?.billing_info?.email_address ?? null,
recipient_name: invoice.primary_recipients?.[0]?.billing_info?.name ?? null,
merchant_name: invoice.detail?.merchant_info?.business_name ?? invoice.detail?.merchant_info?.name ?? null,
reference: invoice.detail?.reference ?? null,
memo: invoice.detail?.memo ?? null,
item_count: Array.isArray(invoice.items) ? invoice.items.length : null,
links: Array.isArray(invoice.links)
? invoice.links.map((link: any) => ({
rel: link.rel ?? null,
method: link.method ?? null,
href: link.href ?? null,
}))
: [],
}
}
export function normalizeTransactionSummary(detail: any) {
const transaction = detail?.transaction_info ?? {}
const payer = detail?.payer_info ?? {}
const payerName = payer.payer_name ?? {}
return {
transaction_id: transaction.transaction_id ?? null,
transaction_event_code: transaction.transaction_event_code ?? null,
transaction_status: transaction.transaction_status ?? null,
transaction_initiation_date: transaction.transaction_initiation_date ?? null,
transaction_updated_date: transaction.transaction_updated_date ?? null,
transaction_amount: transaction.transaction_amount ?? null,
fee_amount: transaction.fee_amount ?? null,
ending_balance: transaction.ending_balance ?? null,
invoice_id: transaction.invoice_id ?? null,
transaction_subject: transaction.transaction_subject ?? null,
transaction_note: transaction.transaction_note ?? null,
payer_email: payer.email_address ?? null,
payer_name: payerName.alternate_full_name ?? payerName.given_name ?? null,
}
}
export function includesText(value: unknown, query: string) {
return String(value ?? '')
.toLowerCase()
.includes(query.toLowerCase())
}
export function toPayPalDate(input: string | Date) {
const date = input instanceof Date ? input : new Date(input)
if (Number.isNaN(date.getTime())) {
throw new Error('Invalid PayPal date: ' + String(input))
}
return date.toISOString().replace(/\.\d{3}Z$/, 'Z')
}