From 36071c2974e8acba936700ec69bf9e59ff4751b3 Mon Sep 17 00:00:00 2001 From: vikvenkataraman Date: Mon, 1 Dec 2025 16:08:18 -0800 Subject: [PATCH 01/10] add payment link mcp crud tools --- README.md | 8 + modelcontextprotocol/src/index.ts | 5 + python/paypal_agent_toolkit/shared/tools.py | 64 +++++++ typescript/src/shared/api.ts | 17 +- typescript/src/shared/functions.ts | 174 +++++++++++++++++++- typescript/src/shared/parameters.ts | 71 +++++++- typescript/src/shared/prompts.ts | 48 ++++++ typescript/src/shared/regex.ts | 1 + typescript/src/shared/tools.ts | 69 +++++++- 9 files changed, 452 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index dd2e5be..7d6f420 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,14 @@ The PayPal Agent toolkit provides the following tools: - `list_transactions`: List transactions with optional pagination and filtering - `get_merchant_insights`: Retrieve business intelligence metrics and analytics for a merchant +**Payment Links** + +- `create_payment_link`: Create a shareable payment link for products or services +- `list_payment_links`: List all payment links with pagination +- `get_payment_link`: Retrieve details of a specific payment link +- `update_payment_link`: Update an existing payment link (full replacement) +- `delete_payment_link`: Permanently delete a payment link + ## TypeScript ### Installation diff --git a/modelcontextprotocol/src/index.ts b/modelcontextprotocol/src/index.ts index 4e9f674..172ae2b 100644 --- a/modelcontextprotocol/src/index.ts +++ b/modelcontextprotocol/src/index.ts @@ -50,6 +50,11 @@ const ACCEPTED_TOOLS = [ 'transactions.list', 'payments.createRefund', 'payments.getRefunds', + 'paymentLinks.create', + 'paymentLinks.list', + 'paymentLinks.get', + 'paymentLinks.update', + 'paymentLinks.delete', ]; export function parseArgs(args: string[]): Options { diff --git a/python/paypal_agent_toolkit/shared/tools.py b/python/paypal_agent_toolkit/shared/tools.py index 10c1fda..bf0b9ee 100644 --- a/python/paypal_agent_toolkit/shared/tools.py +++ b/python/paypal_agent_toolkit/shared/tools.py @@ -45,6 +45,14 @@ GET_MERCHANT_INSIGHTS_PROMPT ) +from ..shared.payment_links.prompts import ( + CREATE_PAYMENT_LINK_PROMPT, + LIST_PAYMENT_LINKS_PROMPT, + GET_PAYMENT_LINK_PROMPT, + UPDATE_PAYMENT_LINK_PROMPT, + DELETE_PAYMENT_LINK_PROMPT +) + from ..shared.orders.parameters import ( CreateOrderParameters, @@ -94,6 +102,14 @@ GetMerchantInsightsParameters ) +from ..shared.payment_links.parameters import ( + CreatePaymentLinkParameters, + ListPaymentLinksParameters, + GetPaymentLinkParameters, + UpdatePaymentLinkParameters, + DeletePaymentLinkParameters +) + from ..shared.orders.tool_handlers import ( create_order, capture_order, @@ -143,6 +159,14 @@ get_merchant_insights ) +from ..shared.payment_links.tool_handlers import ( + create_payment_link, + list_payment_links, + get_payment_link, + update_payment_link, + delete_payment_link +) + from pydantic import BaseModel tools = [ @@ -361,5 +385,45 @@ "args_schema": GetMerchantInsightsParameters, "actions": {"insights": {"get": True}}, "execute": get_merchant_insights, + }, + { + "method": "create_payment_link", + "name": "Create Payment Link", + "description": CREATE_PAYMENT_LINK_PROMPT.strip(), + "args_schema": CreatePaymentLinkParameters, + "actions": {"paymentLinks": {"create": True}}, + "execute": create_payment_link, + }, + { + "method": "list_payment_links", + "name": "List Payment Links", + "description": LIST_PAYMENT_LINKS_PROMPT.strip(), + "args_schema": ListPaymentLinksParameters, + "actions": {"paymentLinks": {"list": True}}, + "execute": list_payment_links, + }, + { + "method": "get_payment_link", + "name": "Get Payment Link", + "description": GET_PAYMENT_LINK_PROMPT.strip(), + "args_schema": GetPaymentLinkParameters, + "actions": {"paymentLinks": {"get": True}}, + "execute": get_payment_link, + }, + { + "method": "update_payment_link", + "name": "Update Payment Link", + "description": UPDATE_PAYMENT_LINK_PROMPT.strip(), + "args_schema": UpdatePaymentLinkParameters, + "actions": {"paymentLinks": {"update": True}}, + "execute": update_payment_link, + }, + { + "method": "delete_payment_link", + "name": "Delete Payment Link", + "description": DELETE_PAYMENT_LINK_PROMPT.strip(), + "args_schema": DeletePaymentLinkParameters, + "actions": {"paymentLinks": {"delete": True}}, + "execute": delete_payment_link, } ] diff --git a/typescript/src/shared/api.ts b/typescript/src/shared/api.ts index 149acb4..bc3688b 100644 --- a/typescript/src/shared/api.ts +++ b/typescript/src/shared/api.ts @@ -28,7 +28,12 @@ import { createRefund, getRefund, updateSubscription, - getMerchantInsights + getMerchantInsights, + createPaymentLink, + listPaymentLinks, + getPaymentLink, + updatePaymentLink, + deletePaymentLink } from './functions'; import type { Context } from './configuration'; @@ -147,6 +152,16 @@ class PayPalAPI { return getRefund(this.paypalClient, this.context, arg); case 'get_merchant_insights': return getMerchantInsights(this.paypalClient, this.context, arg); + case 'create_payment_link': + return createPaymentLink(this.paypalClient, this.context, arg); + case 'list_payment_links': + return listPaymentLinks(this.paypalClient, this.context, arg); + case 'get_payment_link': + return getPaymentLink(this.paypalClient, this.context, arg); + case 'update_payment_link': + return updatePaymentLink(this.paypalClient, this.context, arg); + case 'delete_payment_link': + return deletePaymentLink(this.paypalClient, this.context, arg); default: throw new Error(`Invalid method: ${method}`); } diff --git a/typescript/src/shared/functions.ts b/typescript/src/shared/functions.ts index 57434ad..9b2295b 100644 --- a/typescript/src/shared/functions.ts +++ b/typescript/src/shared/functions.ts @@ -31,7 +31,12 @@ import { createRefundParameters, updateSubscriptionParameters, updatePlanParameters, - getMerchantInsightsParameters + getMerchantInsightsParameters, + createPaymentLinkParameters, + listPaymentLinksParameters, + getPaymentLinkParameters, + updatePaymentLinkParameters, + deletePaymentLinkParameters } from "./parameters"; import {parseOrderDetails, parseUpdateSubscriptionPayload, toQueryString} from "./payloadUtils"; import { TypeOf } from "zod"; @@ -1035,6 +1040,173 @@ export async function getMerchantInsights( } } +// === PAYMENT LINK FUNCTIONS === + +export async function createPaymentLink( + client: PayPalClient, + context: Context, + params: TypeOf> +): Promise { + logger('[createPaymentLink] Starting payment link creation process'); + logger('[createPaymentLink] Request params received:', JSON.stringify(params, null, 2)); + + // Ensure required fields have defaults + const payload = { + integration_mode: params.integration_mode || 'LINK', + type: params.type, + reusable: params.reusable || 'MULTIPLE', + line_items: params.line_items, + ...(params.return_url && { return_url: params.return_url }), + }; + + logger('[createPaymentLink] Final payload to send:', JSON.stringify(payload, null, 2)); + + const headers = await client.getHeaders(); + + // PayPal Payment Links API requires PayPal-Request-Id header for idempotency + // Generate a unique request ID if not already set in context + if (!headers['PayPal-Request-Id']) { + headers['PayPal-Request-Id'] = Date.now().toString(); + } + + logger('[createPaymentLink] Headers obtained'); + logger('[createPaymentLink] PayPal-Request-Id:', headers['PayPal-Request-Id']); + + const url = `${client.getBaseUrl()}/v1/checkout/payment-resources`; + logger(`[createPaymentLink] API URL: ${url}`); + + try { + logger('[createPaymentLink] Sending request to PayPal API'); + const response = await axios.post(url, payload, { headers }); + logger(`[createPaymentLink] Payment link created successfully. Status: ${response.status}`); + logger(`[createPaymentLink] Payment link ID: ${response.data.id || 'N/A'}`); + return response.data; + } catch (error: any) { + logger('[createPaymentLink] Error creating payment link:', error.message); + if (error.response) { + logger('[createPaymentLink] Error response data:', JSON.stringify(error.response.data, null, 2)); + } + handleAxiosError(error); + } +} + +// === LIST PAYMENT LINKS === +export async function listPaymentLinks( + client: PayPalClient, + context: Context, + params: TypeOf> +): Promise { + logger('[listPaymentLinks] Starting payment links list process'); + + const headers = await client.getHeaders(); + const url = `${client.getBaseUrl()}/v1/checkout/payment-resources`; + + try { + logger('[listPaymentLinks] Sending request to PayPal API'); + const response = await axios.get(url, { headers, params }); + logger(`[listPaymentLinks] Payment links retrieved successfully. Status: ${response.status}`); + return response.data; + } catch (error: any) { + logger('[listPaymentLinks] Error listing payment links:', error.message); + handleAxiosError(error); + } +} + +// === GET PAYMENT LINK === +export async function getPaymentLink( + client: PayPalClient, + context: Context, + params: TypeOf> +): Promise { + logger('[getPaymentLink] Starting payment link retrieval process'); + logger(`[getPaymentLink] Payment Link ID: ${params.payment_link_id}`); + + const headers = await client.getHeaders(); + const url = `${client.getBaseUrl()}/v1/checkout/payment-resources/${params.payment_link_id}`; + + try { + logger('[getPaymentLink] Sending request to PayPal API'); + const response = await axios.get(url, { headers }); + logger(`[getPaymentLink] Payment link retrieved successfully. Status: ${response.status}`); + return response.data; + } catch (error: any) { + logger('[getPaymentLink] Error getting payment link:', error.message); + handleAxiosError(error); + } +} + +// === UPDATE PAYMENT LINK (PUT - Full Replacement) === +export async function updatePaymentLink( + client: PayPalClient, + context: Context, + params: TypeOf> +): Promise { + logger('[updatePaymentLink] Starting payment link update process'); + logger('[updatePaymentLink] Request params received:', JSON.stringify(params, null, 2)); + + const { payment_link_id, ...updateData } = params; + + // Ensure required fields have defaults (same as create) + const payload = { + integration_mode: updateData.integration_mode || 'LINK', + type: updateData.type, + reusable: updateData.reusable || 'MULTIPLE', + line_items: updateData.line_items, + ...(updateData.return_url && { return_url: updateData.return_url }), + }; + + logger('[updatePaymentLink] Final payload to send:', JSON.stringify(payload, null, 2)); + + const headers = await client.getHeaders(); + const url = `${client.getBaseUrl()}/v1/checkout/payment-resources/${payment_link_id}`; + + try { + logger('[updatePaymentLink] Sending PUT request to PayPal API'); + const response = await axios.put(url, payload, { headers }); + logger(`[updatePaymentLink] Payment link updated successfully. Status: ${response.status}`); + return response.data; + } catch (error: any) { + logger('[updatePaymentLink] Error updating payment link:', error.message); + if (error.response) { + logger('[updatePaymentLink] Error response data:', JSON.stringify(error.response.data, null, 2)); + } + handleAxiosError(error); + } +} + +// === DELETE PAYMENT LINK === +export async function deletePaymentLink( + client: PayPalClient, + context: Context, + params: TypeOf> +): Promise { + logger('[deletePaymentLink] Starting payment link deletion process'); + logger(`[deletePaymentLink] Payment Link ID: ${params.payment_link_id}`); + + const headers = await client.getHeaders(); + const url = `${client.getBaseUrl()}/v1/checkout/payment-resources/${params.payment_link_id}`; + + try { + logger('[deletePaymentLink] Sending DELETE request to PayPal API'); + const response = await axios.delete(url, { headers }); + + // DELETE operations often return 204 No Content + if (response.status === 204) { + logger(`[deletePaymentLink] Payment link deleted successfully. Status: ${response.status}`); + return { success: true, payment_link_id: params.payment_link_id }; + } + + logger(`[deletePaymentLink] Payment link deleted successfully. Status: ${response.status}`); + return response.data; + } catch (error: any) { + logger('[deletePaymentLink] Error deleting payment link:', error.message); + if (error.response) { + logger('[deletePaymentLink] Error response data:', JSON.stringify(error.response.data, null, 2)); + } + handleAxiosError(error); + } +} + // Helper function to handle Axios errors -> throws LlmError export function handleAxiosError(error: any): never { diff --git a/typescript/src/shared/parameters.ts b/typescript/src/shared/parameters.ts index 50179f3..eb9b311 100644 --- a/typescript/src/shared/parameters.ts +++ b/typescript/src/shared/parameters.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import type { Context } from './configuration'; import {subscriptionKeys} from "./constants"; -import {INVOICE_ID_REGEX, ORDER_ID_REGEX, SUBSCRIPTION_ID_REGEX, PRODUCT_ID_REGEX, PLAN_ID_REGEX, DISPUTE_ID_REGEX, REFUND_ID_REGEX, CAPTURE_ID_REGEX, TRANSACTION_ID_REGEX} from "./regex" +import {INVOICE_ID_REGEX, ORDER_ID_REGEX, SUBSCRIPTION_ID_REGEX, PRODUCT_ID_REGEX, PLAN_ID_REGEX, DISPUTE_ID_REGEX, REFUND_ID_REGEX, CAPTURE_ID_REGEX, TRANSACTION_ID_REGEX, PAYMENT_LINK_ID_REGEX} from "./regex" // === INVOICE PARAMETERS === const invoiceItem = z.object({ @@ -425,4 +425,73 @@ export const getMerchantInsightsParameters = (context: Context) => z.object({ time_interval: z.string().describe('The time periods used for segmenting metrics data. It can be "DAILY", "WEEKLY", "MONTHLY", "QUARTERLY", or "YEARLY"'), }) +// === PAYMENT LINK PARAMETERS === + +// Shared line item schema for payment links (used by both create and update) +const paymentLinkLineItem = z.object({ + name: z.string().min(1).max(127).describe('The product or service name displayed to customers during checkout'), + product_id: z.string().min(1).max(50).optional().describe('Your internal identifier for this product or SKU'), + description: z.string().min(1).max(2048).optional().describe('Detailed information about the product or service shown to customers during checkout'), + unit_amount: z.object({ + currency_code: z.string().length(3).describe('The three-character ISO-4217 currency code (e.g., USD, EUR, GBP, JPY, CAD, AUD). Note: JPY is a zero-decimal currency - use whole numbers without decimal points.'), + value: z.string().max(32).regex(/^((-?\d+)|(-?(\d+)?[.]\d+))$/).describe('The monetary value. Supports up to 3 decimal places for most currencies. For JPY, use whole numbers (e.g., "15000" not "15000.00"). Examples: "29.99", "15000", "9.995"'), + }).describe('The currency and amount for this line item. IMPORTANT: If using variants with pricing in options, this field should be omitted OR set to the base price. Cannot have unit_amount at both product level and variant option level.'), + taxes: z.array(z.object({ + name: z.string().min(1).max(127).optional().describe('Internal label for this tax (e.g., "Sales Tax", "VAT", "GST"). Not visible to customer during checkout.'), + type: z.enum(['PERCENTAGE', 'PREFERENCE']).describe('Tax calculation method. PERCENTAGE: percentage of price (e.g., 8.5% tax). PREFERENCE: uses account default tax settings.'), + value: z.string().min(1).max(20).describe('Tax value. For PERCENTAGE: enter numeric percentage (e.g., "8.5" for 8.5% tax rate). For PREFERENCE: must be "PROFILE" to use account defaults. Maximum 20 characters.'), + })).max(1).optional().describe('Tax configuration for this item (maximum 1). Tax is displayed separately during checkout.'), + shipping: z.array(z.object({ + type: z.enum(['FLAT', 'PREFERENCE']).describe('Shipping calculation method. FLAT: fixed amount (e.g., $9.99 flat rate). PREFERENCE: uses account default shipping settings.'), + value: z.string().min(1).max(20).describe('Shipping value. For FLAT: enter fixed cost (e.g., "9.99" for $9.99 shipping). For PREFERENCE: must be "PROFILE" to use account defaults. Maximum 20 characters.'), + })).max(1).optional().describe('Shipping configuration for this item (maximum 1). Shipping fee is displayed separately during checkout.'), + collect_shipping_address: z.boolean().optional().describe('Set to true to prompt customers for a shipping address during checkout. Required for physical goods that need to be shipped. Default: false'), + customer_notes: z.array(z.object({ + required: z.boolean().optional().describe('When true, customer must provide input before checkout. When false, input is optional. Use for collecting gift messages, custom instructions, engraving text, etc.'), + label: z.string().min(1).max(127).optional().describe('The label displayed to customers for this custom input field. Examples: "Gift message", "Special instructions", "Engraving text", "Delivery notes"'), + })).max(1).optional().describe('Custom field to collect additional information from customers about this item (maximum 1 field per item). Useful for personalization, special requests, or delivery instructions.'), + variants: z.object({ + dimensions: z.array(z.object({ + name: z.string().min(1).max(64).describe('The name of this variant dimension. Examples: "Size", "Color", "Material", "Style". Maximum 64 characters.'), + primary: z.boolean().describe('IMPORTANT: Exactly ONE dimension must have primary: true, all others must be primary: false. The primary dimension typically contains the main product variation and can include pricing in options. Common pattern: Size=primary, Color=non-primary.'), + options: z.array(z.object({}).passthrough()).min(1).max(10).describe('Array of variant options (1-10 options). Each option can have: label (required), unit_amount (optional - only if no product-level unit_amount). Example: [{label: "Small"}, {label: "Medium"}, {label: "Large"}]'), + })).min(1).max(5).describe('List of variant dimensions (1-5 dimensions). Must have exactly 1 primary dimension. Additional dimensions must be non-primary. Example: Size (primary) + Color (non-primary) for t-shirts.'), + }).optional().describe('Product variants configuration for size, color, material, etc. IMPORTANT: If variants have unit_amount in options, do NOT include product-level unit_amount (causes validation error). If variants have NO pricing, product-level unit_amount is REQUIRED.'), + adjustable_quantity: z.object({ + maximum: z.number().int().min(1).max(100).describe('Maximum quantity customers can select (1-100). Use 1 for limited edition items, higher values for bulk products. Examples: 1 for unique items, 10 for regular products, 100 for wholesale.'), + }).optional().describe('Enables quantity selection during checkout. Customers can choose from 1 up to the maximum. Useful for allowing bulk purchases or limiting quantities for special items.'), + return_url: z.string().optional().describe('The URL to redirect to after payment completion') +}).describe('Payment link line item'); + +export const createPaymentLinkParameters = (context: Context) => z.object({ + integration_mode: z.string().optional().default('LINK').describe('The integration mode for the payment link. Default and recommended: "LINK". This determines how the payment link is presented.'), + type: z.string().describe('The type of payment link. Use "BUY_NOW" for standard e-commerce purchases (fully supported). Note: "DONATION" type has limited support and may cause validation errors.'), + reusable: z.literal('MULTIPLE').optional().default('MULTIPLE').describe('Whether the link can be used multiple times. Set to "MULTIPLE" (only supported value for BUY_NOW type). The link can be shared with and used by multiple customers.'), + return_url: z.string().optional().describe('Optional URL to redirect customers after successful payment. Example: "https://yoursite.com/thank-you". If omitted, customers see PayPal default success page.'), + line_items: z.array(paymentLinkLineItem).min(1).describe('Array of products/services in this payment link (minimum 1 item required). Each item represents a product with pricing, taxes, shipping, and optional variants.'), +}).describe('Parameters for creating a new PayPal payment link') + +export const listPaymentLinksParameters = (context: Context) => z.object({ + page: z.number().int().min(1).max(1000).default(1).optional().describe('Page number to retrieve (1-1000). Default: 1. Use for pagination when you have many payment links.'), + page_size: z.number().int().min(1).max(100).default(10).optional().describe('Number of payment links per page (1-100). Default: 10. Increase for bulk operations, decrease for faster responses.'), + total_required: z.boolean().optional().describe('Set to true to include total count of all payment links in response. Useful for pagination UI. Default: false (faster response).'), +}) + +export const getPaymentLinkParameters = (context: Context) => z.object({ + payment_link_id: z.string().regex(PAYMENT_LINK_ID_REGEX, "Invalid PayPal Payment Link ID").describe('The PayPal Payment Link ID to retrieve. Format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). Example: "PLB-1A2B3C4D5E6F"'), +}) + +export const updatePaymentLinkParameters = (context: Context) => z.object({ + payment_link_id: z.string().regex(PAYMENT_LINK_ID_REGEX, "Invalid PayPal Payment Link ID").describe('The PayPal Payment Link ID to update. Format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). Example: "PLB-1A2B3C4D5E6F"'), + type: z.string().describe('The type of payment link. Use "BUY_NOW" for standard e-commerce purchases (fully supported). Note: "DONATION" type has limited support and may cause validation errors.'), + integration_mode: z.string().optional().default('LINK').describe('The integration mode for the payment link. Default and recommended: "LINK".'), + reusable: z.literal('MULTIPLE').optional().default('MULTIPLE').describe('Whether the link can be used multiple times. Set to "MULTIPLE" (only supported value for BUY_NOW type). The link can be shared with and used by multiple customers.'), + return_url: z.string().optional().describe('Optional URL to redirect customers after successful payment. Updates the return URL for this payment link.'), + line_items: z.array(paymentLinkLineItem).min(1).describe('Complete array of line items for the payment link. This is a full replacement - all items must be provided, not just changes.'), +}) + +export const deletePaymentLinkParameters = (context: Context) => z.object({ + payment_link_id: z.string().regex(PAYMENT_LINK_ID_REGEX, "Invalid PayPal Payment Link ID").describe('The PayPal Payment Link ID to delete permanently. Format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). ⚠️ WARNING: Deletion is permanent and cannot be undone.'), +}) + diff --git a/typescript/src/shared/prompts.ts b/typescript/src/shared/prompts.ts index 9e03f39..e4f549f 100644 --- a/typescript/src/shared/prompts.ts +++ b/typescript/src/shared/prompts.ts @@ -279,4 +279,52 @@ export const getMerchantInsightsPrompt = (context: Context) => ` Retrieve business intelligence metrics and analytics for a merchant, filtered by start date, end date, insight type, and time interval. This tool returns actionable insights into orders and sales data, supporting data-driven business decisions. +`; + +// === PAYMENT LINK PARAMETERS === + +export const createPaymentLinkPrompt = (context: Context) => ` +Create a payment link on PayPal. + +This function creates a shareable payment link that can be sent to customers for payment collection. The payment link supports products with pricing, tax calculation, shipping fees, product variants (size, color, material), quantity controls, custom customer notes, and return URLs. + +Important limitations to note: +- Tax types: Only PERCENTAGE and PREFERENCE are supported. FLAT tax type will return a 422 error. +- Shipping types: Only FLAT and PREFERENCE are supported. PERCENTAGE shipping type will return a 422 error. +- Reusable mode: Use "MULTIPLE" for BUY_NOW type. SINGLE mode is not supported in the current version. +- Variants: Must have exactly one primary dimension. Other dimensions must be marked as non-primary. +- Pricing: Cannot specify unit_amount at both product level and variant option level. Choose one approach. +- Type: Use "BUY_NOW" for standard purchases. + +Once created, the link can be shared with customers via email, social media, or embedded on websites. +`; + +export const listPaymentLinksPrompt = (context: Context) => ` +List payment links from PayPal. + +This function retrieves a paginated list of payment links with optional filtering parameters. You can control pagination using page and page_size parameters. Set total_required to true to include the total count of payment links in the response. +`; + +export const getPaymentLinkPrompt = (context: Context) => ` +Get details of a specific payment link from PayPal. + +This function retrieves comprehensive details about a payment link using its ID. The response includes the shareable URL, current status (ACTIVE, INACTIVE, etc.), line items with pricing, taxes, shipping, variant configurations, and return URL settings. + +Payment link ID format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). +`; + +export const updatePaymentLinkPrompt = (context: Context) => ` +Update a payment link on PayPal. + +This function performs a complete replacement of a payment link's configuration using a PUT operation. You must provide all required fields including payment_link_id, type, and line_items. The same limitations apply as when creating a payment link (tax types, shipping types, reusable mode, variants, and pricing rules). + +Note: This is a full replacement operation. All fields must be provided, not just the ones you want to change. +`; + +export const deletePaymentLinkPrompt = (context: Context) => ` +Delete a payment link from PayPal. + +This function permanently deletes a payment link. Once deleted, the shareable link URL will no longer work and the operation cannot be undone. Use with caution. + +Note: PayPal API may return a 500 error instead of 404 for non-existent payment link IDs. This is expected API behavior. `; \ No newline at end of file diff --git a/typescript/src/shared/regex.ts b/typescript/src/shared/regex.ts index 45e45e7..5be7f54 100644 --- a/typescript/src/shared/regex.ts +++ b/typescript/src/shared/regex.ts @@ -7,3 +7,4 @@ export const DISPUTE_ID_REGEX = /^[A-Za-z0-9_-]{1,255}$/; export const REFUND_ID_REGEX = /^[A-Za-z0-9_-]{15,32}$/; export const CAPTURE_ID_REGEX = /^[A-Za-z0-9_-]{15,32}$/; export const TRANSACTION_ID_REGEX = /^[A-Za-z0-9_-]{12,255}$/; +export const PAYMENT_LINK_ID_REGEX = /^PLB-[A-Z0-9]{12,16}$/; diff --git a/typescript/src/shared/tools.ts b/typescript/src/shared/tools.ts index 841903f..cba1eb4 100644 --- a/typescript/src/shared/tools.ts +++ b/typescript/src/shared/tools.ts @@ -32,7 +32,12 @@ import { updateSubscriptionPrompt, getRefundPrompt, createRefundPrompt, - getMerchantInsightsPrompt + getMerchantInsightsPrompt, + createPaymentLinkPrompt, + listPaymentLinksPrompt, + getPaymentLinkPrompt, + updatePaymentLinkPrompt, + deletePaymentLinkPrompt } from './prompts'; import { @@ -67,7 +72,12 @@ import { updateSubscriptionParameters, getRefundParameters, createRefundParameters, - getMerchantInsightsParameters + getMerchantInsightsParameters, + createPaymentLinkParameters, + listPaymentLinksParameters, + getPaymentLinkParameters, + updatePaymentLinkParameters, + deletePaymentLinkParameters } from './parameters'; import type { Context } from './configuration'; @@ -436,6 +446,61 @@ const tools = (context: Context): Tool[] => [ get: true, } } + }, + { + method: 'create_payment_link', + name: 'Create Payment Link', + description: createPaymentLinkPrompt(context), + parameters: createPaymentLinkParameters(context), + actions: { + paymentLinks: { + create: true, + } + } + }, + { + method: 'list_payment_links', + name: 'List Payment Links', + description: listPaymentLinksPrompt(context), + parameters: listPaymentLinksParameters(context), + actions: { + paymentLinks: { + list: true, + } + } + }, + { + method: 'get_payment_link', + name: 'Get Payment Link', + description: getPaymentLinkPrompt(context), + parameters: getPaymentLinkParameters(context), + actions: { + paymentLinks: { + get: true, + } + } + }, + { + method: 'update_payment_link', + name: 'Update Payment Link', + description: updatePaymentLinkPrompt(context), + parameters: updatePaymentLinkParameters(context), + actions: { + paymentLinks: { + update: true, + } + } + }, + { + method: 'delete_payment_link', + name: 'Delete Payment Link', + description: deletePaymentLinkPrompt(context), + parameters: deletePaymentLinkParameters(context), + actions: { + paymentLinks: { + delete: true, + } + } } ]; const allActions = tools({}).reduce((acc, tool) => { From 50548dd4473d21512ac91ff586de815755cd9416 Mon Sep 17 00:00:00 2001 From: vikvenkataraman Date: Mon, 1 Dec 2025 16:09:02 -0800 Subject: [PATCH 02/10] add payment link mcp crud tools --- .../shared/payment_links/__init__.py | 2 + .../shared/payment_links/parameters.py | 95 +++++ .../shared/payment_links/prompts.py | 45 +++ .../shared/payment_links/tool_handlers.py | 134 ++++++ .../shared/payment_links/validation.py | 330 +++++++++++++++ .../src/shared/paymentLinkValidation.ts | 382 ++++++++++++++++++ 6 files changed, 988 insertions(+) create mode 100644 python/paypal_agent_toolkit/shared/payment_links/__init__.py create mode 100644 python/paypal_agent_toolkit/shared/payment_links/parameters.py create mode 100644 python/paypal_agent_toolkit/shared/payment_links/prompts.py create mode 100644 python/paypal_agent_toolkit/shared/payment_links/tool_handlers.py create mode 100644 python/paypal_agent_toolkit/shared/payment_links/validation.py create mode 100644 typescript/src/shared/paymentLinkValidation.ts diff --git a/python/paypal_agent_toolkit/shared/payment_links/__init__.py b/python/paypal_agent_toolkit/shared/payment_links/__init__.py new file mode 100644 index 0000000..551f214 --- /dev/null +++ b/python/paypal_agent_toolkit/shared/payment_links/__init__.py @@ -0,0 +1,2 @@ +# Payment Links module + diff --git a/python/paypal_agent_toolkit/shared/payment_links/parameters.py b/python/paypal_agent_toolkit/shared/payment_links/parameters.py new file mode 100644 index 0000000..0f48b8a --- /dev/null +++ b/python/paypal_agent_toolkit/shared/payment_links/parameters.py @@ -0,0 +1,95 @@ +from pydantic import BaseModel, Field, field_validator +from typing import List, Optional, Any, Dict, Literal + + +class UnitAmount(BaseModel): + currency_code: str = Field(..., min_length=3, max_length=3, description="The three-character ISO-4217 currency code (e.g., USD, EUR, GBP, JPY, CAD, AUD). Note: JPY is a zero-decimal currency - use whole numbers without decimal points.") + value: str = Field(..., max_length=32, pattern=r"^((-?[0-9]+)|(-?([0-9]+)?[.][0-9]+))$", description="The monetary value. Supports up to 3 decimal places for most currencies. For JPY, use whole numbers (e.g., '15000' not '15000.00'). Examples: '29.99', '15000', '9.995'") + + +class Tax(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=127, description="Internal label for this tax (e.g., 'Sales Tax', 'VAT', 'GST'). Not visible to customer during checkout.") + type: Literal['PERCENTAGE', 'PREFERENCE'] = Field(..., description="Tax calculation method. PERCENTAGE: percentage of price (e.g., 8.5% tax). PREFERENCE: uses account default tax settings.") + value: str = Field(..., min_length=1, max_length=20, description="Tax value. For PERCENTAGE: enter numeric percentage (e.g., '8.5' for 8.5% tax rate). For PREFERENCE: must be 'PROFILE' to use account defaults. Maximum 20 characters.") + + +class Shipping(BaseModel): + type: Literal['FLAT', 'PREFERENCE'] = Field(..., description="Shipping calculation method. FLAT: fixed amount (e.g., $9.99 flat rate). PREFERENCE: uses account default shipping settings.") + value: str = Field(..., min_length=1, max_length=20, description="Shipping value. For FLAT: enter fixed cost (e.g., '9.99' for $9.99 shipping). For PREFERENCE: must be 'PROFILE' to use account defaults. Maximum 20 characters.") + + +class CustomerNote(BaseModel): + required: Optional[bool] = Field(None, description="When true, customer must provide input before checkout. When false, input is optional. Use for collecting gift messages, custom instructions, engraving text, etc.") + label: Optional[str] = Field(None, min_length=1, max_length=127, description="The label displayed to customers for this custom input field. Examples: 'Gift message', 'Special instructions', 'Engraving text', 'Delivery notes'") + + +class VariantOption(BaseModel): + """Variant option structure - using passthrough to allow flexible fields""" + class Config: + extra = "allow" + + +class Dimension(BaseModel): + name: str = Field(..., min_length=1, max_length=64, description="The name of this variant dimension. Examples: 'Size', 'Color', 'Material', 'Style'. Maximum 64 characters.") + primary: bool = Field(..., description="IMPORTANT: Exactly ONE dimension must have primary: true, all others must be primary: false. The primary dimension typically contains the main product variation and can include pricing in options. Common pattern: Size=primary, Color=non-primary.") + options: List[Dict[str, Any]] = Field(..., min_items=1, max_items=10, description="Array of variant options (1-10 options). Each option can have: label (required), unit_amount (optional - only if no product-level unit_amount). Example: [{'label': 'Small'}, {'label': 'Medium'}, {'label': 'Large'}]") + + +class Variants(BaseModel): + dimensions: List[Dimension] = Field(..., min_items=1, max_items=5, description="List of variant dimensions (1-5 dimensions). Must have exactly 1 primary dimension. Additional dimensions must be non-primary. Example: Size (primary) + Color (non-primary) for t-shirts.") + + +class AdjustableQuantity(BaseModel): + maximum: int = Field(..., ge=1, le=100, description="Maximum quantity customers can select (1-100). Use 1 for limited edition items, higher values for bulk products. Examples: 1 for unique items, 10 for regular products, 100 for wholesale.") + + +class LineItem(BaseModel): + name: str = Field(..., min_length=1, max_length=127, description="The product or service name displayed to customers during checkout") + product_id: Optional[str] = Field(None, min_length=1, max_length=50, description="Your internal identifier for this product or SKU") + description: Optional[str] = Field(None, min_length=1, max_length=2048, description="Detailed information about the product or service shown to customers during checkout") + unit_amount: UnitAmount = Field(..., description="The currency and amount for this line item. IMPORTANT: If using variants with pricing in options, this field should be omitted OR set to the base price. Cannot have unit_amount at both product level and variant option level.") + taxes: Optional[List[Tax]] = Field(None, max_items=1, description="Tax configuration for this item (maximum 1). Tax is displayed separately during checkout.") + shipping: Optional[List[Shipping]] = Field(None, max_items=1, description="Shipping configuration for this item (maximum 1). Shipping fee is displayed separately during checkout.") + collect_shipping_address: Optional[bool] = Field(None, description="Set to true to prompt customers for a shipping address during checkout. Required for physical goods that need to be shipped. Default: false") + customer_notes: Optional[List[CustomerNote]] = Field(None, max_items=1, description="Custom field to collect additional information from customers about this item (maximum 1 field per item). Useful for personalization, special requests, or delivery instructions.") + variants: Optional[Variants] = Field(None, description="Product variants configuration for size, color, material, etc. IMPORTANT: If variants have unit_amount in options, do NOT include product-level unit_amount (causes validation error). If variants have NO pricing, product-level unit_amount is REQUIRED.") + adjustable_quantity: Optional[AdjustableQuantity] = Field(None, description="Enables quantity selection during checkout. Customers can choose from 1 up to the maximum. Useful for allowing bulk purchases or limiting quantities for special items.") + + +# Payment Link ID validation pattern +PAYMENT_LINK_ID_REGEX = r"^PLB-[A-Z0-9]{12,16}$" + + +class CreatePaymentLinkParameters(BaseModel): + integration_mode: str = Field(default="LINK", description="The integration mode for the payment link. Default and recommended: 'LINK'. This determines how the payment link is presented.") + type: str = Field(..., description="The type of payment link. Use 'BUY_NOW' for standard e-commerce purchases (fully supported).") + reusable: Literal['MULTIPLE'] = Field(default="MULTIPLE", description="Whether the link can be used multiple times. Set to 'MULTIPLE' (only supported value for BUY_NOW type). The link can be shared with and used by multiple customers.") + return_url: Optional[str] = Field(None, description="Optional URL to redirect customers after successful payment. Example: 'https://yoursite.com/thank-you'. If omitted, customers stay at PayPal default success page.") + line_items: List[LineItem] = Field(..., min_items=1, description="Array of products/services in this payment link (minimum 1 item required). Each item represents a product with pricing, taxes, shipping, and optional variants.") + + +class ListPaymentLinksParameters(BaseModel): + """Parameters for listing payment links""" + page: Optional[int] = Field(1, ge=1, le=1000, description="Page number to retrieve (1-1000). Default: 1. Use for pagination when you have many payment links.") + page_size: Optional[int] = Field(10, ge=1, le=100, description="Number of payment links per page (1-100). Default: 10. Increase for bulk operations, decrease for faster responses.") + total_required: Optional[bool] = Field(None, description="Set to true to include total count of all payment links in response. Useful for pagination UI. Default: false (faster response).") + + +class GetPaymentLinkParameters(BaseModel): + """Parameters for retrieving a specific payment link""" + payment_link_id: str = Field(..., pattern=PAYMENT_LINK_ID_REGEX, description="The PayPal Payment Link ID to retrieve. Format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). Example: 'PLB-1A2B3C4D5E6F'") + + +class UpdatePaymentLinkParameters(BaseModel): + """Parameters for updating a payment link (full replacement)""" + payment_link_id: str = Field(..., pattern=PAYMENT_LINK_ID_REGEX, description="The PayPal Payment Link ID to update. Format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). Example: 'PLB-1A2B3C4D5E6F'") + integration_mode: str = Field(default="LINK", description="The integration mode for the payment link. Default and recommended: 'LINK'.") + type: str = Field(..., description="The type of payment link. Use 'BUY_NOW' for standard e-commerce purchases (fully supported). Note: 'DONATION' type has limited support and may cause validation errors.") + reusable: Literal['MULTIPLE'] = Field(default="MULTIPLE", description="Whether the link can be used multiple times. Set to 'MULTIPLE' (only supported value for BUY_NOW type). The link can be shared with and used by multiple customers.") + return_url: Optional[str] = Field(None, description="Optional URL to redirect customers after successful payment. Updates the return URL for this payment link.") + line_items: List[LineItem] = Field(..., min_items=1, description="Complete array of line items for the payment link. This is a full replacement - all items must be provided, not just changes.") + + +class DeletePaymentLinkParameters(BaseModel): + """Parameters for deleting a payment link""" + payment_link_id: str = Field(..., pattern=PAYMENT_LINK_ID_REGEX, description="The PayPal Payment Link ID to delete permanently. Format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). ⚠️ WARNING: Deletion is permanent and cannot be undone.") diff --git a/python/paypal_agent_toolkit/shared/payment_links/prompts.py b/python/paypal_agent_toolkit/shared/payment_links/prompts.py new file mode 100644 index 0000000..0d53160 --- /dev/null +++ b/python/paypal_agent_toolkit/shared/payment_links/prompts.py @@ -0,0 +1,45 @@ +CREATE_PAYMENT_LINK_PROMPT = """ +Create a payment link on PayPal. + +This function creates a shareable payment link that can be sent to customers for payment collection. The payment link supports products with pricing, tax calculation, shipping fees, product variants (size, color, material), quantity controls, custom customer notes, and return URLs. + +Important limitations to note: +- Tax types: Only PERCENTAGE and PREFERENCE are supported. FLAT tax type will return a 422 error. +- Shipping types: Only FLAT and PREFERENCE are supported. PERCENTAGE shipping type will return a 422 error. +- Reusable mode: Use "MULTIPLE" for BUY_NOW type. SINGLE mode is not supported in the current version. +- Variants: Must have exactly one primary dimension. Other dimensions must be marked as non-primary. +- Pricing: Cannot specify unit_amount at both product level and variant option level. Choose one approach. +- Type: Use "BUY_NOW" for standard purchases. + +Once created, the link can be shared with customers via email, social media, or embedded on websites. +""" + +LIST_PAYMENT_LINKS_PROMPT = """ +List payment links from PayPal. + +This function retrieves a paginated list of payment links with optional filtering parameters. You can control pagination using page and page_size parameters. Set total_required to true to include the total count of payment links in the response. +""" + +GET_PAYMENT_LINK_PROMPT = """ +Get details of a specific payment link from PayPal. + +This function retrieves comprehensive details about a payment link using its ID. The response includes the shareable URL, current status (ACTIVE, INACTIVE, etc.), line items with pricing, taxes, shipping, variant configurations, and return URL settings. + +Payment link ID format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). +""" + +UPDATE_PAYMENT_LINK_PROMPT = """ +Update a payment link on PayPal. + +This function performs a complete replacement of a payment link's configuration using a PUT operation. You must provide all required fields including payment_link_id, type, and line_items. The same limitations apply as when creating a payment link (tax types, shipping types, reusable mode, variants, and pricing rules). + +Note: This is a full replacement operation. All fields must be provided, not just the ones you want to change. +""" + +DELETE_PAYMENT_LINK_PROMPT = """ +Delete a payment link from PayPal. + +This function permanently deletes a payment link. Once deleted, the shareable link URL will no longer work and the operation cannot be undone. Use with caution. + +Note: PayPal API may return a 500 error instead of 404 for non-existent payment link IDs. This is expected API behavior. +""" diff --git a/python/paypal_agent_toolkit/shared/payment_links/tool_handlers.py b/python/paypal_agent_toolkit/shared/payment_links/tool_handlers.py new file mode 100644 index 0000000..2ea0667 --- /dev/null +++ b/python/paypal_agent_toolkit/shared/payment_links/tool_handlers.py @@ -0,0 +1,134 @@ +from .parameters import ( + CreatePaymentLinkParameters, + ListPaymentLinksParameters, + GetPaymentLinkParameters, + UpdatePaymentLinkParameters, + DeletePaymentLinkParameters +) +import json + + +def create_payment_link(client, params: dict): + """ + Create a payment link on PayPal. + + Args: + client: PayPal client instance + params: Dictionary containing payment link parameters + + Returns: + JSON string with payment link details including the shareable link URL + """ + validated = CreatePaymentLinkParameters(**params) + payload = validated.model_dump() + + # Ensure required fields have defaults + if 'integration_mode' not in payload or not payload['integration_mode']: + payload['integration_mode'] = 'LINK' + if 'reusable' not in payload or not payload['reusable']: + payload['reusable'] = 'MULTIPLE' + + url = "/v1/checkout/payment-resources" + response = client.post(uri=url, payload=payload) + + return json.dumps(response) + + +def list_payment_links(client, params: dict): + """ + List payment links from PayPal. + + Args: + client: PayPal client instance + params: Dictionary containing pagination parameters + + Returns: + JSON string with list of payment links + """ + validated = ListPaymentLinksParameters(**params) + + # Build query string + query_params = [] + if validated.page: + query_params.append(f"page={validated.page}") + if validated.page_size: + query_params.append(f"page_size={validated.page_size}") + if validated.total_required is not None: + query_params.append(f"total_required={str(validated.total_required).lower()}") + + query_string = "&".join(query_params) + url = f"/v1/checkout/payment-resources?{query_string}" if query_string else "/v1/checkout/payment-resources" + + response = client.get(uri=url) + return json.dumps(response) + + +def get_payment_link(client, params: dict): + """ + Get a specific payment link from PayPal. + + Args: + client: PayPal client instance + params: Dictionary containing payment_link_id + + Returns: + JSON string with payment link details + """ + validated = GetPaymentLinkParameters(**params) + url = f"/v1/checkout/payment-resources/{validated.payment_link_id}" + + response = client.get(uri=url) + return json.dumps(response) + + +def update_payment_link(client, params: dict): + """ + Update a payment link on PayPal (full replacement). + + Args: + client: PayPal client instance + params: Dictionary containing payment link parameters + + Returns: + JSON string with updated payment link details + """ + validated = UpdatePaymentLinkParameters(**params) + payment_link_id = validated.payment_link_id + + # Build payload excluding the ID + payload = validated.model_dump(exclude={'payment_link_id'}) + + # Ensure required fields have defaults + if 'integration_mode' not in payload or not payload['integration_mode']: + payload['integration_mode'] = 'LINK' + if 'reusable' not in payload or not payload['reusable']: + payload['reusable'] = 'MULTIPLE' + + url = f"/v1/checkout/payment-resources/{payment_link_id}" + response = client.put(uri=url, payload=payload) + + return json.dumps(response) + + +def delete_payment_link(client, params: dict): + """ + Delete a payment link from PayPal. + + Args: + client: PayPal client instance + params: Dictionary containing payment_link_id + + Returns: + JSON string with deletion confirmation + """ + validated = DeletePaymentLinkParameters(**params) + url = f"/v1/checkout/payment-resources/{validated.payment_link_id}" + + response = client.delete(uri=url) + + # DELETE often returns None for 204 No Content + if response is None: + return json.dumps({"success": True, "payment_link_id": validated.payment_link_id}) + + return json.dumps(response) + diff --git a/python/paypal_agent_toolkit/shared/payment_links/validation.py b/python/paypal_agent_toolkit/shared/payment_links/validation.py new file mode 100644 index 0000000..0ef622e --- /dev/null +++ b/python/paypal_agent_toolkit/shared/payment_links/validation.py @@ -0,0 +1,330 @@ +""" +Validation helper functions for PayPal Payment Links + +These functions help identify API limitations before making requests, +providing clear error messages that align with PayPal API behavior. +""" + +from typing import List, Optional, Dict, Any, TypedDict + + +class ValidationResult(TypedDict): + valid: bool + error: Optional[str] + field: Optional[str] + + +def validate_tax_config(taxes: Optional[List[Dict[str, Any]]]) -> ValidationResult: + """ + Validate tax configuration + + Validates tax values are correct for their type. + Note: Type enum is now restricted at schema level to PERCENTAGE and PREFERENCE only. + """ + if not taxes: + return {"valid": True, "error": None, "field": None} + + # Validate PERCENTAGE values are numeric + percentage_taxes = [t for t in taxes if t.get("type") == "PERCENTAGE"] + for tax in percentage_taxes: + value = tax.get("value", "") + try: + num_value = float(value) + if num_value < 0 or num_value > 100: + return { + "valid": False, + "error": f'Tax percentage value must be a number between 0 and 100. Got: "{value}"', + "field": "taxes[].value" + } + except ValueError: + return { + "valid": False, + "error": f'Tax percentage value must be numeric. Got: "{value}"', + "field": "taxes[].value" + } + + # Validate PREFERENCE values + preference_taxes = [t for t in taxes if t.get("type") == "PREFERENCE"] + for tax in preference_taxes: + value = tax.get("value") + if value != "PROFILE": + return { + "valid": False, + "error": f'Tax PREFERENCE type requires value "PROFILE". Got: "{value}"', + "field": "taxes[].value" + } + + return {"valid": True, "error": None, "field": None} + + +def validate_shipping_config(shipping: Optional[List[Dict[str, Any]]]) -> ValidationResult: + """ + Validate shipping configuration + + Validates shipping values are correct for their type. + Note: Type enum is now restricted at schema level to FLAT and PREFERENCE only. + """ + if not shipping: + return {"valid": True, "error": None, "field": None} + + # Validate FLAT values are numeric + flat_shipping = [s for s in shipping if s.get("type") == "FLAT"] + for ship in flat_shipping: + value = ship.get("value", "") + try: + num_value = float(value) + if num_value < 0: + return { + "valid": False, + "error": f'Shipping FLAT value must be a non-negative number. Got: "{value}"', + "field": "shipping[].value" + } + except ValueError: + return { + "valid": False, + "error": f'Shipping FLAT value must be numeric. Got: "{value}"', + "field": "shipping[].value" + } + + # Validate PREFERENCE values + preference_shipping = [s for s in shipping if s.get("type") == "PREFERENCE"] + for ship in preference_shipping: + value = ship.get("value") + if value != "PROFILE": + return { + "valid": False, + "error": f'Shipping PREFERENCE type requires value "PROFILE". Got: "{value}"', + "field": "shipping[].value" + } + + return {"valid": True, "error": None, "field": None} + + +def validate_variants(line_item: Dict[str, Any]) -> ValidationResult: + """ + Validate variant configuration + + Requirements: + - Must have exactly 1 primary dimension + - Cannot have unit_amount at both product level and variant option level + """ + variants = line_item.get("variants") + if not variants: + return {"valid": True, "error": None, "field": None} + + dimensions = variants.get("dimensions", []) + + # Check primary dimension count + primary_dimensions = [d for d in dimensions if d.get("primary", False)] + + if len(primary_dimensions) == 0: + return { + "valid": False, + "error": "Expected exactly 1 primary dimension, found 0. Set one dimension to primary: true", + "field": "variants.dimensions[].primary" + } + + if len(primary_dimensions) > 1: + return { + "valid": False, + "error": f"Expected exactly 1 primary dimension, found {len(primary_dimensions)}. Only one dimension can have primary: true, others must be primary: false", + "field": "variants.dimensions[].primary" + } + + # Check for pricing conflicts + has_variant_pricing = any( + any(option.get("unit_amount") is not None for option in dim.get("options", [])) + for dim in dimensions + ) + + if has_variant_pricing and line_item.get("unit_amount"): + return { + "valid": False, + "error": "Cannot specify unit_amount at both product level and variant option level. Remove unit_amount from the product level OR from all variant options.", + "field": "unit_amount / variants.dimensions[].options[].unit_amount" + } + + # Validate dimension count + if len(dimensions) < 1 or len(dimensions) > 5: + return { + "valid": False, + "error": f"Variants must have 1-5 dimensions. Found {len(dimensions)}", + "field": "variants.dimensions" + } + + # Validate options count per dimension + for i, dim in enumerate(dimensions): + options = dim.get("options", []) + if len(options) < 1 or len(options) > 10: + return { + "valid": False, + "error": f'Dimension "{dim.get("name")}" must have 1-10 options. Found {len(options)}', + "field": f"variants.dimensions[{i}].options" + } + + return {"valid": True, "error": None, "field": None} + + +def validate_currency(currency_code: str, value: str) -> ValidationResult: + """ + Validate currency code and amount + """ + # Check currency code length + if len(currency_code) != 3: + return { + "valid": False, + "error": f'Currency code must be 3 characters (ISO-4217). Got: "{currency_code}"', + "field": "unit_amount.currency_code" + } + + # Check if value is numeric + try: + num_value = float(value) + except ValueError: + return { + "valid": False, + "error": f'Currency value must be a valid number. Got: "{value}"', + "field": "unit_amount.value" + } + + # Special validation for zero-decimal currencies + zero_decimal_currencies = ['JPY', 'KRW', 'VND', 'CLP', 'TWD', 'PYG'] + + if currency_code.upper() in zero_decimal_currencies: + if '.' in value: + return { + "valid": False, + "error": f'{currency_code} is a zero-decimal currency and should not have decimal places. Use whole numbers (e.g., "15000" not "15000.00")', + "field": "unit_amount.value" + } + + # Check decimal places (max 3) + if '.' in value: + decimal_part = value.split('.')[1] + if len(decimal_part) > 3: + return { + "valid": False, + "error": f'Currency value supports up to 3 decimal places. Got {len(decimal_part)} decimal places in "{value}"', + "field": "unit_amount.value" + } + + return {"valid": True, "error": None, "field": None} + + +def validate_reusable_mode(reusable: str, payment_type: str) -> ValidationResult: + """ + Validate reusable mode + + Note: Reusable is now restricted at schema level to "MULTIPLE" only. + This validation is kept for consistency but should not trigger with proper typing. + """ + if reusable != 'MULTIPLE': + return { + "valid": False, + "error": f'Reusable must be "MULTIPLE". Got: "{reusable}"', + "field": "reusable" + } + + return {"valid": True, "error": None, "field": None} + + +def validate_payment_link_type(payment_type: str) -> ValidationResult: + """ + Validate payment link type + """ + supported_types = ['BUY_NOW', 'DONATION'] + + if payment_type not in supported_types: + return { + "valid": False, + "error": f'Payment link type must be one of: {", ".join(supported_types)}. Got: "{payment_type}"', + "field": "type" + } + + if payment_type == 'DONATION': + return { + "valid": True, + "error": "Warning: DONATION type has limited support and may cause validation errors. Consider using BUY_NOW for standard transactions.", + "field": "type" + } + + return {"valid": True, "error": None, "field": None} + + +def validate_line_item(line_item: Dict[str, Any]) -> ValidationResult: + """ + Validate complete line item + + Runs all validations on a line item. + """ + # Validate taxes + tax_result = validate_tax_config(line_item.get("taxes")) + if not tax_result["valid"]: + return tax_result + + # Validate shipping + shipping_result = validate_shipping_config(line_item.get("shipping")) + if not shipping_result["valid"]: + return shipping_result + + # Validate variants + variants_result = validate_variants(line_item) + if not variants_result["valid"]: + return variants_result + + # Validate currency if unit_amount exists + unit_amount = line_item.get("unit_amount") + if unit_amount: + currency_result = validate_currency( + unit_amount.get("currency_code", ""), + unit_amount.get("value", "") + ) + if not currency_result["valid"]: + return currency_result + elif not line_item.get("variants"): + # If no unit_amount and no variants, that's an error + return { + "valid": False, + "error": "Line item must have either unit_amount (product-level pricing) or variants with pricing in options", + "field": "unit_amount" + } + + return {"valid": True, "error": None, "field": None} + + +def validate_create_payment_link(params: Dict[str, Any]) -> ValidationResult: + """ + Validate complete payment link creation request + """ + # Validate type + payment_type = params.get("type", "") + type_result = validate_payment_link_type(payment_type) + if not type_result["valid"]: + return type_result + + # Validate reusable + reusable_value = params.get("reusable", "MULTIPLE") + reusable_result = validate_reusable_mode(reusable_value, payment_type) + if not reusable_result["valid"]: + return reusable_result + + # Validate line items exist + line_items = params.get("line_items", []) + if not line_items: + return { + "valid": False, + "error": "At least one line item is required", + "field": "line_items" + } + + # Validate each line item + for i, line_item in enumerate(line_items): + item_result = validate_line_item(line_item) + if not item_result["valid"]: + return { + "valid": False, + "error": item_result["error"], + "field": f'line_items[{i}].{item_result["field"]}' + } + + return {"valid": True, "error": None, "field": None} diff --git a/typescript/src/shared/paymentLinkValidation.ts b/typescript/src/shared/paymentLinkValidation.ts new file mode 100644 index 0000000..bd69aa3 --- /dev/null +++ b/typescript/src/shared/paymentLinkValidation.ts @@ -0,0 +1,382 @@ +/** + * Validation helper functions for PayPal Payment Links + * + * These functions help identify API limitations before making requests, + * providing clear error messages that align with PayPal API behavior. + */ + +export interface ValidationResult { + valid: boolean; + error?: string; + field?: string; +} + +export interface Tax { + name?: string; + type: 'PERCENTAGE' | 'PREFERENCE'; + value: string; +} + +export interface Shipping { + type: 'FLAT' | 'PREFERENCE'; + value: string; +} + +export interface Dimension { + name: string; + primary: boolean; + options: Array<{ + label: string; + unit_amount?: { + currency_code: string; + value: string; + }; + [key: string]: any; + }>; +} + +export interface Variants { + dimensions: Dimension[]; +} + +export interface LineItem { + name: string; + unit_amount?: { + currency_code: string; + value: string; + }; + taxes?: Tax[]; + shipping?: Shipping[]; + variants?: Variants; + [key: string]: any; +} + +/** + * Validate tax configuration + * + * Validates tax values are correct for their type. + * Note: Type enum is now restricted at schema level to PERCENTAGE and PREFERENCE only. + */ +export function validateTaxConfig(taxes?: Tax[]): ValidationResult { + if (!taxes || taxes.length === 0) { + return { valid: true }; + } + + // Validate PERCENTAGE values are numeric + const percentageTaxes = taxes.filter(t => t.type === 'PERCENTAGE'); + for (const tax of percentageTaxes) { + const value = parseFloat(tax.value); + if (isNaN(value) || value < 0 || value > 100) { + return { + valid: false, + error: `Tax percentage value must be a number between 0 and 100. Got: "${tax.value}"`, + field: 'taxes[].value' + }; + } + } + + // Validate PREFERENCE values + const preferenceTaxes = taxes.filter(t => t.type === 'PREFERENCE'); + for (const tax of preferenceTaxes) { + if (tax.value !== 'PROFILE') { + return { + valid: false, + error: `Tax PREFERENCE type requires value "PROFILE". Got: "${tax.value}"`, + field: 'taxes[].value' + }; + } + } + + return { valid: true }; +} + +/** + * Validate shipping configuration + * + * Validates shipping values are correct for their type. + * Note: Type enum is now restricted at schema level to FLAT and PREFERENCE only. + */ +export function validateShippingConfig(shipping?: Shipping[]): ValidationResult { + if (!shipping || shipping.length === 0) { + return { valid: true }; + } + + // Validate FLAT values are numeric + const flatShipping = shipping.filter(s => s.type === 'FLAT'); + for (const ship of flatShipping) { + const value = parseFloat(ship.value); + if (isNaN(value) || value < 0) { + return { + valid: false, + error: `Shipping FLAT value must be a non-negative number. Got: "${ship.value}"`, + field: 'shipping[].value' + }; + } + } + + // Validate PREFERENCE values + const preferenceShipping = shipping.filter(s => s.type === 'PREFERENCE'); + for (const ship of preferenceShipping) { + if (ship.value !== 'PROFILE') { + return { + valid: false, + error: `Shipping PREFERENCE type requires value "PROFILE". Got: "${ship.value}"`, + field: 'shipping[].value' + }; + } + } + + return { valid: true }; +} + +/** + * Validate variant configuration + * + * Requirements: + * - Must have exactly 1 primary dimension + * - Cannot have unit_amount at both product level and variant option level + */ +export function validateVariants(lineItem: LineItem): ValidationResult { + if (!lineItem.variants) { + return { valid: true }; + } + + const { dimensions } = lineItem.variants; + + // Check primary dimension count + const primaryDimensions = dimensions.filter(d => d.primary); + + if (primaryDimensions.length === 0) { + return { + valid: false, + error: 'Expected exactly 1 primary dimension, found 0. Set one dimension to primary: true', + field: 'variants.dimensions[].primary' + }; + } + + if (primaryDimensions.length > 1) { + return { + valid: false, + error: `Expected exactly 1 primary dimension, found ${primaryDimensions.length}. Only one dimension can have primary: true, others must be primary: false`, + field: 'variants.dimensions[].primary' + }; + } + + // Check for pricing conflicts + const hasVariantPricing = dimensions.some(d => + d.options.some(o => o.unit_amount !== undefined) + ); + + if (hasVariantPricing && lineItem.unit_amount) { + return { + valid: false, + error: 'Cannot specify unit_amount at both product level and variant option level. Remove unit_amount from the product level OR from all variant options.', + field: 'unit_amount / variants.dimensions[].options[].unit_amount' + }; + } + + // Validate dimension count + if (dimensions.length < 1 || dimensions.length > 5) { + return { + valid: false, + error: `Variants must have 1-5 dimensions. Found ${dimensions.length}`, + field: 'variants.dimensions' + }; + } + + // Validate options count per dimension + for (let i = 0; i < dimensions.length; i++) { + const dim = dimensions[i]; + if (dim.options.length < 1 || dim.options.length > 10) { + return { + valid: false, + error: `Dimension "${dim.name}" must have 1-10 options. Found ${dim.options.length}`, + field: `variants.dimensions[${i}].options` + }; + } + } + + return { valid: true }; +} + +/** + * Validate currency code and amount + */ +export function validateCurrency(currencyCode: string, value: string): ValidationResult { + // Check currency code length + if (currencyCode.length !== 3) { + return { + valid: false, + error: `Currency code must be 3 characters (ISO-4217). Got: "${currencyCode}"`, + field: 'unit_amount.currency_code' + }; + } + + // Check if value is numeric + const numValue = parseFloat(value); + if (isNaN(numValue)) { + return { + valid: false, + error: `Currency value must be a valid number. Got: "${value}"`, + field: 'unit_amount.value' + }; + } + + // Special validation for zero-decimal currencies (JPY, KRW, etc.) + const zeroDecimalCurrencies = ['JPY', 'KRW', 'VND', 'CLP', 'TWD', 'PYG']; + + if (zeroDecimalCurrencies.includes(currencyCode.toUpperCase())) { + if (value.includes('.')) { + return { + valid: false, + error: `${currencyCode} is a zero-decimal currency and should not have decimal places. Use whole numbers (e.g., "15000" not "15000.00")`, + field: 'unit_amount.value' + }; + } + } + + // Check decimal places (max 3) + const decimalPart = value.split('.')[1]; + if (decimalPart && decimalPart.length > 3) { + return { + valid: false, + error: `Currency value supports up to 3 decimal places. Got ${decimalPart.length} decimal places in "${value}"`, + field: 'unit_amount.value' + }; + } + + return { valid: true }; +} + +/** + * Validate reusable mode + * + * Note: Reusable is now restricted at schema level to "MULTIPLE" only. + * This validation is kept for consistency but should not trigger with proper typing. + */ +export function validateReusableMode(reusable: string, type: string): ValidationResult { + if (reusable !== 'MULTIPLE') { + return { + valid: false, + error: `Reusable must be "MULTIPLE". Got: "${reusable}"`, + field: 'reusable' + }; + } + + return { valid: true }; +} + +/** + * Validate payment link type + */ +export function validatePaymentLinkType(type: string): ValidationResult { + const supportedTypes = ['BUY_NOW', 'DONATION']; + + if (!supportedTypes.includes(type)) { + return { + valid: false, + error: `Payment link type must be one of: ${supportedTypes.join(', ')}. Got: "${type}"`, + field: 'type' + }; + } + + if (type === 'DONATION') { + return { + valid: true, // Warning, not error + error: 'Warning: DONATION type has limited support and may cause validation errors. Consider using BUY_NOW for standard transactions.', + field: 'type' + }; + } + + return { valid: true }; +} + +/** + * Validate complete line item + * + * Runs all validations on a line item. + */ +export function validateLineItem(lineItem: LineItem): ValidationResult { + // Validate taxes + const taxResult = validateTaxConfig(lineItem.taxes); + if (!taxResult.valid) { + return taxResult; + } + + // Validate shipping + const shippingResult = validateShippingConfig(lineItem.shipping); + if (!shippingResult.valid) { + return shippingResult; + } + + // Validate variants + const variantsResult = validateVariants(lineItem); + if (!variantsResult.valid) { + return variantsResult; + } + + // Validate currency if unit_amount exists + if (lineItem.unit_amount) { + const currencyResult = validateCurrency( + lineItem.unit_amount.currency_code, + lineItem.unit_amount.value + ); + if (!currencyResult.valid) { + return currencyResult; + } + } else if (!lineItem.variants) { + // If no unit_amount and no variants, that's an error + return { + valid: false, + error: 'Line item must have either unit_amount (product-level pricing) or variants with pricing in options', + field: 'unit_amount' + }; + } + + return { valid: true }; +} + +/** + * Validate complete payment link creation request + */ +export function validateCreatePaymentLink(params: { + type: string; + reusable?: string; + line_items: LineItem[]; + [key: string]: any; +}): ValidationResult { + // Validate type + const typeResult = validatePaymentLinkType(params.type); + if (!typeResult.valid) { + return typeResult; + } + + // Validate reusable + const reusableValue = params.reusable || 'MULTIPLE'; + const reusableResult = validateReusableMode(reusableValue, params.type); + if (!reusableResult.valid) { + return reusableResult; + } + + // Validate line items exist + if (!params.line_items || params.line_items.length === 0) { + return { + valid: false, + error: 'At least one line item is required', + field: 'line_items' + }; + } + + // Validate each line item + for (let i = 0; i < params.line_items.length; i++) { + const itemResult = validateLineItem(params.line_items[i]); + if (!itemResult.valid) { + return { + ...itemResult, + field: `line_items[${i}].${itemResult.field}` + }; + } + } + + return { valid: true }; +} From 779cb0cf5055b65dd8666f12222249a075abace0 Mon Sep 17 00:00:00 2001 From: vikvenkataraman Date: Fri, 5 Dec 2025 10:02:51 -0800 Subject: [PATCH 03/10] minor updates for better prompts --- .../paypal_agent_toolkit/shared/payment_links/__init__.py | 1 + .../shared/payment_links/parameters.py | 8 ++++---- typescript/src/shared/parameters.ts | 8 ++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/python/paypal_agent_toolkit/shared/payment_links/__init__.py b/python/paypal_agent_toolkit/shared/payment_links/__init__.py index 551f214..793099a 100644 --- a/python/paypal_agent_toolkit/shared/payment_links/__init__.py +++ b/python/paypal_agent_toolkit/shared/payment_links/__init__.py @@ -1,2 +1,3 @@ # Payment Links module + diff --git a/python/paypal_agent_toolkit/shared/payment_links/parameters.py b/python/paypal_agent_toolkit/shared/payment_links/parameters.py index 0f48b8a..0fd0083 100644 --- a/python/paypal_agent_toolkit/shared/payment_links/parameters.py +++ b/python/paypal_agent_toolkit/shared/payment_links/parameters.py @@ -63,9 +63,9 @@ class LineItem(BaseModel): class CreatePaymentLinkParameters(BaseModel): integration_mode: str = Field(default="LINK", description="The integration mode for the payment link. Default and recommended: 'LINK'. This determines how the payment link is presented.") type: str = Field(..., description="The type of payment link. Use 'BUY_NOW' for standard e-commerce purchases (fully supported).") - reusable: Literal['MULTIPLE'] = Field(default="MULTIPLE", description="Whether the link can be used multiple times. Set to 'MULTIPLE' (only supported value for BUY_NOW type). The link can be shared with and used by multiple customers.") - return_url: Optional[str] = Field(None, description="Optional URL to redirect customers after successful payment. Example: 'https://yoursite.com/thank-you'. If omitted, customers stay at PayPal default success page.") - line_items: List[LineItem] = Field(..., min_items=1, description="Array of products/services in this payment link (minimum 1 item required). Each item represents a product with pricing, taxes, shipping, and optional variants.") + reusable: Literal['MULTIPLE'] = Field(default="MULTIPLE", description="Determines link reusability. For BUY_NOW type, must be set to 'MULTIPLE' to enable sharing and multiple uses across different customers.") + return_url: Optional[str] = Field(None, description="Optional URL to redirect customers after successful payment. Example: 'https://yoursite.com/thank-you'. If omitted, customers will stay in PayPal default success page") + line_items: List[LineItem] = Field(..., min_items=1, description="AArray of products/services in this payment link. Currently supports exactly 1 item. Each item represents a product with pricing, taxes, shipping, and optional variants.") class ListPaymentLinksParameters(BaseModel): @@ -85,7 +85,7 @@ class UpdatePaymentLinkParameters(BaseModel): payment_link_id: str = Field(..., pattern=PAYMENT_LINK_ID_REGEX, description="The PayPal Payment Link ID to update. Format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). Example: 'PLB-1A2B3C4D5E6F'") integration_mode: str = Field(default="LINK", description="The integration mode for the payment link. Default and recommended: 'LINK'.") type: str = Field(..., description="The type of payment link. Use 'BUY_NOW' for standard e-commerce purchases (fully supported). Note: 'DONATION' type has limited support and may cause validation errors.") - reusable: Literal['MULTIPLE'] = Field(default="MULTIPLE", description="Whether the link can be used multiple times. Set to 'MULTIPLE' (only supported value for BUY_NOW type). The link can be shared with and used by multiple customers.") + reusable: Literal['MULTIPLE'] = Field(default="MULTIPLE", description="Determines link reusability. For BUY_NOW type, must be set to 'MULTIPLE' to enable sharing and multiple uses across different customers.") return_url: Optional[str] = Field(None, description="Optional URL to redirect customers after successful payment. Updates the return URL for this payment link.") line_items: List[LineItem] = Field(..., min_items=1, description="Complete array of line items for the payment link. This is a full replacement - all items must be provided, not just changes.") diff --git a/typescript/src/shared/parameters.ts b/typescript/src/shared/parameters.ts index eb9b311..051fe74 100644 --- a/typescript/src/shared/parameters.ts +++ b/typescript/src/shared/parameters.ts @@ -466,9 +466,9 @@ const paymentLinkLineItem = z.object({ export const createPaymentLinkParameters = (context: Context) => z.object({ integration_mode: z.string().optional().default('LINK').describe('The integration mode for the payment link. Default and recommended: "LINK". This determines how the payment link is presented.'), type: z.string().describe('The type of payment link. Use "BUY_NOW" for standard e-commerce purchases (fully supported). Note: "DONATION" type has limited support and may cause validation errors.'), - reusable: z.literal('MULTIPLE').optional().default('MULTIPLE').describe('Whether the link can be used multiple times. Set to "MULTIPLE" (only supported value for BUY_NOW type). The link can be shared with and used by multiple customers.'), - return_url: z.string().optional().describe('Optional URL to redirect customers after successful payment. Example: "https://yoursite.com/thank-you". If omitted, customers see PayPal default success page.'), - line_items: z.array(paymentLinkLineItem).min(1).describe('Array of products/services in this payment link (minimum 1 item required). Each item represents a product with pricing, taxes, shipping, and optional variants.'), + reusable: z.literal('MULTIPLE').optional().default('MULTIPLE').describe('Determines link reusability. For BUY_NOW type, must be set to "MULTIPLE" to enable sharing and multiple uses across different customers.'), + return_url: z.string().optional().describe('Optional URL to redirect customers after successful payment. Example: "https://yoursite.com/thank-you". If omitted, customers will stay in PayPal default success page'), + line_items: z.array(paymentLinkLineItem).min(1).describe('Array of products/services in this payment link. Currently supports exactly 1 item. Each item represents a product with pricing, taxes, shipping, and optional variants.'), }).describe('Parameters for creating a new PayPal payment link') export const listPaymentLinksParameters = (context: Context) => z.object({ @@ -485,7 +485,7 @@ export const updatePaymentLinkParameters = (context: Context) => z.object({ payment_link_id: z.string().regex(PAYMENT_LINK_ID_REGEX, "Invalid PayPal Payment Link ID").describe('The PayPal Payment Link ID to update. Format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). Example: "PLB-1A2B3C4D5E6F"'), type: z.string().describe('The type of payment link. Use "BUY_NOW" for standard e-commerce purchases (fully supported). Note: "DONATION" type has limited support and may cause validation errors.'), integration_mode: z.string().optional().default('LINK').describe('The integration mode for the payment link. Default and recommended: "LINK".'), - reusable: z.literal('MULTIPLE').optional().default('MULTIPLE').describe('Whether the link can be used multiple times. Set to "MULTIPLE" (only supported value for BUY_NOW type). The link can be shared with and used by multiple customers.'), + reusable: z.literal('MULTIPLE').optional().default('MULTIPLE').describe('Determines link reusability. For BUY_NOW type, must be set to "MULTIPLE" to enable sharing and multiple uses across different customers.'), return_url: z.string().optional().describe('Optional URL to redirect customers after successful payment. Updates the return URL for this payment link.'), line_items: z.array(paymentLinkLineItem).min(1).describe('Complete array of line items for the payment link. This is a full replacement - all items must be provided, not just changes.'), }) From 39b661a979bdc618a4ee82c4adfa254c37c808b4 Mon Sep 17 00:00:00 2001 From: vikvenkataraman Date: Fri, 5 Dec 2025 11:09:54 -0800 Subject: [PATCH 04/10] fixing typos --- .../paypal_agent_toolkit/shared/payment_links/parameters.py | 4 ++-- typescript/src/shared/parameters.ts | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/python/paypal_agent_toolkit/shared/payment_links/parameters.py b/python/paypal_agent_toolkit/shared/payment_links/parameters.py index 0fd0083..c581941 100644 --- a/python/paypal_agent_toolkit/shared/payment_links/parameters.py +++ b/python/paypal_agent_toolkit/shared/payment_links/parameters.py @@ -64,8 +64,8 @@ class CreatePaymentLinkParameters(BaseModel): integration_mode: str = Field(default="LINK", description="The integration mode for the payment link. Default and recommended: 'LINK'. This determines how the payment link is presented.") type: str = Field(..., description="The type of payment link. Use 'BUY_NOW' for standard e-commerce purchases (fully supported).") reusable: Literal['MULTIPLE'] = Field(default="MULTIPLE", description="Determines link reusability. For BUY_NOW type, must be set to 'MULTIPLE' to enable sharing and multiple uses across different customers.") - return_url: Optional[str] = Field(None, description="Optional URL to redirect customers after successful payment. Example: 'https://yoursite.com/thank-you'. If omitted, customers will stay in PayPal default success page") - line_items: List[LineItem] = Field(..., min_items=1, description="AArray of products/services in this payment link. Currently supports exactly 1 item. Each item represents a product with pricing, taxes, shipping, and optional variants.") + return_url: Optional[str] = Field(None, description="Optional URL to redirect customers after successful payment. Example: 'https://yoursite.com/thank-you'. If omitted, customers will stay on the PayPal default success page") + line_items: List[LineItem] = Field(..., min_items=1, description="Array of products/services in this payment link. Currently supports exactly 1 item. Each item represents a product with pricing, taxes, shipping, and optional variants.") class ListPaymentLinksParameters(BaseModel): diff --git a/typescript/src/shared/parameters.ts b/typescript/src/shared/parameters.ts index 051fe74..bb8aa5a 100644 --- a/typescript/src/shared/parameters.ts +++ b/typescript/src/shared/parameters.ts @@ -459,15 +459,14 @@ const paymentLinkLineItem = z.object({ }).optional().describe('Product variants configuration for size, color, material, etc. IMPORTANT: If variants have unit_amount in options, do NOT include product-level unit_amount (causes validation error). If variants have NO pricing, product-level unit_amount is REQUIRED.'), adjustable_quantity: z.object({ maximum: z.number().int().min(1).max(100).describe('Maximum quantity customers can select (1-100). Use 1 for limited edition items, higher values for bulk products. Examples: 1 for unique items, 10 for regular products, 100 for wholesale.'), - }).optional().describe('Enables quantity selection during checkout. Customers can choose from 1 up to the maximum. Useful for allowing bulk purchases or limiting quantities for special items.'), - return_url: z.string().optional().describe('The URL to redirect to after payment completion') + }).optional().describe('Enables quantity selection during checkout. Customers can choose from 1 up to the maximum. Useful for allowing bulk purchases or limiting quantities for special items.') }).describe('Payment link line item'); export const createPaymentLinkParameters = (context: Context) => z.object({ integration_mode: z.string().optional().default('LINK').describe('The integration mode for the payment link. Default and recommended: "LINK". This determines how the payment link is presented.'), type: z.string().describe('The type of payment link. Use "BUY_NOW" for standard e-commerce purchases (fully supported). Note: "DONATION" type has limited support and may cause validation errors.'), reusable: z.literal('MULTIPLE').optional().default('MULTIPLE').describe('Determines link reusability. For BUY_NOW type, must be set to "MULTIPLE" to enable sharing and multiple uses across different customers.'), - return_url: z.string().optional().describe('Optional URL to redirect customers after successful payment. Example: "https://yoursite.com/thank-you". If omitted, customers will stay in PayPal default success page'), + return_url: z.string().optional().describe('Optional URL to redirect customers after successful payment. Example: "https://yoursite.com/thank-you". If omitted, customers will stay on the PayPal default success page'), line_items: z.array(paymentLinkLineItem).min(1).describe('Array of products/services in this payment link. Currently supports exactly 1 item. Each item represents a product with pricing, taxes, shipping, and optional variants.'), }).describe('Parameters for creating a new PayPal payment link') From 243b602eff0a7797fe8251bd4907a1903478fe13 Mon Sep 17 00:00:00 2001 From: vikvenkataraman Date: Wed, 10 Dec 2025 09:41:28 -0800 Subject: [PATCH 05/10] update pr review updates --- .../shared/payment_links/parameters.py | 2 +- .../shared/payment_links/prompts.py | 19 ++++++++++++++++--- .../shared/payment_links/validation.py | 9 +-------- typescript/src/shared/parameters.ts | 4 ++-- .../src/shared/paymentLinkValidation.ts | 10 +--------- typescript/src/shared/prompts.ts | 19 ++++++++++++++++--- 6 files changed, 37 insertions(+), 26 deletions(-) diff --git a/python/paypal_agent_toolkit/shared/payment_links/parameters.py b/python/paypal_agent_toolkit/shared/payment_links/parameters.py index c581941..e788fe8 100644 --- a/python/paypal_agent_toolkit/shared/payment_links/parameters.py +++ b/python/paypal_agent_toolkit/shared/payment_links/parameters.py @@ -84,7 +84,7 @@ class UpdatePaymentLinkParameters(BaseModel): """Parameters for updating a payment link (full replacement)""" payment_link_id: str = Field(..., pattern=PAYMENT_LINK_ID_REGEX, description="The PayPal Payment Link ID to update. Format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). Example: 'PLB-1A2B3C4D5E6F'") integration_mode: str = Field(default="LINK", description="The integration mode for the payment link. Default and recommended: 'LINK'.") - type: str = Field(..., description="The type of payment link. Use 'BUY_NOW' for standard e-commerce purchases (fully supported). Note: 'DONATION' type has limited support and may cause validation errors.") + type: str = Field(..., description="The type of payment link. Use 'BUY_NOW' for standard e-commerce purchases.") reusable: Literal['MULTIPLE'] = Field(default="MULTIPLE", description="Determines link reusability. For BUY_NOW type, must be set to 'MULTIPLE' to enable sharing and multiple uses across different customers.") return_url: Optional[str] = Field(None, description="Optional URL to redirect customers after successful payment. Updates the return URL for this payment link.") line_items: List[LineItem] = Field(..., min_items=1, description="Complete array of line items for the payment link. This is a full replacement - all items must be provided, not just changes.") diff --git a/python/paypal_agent_toolkit/shared/payment_links/prompts.py b/python/paypal_agent_toolkit/shared/payment_links/prompts.py index 0d53160..bd7c05d 100644 --- a/python/paypal_agent_toolkit/shared/payment_links/prompts.py +++ b/python/paypal_agent_toolkit/shared/payment_links/prompts.py @@ -37,9 +37,22 @@ """ DELETE_PAYMENT_LINK_PROMPT = """ -Delete a payment link from PayPal. +Delete a payment link created through the PayPal Pay Links & Buttons API. -This function permanently deletes a payment link. Once deleted, the shareable link URL will no longer work and the operation cannot be undone. Use with caution. +This operation deactivates the payment link so it can no longer be used by customers. +Once deleted, the shareable link URL will stop working. -Note: PayPal API may return a 500 error instead of 404 for non-existent payment link IDs. This is expected API behavior. +### Important notes +- Deletion removes the payment resource from active use. A deleted link cannot be used for future payments. +- If an invalid or non-existent payment link ID is provided, the PayPal API may return a 404 Not Found error. +- This operation does not affect completed transactions tied to the link. + +### Required fields +- `id`: Unique identifier of the payment link you want to delete. + +### Example usage +To delete a payment link: +delete_payment_link( +id="PR-1234567890" +) """ diff --git a/python/paypal_agent_toolkit/shared/payment_links/validation.py b/python/paypal_agent_toolkit/shared/payment_links/validation.py index 0ef622e..9f88976 100644 --- a/python/paypal_agent_toolkit/shared/payment_links/validation.py +++ b/python/paypal_agent_toolkit/shared/payment_links/validation.py @@ -232,7 +232,7 @@ def validate_payment_link_type(payment_type: str) -> ValidationResult: """ Validate payment link type """ - supported_types = ['BUY_NOW', 'DONATION'] + supported_types = ['BUY_NOW'] if payment_type not in supported_types: return { @@ -241,13 +241,6 @@ def validate_payment_link_type(payment_type: str) -> ValidationResult: "field": "type" } - if payment_type == 'DONATION': - return { - "valid": True, - "error": "Warning: DONATION type has limited support and may cause validation errors. Consider using BUY_NOW for standard transactions.", - "field": "type" - } - return {"valid": True, "error": None, "field": None} diff --git a/typescript/src/shared/parameters.ts b/typescript/src/shared/parameters.ts index bb8aa5a..738f660 100644 --- a/typescript/src/shared/parameters.ts +++ b/typescript/src/shared/parameters.ts @@ -464,7 +464,7 @@ const paymentLinkLineItem = z.object({ export const createPaymentLinkParameters = (context: Context) => z.object({ integration_mode: z.string().optional().default('LINK').describe('The integration mode for the payment link. Default and recommended: "LINK". This determines how the payment link is presented.'), - type: z.string().describe('The type of payment link. Use "BUY_NOW" for standard e-commerce purchases (fully supported). Note: "DONATION" type has limited support and may cause validation errors.'), + type: z.string().describe('The type of payment link. Use "BUY_NOW" for standard e-commerce purchases.'), reusable: z.literal('MULTIPLE').optional().default('MULTIPLE').describe('Determines link reusability. For BUY_NOW type, must be set to "MULTIPLE" to enable sharing and multiple uses across different customers.'), return_url: z.string().optional().describe('Optional URL to redirect customers after successful payment. Example: "https://yoursite.com/thank-you". If omitted, customers will stay on the PayPal default success page'), line_items: z.array(paymentLinkLineItem).min(1).describe('Array of products/services in this payment link. Currently supports exactly 1 item. Each item represents a product with pricing, taxes, shipping, and optional variants.'), @@ -482,7 +482,7 @@ export const getPaymentLinkParameters = (context: Context) => z.object({ export const updatePaymentLinkParameters = (context: Context) => z.object({ payment_link_id: z.string().regex(PAYMENT_LINK_ID_REGEX, "Invalid PayPal Payment Link ID").describe('The PayPal Payment Link ID to update. Format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). Example: "PLB-1A2B3C4D5E6F"'), - type: z.string().describe('The type of payment link. Use "BUY_NOW" for standard e-commerce purchases (fully supported). Note: "DONATION" type has limited support and may cause validation errors.'), + type: z.string().describe('The type of payment link. Use "BUY_NOW" for standard e-commerce purchases.'), integration_mode: z.string().optional().default('LINK').describe('The integration mode for the payment link. Default and recommended: "LINK".'), reusable: z.literal('MULTIPLE').optional().default('MULTIPLE').describe('Determines link reusability. For BUY_NOW type, must be set to "MULTIPLE" to enable sharing and multiple uses across different customers.'), return_url: z.string().optional().describe('Optional URL to redirect customers after successful payment. Updates the return URL for this payment link.'), diff --git a/typescript/src/shared/paymentLinkValidation.ts b/typescript/src/shared/paymentLinkValidation.ts index bd69aa3..b567e90 100644 --- a/typescript/src/shared/paymentLinkValidation.ts +++ b/typescript/src/shared/paymentLinkValidation.ts @@ -270,7 +270,7 @@ export function validateReusableMode(reusable: string, type: string): Validation * Validate payment link type */ export function validatePaymentLinkType(type: string): ValidationResult { - const supportedTypes = ['BUY_NOW', 'DONATION']; + const supportedTypes = ['BUY_NOW']; if (!supportedTypes.includes(type)) { return { @@ -280,14 +280,6 @@ export function validatePaymentLinkType(type: string): ValidationResult { }; } - if (type === 'DONATION') { - return { - valid: true, // Warning, not error - error: 'Warning: DONATION type has limited support and may cause validation errors. Consider using BUY_NOW for standard transactions.', - field: 'type' - }; - } - return { valid: true }; } diff --git a/typescript/src/shared/prompts.ts b/typescript/src/shared/prompts.ts index e4f549f..90cb8e0 100644 --- a/typescript/src/shared/prompts.ts +++ b/typescript/src/shared/prompts.ts @@ -322,9 +322,22 @@ Note: This is a full replacement operation. All fields must be provided, not jus `; export const deletePaymentLinkPrompt = (context: Context) => ` -Delete a payment link from PayPal. +Delete a payment link created through the PayPal Pay Links & Buttons API. -This function permanently deletes a payment link. Once deleted, the shareable link URL will no longer work and the operation cannot be undone. Use with caution. +This operation deactivates the payment link so it can no longer be used by customers. +Once deleted, the shareable link URL will stop working. -Note: PayPal API may return a 500 error instead of 404 for non-existent payment link IDs. This is expected API behavior. +### Important notes +- Deletion removes the payment resource from active use. A deleted link cannot be used for future payments. +- If an invalid or non-existent payment link ID is provided, the PayPal API may return a 404 Not Found error. +- This operation does not affect completed transactions tied to the link. + +### Required fields +- id: Unique identifier of the payment link you want to delete. + +### Example usage +To delete a payment link: +delete_payment_link( +id="PR-1234567890" +) `; \ No newline at end of file From 5e1e5a6daf02285d1e1e53657bb2e2cfb37133d6 Mon Sep 17 00:00:00 2001 From: vikvenkataraman Date: Mon, 1 Dec 2025 16:08:18 -0800 Subject: [PATCH 06/10] add payment link mcp crud tools --- README.md | 8 + python/paypal_agent_toolkit/shared/tools.py | 64 +++++++ typescript/src/shared/api.ts | 17 +- typescript/src/shared/functions.ts | 174 +++++++++++++++++++- typescript/src/shared/parameters.ts | 71 +++++++- typescript/src/shared/prompts.ts | 48 ++++++ typescript/src/shared/regex.ts | 1 + typescript/src/shared/tools.ts | 69 +++++++- 8 files changed, 447 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e5a3bbf..6da964f 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,14 @@ The PayPal Agent toolkit provides the following tools: - `list_transactions`: List transactions with optional pagination and filtering - `get_merchant_insights`: Retrieve business intelligence metrics and analytics for a merchant +**Payment Links** + +- `create_payment_link`: Create a shareable payment link for products or services +- `list_payment_links`: List all payment links with pagination +- `get_payment_link`: Retrieve details of a specific payment link +- `update_payment_link`: Update an existing payment link (full replacement) +- `delete_payment_link`: Permanently delete a payment link + ## TypeScript ### Installation diff --git a/python/paypal_agent_toolkit/shared/tools.py b/python/paypal_agent_toolkit/shared/tools.py index 10c1fda..bf0b9ee 100644 --- a/python/paypal_agent_toolkit/shared/tools.py +++ b/python/paypal_agent_toolkit/shared/tools.py @@ -45,6 +45,14 @@ GET_MERCHANT_INSIGHTS_PROMPT ) +from ..shared.payment_links.prompts import ( + CREATE_PAYMENT_LINK_PROMPT, + LIST_PAYMENT_LINKS_PROMPT, + GET_PAYMENT_LINK_PROMPT, + UPDATE_PAYMENT_LINK_PROMPT, + DELETE_PAYMENT_LINK_PROMPT +) + from ..shared.orders.parameters import ( CreateOrderParameters, @@ -94,6 +102,14 @@ GetMerchantInsightsParameters ) +from ..shared.payment_links.parameters import ( + CreatePaymentLinkParameters, + ListPaymentLinksParameters, + GetPaymentLinkParameters, + UpdatePaymentLinkParameters, + DeletePaymentLinkParameters +) + from ..shared.orders.tool_handlers import ( create_order, capture_order, @@ -143,6 +159,14 @@ get_merchant_insights ) +from ..shared.payment_links.tool_handlers import ( + create_payment_link, + list_payment_links, + get_payment_link, + update_payment_link, + delete_payment_link +) + from pydantic import BaseModel tools = [ @@ -361,5 +385,45 @@ "args_schema": GetMerchantInsightsParameters, "actions": {"insights": {"get": True}}, "execute": get_merchant_insights, + }, + { + "method": "create_payment_link", + "name": "Create Payment Link", + "description": CREATE_PAYMENT_LINK_PROMPT.strip(), + "args_schema": CreatePaymentLinkParameters, + "actions": {"paymentLinks": {"create": True}}, + "execute": create_payment_link, + }, + { + "method": "list_payment_links", + "name": "List Payment Links", + "description": LIST_PAYMENT_LINKS_PROMPT.strip(), + "args_schema": ListPaymentLinksParameters, + "actions": {"paymentLinks": {"list": True}}, + "execute": list_payment_links, + }, + { + "method": "get_payment_link", + "name": "Get Payment Link", + "description": GET_PAYMENT_LINK_PROMPT.strip(), + "args_schema": GetPaymentLinkParameters, + "actions": {"paymentLinks": {"get": True}}, + "execute": get_payment_link, + }, + { + "method": "update_payment_link", + "name": "Update Payment Link", + "description": UPDATE_PAYMENT_LINK_PROMPT.strip(), + "args_schema": UpdatePaymentLinkParameters, + "actions": {"paymentLinks": {"update": True}}, + "execute": update_payment_link, + }, + { + "method": "delete_payment_link", + "name": "Delete Payment Link", + "description": DELETE_PAYMENT_LINK_PROMPT.strip(), + "args_schema": DeletePaymentLinkParameters, + "actions": {"paymentLinks": {"delete": True}}, + "execute": delete_payment_link, } ] diff --git a/typescript/src/shared/api.ts b/typescript/src/shared/api.ts index 149acb4..bc3688b 100644 --- a/typescript/src/shared/api.ts +++ b/typescript/src/shared/api.ts @@ -28,7 +28,12 @@ import { createRefund, getRefund, updateSubscription, - getMerchantInsights + getMerchantInsights, + createPaymentLink, + listPaymentLinks, + getPaymentLink, + updatePaymentLink, + deletePaymentLink } from './functions'; import type { Context } from './configuration'; @@ -147,6 +152,16 @@ class PayPalAPI { return getRefund(this.paypalClient, this.context, arg); case 'get_merchant_insights': return getMerchantInsights(this.paypalClient, this.context, arg); + case 'create_payment_link': + return createPaymentLink(this.paypalClient, this.context, arg); + case 'list_payment_links': + return listPaymentLinks(this.paypalClient, this.context, arg); + case 'get_payment_link': + return getPaymentLink(this.paypalClient, this.context, arg); + case 'update_payment_link': + return updatePaymentLink(this.paypalClient, this.context, arg); + case 'delete_payment_link': + return deletePaymentLink(this.paypalClient, this.context, arg); default: throw new Error(`Invalid method: ${method}`); } diff --git a/typescript/src/shared/functions.ts b/typescript/src/shared/functions.ts index 57434ad..9b2295b 100644 --- a/typescript/src/shared/functions.ts +++ b/typescript/src/shared/functions.ts @@ -31,7 +31,12 @@ import { createRefundParameters, updateSubscriptionParameters, updatePlanParameters, - getMerchantInsightsParameters + getMerchantInsightsParameters, + createPaymentLinkParameters, + listPaymentLinksParameters, + getPaymentLinkParameters, + updatePaymentLinkParameters, + deletePaymentLinkParameters } from "./parameters"; import {parseOrderDetails, parseUpdateSubscriptionPayload, toQueryString} from "./payloadUtils"; import { TypeOf } from "zod"; @@ -1035,6 +1040,173 @@ export async function getMerchantInsights( } } +// === PAYMENT LINK FUNCTIONS === + +export async function createPaymentLink( + client: PayPalClient, + context: Context, + params: TypeOf> +): Promise { + logger('[createPaymentLink] Starting payment link creation process'); + logger('[createPaymentLink] Request params received:', JSON.stringify(params, null, 2)); + + // Ensure required fields have defaults + const payload = { + integration_mode: params.integration_mode || 'LINK', + type: params.type, + reusable: params.reusable || 'MULTIPLE', + line_items: params.line_items, + ...(params.return_url && { return_url: params.return_url }), + }; + + logger('[createPaymentLink] Final payload to send:', JSON.stringify(payload, null, 2)); + + const headers = await client.getHeaders(); + + // PayPal Payment Links API requires PayPal-Request-Id header for idempotency + // Generate a unique request ID if not already set in context + if (!headers['PayPal-Request-Id']) { + headers['PayPal-Request-Id'] = Date.now().toString(); + } + + logger('[createPaymentLink] Headers obtained'); + logger('[createPaymentLink] PayPal-Request-Id:', headers['PayPal-Request-Id']); + + const url = `${client.getBaseUrl()}/v1/checkout/payment-resources`; + logger(`[createPaymentLink] API URL: ${url}`); + + try { + logger('[createPaymentLink] Sending request to PayPal API'); + const response = await axios.post(url, payload, { headers }); + logger(`[createPaymentLink] Payment link created successfully. Status: ${response.status}`); + logger(`[createPaymentLink] Payment link ID: ${response.data.id || 'N/A'}`); + return response.data; + } catch (error: any) { + logger('[createPaymentLink] Error creating payment link:', error.message); + if (error.response) { + logger('[createPaymentLink] Error response data:', JSON.stringify(error.response.data, null, 2)); + } + handleAxiosError(error); + } +} + +// === LIST PAYMENT LINKS === +export async function listPaymentLinks( + client: PayPalClient, + context: Context, + params: TypeOf> +): Promise { + logger('[listPaymentLinks] Starting payment links list process'); + + const headers = await client.getHeaders(); + const url = `${client.getBaseUrl()}/v1/checkout/payment-resources`; + + try { + logger('[listPaymentLinks] Sending request to PayPal API'); + const response = await axios.get(url, { headers, params }); + logger(`[listPaymentLinks] Payment links retrieved successfully. Status: ${response.status}`); + return response.data; + } catch (error: any) { + logger('[listPaymentLinks] Error listing payment links:', error.message); + handleAxiosError(error); + } +} + +// === GET PAYMENT LINK === +export async function getPaymentLink( + client: PayPalClient, + context: Context, + params: TypeOf> +): Promise { + logger('[getPaymentLink] Starting payment link retrieval process'); + logger(`[getPaymentLink] Payment Link ID: ${params.payment_link_id}`); + + const headers = await client.getHeaders(); + const url = `${client.getBaseUrl()}/v1/checkout/payment-resources/${params.payment_link_id}`; + + try { + logger('[getPaymentLink] Sending request to PayPal API'); + const response = await axios.get(url, { headers }); + logger(`[getPaymentLink] Payment link retrieved successfully. Status: ${response.status}`); + return response.data; + } catch (error: any) { + logger('[getPaymentLink] Error getting payment link:', error.message); + handleAxiosError(error); + } +} + +// === UPDATE PAYMENT LINK (PUT - Full Replacement) === +export async function updatePaymentLink( + client: PayPalClient, + context: Context, + params: TypeOf> +): Promise { + logger('[updatePaymentLink] Starting payment link update process'); + logger('[updatePaymentLink] Request params received:', JSON.stringify(params, null, 2)); + + const { payment_link_id, ...updateData } = params; + + // Ensure required fields have defaults (same as create) + const payload = { + integration_mode: updateData.integration_mode || 'LINK', + type: updateData.type, + reusable: updateData.reusable || 'MULTIPLE', + line_items: updateData.line_items, + ...(updateData.return_url && { return_url: updateData.return_url }), + }; + + logger('[updatePaymentLink] Final payload to send:', JSON.stringify(payload, null, 2)); + + const headers = await client.getHeaders(); + const url = `${client.getBaseUrl()}/v1/checkout/payment-resources/${payment_link_id}`; + + try { + logger('[updatePaymentLink] Sending PUT request to PayPal API'); + const response = await axios.put(url, payload, { headers }); + logger(`[updatePaymentLink] Payment link updated successfully. Status: ${response.status}`); + return response.data; + } catch (error: any) { + logger('[updatePaymentLink] Error updating payment link:', error.message); + if (error.response) { + logger('[updatePaymentLink] Error response data:', JSON.stringify(error.response.data, null, 2)); + } + handleAxiosError(error); + } +} + +// === DELETE PAYMENT LINK === +export async function deletePaymentLink( + client: PayPalClient, + context: Context, + params: TypeOf> +): Promise { + logger('[deletePaymentLink] Starting payment link deletion process'); + logger(`[deletePaymentLink] Payment Link ID: ${params.payment_link_id}`); + + const headers = await client.getHeaders(); + const url = `${client.getBaseUrl()}/v1/checkout/payment-resources/${params.payment_link_id}`; + + try { + logger('[deletePaymentLink] Sending DELETE request to PayPal API'); + const response = await axios.delete(url, { headers }); + + // DELETE operations often return 204 No Content + if (response.status === 204) { + logger(`[deletePaymentLink] Payment link deleted successfully. Status: ${response.status}`); + return { success: true, payment_link_id: params.payment_link_id }; + } + + logger(`[deletePaymentLink] Payment link deleted successfully. Status: ${response.status}`); + return response.data; + } catch (error: any) { + logger('[deletePaymentLink] Error deleting payment link:', error.message); + if (error.response) { + logger('[deletePaymentLink] Error response data:', JSON.stringify(error.response.data, null, 2)); + } + handleAxiosError(error); + } +} + // Helper function to handle Axios errors -> throws LlmError export function handleAxiosError(error: any): never { diff --git a/typescript/src/shared/parameters.ts b/typescript/src/shared/parameters.ts index 50179f3..eb9b311 100644 --- a/typescript/src/shared/parameters.ts +++ b/typescript/src/shared/parameters.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import type { Context } from './configuration'; import {subscriptionKeys} from "./constants"; -import {INVOICE_ID_REGEX, ORDER_ID_REGEX, SUBSCRIPTION_ID_REGEX, PRODUCT_ID_REGEX, PLAN_ID_REGEX, DISPUTE_ID_REGEX, REFUND_ID_REGEX, CAPTURE_ID_REGEX, TRANSACTION_ID_REGEX} from "./regex" +import {INVOICE_ID_REGEX, ORDER_ID_REGEX, SUBSCRIPTION_ID_REGEX, PRODUCT_ID_REGEX, PLAN_ID_REGEX, DISPUTE_ID_REGEX, REFUND_ID_REGEX, CAPTURE_ID_REGEX, TRANSACTION_ID_REGEX, PAYMENT_LINK_ID_REGEX} from "./regex" // === INVOICE PARAMETERS === const invoiceItem = z.object({ @@ -425,4 +425,73 @@ export const getMerchantInsightsParameters = (context: Context) => z.object({ time_interval: z.string().describe('The time periods used for segmenting metrics data. It can be "DAILY", "WEEKLY", "MONTHLY", "QUARTERLY", or "YEARLY"'), }) +// === PAYMENT LINK PARAMETERS === + +// Shared line item schema for payment links (used by both create and update) +const paymentLinkLineItem = z.object({ + name: z.string().min(1).max(127).describe('The product or service name displayed to customers during checkout'), + product_id: z.string().min(1).max(50).optional().describe('Your internal identifier for this product or SKU'), + description: z.string().min(1).max(2048).optional().describe('Detailed information about the product or service shown to customers during checkout'), + unit_amount: z.object({ + currency_code: z.string().length(3).describe('The three-character ISO-4217 currency code (e.g., USD, EUR, GBP, JPY, CAD, AUD). Note: JPY is a zero-decimal currency - use whole numbers without decimal points.'), + value: z.string().max(32).regex(/^((-?\d+)|(-?(\d+)?[.]\d+))$/).describe('The monetary value. Supports up to 3 decimal places for most currencies. For JPY, use whole numbers (e.g., "15000" not "15000.00"). Examples: "29.99", "15000", "9.995"'), + }).describe('The currency and amount for this line item. IMPORTANT: If using variants with pricing in options, this field should be omitted OR set to the base price. Cannot have unit_amount at both product level and variant option level.'), + taxes: z.array(z.object({ + name: z.string().min(1).max(127).optional().describe('Internal label for this tax (e.g., "Sales Tax", "VAT", "GST"). Not visible to customer during checkout.'), + type: z.enum(['PERCENTAGE', 'PREFERENCE']).describe('Tax calculation method. PERCENTAGE: percentage of price (e.g., 8.5% tax). PREFERENCE: uses account default tax settings.'), + value: z.string().min(1).max(20).describe('Tax value. For PERCENTAGE: enter numeric percentage (e.g., "8.5" for 8.5% tax rate). For PREFERENCE: must be "PROFILE" to use account defaults. Maximum 20 characters.'), + })).max(1).optional().describe('Tax configuration for this item (maximum 1). Tax is displayed separately during checkout.'), + shipping: z.array(z.object({ + type: z.enum(['FLAT', 'PREFERENCE']).describe('Shipping calculation method. FLAT: fixed amount (e.g., $9.99 flat rate). PREFERENCE: uses account default shipping settings.'), + value: z.string().min(1).max(20).describe('Shipping value. For FLAT: enter fixed cost (e.g., "9.99" for $9.99 shipping). For PREFERENCE: must be "PROFILE" to use account defaults. Maximum 20 characters.'), + })).max(1).optional().describe('Shipping configuration for this item (maximum 1). Shipping fee is displayed separately during checkout.'), + collect_shipping_address: z.boolean().optional().describe('Set to true to prompt customers for a shipping address during checkout. Required for physical goods that need to be shipped. Default: false'), + customer_notes: z.array(z.object({ + required: z.boolean().optional().describe('When true, customer must provide input before checkout. When false, input is optional. Use for collecting gift messages, custom instructions, engraving text, etc.'), + label: z.string().min(1).max(127).optional().describe('The label displayed to customers for this custom input field. Examples: "Gift message", "Special instructions", "Engraving text", "Delivery notes"'), + })).max(1).optional().describe('Custom field to collect additional information from customers about this item (maximum 1 field per item). Useful for personalization, special requests, or delivery instructions.'), + variants: z.object({ + dimensions: z.array(z.object({ + name: z.string().min(1).max(64).describe('The name of this variant dimension. Examples: "Size", "Color", "Material", "Style". Maximum 64 characters.'), + primary: z.boolean().describe('IMPORTANT: Exactly ONE dimension must have primary: true, all others must be primary: false. The primary dimension typically contains the main product variation and can include pricing in options. Common pattern: Size=primary, Color=non-primary.'), + options: z.array(z.object({}).passthrough()).min(1).max(10).describe('Array of variant options (1-10 options). Each option can have: label (required), unit_amount (optional - only if no product-level unit_amount). Example: [{label: "Small"}, {label: "Medium"}, {label: "Large"}]'), + })).min(1).max(5).describe('List of variant dimensions (1-5 dimensions). Must have exactly 1 primary dimension. Additional dimensions must be non-primary. Example: Size (primary) + Color (non-primary) for t-shirts.'), + }).optional().describe('Product variants configuration for size, color, material, etc. IMPORTANT: If variants have unit_amount in options, do NOT include product-level unit_amount (causes validation error). If variants have NO pricing, product-level unit_amount is REQUIRED.'), + adjustable_quantity: z.object({ + maximum: z.number().int().min(1).max(100).describe('Maximum quantity customers can select (1-100). Use 1 for limited edition items, higher values for bulk products. Examples: 1 for unique items, 10 for regular products, 100 for wholesale.'), + }).optional().describe('Enables quantity selection during checkout. Customers can choose from 1 up to the maximum. Useful for allowing bulk purchases or limiting quantities for special items.'), + return_url: z.string().optional().describe('The URL to redirect to after payment completion') +}).describe('Payment link line item'); + +export const createPaymentLinkParameters = (context: Context) => z.object({ + integration_mode: z.string().optional().default('LINK').describe('The integration mode for the payment link. Default and recommended: "LINK". This determines how the payment link is presented.'), + type: z.string().describe('The type of payment link. Use "BUY_NOW" for standard e-commerce purchases (fully supported). Note: "DONATION" type has limited support and may cause validation errors.'), + reusable: z.literal('MULTIPLE').optional().default('MULTIPLE').describe('Whether the link can be used multiple times. Set to "MULTIPLE" (only supported value for BUY_NOW type). The link can be shared with and used by multiple customers.'), + return_url: z.string().optional().describe('Optional URL to redirect customers after successful payment. Example: "https://yoursite.com/thank-you". If omitted, customers see PayPal default success page.'), + line_items: z.array(paymentLinkLineItem).min(1).describe('Array of products/services in this payment link (minimum 1 item required). Each item represents a product with pricing, taxes, shipping, and optional variants.'), +}).describe('Parameters for creating a new PayPal payment link') + +export const listPaymentLinksParameters = (context: Context) => z.object({ + page: z.number().int().min(1).max(1000).default(1).optional().describe('Page number to retrieve (1-1000). Default: 1. Use for pagination when you have many payment links.'), + page_size: z.number().int().min(1).max(100).default(10).optional().describe('Number of payment links per page (1-100). Default: 10. Increase for bulk operations, decrease for faster responses.'), + total_required: z.boolean().optional().describe('Set to true to include total count of all payment links in response. Useful for pagination UI. Default: false (faster response).'), +}) + +export const getPaymentLinkParameters = (context: Context) => z.object({ + payment_link_id: z.string().regex(PAYMENT_LINK_ID_REGEX, "Invalid PayPal Payment Link ID").describe('The PayPal Payment Link ID to retrieve. Format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). Example: "PLB-1A2B3C4D5E6F"'), +}) + +export const updatePaymentLinkParameters = (context: Context) => z.object({ + payment_link_id: z.string().regex(PAYMENT_LINK_ID_REGEX, "Invalid PayPal Payment Link ID").describe('The PayPal Payment Link ID to update. Format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). Example: "PLB-1A2B3C4D5E6F"'), + type: z.string().describe('The type of payment link. Use "BUY_NOW" for standard e-commerce purchases (fully supported). Note: "DONATION" type has limited support and may cause validation errors.'), + integration_mode: z.string().optional().default('LINK').describe('The integration mode for the payment link. Default and recommended: "LINK".'), + reusable: z.literal('MULTIPLE').optional().default('MULTIPLE').describe('Whether the link can be used multiple times. Set to "MULTIPLE" (only supported value for BUY_NOW type). The link can be shared with and used by multiple customers.'), + return_url: z.string().optional().describe('Optional URL to redirect customers after successful payment. Updates the return URL for this payment link.'), + line_items: z.array(paymentLinkLineItem).min(1).describe('Complete array of line items for the payment link. This is a full replacement - all items must be provided, not just changes.'), +}) + +export const deletePaymentLinkParameters = (context: Context) => z.object({ + payment_link_id: z.string().regex(PAYMENT_LINK_ID_REGEX, "Invalid PayPal Payment Link ID").describe('The PayPal Payment Link ID to delete permanently. Format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). ⚠️ WARNING: Deletion is permanent and cannot be undone.'), +}) + diff --git a/typescript/src/shared/prompts.ts b/typescript/src/shared/prompts.ts index 9e03f39..e4f549f 100644 --- a/typescript/src/shared/prompts.ts +++ b/typescript/src/shared/prompts.ts @@ -279,4 +279,52 @@ export const getMerchantInsightsPrompt = (context: Context) => ` Retrieve business intelligence metrics and analytics for a merchant, filtered by start date, end date, insight type, and time interval. This tool returns actionable insights into orders and sales data, supporting data-driven business decisions. +`; + +// === PAYMENT LINK PARAMETERS === + +export const createPaymentLinkPrompt = (context: Context) => ` +Create a payment link on PayPal. + +This function creates a shareable payment link that can be sent to customers for payment collection. The payment link supports products with pricing, tax calculation, shipping fees, product variants (size, color, material), quantity controls, custom customer notes, and return URLs. + +Important limitations to note: +- Tax types: Only PERCENTAGE and PREFERENCE are supported. FLAT tax type will return a 422 error. +- Shipping types: Only FLAT and PREFERENCE are supported. PERCENTAGE shipping type will return a 422 error. +- Reusable mode: Use "MULTIPLE" for BUY_NOW type. SINGLE mode is not supported in the current version. +- Variants: Must have exactly one primary dimension. Other dimensions must be marked as non-primary. +- Pricing: Cannot specify unit_amount at both product level and variant option level. Choose one approach. +- Type: Use "BUY_NOW" for standard purchases. + +Once created, the link can be shared with customers via email, social media, or embedded on websites. +`; + +export const listPaymentLinksPrompt = (context: Context) => ` +List payment links from PayPal. + +This function retrieves a paginated list of payment links with optional filtering parameters. You can control pagination using page and page_size parameters. Set total_required to true to include the total count of payment links in the response. +`; + +export const getPaymentLinkPrompt = (context: Context) => ` +Get details of a specific payment link from PayPal. + +This function retrieves comprehensive details about a payment link using its ID. The response includes the shareable URL, current status (ACTIVE, INACTIVE, etc.), line items with pricing, taxes, shipping, variant configurations, and return URL settings. + +Payment link ID format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). +`; + +export const updatePaymentLinkPrompt = (context: Context) => ` +Update a payment link on PayPal. + +This function performs a complete replacement of a payment link's configuration using a PUT operation. You must provide all required fields including payment_link_id, type, and line_items. The same limitations apply as when creating a payment link (tax types, shipping types, reusable mode, variants, and pricing rules). + +Note: This is a full replacement operation. All fields must be provided, not just the ones you want to change. +`; + +export const deletePaymentLinkPrompt = (context: Context) => ` +Delete a payment link from PayPal. + +This function permanently deletes a payment link. Once deleted, the shareable link URL will no longer work and the operation cannot be undone. Use with caution. + +Note: PayPal API may return a 500 error instead of 404 for non-existent payment link IDs. This is expected API behavior. `; \ No newline at end of file diff --git a/typescript/src/shared/regex.ts b/typescript/src/shared/regex.ts index 45e45e7..5be7f54 100644 --- a/typescript/src/shared/regex.ts +++ b/typescript/src/shared/regex.ts @@ -7,3 +7,4 @@ export const DISPUTE_ID_REGEX = /^[A-Za-z0-9_-]{1,255}$/; export const REFUND_ID_REGEX = /^[A-Za-z0-9_-]{15,32}$/; export const CAPTURE_ID_REGEX = /^[A-Za-z0-9_-]{15,32}$/; export const TRANSACTION_ID_REGEX = /^[A-Za-z0-9_-]{12,255}$/; +export const PAYMENT_LINK_ID_REGEX = /^PLB-[A-Z0-9]{12,16}$/; diff --git a/typescript/src/shared/tools.ts b/typescript/src/shared/tools.ts index 841903f..cba1eb4 100644 --- a/typescript/src/shared/tools.ts +++ b/typescript/src/shared/tools.ts @@ -32,7 +32,12 @@ import { updateSubscriptionPrompt, getRefundPrompt, createRefundPrompt, - getMerchantInsightsPrompt + getMerchantInsightsPrompt, + createPaymentLinkPrompt, + listPaymentLinksPrompt, + getPaymentLinkPrompt, + updatePaymentLinkPrompt, + deletePaymentLinkPrompt } from './prompts'; import { @@ -67,7 +72,12 @@ import { updateSubscriptionParameters, getRefundParameters, createRefundParameters, - getMerchantInsightsParameters + getMerchantInsightsParameters, + createPaymentLinkParameters, + listPaymentLinksParameters, + getPaymentLinkParameters, + updatePaymentLinkParameters, + deletePaymentLinkParameters } from './parameters'; import type { Context } from './configuration'; @@ -436,6 +446,61 @@ const tools = (context: Context): Tool[] => [ get: true, } } + }, + { + method: 'create_payment_link', + name: 'Create Payment Link', + description: createPaymentLinkPrompt(context), + parameters: createPaymentLinkParameters(context), + actions: { + paymentLinks: { + create: true, + } + } + }, + { + method: 'list_payment_links', + name: 'List Payment Links', + description: listPaymentLinksPrompt(context), + parameters: listPaymentLinksParameters(context), + actions: { + paymentLinks: { + list: true, + } + } + }, + { + method: 'get_payment_link', + name: 'Get Payment Link', + description: getPaymentLinkPrompt(context), + parameters: getPaymentLinkParameters(context), + actions: { + paymentLinks: { + get: true, + } + } + }, + { + method: 'update_payment_link', + name: 'Update Payment Link', + description: updatePaymentLinkPrompt(context), + parameters: updatePaymentLinkParameters(context), + actions: { + paymentLinks: { + update: true, + } + } + }, + { + method: 'delete_payment_link', + name: 'Delete Payment Link', + description: deletePaymentLinkPrompt(context), + parameters: deletePaymentLinkParameters(context), + actions: { + paymentLinks: { + delete: true, + } + } } ]; const allActions = tools({}).reduce((acc, tool) => { From 018eaef897c74ae7e8bb2be2c7422e1eac60a878 Mon Sep 17 00:00:00 2001 From: vikvenkataraman Date: Mon, 1 Dec 2025 16:09:02 -0800 Subject: [PATCH 07/10] add payment link mcp crud tools --- .../shared/payment_links/__init__.py | 2 + .../shared/payment_links/parameters.py | 95 +++++ .../shared/payment_links/prompts.py | 45 +++ .../shared/payment_links/tool_handlers.py | 134 ++++++ .../shared/payment_links/validation.py | 330 +++++++++++++++ .../src/shared/paymentLinkValidation.ts | 382 ++++++++++++++++++ 6 files changed, 988 insertions(+) create mode 100644 python/paypal_agent_toolkit/shared/payment_links/__init__.py create mode 100644 python/paypal_agent_toolkit/shared/payment_links/parameters.py create mode 100644 python/paypal_agent_toolkit/shared/payment_links/prompts.py create mode 100644 python/paypal_agent_toolkit/shared/payment_links/tool_handlers.py create mode 100644 python/paypal_agent_toolkit/shared/payment_links/validation.py create mode 100644 typescript/src/shared/paymentLinkValidation.ts diff --git a/python/paypal_agent_toolkit/shared/payment_links/__init__.py b/python/paypal_agent_toolkit/shared/payment_links/__init__.py new file mode 100644 index 0000000..551f214 --- /dev/null +++ b/python/paypal_agent_toolkit/shared/payment_links/__init__.py @@ -0,0 +1,2 @@ +# Payment Links module + diff --git a/python/paypal_agent_toolkit/shared/payment_links/parameters.py b/python/paypal_agent_toolkit/shared/payment_links/parameters.py new file mode 100644 index 0000000..0f48b8a --- /dev/null +++ b/python/paypal_agent_toolkit/shared/payment_links/parameters.py @@ -0,0 +1,95 @@ +from pydantic import BaseModel, Field, field_validator +from typing import List, Optional, Any, Dict, Literal + + +class UnitAmount(BaseModel): + currency_code: str = Field(..., min_length=3, max_length=3, description="The three-character ISO-4217 currency code (e.g., USD, EUR, GBP, JPY, CAD, AUD). Note: JPY is a zero-decimal currency - use whole numbers without decimal points.") + value: str = Field(..., max_length=32, pattern=r"^((-?[0-9]+)|(-?([0-9]+)?[.][0-9]+))$", description="The monetary value. Supports up to 3 decimal places for most currencies. For JPY, use whole numbers (e.g., '15000' not '15000.00'). Examples: '29.99', '15000', '9.995'") + + +class Tax(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=127, description="Internal label for this tax (e.g., 'Sales Tax', 'VAT', 'GST'). Not visible to customer during checkout.") + type: Literal['PERCENTAGE', 'PREFERENCE'] = Field(..., description="Tax calculation method. PERCENTAGE: percentage of price (e.g., 8.5% tax). PREFERENCE: uses account default tax settings.") + value: str = Field(..., min_length=1, max_length=20, description="Tax value. For PERCENTAGE: enter numeric percentage (e.g., '8.5' for 8.5% tax rate). For PREFERENCE: must be 'PROFILE' to use account defaults. Maximum 20 characters.") + + +class Shipping(BaseModel): + type: Literal['FLAT', 'PREFERENCE'] = Field(..., description="Shipping calculation method. FLAT: fixed amount (e.g., $9.99 flat rate). PREFERENCE: uses account default shipping settings.") + value: str = Field(..., min_length=1, max_length=20, description="Shipping value. For FLAT: enter fixed cost (e.g., '9.99' for $9.99 shipping). For PREFERENCE: must be 'PROFILE' to use account defaults. Maximum 20 characters.") + + +class CustomerNote(BaseModel): + required: Optional[bool] = Field(None, description="When true, customer must provide input before checkout. When false, input is optional. Use for collecting gift messages, custom instructions, engraving text, etc.") + label: Optional[str] = Field(None, min_length=1, max_length=127, description="The label displayed to customers for this custom input field. Examples: 'Gift message', 'Special instructions', 'Engraving text', 'Delivery notes'") + + +class VariantOption(BaseModel): + """Variant option structure - using passthrough to allow flexible fields""" + class Config: + extra = "allow" + + +class Dimension(BaseModel): + name: str = Field(..., min_length=1, max_length=64, description="The name of this variant dimension. Examples: 'Size', 'Color', 'Material', 'Style'. Maximum 64 characters.") + primary: bool = Field(..., description="IMPORTANT: Exactly ONE dimension must have primary: true, all others must be primary: false. The primary dimension typically contains the main product variation and can include pricing in options. Common pattern: Size=primary, Color=non-primary.") + options: List[Dict[str, Any]] = Field(..., min_items=1, max_items=10, description="Array of variant options (1-10 options). Each option can have: label (required), unit_amount (optional - only if no product-level unit_amount). Example: [{'label': 'Small'}, {'label': 'Medium'}, {'label': 'Large'}]") + + +class Variants(BaseModel): + dimensions: List[Dimension] = Field(..., min_items=1, max_items=5, description="List of variant dimensions (1-5 dimensions). Must have exactly 1 primary dimension. Additional dimensions must be non-primary. Example: Size (primary) + Color (non-primary) for t-shirts.") + + +class AdjustableQuantity(BaseModel): + maximum: int = Field(..., ge=1, le=100, description="Maximum quantity customers can select (1-100). Use 1 for limited edition items, higher values for bulk products. Examples: 1 for unique items, 10 for regular products, 100 for wholesale.") + + +class LineItem(BaseModel): + name: str = Field(..., min_length=1, max_length=127, description="The product or service name displayed to customers during checkout") + product_id: Optional[str] = Field(None, min_length=1, max_length=50, description="Your internal identifier for this product or SKU") + description: Optional[str] = Field(None, min_length=1, max_length=2048, description="Detailed information about the product or service shown to customers during checkout") + unit_amount: UnitAmount = Field(..., description="The currency and amount for this line item. IMPORTANT: If using variants with pricing in options, this field should be omitted OR set to the base price. Cannot have unit_amount at both product level and variant option level.") + taxes: Optional[List[Tax]] = Field(None, max_items=1, description="Tax configuration for this item (maximum 1). Tax is displayed separately during checkout.") + shipping: Optional[List[Shipping]] = Field(None, max_items=1, description="Shipping configuration for this item (maximum 1). Shipping fee is displayed separately during checkout.") + collect_shipping_address: Optional[bool] = Field(None, description="Set to true to prompt customers for a shipping address during checkout. Required for physical goods that need to be shipped. Default: false") + customer_notes: Optional[List[CustomerNote]] = Field(None, max_items=1, description="Custom field to collect additional information from customers about this item (maximum 1 field per item). Useful for personalization, special requests, or delivery instructions.") + variants: Optional[Variants] = Field(None, description="Product variants configuration for size, color, material, etc. IMPORTANT: If variants have unit_amount in options, do NOT include product-level unit_amount (causes validation error). If variants have NO pricing, product-level unit_amount is REQUIRED.") + adjustable_quantity: Optional[AdjustableQuantity] = Field(None, description="Enables quantity selection during checkout. Customers can choose from 1 up to the maximum. Useful for allowing bulk purchases or limiting quantities for special items.") + + +# Payment Link ID validation pattern +PAYMENT_LINK_ID_REGEX = r"^PLB-[A-Z0-9]{12,16}$" + + +class CreatePaymentLinkParameters(BaseModel): + integration_mode: str = Field(default="LINK", description="The integration mode for the payment link. Default and recommended: 'LINK'. This determines how the payment link is presented.") + type: str = Field(..., description="The type of payment link. Use 'BUY_NOW' for standard e-commerce purchases (fully supported).") + reusable: Literal['MULTIPLE'] = Field(default="MULTIPLE", description="Whether the link can be used multiple times. Set to 'MULTIPLE' (only supported value for BUY_NOW type). The link can be shared with and used by multiple customers.") + return_url: Optional[str] = Field(None, description="Optional URL to redirect customers after successful payment. Example: 'https://yoursite.com/thank-you'. If omitted, customers stay at PayPal default success page.") + line_items: List[LineItem] = Field(..., min_items=1, description="Array of products/services in this payment link (minimum 1 item required). Each item represents a product with pricing, taxes, shipping, and optional variants.") + + +class ListPaymentLinksParameters(BaseModel): + """Parameters for listing payment links""" + page: Optional[int] = Field(1, ge=1, le=1000, description="Page number to retrieve (1-1000). Default: 1. Use for pagination when you have many payment links.") + page_size: Optional[int] = Field(10, ge=1, le=100, description="Number of payment links per page (1-100). Default: 10. Increase for bulk operations, decrease for faster responses.") + total_required: Optional[bool] = Field(None, description="Set to true to include total count of all payment links in response. Useful for pagination UI. Default: false (faster response).") + + +class GetPaymentLinkParameters(BaseModel): + """Parameters for retrieving a specific payment link""" + payment_link_id: str = Field(..., pattern=PAYMENT_LINK_ID_REGEX, description="The PayPal Payment Link ID to retrieve. Format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). Example: 'PLB-1A2B3C4D5E6F'") + + +class UpdatePaymentLinkParameters(BaseModel): + """Parameters for updating a payment link (full replacement)""" + payment_link_id: str = Field(..., pattern=PAYMENT_LINK_ID_REGEX, description="The PayPal Payment Link ID to update. Format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). Example: 'PLB-1A2B3C4D5E6F'") + integration_mode: str = Field(default="LINK", description="The integration mode for the payment link. Default and recommended: 'LINK'.") + type: str = Field(..., description="The type of payment link. Use 'BUY_NOW' for standard e-commerce purchases (fully supported). Note: 'DONATION' type has limited support and may cause validation errors.") + reusable: Literal['MULTIPLE'] = Field(default="MULTIPLE", description="Whether the link can be used multiple times. Set to 'MULTIPLE' (only supported value for BUY_NOW type). The link can be shared with and used by multiple customers.") + return_url: Optional[str] = Field(None, description="Optional URL to redirect customers after successful payment. Updates the return URL for this payment link.") + line_items: List[LineItem] = Field(..., min_items=1, description="Complete array of line items for the payment link. This is a full replacement - all items must be provided, not just changes.") + + +class DeletePaymentLinkParameters(BaseModel): + """Parameters for deleting a payment link""" + payment_link_id: str = Field(..., pattern=PAYMENT_LINK_ID_REGEX, description="The PayPal Payment Link ID to delete permanently. Format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). ⚠️ WARNING: Deletion is permanent and cannot be undone.") diff --git a/python/paypal_agent_toolkit/shared/payment_links/prompts.py b/python/paypal_agent_toolkit/shared/payment_links/prompts.py new file mode 100644 index 0000000..0d53160 --- /dev/null +++ b/python/paypal_agent_toolkit/shared/payment_links/prompts.py @@ -0,0 +1,45 @@ +CREATE_PAYMENT_LINK_PROMPT = """ +Create a payment link on PayPal. + +This function creates a shareable payment link that can be sent to customers for payment collection. The payment link supports products with pricing, tax calculation, shipping fees, product variants (size, color, material), quantity controls, custom customer notes, and return URLs. + +Important limitations to note: +- Tax types: Only PERCENTAGE and PREFERENCE are supported. FLAT tax type will return a 422 error. +- Shipping types: Only FLAT and PREFERENCE are supported. PERCENTAGE shipping type will return a 422 error. +- Reusable mode: Use "MULTIPLE" for BUY_NOW type. SINGLE mode is not supported in the current version. +- Variants: Must have exactly one primary dimension. Other dimensions must be marked as non-primary. +- Pricing: Cannot specify unit_amount at both product level and variant option level. Choose one approach. +- Type: Use "BUY_NOW" for standard purchases. + +Once created, the link can be shared with customers via email, social media, or embedded on websites. +""" + +LIST_PAYMENT_LINKS_PROMPT = """ +List payment links from PayPal. + +This function retrieves a paginated list of payment links with optional filtering parameters. You can control pagination using page and page_size parameters. Set total_required to true to include the total count of payment links in the response. +""" + +GET_PAYMENT_LINK_PROMPT = """ +Get details of a specific payment link from PayPal. + +This function retrieves comprehensive details about a payment link using its ID. The response includes the shareable URL, current status (ACTIVE, INACTIVE, etc.), line items with pricing, taxes, shipping, variant configurations, and return URL settings. + +Payment link ID format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). +""" + +UPDATE_PAYMENT_LINK_PROMPT = """ +Update a payment link on PayPal. + +This function performs a complete replacement of a payment link's configuration using a PUT operation. You must provide all required fields including payment_link_id, type, and line_items. The same limitations apply as when creating a payment link (tax types, shipping types, reusable mode, variants, and pricing rules). + +Note: This is a full replacement operation. All fields must be provided, not just the ones you want to change. +""" + +DELETE_PAYMENT_LINK_PROMPT = """ +Delete a payment link from PayPal. + +This function permanently deletes a payment link. Once deleted, the shareable link URL will no longer work and the operation cannot be undone. Use with caution. + +Note: PayPal API may return a 500 error instead of 404 for non-existent payment link IDs. This is expected API behavior. +""" diff --git a/python/paypal_agent_toolkit/shared/payment_links/tool_handlers.py b/python/paypal_agent_toolkit/shared/payment_links/tool_handlers.py new file mode 100644 index 0000000..2ea0667 --- /dev/null +++ b/python/paypal_agent_toolkit/shared/payment_links/tool_handlers.py @@ -0,0 +1,134 @@ +from .parameters import ( + CreatePaymentLinkParameters, + ListPaymentLinksParameters, + GetPaymentLinkParameters, + UpdatePaymentLinkParameters, + DeletePaymentLinkParameters +) +import json + + +def create_payment_link(client, params: dict): + """ + Create a payment link on PayPal. + + Args: + client: PayPal client instance + params: Dictionary containing payment link parameters + + Returns: + JSON string with payment link details including the shareable link URL + """ + validated = CreatePaymentLinkParameters(**params) + payload = validated.model_dump() + + # Ensure required fields have defaults + if 'integration_mode' not in payload or not payload['integration_mode']: + payload['integration_mode'] = 'LINK' + if 'reusable' not in payload or not payload['reusable']: + payload['reusable'] = 'MULTIPLE' + + url = "/v1/checkout/payment-resources" + response = client.post(uri=url, payload=payload) + + return json.dumps(response) + + +def list_payment_links(client, params: dict): + """ + List payment links from PayPal. + + Args: + client: PayPal client instance + params: Dictionary containing pagination parameters + + Returns: + JSON string with list of payment links + """ + validated = ListPaymentLinksParameters(**params) + + # Build query string + query_params = [] + if validated.page: + query_params.append(f"page={validated.page}") + if validated.page_size: + query_params.append(f"page_size={validated.page_size}") + if validated.total_required is not None: + query_params.append(f"total_required={str(validated.total_required).lower()}") + + query_string = "&".join(query_params) + url = f"/v1/checkout/payment-resources?{query_string}" if query_string else "/v1/checkout/payment-resources" + + response = client.get(uri=url) + return json.dumps(response) + + +def get_payment_link(client, params: dict): + """ + Get a specific payment link from PayPal. + + Args: + client: PayPal client instance + params: Dictionary containing payment_link_id + + Returns: + JSON string with payment link details + """ + validated = GetPaymentLinkParameters(**params) + url = f"/v1/checkout/payment-resources/{validated.payment_link_id}" + + response = client.get(uri=url) + return json.dumps(response) + + +def update_payment_link(client, params: dict): + """ + Update a payment link on PayPal (full replacement). + + Args: + client: PayPal client instance + params: Dictionary containing payment link parameters + + Returns: + JSON string with updated payment link details + """ + validated = UpdatePaymentLinkParameters(**params) + payment_link_id = validated.payment_link_id + + # Build payload excluding the ID + payload = validated.model_dump(exclude={'payment_link_id'}) + + # Ensure required fields have defaults + if 'integration_mode' not in payload or not payload['integration_mode']: + payload['integration_mode'] = 'LINK' + if 'reusable' not in payload or not payload['reusable']: + payload['reusable'] = 'MULTIPLE' + + url = f"/v1/checkout/payment-resources/{payment_link_id}" + response = client.put(uri=url, payload=payload) + + return json.dumps(response) + + +def delete_payment_link(client, params: dict): + """ + Delete a payment link from PayPal. + + Args: + client: PayPal client instance + params: Dictionary containing payment_link_id + + Returns: + JSON string with deletion confirmation + """ + validated = DeletePaymentLinkParameters(**params) + url = f"/v1/checkout/payment-resources/{validated.payment_link_id}" + + response = client.delete(uri=url) + + # DELETE often returns None for 204 No Content + if response is None: + return json.dumps({"success": True, "payment_link_id": validated.payment_link_id}) + + return json.dumps(response) + diff --git a/python/paypal_agent_toolkit/shared/payment_links/validation.py b/python/paypal_agent_toolkit/shared/payment_links/validation.py new file mode 100644 index 0000000..0ef622e --- /dev/null +++ b/python/paypal_agent_toolkit/shared/payment_links/validation.py @@ -0,0 +1,330 @@ +""" +Validation helper functions for PayPal Payment Links + +These functions help identify API limitations before making requests, +providing clear error messages that align with PayPal API behavior. +""" + +from typing import List, Optional, Dict, Any, TypedDict + + +class ValidationResult(TypedDict): + valid: bool + error: Optional[str] + field: Optional[str] + + +def validate_tax_config(taxes: Optional[List[Dict[str, Any]]]) -> ValidationResult: + """ + Validate tax configuration + + Validates tax values are correct for their type. + Note: Type enum is now restricted at schema level to PERCENTAGE and PREFERENCE only. + """ + if not taxes: + return {"valid": True, "error": None, "field": None} + + # Validate PERCENTAGE values are numeric + percentage_taxes = [t for t in taxes if t.get("type") == "PERCENTAGE"] + for tax in percentage_taxes: + value = tax.get("value", "") + try: + num_value = float(value) + if num_value < 0 or num_value > 100: + return { + "valid": False, + "error": f'Tax percentage value must be a number between 0 and 100. Got: "{value}"', + "field": "taxes[].value" + } + except ValueError: + return { + "valid": False, + "error": f'Tax percentage value must be numeric. Got: "{value}"', + "field": "taxes[].value" + } + + # Validate PREFERENCE values + preference_taxes = [t for t in taxes if t.get("type") == "PREFERENCE"] + for tax in preference_taxes: + value = tax.get("value") + if value != "PROFILE": + return { + "valid": False, + "error": f'Tax PREFERENCE type requires value "PROFILE". Got: "{value}"', + "field": "taxes[].value" + } + + return {"valid": True, "error": None, "field": None} + + +def validate_shipping_config(shipping: Optional[List[Dict[str, Any]]]) -> ValidationResult: + """ + Validate shipping configuration + + Validates shipping values are correct for their type. + Note: Type enum is now restricted at schema level to FLAT and PREFERENCE only. + """ + if not shipping: + return {"valid": True, "error": None, "field": None} + + # Validate FLAT values are numeric + flat_shipping = [s for s in shipping if s.get("type") == "FLAT"] + for ship in flat_shipping: + value = ship.get("value", "") + try: + num_value = float(value) + if num_value < 0: + return { + "valid": False, + "error": f'Shipping FLAT value must be a non-negative number. Got: "{value}"', + "field": "shipping[].value" + } + except ValueError: + return { + "valid": False, + "error": f'Shipping FLAT value must be numeric. Got: "{value}"', + "field": "shipping[].value" + } + + # Validate PREFERENCE values + preference_shipping = [s for s in shipping if s.get("type") == "PREFERENCE"] + for ship in preference_shipping: + value = ship.get("value") + if value != "PROFILE": + return { + "valid": False, + "error": f'Shipping PREFERENCE type requires value "PROFILE". Got: "{value}"', + "field": "shipping[].value" + } + + return {"valid": True, "error": None, "field": None} + + +def validate_variants(line_item: Dict[str, Any]) -> ValidationResult: + """ + Validate variant configuration + + Requirements: + - Must have exactly 1 primary dimension + - Cannot have unit_amount at both product level and variant option level + """ + variants = line_item.get("variants") + if not variants: + return {"valid": True, "error": None, "field": None} + + dimensions = variants.get("dimensions", []) + + # Check primary dimension count + primary_dimensions = [d for d in dimensions if d.get("primary", False)] + + if len(primary_dimensions) == 0: + return { + "valid": False, + "error": "Expected exactly 1 primary dimension, found 0. Set one dimension to primary: true", + "field": "variants.dimensions[].primary" + } + + if len(primary_dimensions) > 1: + return { + "valid": False, + "error": f"Expected exactly 1 primary dimension, found {len(primary_dimensions)}. Only one dimension can have primary: true, others must be primary: false", + "field": "variants.dimensions[].primary" + } + + # Check for pricing conflicts + has_variant_pricing = any( + any(option.get("unit_amount") is not None for option in dim.get("options", [])) + for dim in dimensions + ) + + if has_variant_pricing and line_item.get("unit_amount"): + return { + "valid": False, + "error": "Cannot specify unit_amount at both product level and variant option level. Remove unit_amount from the product level OR from all variant options.", + "field": "unit_amount / variants.dimensions[].options[].unit_amount" + } + + # Validate dimension count + if len(dimensions) < 1 or len(dimensions) > 5: + return { + "valid": False, + "error": f"Variants must have 1-5 dimensions. Found {len(dimensions)}", + "field": "variants.dimensions" + } + + # Validate options count per dimension + for i, dim in enumerate(dimensions): + options = dim.get("options", []) + if len(options) < 1 or len(options) > 10: + return { + "valid": False, + "error": f'Dimension "{dim.get("name")}" must have 1-10 options. Found {len(options)}', + "field": f"variants.dimensions[{i}].options" + } + + return {"valid": True, "error": None, "field": None} + + +def validate_currency(currency_code: str, value: str) -> ValidationResult: + """ + Validate currency code and amount + """ + # Check currency code length + if len(currency_code) != 3: + return { + "valid": False, + "error": f'Currency code must be 3 characters (ISO-4217). Got: "{currency_code}"', + "field": "unit_amount.currency_code" + } + + # Check if value is numeric + try: + num_value = float(value) + except ValueError: + return { + "valid": False, + "error": f'Currency value must be a valid number. Got: "{value}"', + "field": "unit_amount.value" + } + + # Special validation for zero-decimal currencies + zero_decimal_currencies = ['JPY', 'KRW', 'VND', 'CLP', 'TWD', 'PYG'] + + if currency_code.upper() in zero_decimal_currencies: + if '.' in value: + return { + "valid": False, + "error": f'{currency_code} is a zero-decimal currency and should not have decimal places. Use whole numbers (e.g., "15000" not "15000.00")', + "field": "unit_amount.value" + } + + # Check decimal places (max 3) + if '.' in value: + decimal_part = value.split('.')[1] + if len(decimal_part) > 3: + return { + "valid": False, + "error": f'Currency value supports up to 3 decimal places. Got {len(decimal_part)} decimal places in "{value}"', + "field": "unit_amount.value" + } + + return {"valid": True, "error": None, "field": None} + + +def validate_reusable_mode(reusable: str, payment_type: str) -> ValidationResult: + """ + Validate reusable mode + + Note: Reusable is now restricted at schema level to "MULTIPLE" only. + This validation is kept for consistency but should not trigger with proper typing. + """ + if reusable != 'MULTIPLE': + return { + "valid": False, + "error": f'Reusable must be "MULTIPLE". Got: "{reusable}"', + "field": "reusable" + } + + return {"valid": True, "error": None, "field": None} + + +def validate_payment_link_type(payment_type: str) -> ValidationResult: + """ + Validate payment link type + """ + supported_types = ['BUY_NOW', 'DONATION'] + + if payment_type not in supported_types: + return { + "valid": False, + "error": f'Payment link type must be one of: {", ".join(supported_types)}. Got: "{payment_type}"', + "field": "type" + } + + if payment_type == 'DONATION': + return { + "valid": True, + "error": "Warning: DONATION type has limited support and may cause validation errors. Consider using BUY_NOW for standard transactions.", + "field": "type" + } + + return {"valid": True, "error": None, "field": None} + + +def validate_line_item(line_item: Dict[str, Any]) -> ValidationResult: + """ + Validate complete line item + + Runs all validations on a line item. + """ + # Validate taxes + tax_result = validate_tax_config(line_item.get("taxes")) + if not tax_result["valid"]: + return tax_result + + # Validate shipping + shipping_result = validate_shipping_config(line_item.get("shipping")) + if not shipping_result["valid"]: + return shipping_result + + # Validate variants + variants_result = validate_variants(line_item) + if not variants_result["valid"]: + return variants_result + + # Validate currency if unit_amount exists + unit_amount = line_item.get("unit_amount") + if unit_amount: + currency_result = validate_currency( + unit_amount.get("currency_code", ""), + unit_amount.get("value", "") + ) + if not currency_result["valid"]: + return currency_result + elif not line_item.get("variants"): + # If no unit_amount and no variants, that's an error + return { + "valid": False, + "error": "Line item must have either unit_amount (product-level pricing) or variants with pricing in options", + "field": "unit_amount" + } + + return {"valid": True, "error": None, "field": None} + + +def validate_create_payment_link(params: Dict[str, Any]) -> ValidationResult: + """ + Validate complete payment link creation request + """ + # Validate type + payment_type = params.get("type", "") + type_result = validate_payment_link_type(payment_type) + if not type_result["valid"]: + return type_result + + # Validate reusable + reusable_value = params.get("reusable", "MULTIPLE") + reusable_result = validate_reusable_mode(reusable_value, payment_type) + if not reusable_result["valid"]: + return reusable_result + + # Validate line items exist + line_items = params.get("line_items", []) + if not line_items: + return { + "valid": False, + "error": "At least one line item is required", + "field": "line_items" + } + + # Validate each line item + for i, line_item in enumerate(line_items): + item_result = validate_line_item(line_item) + if not item_result["valid"]: + return { + "valid": False, + "error": item_result["error"], + "field": f'line_items[{i}].{item_result["field"]}' + } + + return {"valid": True, "error": None, "field": None} diff --git a/typescript/src/shared/paymentLinkValidation.ts b/typescript/src/shared/paymentLinkValidation.ts new file mode 100644 index 0000000..bd69aa3 --- /dev/null +++ b/typescript/src/shared/paymentLinkValidation.ts @@ -0,0 +1,382 @@ +/** + * Validation helper functions for PayPal Payment Links + * + * These functions help identify API limitations before making requests, + * providing clear error messages that align with PayPal API behavior. + */ + +export interface ValidationResult { + valid: boolean; + error?: string; + field?: string; +} + +export interface Tax { + name?: string; + type: 'PERCENTAGE' | 'PREFERENCE'; + value: string; +} + +export interface Shipping { + type: 'FLAT' | 'PREFERENCE'; + value: string; +} + +export interface Dimension { + name: string; + primary: boolean; + options: Array<{ + label: string; + unit_amount?: { + currency_code: string; + value: string; + }; + [key: string]: any; + }>; +} + +export interface Variants { + dimensions: Dimension[]; +} + +export interface LineItem { + name: string; + unit_amount?: { + currency_code: string; + value: string; + }; + taxes?: Tax[]; + shipping?: Shipping[]; + variants?: Variants; + [key: string]: any; +} + +/** + * Validate tax configuration + * + * Validates tax values are correct for their type. + * Note: Type enum is now restricted at schema level to PERCENTAGE and PREFERENCE only. + */ +export function validateTaxConfig(taxes?: Tax[]): ValidationResult { + if (!taxes || taxes.length === 0) { + return { valid: true }; + } + + // Validate PERCENTAGE values are numeric + const percentageTaxes = taxes.filter(t => t.type === 'PERCENTAGE'); + for (const tax of percentageTaxes) { + const value = parseFloat(tax.value); + if (isNaN(value) || value < 0 || value > 100) { + return { + valid: false, + error: `Tax percentage value must be a number between 0 and 100. Got: "${tax.value}"`, + field: 'taxes[].value' + }; + } + } + + // Validate PREFERENCE values + const preferenceTaxes = taxes.filter(t => t.type === 'PREFERENCE'); + for (const tax of preferenceTaxes) { + if (tax.value !== 'PROFILE') { + return { + valid: false, + error: `Tax PREFERENCE type requires value "PROFILE". Got: "${tax.value}"`, + field: 'taxes[].value' + }; + } + } + + return { valid: true }; +} + +/** + * Validate shipping configuration + * + * Validates shipping values are correct for their type. + * Note: Type enum is now restricted at schema level to FLAT and PREFERENCE only. + */ +export function validateShippingConfig(shipping?: Shipping[]): ValidationResult { + if (!shipping || shipping.length === 0) { + return { valid: true }; + } + + // Validate FLAT values are numeric + const flatShipping = shipping.filter(s => s.type === 'FLAT'); + for (const ship of flatShipping) { + const value = parseFloat(ship.value); + if (isNaN(value) || value < 0) { + return { + valid: false, + error: `Shipping FLAT value must be a non-negative number. Got: "${ship.value}"`, + field: 'shipping[].value' + }; + } + } + + // Validate PREFERENCE values + const preferenceShipping = shipping.filter(s => s.type === 'PREFERENCE'); + for (const ship of preferenceShipping) { + if (ship.value !== 'PROFILE') { + return { + valid: false, + error: `Shipping PREFERENCE type requires value "PROFILE". Got: "${ship.value}"`, + field: 'shipping[].value' + }; + } + } + + return { valid: true }; +} + +/** + * Validate variant configuration + * + * Requirements: + * - Must have exactly 1 primary dimension + * - Cannot have unit_amount at both product level and variant option level + */ +export function validateVariants(lineItem: LineItem): ValidationResult { + if (!lineItem.variants) { + return { valid: true }; + } + + const { dimensions } = lineItem.variants; + + // Check primary dimension count + const primaryDimensions = dimensions.filter(d => d.primary); + + if (primaryDimensions.length === 0) { + return { + valid: false, + error: 'Expected exactly 1 primary dimension, found 0. Set one dimension to primary: true', + field: 'variants.dimensions[].primary' + }; + } + + if (primaryDimensions.length > 1) { + return { + valid: false, + error: `Expected exactly 1 primary dimension, found ${primaryDimensions.length}. Only one dimension can have primary: true, others must be primary: false`, + field: 'variants.dimensions[].primary' + }; + } + + // Check for pricing conflicts + const hasVariantPricing = dimensions.some(d => + d.options.some(o => o.unit_amount !== undefined) + ); + + if (hasVariantPricing && lineItem.unit_amount) { + return { + valid: false, + error: 'Cannot specify unit_amount at both product level and variant option level. Remove unit_amount from the product level OR from all variant options.', + field: 'unit_amount / variants.dimensions[].options[].unit_amount' + }; + } + + // Validate dimension count + if (dimensions.length < 1 || dimensions.length > 5) { + return { + valid: false, + error: `Variants must have 1-5 dimensions. Found ${dimensions.length}`, + field: 'variants.dimensions' + }; + } + + // Validate options count per dimension + for (let i = 0; i < dimensions.length; i++) { + const dim = dimensions[i]; + if (dim.options.length < 1 || dim.options.length > 10) { + return { + valid: false, + error: `Dimension "${dim.name}" must have 1-10 options. Found ${dim.options.length}`, + field: `variants.dimensions[${i}].options` + }; + } + } + + return { valid: true }; +} + +/** + * Validate currency code and amount + */ +export function validateCurrency(currencyCode: string, value: string): ValidationResult { + // Check currency code length + if (currencyCode.length !== 3) { + return { + valid: false, + error: `Currency code must be 3 characters (ISO-4217). Got: "${currencyCode}"`, + field: 'unit_amount.currency_code' + }; + } + + // Check if value is numeric + const numValue = parseFloat(value); + if (isNaN(numValue)) { + return { + valid: false, + error: `Currency value must be a valid number. Got: "${value}"`, + field: 'unit_amount.value' + }; + } + + // Special validation for zero-decimal currencies (JPY, KRW, etc.) + const zeroDecimalCurrencies = ['JPY', 'KRW', 'VND', 'CLP', 'TWD', 'PYG']; + + if (zeroDecimalCurrencies.includes(currencyCode.toUpperCase())) { + if (value.includes('.')) { + return { + valid: false, + error: `${currencyCode} is a zero-decimal currency and should not have decimal places. Use whole numbers (e.g., "15000" not "15000.00")`, + field: 'unit_amount.value' + }; + } + } + + // Check decimal places (max 3) + const decimalPart = value.split('.')[1]; + if (decimalPart && decimalPart.length > 3) { + return { + valid: false, + error: `Currency value supports up to 3 decimal places. Got ${decimalPart.length} decimal places in "${value}"`, + field: 'unit_amount.value' + }; + } + + return { valid: true }; +} + +/** + * Validate reusable mode + * + * Note: Reusable is now restricted at schema level to "MULTIPLE" only. + * This validation is kept for consistency but should not trigger with proper typing. + */ +export function validateReusableMode(reusable: string, type: string): ValidationResult { + if (reusable !== 'MULTIPLE') { + return { + valid: false, + error: `Reusable must be "MULTIPLE". Got: "${reusable}"`, + field: 'reusable' + }; + } + + return { valid: true }; +} + +/** + * Validate payment link type + */ +export function validatePaymentLinkType(type: string): ValidationResult { + const supportedTypes = ['BUY_NOW', 'DONATION']; + + if (!supportedTypes.includes(type)) { + return { + valid: false, + error: `Payment link type must be one of: ${supportedTypes.join(', ')}. Got: "${type}"`, + field: 'type' + }; + } + + if (type === 'DONATION') { + return { + valid: true, // Warning, not error + error: 'Warning: DONATION type has limited support and may cause validation errors. Consider using BUY_NOW for standard transactions.', + field: 'type' + }; + } + + return { valid: true }; +} + +/** + * Validate complete line item + * + * Runs all validations on a line item. + */ +export function validateLineItem(lineItem: LineItem): ValidationResult { + // Validate taxes + const taxResult = validateTaxConfig(lineItem.taxes); + if (!taxResult.valid) { + return taxResult; + } + + // Validate shipping + const shippingResult = validateShippingConfig(lineItem.shipping); + if (!shippingResult.valid) { + return shippingResult; + } + + // Validate variants + const variantsResult = validateVariants(lineItem); + if (!variantsResult.valid) { + return variantsResult; + } + + // Validate currency if unit_amount exists + if (lineItem.unit_amount) { + const currencyResult = validateCurrency( + lineItem.unit_amount.currency_code, + lineItem.unit_amount.value + ); + if (!currencyResult.valid) { + return currencyResult; + } + } else if (!lineItem.variants) { + // If no unit_amount and no variants, that's an error + return { + valid: false, + error: 'Line item must have either unit_amount (product-level pricing) or variants with pricing in options', + field: 'unit_amount' + }; + } + + return { valid: true }; +} + +/** + * Validate complete payment link creation request + */ +export function validateCreatePaymentLink(params: { + type: string; + reusable?: string; + line_items: LineItem[]; + [key: string]: any; +}): ValidationResult { + // Validate type + const typeResult = validatePaymentLinkType(params.type); + if (!typeResult.valid) { + return typeResult; + } + + // Validate reusable + const reusableValue = params.reusable || 'MULTIPLE'; + const reusableResult = validateReusableMode(reusableValue, params.type); + if (!reusableResult.valid) { + return reusableResult; + } + + // Validate line items exist + if (!params.line_items || params.line_items.length === 0) { + return { + valid: false, + error: 'At least one line item is required', + field: 'line_items' + }; + } + + // Validate each line item + for (let i = 0; i < params.line_items.length; i++) { + const itemResult = validateLineItem(params.line_items[i]); + if (!itemResult.valid) { + return { + ...itemResult, + field: `line_items[${i}].${itemResult.field}` + }; + } + } + + return { valid: true }; +} From dd633c2f2704b76035538981c9a87901e1747e51 Mon Sep 17 00:00:00 2001 From: vikvenkataraman Date: Fri, 5 Dec 2025 10:02:51 -0800 Subject: [PATCH 08/10] minor updates for better prompts --- .../paypal_agent_toolkit/shared/payment_links/__init__.py | 1 + .../shared/payment_links/parameters.py | 8 ++++---- typescript/src/shared/parameters.ts | 8 ++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/python/paypal_agent_toolkit/shared/payment_links/__init__.py b/python/paypal_agent_toolkit/shared/payment_links/__init__.py index 551f214..793099a 100644 --- a/python/paypal_agent_toolkit/shared/payment_links/__init__.py +++ b/python/paypal_agent_toolkit/shared/payment_links/__init__.py @@ -1,2 +1,3 @@ # Payment Links module + diff --git a/python/paypal_agent_toolkit/shared/payment_links/parameters.py b/python/paypal_agent_toolkit/shared/payment_links/parameters.py index 0f48b8a..0fd0083 100644 --- a/python/paypal_agent_toolkit/shared/payment_links/parameters.py +++ b/python/paypal_agent_toolkit/shared/payment_links/parameters.py @@ -63,9 +63,9 @@ class LineItem(BaseModel): class CreatePaymentLinkParameters(BaseModel): integration_mode: str = Field(default="LINK", description="The integration mode for the payment link. Default and recommended: 'LINK'. This determines how the payment link is presented.") type: str = Field(..., description="The type of payment link. Use 'BUY_NOW' for standard e-commerce purchases (fully supported).") - reusable: Literal['MULTIPLE'] = Field(default="MULTIPLE", description="Whether the link can be used multiple times. Set to 'MULTIPLE' (only supported value for BUY_NOW type). The link can be shared with and used by multiple customers.") - return_url: Optional[str] = Field(None, description="Optional URL to redirect customers after successful payment. Example: 'https://yoursite.com/thank-you'. If omitted, customers stay at PayPal default success page.") - line_items: List[LineItem] = Field(..., min_items=1, description="Array of products/services in this payment link (minimum 1 item required). Each item represents a product with pricing, taxes, shipping, and optional variants.") + reusable: Literal['MULTIPLE'] = Field(default="MULTIPLE", description="Determines link reusability. For BUY_NOW type, must be set to 'MULTIPLE' to enable sharing and multiple uses across different customers.") + return_url: Optional[str] = Field(None, description="Optional URL to redirect customers after successful payment. Example: 'https://yoursite.com/thank-you'. If omitted, customers will stay in PayPal default success page") + line_items: List[LineItem] = Field(..., min_items=1, description="AArray of products/services in this payment link. Currently supports exactly 1 item. Each item represents a product with pricing, taxes, shipping, and optional variants.") class ListPaymentLinksParameters(BaseModel): @@ -85,7 +85,7 @@ class UpdatePaymentLinkParameters(BaseModel): payment_link_id: str = Field(..., pattern=PAYMENT_LINK_ID_REGEX, description="The PayPal Payment Link ID to update. Format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). Example: 'PLB-1A2B3C4D5E6F'") integration_mode: str = Field(default="LINK", description="The integration mode for the payment link. Default and recommended: 'LINK'.") type: str = Field(..., description="The type of payment link. Use 'BUY_NOW' for standard e-commerce purchases (fully supported). Note: 'DONATION' type has limited support and may cause validation errors.") - reusable: Literal['MULTIPLE'] = Field(default="MULTIPLE", description="Whether the link can be used multiple times. Set to 'MULTIPLE' (only supported value for BUY_NOW type). The link can be shared with and used by multiple customers.") + reusable: Literal['MULTIPLE'] = Field(default="MULTIPLE", description="Determines link reusability. For BUY_NOW type, must be set to 'MULTIPLE' to enable sharing and multiple uses across different customers.") return_url: Optional[str] = Field(None, description="Optional URL to redirect customers after successful payment. Updates the return URL for this payment link.") line_items: List[LineItem] = Field(..., min_items=1, description="Complete array of line items for the payment link. This is a full replacement - all items must be provided, not just changes.") diff --git a/typescript/src/shared/parameters.ts b/typescript/src/shared/parameters.ts index eb9b311..051fe74 100644 --- a/typescript/src/shared/parameters.ts +++ b/typescript/src/shared/parameters.ts @@ -466,9 +466,9 @@ const paymentLinkLineItem = z.object({ export const createPaymentLinkParameters = (context: Context) => z.object({ integration_mode: z.string().optional().default('LINK').describe('The integration mode for the payment link. Default and recommended: "LINK". This determines how the payment link is presented.'), type: z.string().describe('The type of payment link. Use "BUY_NOW" for standard e-commerce purchases (fully supported). Note: "DONATION" type has limited support and may cause validation errors.'), - reusable: z.literal('MULTIPLE').optional().default('MULTIPLE').describe('Whether the link can be used multiple times. Set to "MULTIPLE" (only supported value for BUY_NOW type). The link can be shared with and used by multiple customers.'), - return_url: z.string().optional().describe('Optional URL to redirect customers after successful payment. Example: "https://yoursite.com/thank-you". If omitted, customers see PayPal default success page.'), - line_items: z.array(paymentLinkLineItem).min(1).describe('Array of products/services in this payment link (minimum 1 item required). Each item represents a product with pricing, taxes, shipping, and optional variants.'), + reusable: z.literal('MULTIPLE').optional().default('MULTIPLE').describe('Determines link reusability. For BUY_NOW type, must be set to "MULTIPLE" to enable sharing and multiple uses across different customers.'), + return_url: z.string().optional().describe('Optional URL to redirect customers after successful payment. Example: "https://yoursite.com/thank-you". If omitted, customers will stay in PayPal default success page'), + line_items: z.array(paymentLinkLineItem).min(1).describe('Array of products/services in this payment link. Currently supports exactly 1 item. Each item represents a product with pricing, taxes, shipping, and optional variants.'), }).describe('Parameters for creating a new PayPal payment link') export const listPaymentLinksParameters = (context: Context) => z.object({ @@ -485,7 +485,7 @@ export const updatePaymentLinkParameters = (context: Context) => z.object({ payment_link_id: z.string().regex(PAYMENT_LINK_ID_REGEX, "Invalid PayPal Payment Link ID").describe('The PayPal Payment Link ID to update. Format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). Example: "PLB-1A2B3C4D5E6F"'), type: z.string().describe('The type of payment link. Use "BUY_NOW" for standard e-commerce purchases (fully supported). Note: "DONATION" type has limited support and may cause validation errors.'), integration_mode: z.string().optional().default('LINK').describe('The integration mode for the payment link. Default and recommended: "LINK".'), - reusable: z.literal('MULTIPLE').optional().default('MULTIPLE').describe('Whether the link can be used multiple times. Set to "MULTIPLE" (only supported value for BUY_NOW type). The link can be shared with and used by multiple customers.'), + reusable: z.literal('MULTIPLE').optional().default('MULTIPLE').describe('Determines link reusability. For BUY_NOW type, must be set to "MULTIPLE" to enable sharing and multiple uses across different customers.'), return_url: z.string().optional().describe('Optional URL to redirect customers after successful payment. Updates the return URL for this payment link.'), line_items: z.array(paymentLinkLineItem).min(1).describe('Complete array of line items for the payment link. This is a full replacement - all items must be provided, not just changes.'), }) From c1692ac92b0524de5ae3fad9baff6189def58c0b Mon Sep 17 00:00:00 2001 From: vikvenkataraman Date: Fri, 5 Dec 2025 11:09:54 -0800 Subject: [PATCH 09/10] fixing typos --- .../paypal_agent_toolkit/shared/payment_links/parameters.py | 4 ++-- typescript/src/shared/parameters.ts | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/python/paypal_agent_toolkit/shared/payment_links/parameters.py b/python/paypal_agent_toolkit/shared/payment_links/parameters.py index 0fd0083..c581941 100644 --- a/python/paypal_agent_toolkit/shared/payment_links/parameters.py +++ b/python/paypal_agent_toolkit/shared/payment_links/parameters.py @@ -64,8 +64,8 @@ class CreatePaymentLinkParameters(BaseModel): integration_mode: str = Field(default="LINK", description="The integration mode for the payment link. Default and recommended: 'LINK'. This determines how the payment link is presented.") type: str = Field(..., description="The type of payment link. Use 'BUY_NOW' for standard e-commerce purchases (fully supported).") reusable: Literal['MULTIPLE'] = Field(default="MULTIPLE", description="Determines link reusability. For BUY_NOW type, must be set to 'MULTIPLE' to enable sharing and multiple uses across different customers.") - return_url: Optional[str] = Field(None, description="Optional URL to redirect customers after successful payment. Example: 'https://yoursite.com/thank-you'. If omitted, customers will stay in PayPal default success page") - line_items: List[LineItem] = Field(..., min_items=1, description="AArray of products/services in this payment link. Currently supports exactly 1 item. Each item represents a product with pricing, taxes, shipping, and optional variants.") + return_url: Optional[str] = Field(None, description="Optional URL to redirect customers after successful payment. Example: 'https://yoursite.com/thank-you'. If omitted, customers will stay on the PayPal default success page") + line_items: List[LineItem] = Field(..., min_items=1, description="Array of products/services in this payment link. Currently supports exactly 1 item. Each item represents a product with pricing, taxes, shipping, and optional variants.") class ListPaymentLinksParameters(BaseModel): diff --git a/typescript/src/shared/parameters.ts b/typescript/src/shared/parameters.ts index 051fe74..bb8aa5a 100644 --- a/typescript/src/shared/parameters.ts +++ b/typescript/src/shared/parameters.ts @@ -459,15 +459,14 @@ const paymentLinkLineItem = z.object({ }).optional().describe('Product variants configuration for size, color, material, etc. IMPORTANT: If variants have unit_amount in options, do NOT include product-level unit_amount (causes validation error). If variants have NO pricing, product-level unit_amount is REQUIRED.'), adjustable_quantity: z.object({ maximum: z.number().int().min(1).max(100).describe('Maximum quantity customers can select (1-100). Use 1 for limited edition items, higher values for bulk products. Examples: 1 for unique items, 10 for regular products, 100 for wholesale.'), - }).optional().describe('Enables quantity selection during checkout. Customers can choose from 1 up to the maximum. Useful for allowing bulk purchases or limiting quantities for special items.'), - return_url: z.string().optional().describe('The URL to redirect to after payment completion') + }).optional().describe('Enables quantity selection during checkout. Customers can choose from 1 up to the maximum. Useful for allowing bulk purchases or limiting quantities for special items.') }).describe('Payment link line item'); export const createPaymentLinkParameters = (context: Context) => z.object({ integration_mode: z.string().optional().default('LINK').describe('The integration mode for the payment link. Default and recommended: "LINK". This determines how the payment link is presented.'), type: z.string().describe('The type of payment link. Use "BUY_NOW" for standard e-commerce purchases (fully supported). Note: "DONATION" type has limited support and may cause validation errors.'), reusable: z.literal('MULTIPLE').optional().default('MULTIPLE').describe('Determines link reusability. For BUY_NOW type, must be set to "MULTIPLE" to enable sharing and multiple uses across different customers.'), - return_url: z.string().optional().describe('Optional URL to redirect customers after successful payment. Example: "https://yoursite.com/thank-you". If omitted, customers will stay in PayPal default success page'), + return_url: z.string().optional().describe('Optional URL to redirect customers after successful payment. Example: "https://yoursite.com/thank-you". If omitted, customers will stay on the PayPal default success page'), line_items: z.array(paymentLinkLineItem).min(1).describe('Array of products/services in this payment link. Currently supports exactly 1 item. Each item represents a product with pricing, taxes, shipping, and optional variants.'), }).describe('Parameters for creating a new PayPal payment link') From ab3b779b4629dcc9e003d51bff7cb826071b0b6c Mon Sep 17 00:00:00 2001 From: vikvenkataraman Date: Wed, 10 Dec 2025 09:41:28 -0800 Subject: [PATCH 10/10] update pr review updates --- .../shared/payment_links/parameters.py | 2 +- .../shared/payment_links/prompts.py | 19 ++++++++++++++++--- .../shared/payment_links/validation.py | 9 +-------- typescript/src/shared/parameters.ts | 4 ++-- .../src/shared/paymentLinkValidation.ts | 10 +--------- typescript/src/shared/prompts.ts | 19 ++++++++++++++++--- 6 files changed, 37 insertions(+), 26 deletions(-) diff --git a/python/paypal_agent_toolkit/shared/payment_links/parameters.py b/python/paypal_agent_toolkit/shared/payment_links/parameters.py index c581941..e788fe8 100644 --- a/python/paypal_agent_toolkit/shared/payment_links/parameters.py +++ b/python/paypal_agent_toolkit/shared/payment_links/parameters.py @@ -84,7 +84,7 @@ class UpdatePaymentLinkParameters(BaseModel): """Parameters for updating a payment link (full replacement)""" payment_link_id: str = Field(..., pattern=PAYMENT_LINK_ID_REGEX, description="The PayPal Payment Link ID to update. Format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). Example: 'PLB-1A2B3C4D5E6F'") integration_mode: str = Field(default="LINK", description="The integration mode for the payment link. Default and recommended: 'LINK'.") - type: str = Field(..., description="The type of payment link. Use 'BUY_NOW' for standard e-commerce purchases (fully supported). Note: 'DONATION' type has limited support and may cause validation errors.") + type: str = Field(..., description="The type of payment link. Use 'BUY_NOW' for standard e-commerce purchases.") reusable: Literal['MULTIPLE'] = Field(default="MULTIPLE", description="Determines link reusability. For BUY_NOW type, must be set to 'MULTIPLE' to enable sharing and multiple uses across different customers.") return_url: Optional[str] = Field(None, description="Optional URL to redirect customers after successful payment. Updates the return URL for this payment link.") line_items: List[LineItem] = Field(..., min_items=1, description="Complete array of line items for the payment link. This is a full replacement - all items must be provided, not just changes.") diff --git a/python/paypal_agent_toolkit/shared/payment_links/prompts.py b/python/paypal_agent_toolkit/shared/payment_links/prompts.py index 0d53160..bd7c05d 100644 --- a/python/paypal_agent_toolkit/shared/payment_links/prompts.py +++ b/python/paypal_agent_toolkit/shared/payment_links/prompts.py @@ -37,9 +37,22 @@ """ DELETE_PAYMENT_LINK_PROMPT = """ -Delete a payment link from PayPal. +Delete a payment link created through the PayPal Pay Links & Buttons API. -This function permanently deletes a payment link. Once deleted, the shareable link URL will no longer work and the operation cannot be undone. Use with caution. +This operation deactivates the payment link so it can no longer be used by customers. +Once deleted, the shareable link URL will stop working. -Note: PayPal API may return a 500 error instead of 404 for non-existent payment link IDs. This is expected API behavior. +### Important notes +- Deletion removes the payment resource from active use. A deleted link cannot be used for future payments. +- If an invalid or non-existent payment link ID is provided, the PayPal API may return a 404 Not Found error. +- This operation does not affect completed transactions tied to the link. + +### Required fields +- `id`: Unique identifier of the payment link you want to delete. + +### Example usage +To delete a payment link: +delete_payment_link( +id="PR-1234567890" +) """ diff --git a/python/paypal_agent_toolkit/shared/payment_links/validation.py b/python/paypal_agent_toolkit/shared/payment_links/validation.py index 0ef622e..9f88976 100644 --- a/python/paypal_agent_toolkit/shared/payment_links/validation.py +++ b/python/paypal_agent_toolkit/shared/payment_links/validation.py @@ -232,7 +232,7 @@ def validate_payment_link_type(payment_type: str) -> ValidationResult: """ Validate payment link type """ - supported_types = ['BUY_NOW', 'DONATION'] + supported_types = ['BUY_NOW'] if payment_type not in supported_types: return { @@ -241,13 +241,6 @@ def validate_payment_link_type(payment_type: str) -> ValidationResult: "field": "type" } - if payment_type == 'DONATION': - return { - "valid": True, - "error": "Warning: DONATION type has limited support and may cause validation errors. Consider using BUY_NOW for standard transactions.", - "field": "type" - } - return {"valid": True, "error": None, "field": None} diff --git a/typescript/src/shared/parameters.ts b/typescript/src/shared/parameters.ts index bb8aa5a..738f660 100644 --- a/typescript/src/shared/parameters.ts +++ b/typescript/src/shared/parameters.ts @@ -464,7 +464,7 @@ const paymentLinkLineItem = z.object({ export const createPaymentLinkParameters = (context: Context) => z.object({ integration_mode: z.string().optional().default('LINK').describe('The integration mode for the payment link. Default and recommended: "LINK". This determines how the payment link is presented.'), - type: z.string().describe('The type of payment link. Use "BUY_NOW" for standard e-commerce purchases (fully supported). Note: "DONATION" type has limited support and may cause validation errors.'), + type: z.string().describe('The type of payment link. Use "BUY_NOW" for standard e-commerce purchases.'), reusable: z.literal('MULTIPLE').optional().default('MULTIPLE').describe('Determines link reusability. For BUY_NOW type, must be set to "MULTIPLE" to enable sharing and multiple uses across different customers.'), return_url: z.string().optional().describe('Optional URL to redirect customers after successful payment. Example: "https://yoursite.com/thank-you". If omitted, customers will stay on the PayPal default success page'), line_items: z.array(paymentLinkLineItem).min(1).describe('Array of products/services in this payment link. Currently supports exactly 1 item. Each item represents a product with pricing, taxes, shipping, and optional variants.'), @@ -482,7 +482,7 @@ export const getPaymentLinkParameters = (context: Context) => z.object({ export const updatePaymentLinkParameters = (context: Context) => z.object({ payment_link_id: z.string().regex(PAYMENT_LINK_ID_REGEX, "Invalid PayPal Payment Link ID").describe('The PayPal Payment Link ID to update. Format: PLB-XXXXXXXXXXXX (PLB- prefix followed by 12-16 alphanumeric characters). Example: "PLB-1A2B3C4D5E6F"'), - type: z.string().describe('The type of payment link. Use "BUY_NOW" for standard e-commerce purchases (fully supported). Note: "DONATION" type has limited support and may cause validation errors.'), + type: z.string().describe('The type of payment link. Use "BUY_NOW" for standard e-commerce purchases.'), integration_mode: z.string().optional().default('LINK').describe('The integration mode for the payment link. Default and recommended: "LINK".'), reusable: z.literal('MULTIPLE').optional().default('MULTIPLE').describe('Determines link reusability. For BUY_NOW type, must be set to "MULTIPLE" to enable sharing and multiple uses across different customers.'), return_url: z.string().optional().describe('Optional URL to redirect customers after successful payment. Updates the return URL for this payment link.'), diff --git a/typescript/src/shared/paymentLinkValidation.ts b/typescript/src/shared/paymentLinkValidation.ts index bd69aa3..b567e90 100644 --- a/typescript/src/shared/paymentLinkValidation.ts +++ b/typescript/src/shared/paymentLinkValidation.ts @@ -270,7 +270,7 @@ export function validateReusableMode(reusable: string, type: string): Validation * Validate payment link type */ export function validatePaymentLinkType(type: string): ValidationResult { - const supportedTypes = ['BUY_NOW', 'DONATION']; + const supportedTypes = ['BUY_NOW']; if (!supportedTypes.includes(type)) { return { @@ -280,14 +280,6 @@ export function validatePaymentLinkType(type: string): ValidationResult { }; } - if (type === 'DONATION') { - return { - valid: true, // Warning, not error - error: 'Warning: DONATION type has limited support and may cause validation errors. Consider using BUY_NOW for standard transactions.', - field: 'type' - }; - } - return { valid: true }; } diff --git a/typescript/src/shared/prompts.ts b/typescript/src/shared/prompts.ts index e4f549f..90cb8e0 100644 --- a/typescript/src/shared/prompts.ts +++ b/typescript/src/shared/prompts.ts @@ -322,9 +322,22 @@ Note: This is a full replacement operation. All fields must be provided, not jus `; export const deletePaymentLinkPrompt = (context: Context) => ` -Delete a payment link from PayPal. +Delete a payment link created through the PayPal Pay Links & Buttons API. -This function permanently deletes a payment link. Once deleted, the shareable link URL will no longer work and the operation cannot be undone. Use with caution. +This operation deactivates the payment link so it can no longer be used by customers. +Once deleted, the shareable link URL will stop working. -Note: PayPal API may return a 500 error instead of 404 for non-existent payment link IDs. This is expected API behavior. +### Important notes +- Deletion removes the payment resource from active use. A deleted link cannot be used for future payments. +- If an invalid or non-existent payment link ID is provided, the PayPal API may return a 404 Not Found error. +- This operation does not affect completed transactions tied to the link. + +### Required fields +- id: Unique identifier of the payment link you want to delete. + +### Example usage +To delete a payment link: +delete_payment_link( +id="PR-1234567890" +) `; \ No newline at end of file