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( 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-. 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-. 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 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 } 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 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 } export function buildPayoutItem(item: PayoutItemInput): Record { 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, } }