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/payment_links/__init__.py b/python/paypal_agent_toolkit/shared/payment_links/__init__.py new file mode 100644 index 0000000..793099a --- /dev/null +++ b/python/paypal_agent_toolkit/shared/payment_links/__init__.py @@ -0,0 +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 new file mode 100644 index 0000000..e788fe8 --- /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="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 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): + """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.") + 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.") + + +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..bd7c05d --- /dev/null +++ b/python/paypal_agent_toolkit/shared/payment_links/prompts.py @@ -0,0 +1,58 @@ +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 created through the PayPal Pay Links & Buttons API. + +This operation deactivates the payment link so it can no longer be used by customers. +Once deleted, the shareable link URL will stop working. + +### 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/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..9f88976 --- /dev/null +++ b/python/paypal_agent_toolkit/shared/payment_links/validation.py @@ -0,0 +1,323 @@ +""" +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'] + + 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" + } + + 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/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..738f660 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,72 @@ 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.') +}).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.'), + 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.'), +}).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.'), + 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.'), + 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/paymentLinkValidation.ts b/typescript/src/shared/paymentLinkValidation.ts new file mode 100644 index 0000000..b567e90 --- /dev/null +++ b/typescript/src/shared/paymentLinkValidation.ts @@ -0,0 +1,374 @@ +/** + * 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']; + + if (!supportedTypes.includes(type)) { + return { + valid: false, + error: `Payment link type must be one of: ${supportedTypes.join(', ')}. Got: "${type}"`, + 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 }; +} diff --git a/typescript/src/shared/prompts.ts b/typescript/src/shared/prompts.ts index 9e03f39..90cb8e0 100644 --- a/typescript/src/shared/prompts.ts +++ b/typescript/src/shared/prompts.ts @@ -279,4 +279,65 @@ 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 created through the PayPal Pay Links & Buttons API. + +This operation deactivates the payment link so it can no longer be used by customers. +Once deleted, the shareable link URL will stop working. + +### 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 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) => {