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,
}
}