Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions python/paypal_agent_toolkit/shared/payment_links/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Payment Links module


95 changes: 95 additions & 0 deletions python/paypal_agent_toolkit/shared/payment_links/parameters.py
Original file line number Diff line number Diff line change
@@ -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'.")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove 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.")


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.")
58 changes: 58 additions & 0 deletions python/paypal_agent_toolkit/shared/payment_links/prompts.py
Original file line number Diff line number Diff line change
@@ -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"
)
"""
134 changes: 134 additions & 0 deletions python/paypal_agent_toolkit/shared/payment_links/tool_handlers.py
Original file line number Diff line number Diff line change
@@ -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)

Loading