Skip to content

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

Package listing

@kody/paypal

src/paypal-helpers.ts

395 lines · 12.6 KB · TypeScript
export const LIVE_API_BASE_URL = 'https://api-m.paypal.com'
export const SANDBOX_API_BASE_URL = 'https://api-m.sandbox.paypal.com'
export const LIVE_API_HOST = 'api-m.paypal.com'
export const SANDBOX_API_HOST = 'api-m.sandbox.paypal.com'
export const DASHBOARD_APPS_URL = 'https://developer.paypal.com/dashboard/applications'
export const DEFAULT_CLIENT_ID_SECRET = 'paypalClientId'
export const DEFAULT_CLIENT_SECRET_SECRET = 'paypalClientSecret'

const CLIENT_ID_PATTERN = /^paypalClientId(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const CLIENT_SECRET_PATTERN = /^paypalClientSecret(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const ACCOUNT_LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/

export type PayPalEnvironment = 'live' | 'sandbox'

export type PayPalAuthInput = {
	environment?: PayPalEnvironment
	apiBaseUrl?: string
	/** Extra account label. `work` reads `paypalClientId-work`. */
	account?: string
	clientIdSecret?: string
	clientSecretSecret?: string
	secretScope?: 'user' | 'app' | 'session'
}

export function assertNever(value: never, message: string): never {
	throw new Error(message + String(value))
}

export function parseAction<T extends string>(
	value: unknown,
	allowed: readonly T[],
	fallback: T,
	label: string,
): T {
	const action = (value == null || value === '' ? fallback : value) as unknown
	if (typeof action === 'string' && (allowed as readonly string[]).includes(action)) {
		return action as T
	}
	throw new Error(
		'Unknown ' + label + ' action: ' + String(action) + '. Valid actions: ' + allowed.join(', '),
	)
}

function accountLabel(value: string | undefined): string | null {
	const trimmed = (value ?? '').trim()
	if (!trimmed || trimmed === 'default') return null
	if (!ACCOUNT_LABEL_PATTERN.test(trimmed)) {
		throw new Error(
			'account must be a short label such as "work" or "sandbox" (letters, numbers, _ or -).',
		)
	}
	return trimmed
}

function suffixName(base: string, account: string | null): string {
	return account ? base + '-' + account : base
}

/** Resolve the user-scoped PayPal client-id secret name for this call. */
export function resolveClientIdSecretName(input: PayPalAuthInput = {}): string {
	if (input.clientIdSecret != null && String(input.clientIdSecret).trim() !== '') {
		const name = String(input.clientIdSecret).trim()
		if (!CLIENT_ID_PATTERN.test(name)) {
			throw new Error(
				'clientIdSecret must be paypalClientId or paypalClientId-<account>. Got ' +
					name +
					'. Save it at ' +
					clientIdSetupUrl(DEFAULT_CLIENT_ID_SECRET) +
					'.',
			)
		}
		return name
	}
	return suffixName(DEFAULT_CLIENT_ID_SECRET, accountLabel(input.account))
}

/** Resolve the user-scoped PayPal client-secret secret name for this call. */
export function resolveClientSecretSecretName(input: PayPalAuthInput = {}): string {
	if (input.clientSecretSecret != null && String(input.clientSecretSecret).trim() !== '') {
		const name = String(input.clientSecretSecret).trim()
		if (!CLIENT_SECRET_PATTERN.test(name)) {
			throw new Error(
				'clientSecretSecret must be paypalClientSecret or paypalClientSecret-<account>. Got ' +
					name +
					'. Save it at ' +
					clientSecretSetupUrl(DEFAULT_CLIENT_SECRET_SECRET) +
					'.',
			)
		}
		return name
	}
	return suffixName(DEFAULT_CLIENT_SECRET_SECRET, accountLabel(input.account))
}

export function clientIdSetupUrl(secretName: string = DEFAULT_CLIENT_ID_SECRET): string {
	const params = new URLSearchParams({
		name: secretName,
		description: 'PayPal REST API client ID for invoices, transactions, and payouts',
		allowedHosts: LIVE_API_HOST + ',' + SANDBOX_API_HOST,
		scope: 'user',
	})
	return 'https://kody.codes/account/secrets/new?' + params.toString()
}

export function clientSecretSetupUrl(secretName: string = DEFAULT_CLIENT_SECRET_SECRET): string {
	const params = new URLSearchParams({
		name: secretName,
		description: 'PayPal REST API client secret for invoices, transactions, and payouts',
		allowedHosts: LIVE_API_HOST + ',' + SANDBOX_API_HOST,
		scope: 'user',
	})
	return 'https://kody.codes/account/secrets/new?' + params.toString()
}

export const CLIENT_ID_SETUP_URL = clientIdSetupUrl()
export const CLIENT_SECRET_SETUP_URL = clientSecretSetupUrl()

export function getPayPalApiBaseUrl(input: PayPalAuthInput = {}) {
	const baseUrl = input.apiBaseUrl ?? (input.environment === 'sandbox' ? SANDBOX_API_BASE_URL : LIVE_API_BASE_URL)
	return String(baseUrl).replace(/\/+$/, '')
}

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 normalizePayoutSummary(batch: any) {
	const header = batch?.batch_header ?? batch ?? {}
	const sender = header.sender_batch_header ?? {}
	return {
		payout_batch_id: header.payout_batch_id ?? null,
		batch_status: header.batch_status ?? null,
		sender_batch_id: sender.sender_batch_id ?? null,
		amount: header.amount ?? null,
		fees: header.fees ?? null,
		time_created: header.time_created ?? null,
		time_completed: header.time_completed ?? null,
		item_count: Array.isArray(batch?.items) ? batch.items.length : 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')
}

export function moneyValue(amount: string | number) {
	const value = Number(amount)
	if (!Number.isFinite(value) || value < 0) {
		throw new Error('amount must be a non-negative number.')
	}
	return value.toFixed(2)
}

/** Throw unless the caller passed confirm: true for a money-moving action. */
export function requireConfirm(input: { confirm?: boolean }, action: string) {
	if (input.confirm !== true) {
		throw new Error(
			'Refusing to ' +
				action +
				' without confirm: true. This action changes live PayPal state; pass dryRun: true to preview, or confirm: true to proceed.',
		)
	}
}

export type PayPalDryRun = {
	dryRun: true
	action: string
	method: 'POST'
	path: string
	body?: Record<string, unknown>
}

export type MutationGuardInput = PayPalAuthInput & {
	dryRun?: boolean
	confirm?: boolean
}

/**
 * Preview a mutation when `dryRun: true`. Money-moving helpers also require
 * `confirm: true` before they contact PayPal.
 */
export function mutationPreview(
	input: MutationGuardInput,
	options: {
		action: string
		method: 'POST'
		path: string
		body?: Record<string, unknown>
		requireConfirm?: boolean
	},
): PayPalDryRun | null {
	if (input.dryRun === true) {
		return {
			dryRun: true,
			action: options.action,
			method: options.method,
			path: options.path,
			body: options.body,
		}
	}
	if (options.requireConfirm) requireConfirm(input, options.action)
	return null
}

export type InvoiceBodyInput = {
	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
}

export function buildInvoiceBody(input: InvoiceBodyInput) {
	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 = moneyValue(input.amount)
	return {
		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 },
			},
		],
	}
}

export type PayoutRecipientType = 'EMAIL' | 'PHONE' | 'PAYPAL_ID' | 'USER_HANDLE'

export type PayoutItemInput = {
	receiver: string
	amount: string | number
	currencyCode?: string
	recipientType?: PayoutRecipientType
	note?: string
	senderItemId?: string
	recipientWallet?: 'PAYPAL' | 'VENMO'
}

export type PayoutBodyInput = {
	receiver?: string
	amount?: string | number
	currencyCode?: string
	recipientType?: PayoutRecipientType
	note?: string
	emailSubject?: string
	emailMessage?: string
	senderBatchId?: string
	senderItemId?: string
	recipientWallet?: 'PAYPAL' | 'VENMO'
	items?: Array<PayoutItemInput>
}

export function buildPayoutItem(item: PayoutItemInput): Record<string, unknown> {
	if (!item.receiver) throw new Error('createPayout item requires receiver.')
	if (item.amount == null || item.amount === '') throw new Error('createPayout item requires amount.')
	const currency = item.currencyCode ?? 'USD'
	return {
		recipient_type: item.recipientType ?? 'EMAIL',
		amount: { value: moneyValue(item.amount), currency },
		receiver: item.receiver,
		note: item.note,
		sender_item_id: item.senderItemId,
		recipient_wallet: item.recipientWallet,
	}
}

export function buildPayoutBody(input: PayoutBodyInput) {
	const items = input.items?.length
		? input.items.map(buildPayoutItem)
		: [
				buildPayoutItem({
					receiver: input.receiver ?? '',
					amount: input.amount ?? '',
					currencyCode: input.currencyCode,
					recipientType: input.recipientType,
					note: input.note,
					senderItemId: input.senderItemId,
					recipientWallet: input.recipientWallet,
				}),
			]
	return {
		sender_batch_header: {
			sender_batch_id: input.senderBatchId,
			recipient_type: input.recipientType ?? 'EMAIL',
			email_subject: input.emailSubject,
			email_message: input.emailMessage,
		},
		items,
	}
}