diff --git a/PROMPTS.md b/PROMPTS.md index 4583b41a..75384224 100644 --- a/PROMPTS.md +++ b/PROMPTS.md @@ -75,6 +75,100 @@ The PayPal Agent toolkit provides the following tools: > **Example Prompt**: > Generate a QR code for invoice {invoice_id} +--- + +**`delete_invoice`** - Permanently deletes a draft or scheduled invoice. Does not work on invoices that have already been sent -- use `cancel_sent_invoice` for those instead. + +- `invoice_id` (string, required): The ID of the draft or scheduled invoice to delete. + +> **Example Prompt**: +> Delete invoice {invoice_id} + +--- + +**`generate_invoice_number`** - Generates the next invoice number available to the merchant, based on the prefix/suffix and numeric portion of their last invoice number. + +- No parameters required. + +> **Example Prompt**: +> Generate the next invoice number for me + +--- + +**`search_invoicing`** - Searches for invoices or recurring invoice series. Set `resource_type` to `"invoice"` with `invoice_filters`, or `"recurring_series"` with `recurring_series_filters` -- only set the matching filters object. + +- `resource_type` (string, required): `"invoice"` or `"recurring_series"`. +- `page` (number, optional): The page number of the result set to fetch. Defaults to 1. +- `page_size` (number, optional): The number of records to return per page (max 100). Defaults to 20. +- `total_required` (boolean, optional): Whether to include total_pages/total_items. Only applies to invoice search. +- `invoice_filters` (object, optional): Filters such as `recipient_email`, `status`, `invoice_number`, `total_amount_range`, `invoice_date_range`. Set only when `resource_type` is `"invoice"`. +- `recurring_series_filters` (object, optional): Filters such as `search_text`, `search_fields`, and structured `search_filters` (status, currency_code, date ranges). Set only when `resource_type` is `"recurring_series"`. Covers only the past 3 years. + +> **Example Prompt**: +> Search for all PAID invoices for recipient {recipient_email} +> Search for active recurring series with a total amount between {lower_amount} and {upper_amount} + +--- + +**`update_invoicing`** - Updates an existing invoice or recurring invoice series. This is a full-replacement update -- resend the complete invoice/series content, not just the changed fields. Set `resource_type` to `"invoice"` with `invoice_update`, or `"recurring_series"` with `recurring_series_update` -- only set the matching object. + +- `resource_type` (string, required): `"invoice"` or `"recurring_series"`. +- `invoice_update` (object, optional): Full replacement content for the invoice, including `invoice_id`, plus everything `create_invoice` accepts. Set only when `resource_type` is `"invoice"`. The recipient (`primary_recipients`) can only be changed 2 times within any 72-hour window. +- `recurring_series_update` (object, optional): Full replacement content for the series, including `recurring_series_id`, plus everything `create_recurring_series` accepts. Set only when `resource_type` is `"recurring_series"`. + +> **Example Prompt**: +> Update invoice {invoice_id} to change the due date to {due_date} +> Update recurring series {recurring_series_id} to change the billing amount to {amount} + +--- + +**`cancel_invoice_auto_reminder`** - Permanently cancels every automatic reminder scheduled for a specific invoice. This action is irreversible. + +- `invoice_id` (string, required): The ID of the invoice for which to cancel all scheduled automatic reminders. + +> **Example Prompt**: +> Cancel all auto reminders for invoice {invoice_id} + +--- + +**`record_payment_for_invoice`** - Records an external or manual payment (cash, check, bank transfer, or a PayPal transaction) against an invoice. Does not process a new payment -- only logs one that was already collected. + +- `invoice_id` (string, required): The ID of the invoice to record the payment against. +- `method` (string, required): The payment method. One of `BANK_TRANSFER`, `CASH`, `CHECK`, `CREDIT_CARD`, `DEBIT_CARD`, `PAYPAL`, `WIRE_TRANSFER`, `OTHER`. +- `payment_id` (string, optional): The ID of a PayPal payment transaction. Required for the `PAYPAL` payment type. +- `payment_date` (string, optional): The date the payment was recorded, in `yyyy-MM-dd` format. +- `amount` (object, optional): The currency and amount for the payment. +- `note` (string, optional): A note associated with an external cash or check payment. + +> **Example Prompt**: +> Record a cash payment of {amount} {currency} against invoice {invoice_id} + +--- + +**`record_refund_for_invoice`** - Records a refund against an invoice. Does not process a new refund -- only logs one that was already issued. + +- `invoice_id` (string, required): The ID of the invoice to mark as refunded. +- `method` (string, required): The refund method. One of `BANK_TRANSFER`, `CASH`, `CHECK`, `CREDIT_CARD`, `DEBIT_CARD`, `PAYPAL`, `WIRE_TRANSFER`, `OTHER`. +- `refund_date` (string, optional): The date the refund was recorded, in `yyyy-MM-dd` format. +- `amount` (object, optional): The currency and amount for the refund. + +> **Example Prompt**: +> Record a refund of {amount} {currency} for invoice {invoice_id} + +--- + +**`create_conditional_rules_for_invoice`** - Creates conditional rules on an invoice, such as an early payment discount or an automatic cancellation date. + +- `invoice_id` (string, required): The ID of the invoice for which the conditional rules are to be created. +- `rules` (array, required): The list of conditional rules to create. + - `conditional_rule_type` (string, required): `EARLY_PAYMENT_DISCOUNT` or `AUTO_CANCEL`. + - `conditional_rule_value_type` (string, required only for `EARLY_PAYMENT_DISCOUNT`): `PERCENT` or `AMOUNT`. + - `conditional_rule_value` (string, required only for `EARLY_PAYMENT_DISCOUNT`): The discount value; must be between 1 and 100 when the value type is `PERCENT`. + - `rule_expiry_terms` (object, required): When the rule expires -- a specific date (`condition_rule_end_date`) or a period relative to the invoice issue date. + +> **Example Prompt**: +> Add an early payment discount of {percent}% to invoice {invoice_id} if paid within 7 days of issue + ### **Payments** --- diff --git a/README.md b/README.md index 51cc5b7a..6dc6944d 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,26 @@ The PayPal Agent toolkit provides the following tools: **Invoices** - `create_invoice`: Create a new invoice in the PayPal system, including recipient billing details, line items, an invoice note, a custom color theme, an optional shipping cost, and an option to enable PAY_BY_BANK as a payment method -- `create_recurring_series`: Create a recurring invoice series that automatically generates and sends invoices on a schedule - `list_invoices`: List invoices with optional pagination and filtering - `get_invoice`: Retrieve details of a specific invoice - `send_invoice`: Send an invoice to recipients - `send_invoice_reminder`: Send a reminder for an existing invoice - `cancel_sent_invoice`: Cancel a sent invoice - `generate_invoice_qr_code`: Generate a QR code for an invoice +- `delete_invoice`: Permanently delete a draft or scheduled invoice +- `generate_invoice_number`: Generate the next invoice number available to the merchant +- `search_invoicing`: Search for invoices or recurring invoice series +- `update_invoicing`: Update an existing invoice or recurring invoice series (full-replacement) +- `cancel_invoice_auto_reminder`: Cancel all scheduled automatic reminders for an invoice +- `record_payment_for_invoice`: Record an external or manual payment against an invoice +- `record_refund_for_invoice`: Record a refund against an invoice +- `create_conditional_rules_for_invoice`: Create conditional rules for an invoice, such as an early payment discount or automatic cancellation date + +- `create_recurring_series`: Create a recurring invoice series that automatically generates and sends invoices on a schedule +- `activate_recurring_series`: Activate a draft recurring invoice series +- `get_recurring_series`: Retrieve details of a specific recurring invoice series +- `cancel_recurring_series`: Cancel an active recurring invoice series +- `delete_recurring_series`: Delete a draft recurring invoice series **Payments** @@ -96,7 +109,15 @@ const paypalToolkit = new PayPalAgentToolkit({ send: true, sendReminder: true, cancel: true, + delete: true, generateQRC: true, + generateInvoiceNumber: true, + search: true, + update: true, + cancelReminders: true, + recordPayment: true, + recordRefund: true, + createConditionalRules: true, }, products: { create: true, list: true, update: true }, subscriptionPlans: { create: true, list: true, show: true }, diff --git a/python/README.md b/python/README.md index 3eb556c1..5fbc75ee 100644 --- a/python/README.md +++ b/python/README.md @@ -15,6 +15,20 @@ The PayPal Agent toolkit provides the following tools: - `send_invoice_reminder`: Send a reminder for an existing invoice - `cancel_sent_invoice`: Cancel a sent invoice - `generate_invoice_qr_code`: Generate a QR code for an invoice +- `delete_invoice`: Permanently delete a draft or scheduled invoice +- `generate_invoice_number`: Generate the next invoice number available to the merchant +- `search_invoicing`: Search for invoices or recurring invoice series +- `update_invoicing`: Update an existing invoice or recurring invoice series (full-replacement) +- `cancel_invoice_auto_reminder`: Cancel all scheduled automatic reminders for an invoice +- `record_payment_for_invoice`: Record an external or manual payment against an invoice +- `record_refund_for_invoice`: Record a refund against an invoice +- `create_conditional_rules_for_invoice`: Create conditional rules for an invoice, such as an early payment discount or automatic cancellation date + +- `create_recurring_series`: Create a recurring invoice series that automatically generates and sends invoices on a schedule +- `activate_recurring_series`: Activate a draft recurring invoice series +- `get_recurring_series`: Retrieve details of a specific recurring invoice series +- `cancel_recurring_series`: Cancel an active recurring invoice series +- `delete_recurring_series`: Delete a draft recurring invoice series **Payments** diff --git a/python/paypal_agent_toolkit/shared/invoices/parameters.py b/python/paypal_agent_toolkit/shared/invoices/parameters.py index 7c369826..24f896a9 100644 --- a/python/paypal_agent_toolkit/shared/invoices/parameters.py +++ b/python/paypal_agent_toolkit/shared/invoices/parameters.py @@ -1,9 +1,10 @@ -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, model_validator, field_validator from typing import List, Optional, Literal from ..regex import ( INVOICE_ID_REGEX, HEX_COLOR_REGEX, DATE_NO_TIME_REGEX, + DATE_TIME_REGEX, RECURRING_SERIES_ID_REGEX, COUNTRY_CODE_REGEX, LANGUAGE_REGEX, @@ -18,6 +19,28 @@ class Money(BaseModel): value: str = Field(..., pattern=DECIMAL_STRING_REGEX.pattern, description="The amount, as a signed decimal string with up to 2 decimal places (e.g. '50.00').") +class AmountRange(BaseModel): + lower_amount: Money = Field(..., description="The lower bound of the amount range.") + upper_amount: Money = Field(..., description="The upper bound of the amount range.") + + +class DateRange(BaseModel): + start: str = Field(..., pattern=DATE_NO_TIME_REGEX.pattern, description="The start date, in yyyy-MM-DD format.") + end: str = Field(..., pattern=DATE_NO_TIME_REGEX.pattern, description="The end date, in yyyy-MM-DD format.") + + +class DateTimeRange(BaseModel): + start: str = Field(..., pattern=DATE_TIME_REGEX.pattern, min_length=20, max_length=64, description="The start date and time, in ISO8601 format (for example, 2018-06-01T00:00:00Z). Seconds are required; fractional seconds are optional. A plain date (yyyy-MM-DD) is also accepted and is expanded to the start of that day (00:00:00Z).") + end: str = Field(..., pattern=DATE_TIME_REGEX.pattern, min_length=20, max_length=64, description="The end date and time, in ISO8601 format (for example, 2018-06-21T23:59:59Z). Seconds are required; fractional seconds are optional. A plain date (yyyy-MM-DD) is also accepted and is expanded to the end of that day (23:59:59Z).") + + @field_validator("start", "end", mode="before") + @classmethod + def _expand_date_only(cls, v, info): + if isinstance(v, str) and DATE_NO_TIME_REGEX.match(v): + return f"{v}T00:00:00Z" if info.field_name == "start" else f"{v}T23:59:59Z" + return v + + class PersonName(BaseModel): given_name: Optional[str] = Field(None, description="The first name of the person.") surname: Optional[str] = Field(None, description="The last name of the person.") @@ -31,6 +54,11 @@ class Address(BaseModel): postal_code: Optional[str] = Field(None, description="The postal code, which is the zip code or equivalent.") country_code: Optional[str] = Field(None, pattern=COUNTRY_CODE_REGEX.pattern, description="The two-character ISO 3166-1 country code (for example, US or GB).") + @field_validator("country_code", mode="before") + @classmethod + def _empty_string_to_none(cls, v): + return None if v == "" else v + class Phone(BaseModel): country_code: str = Field(..., description="The country calling code, in E.164 format (for example, '1' for the United States).") @@ -162,6 +190,18 @@ class ActivateRecurringSeriesParameters(BaseModel): recurring_series_id: str = Field(..., description="The ID of the recurring invoice series to activate.", pattern=RECURRING_SERIES_ID_REGEX.pattern) +class GetRecurringSeriesParameters(BaseModel): + recurring_series_id: str = Field(..., description="The ID of the recurring invoice series to retrieve.", pattern=RECURRING_SERIES_ID_REGEX.pattern) + + +class CancelRecurringSeriesParameters(BaseModel): + recurring_series_id: str = Field(..., description="The ID of the recurring invoice series to cancel.", pattern=RECURRING_SERIES_ID_REGEX.pattern) + + +class DeleteRecurringSeriesParameters(BaseModel): + recurring_series_id: str = Field(..., description="The ID of the recurring invoice series to delete. Only series in DRAFT status can be deleted; use CancelRecurringSeriesParameters for an activated series.", pattern=RECURRING_SERIES_ID_REGEX.pattern) + + class GetInvoiceParameters(BaseModel): invoice_id: str = Field(..., description="The ID of the invoice to retrieve.", pattern=INVOICE_ID_REGEX) @@ -193,12 +233,20 @@ class CancelSentInvoiceParameters(BaseModel): additional_recipients: Optional[List[str]] = Field(None, description="Additional email addresses to which to send the cancellation.") +class DeleteInvoiceParameters(BaseModel): + invoice_id: str = Field(..., description="The ID of the draft or scheduled invoice to delete.", pattern=INVOICE_ID_REGEX.pattern) + + class GenerateInvoiceQrCodeParameters(BaseModel): invoice_id: str = Field(..., description="The invoice id to generate QR code for", pattern=INVOICE_ID_REGEX) width: int = Field(300, description="The QR code width") height: int = Field(300, description="The QR code height") +class GenerateInvoiceNumberParameters(BaseModel): + pass + + class ReminderInterval(BaseModel): unit: Literal["DAY"] = Field("DAY", description="The unit of time for the reminder interval. The interval unit is always DAY.") value: int = Field(..., description="The number of interval units before/after the due date at which to send the reminder.") @@ -226,6 +274,10 @@ class SetupInvoiceAutoReminderParameters(BaseModel): ) +class CancelInvoiceAutoReminderParameters(BaseModel): + invoice_id: str = Field(..., description="The ID of the invoice for which to cancel all scheduled automatic reminders.", pattern=INVOICE_ID_REGEX) + + class UpdateInvoiceAutoReminderParameters(BaseModel): reminder_configuration_id: str = Field(..., description="The ID of the auto reminder configuration to update.") type: Literal["BEFORE_DUE", "AFTER_DUE"] @@ -235,3 +287,302 @@ class UpdateInvoiceAutoReminderParameters(BaseModel): notification: Optional[ReminderNotification] = Field(None, description="Notification settings for the reminder.") +# ---- search_invoicing: one external tool that internally branches to invoice search or ---- +# ---- recurring-series search based on resource_type. invoice_filters/recurring_series_filters ---- +# ---- map 1:1 onto PayPal's real request bodies for /v2/invoicing/search-invoices and ---- +# ---- /v2/invoicing/search-recurring-invoices respectively -- no payload reshaping needed. ---- + +# Some MCP clients "clear" a filter by blanking its leaf values (e.g. a range's start/end, +# or an array/string field) instead of omitting the field entirely -- these helpers treat +# such blanked-out values as not provided, used from field_validator(mode="before") below. + +def _blank_range_to_none(v): + if isinstance(v, dict): + if not v.get("start") or not v.get("end"): + return None + return v + + +def _blank_list_to_none(v): + if isinstance(v, list) and len(v) == 0: + return None + return v + + +def _blank_str_to_none(v): + if isinstance(v, str) and not v.strip(): + return None + return v + + +def _is_empty_filters(value): + if value is None: + return True + if isinstance(value, BaseModel): + return _is_empty_filters(value.model_dump()) + if isinstance(value, str): + return not value.strip() + if isinstance(value, dict): + return all(_is_empty_filters(v) for v in value.values()) + if isinstance(value, list): + return len(value) == 0 + return False + + +class SearchInvoicesFilters(BaseModel): + recipient_email: Optional[str] = Field(None, max_length=254, description="Filters the search by the recipient's email address.") + recipient_first_name: Optional[str] = Field(None, max_length=140, description="Filters the search by the recipient's first name.") + recipient_last_name: Optional[str] = Field(None, max_length=140, description="Filters the search by the recipient's last name.") + recipient_business_name: Optional[str] = Field(None, max_length=300, description="Filters the search by the recipient's business name.") + invoice_number: Optional[str] = Field(None, max_length=25, description="Filters the search by the invoice number.") + status: Optional[List[Literal[ + "DRAFT", "SENT", "SCHEDULED", "PAID", "MARKED_AS_PAID", "CANCELLED", "REFUNDED", + "PARTIALLY_PAID", "PARTIALLY_REFUNDED", "MARKED_AS_REFUNDED", "UNPAID", "PAYMENT_PENDING", + "AUTO_CANCELLED", "PAID_EXTERNAL", "REFUNDED_EXTERNAL", "SHARED", + ]]] = Field(None, max_length=5, description="Filters the search by up to 5 invoice status values.") + reference: Optional[str] = Field(None, max_length=120, description="Filters the search by reference data, such as a purchase order (PO) number.") + currency_code: Optional[str] = Field(None, description="The three-character ISO-4217 currency code that identifies the currency.") + total_amount_range: Optional[AmountRange] = Field(None, description="Filters the search by a range of invoice total amounts.") + invoice_date_range: Optional[DateRange] = Field(None, description="Filters the search by the invoice's own date (the date shown on the invoice itself, also called the billing date). Use this for most 'invoices dated/created/issued between X and Y' requests -- creation_date_range is for PayPal's internal record-creation timestamp, not the invoice's date.") + due_date_range: Optional[DateRange] = Field(None, description="Filters the search by the invoice's due date.") + payment_date_range: Optional[DateTimeRange] = Field(None, description="Filters the search by the date and time PayPal recorded the invoice as paid (a system timestamp).") + creation_date_range: Optional[DateTimeRange] = Field(None, description="Filters the search by the date and time PayPal's system recorded the invoice record as created (an internal system timestamp, NOT the invoice's own date). For 'invoices dated/created between X and Y' requests, prefer invoice_date_range unless the user specifically means when the record was created in PayPal.") + + @field_validator("total_amount_range", mode="before") + @classmethod + def _drop_blank_amount_range(cls, v): + if isinstance(v, dict): + lower_value = (v.get("lower_amount") or {}).get("value") + upper_value = (v.get("upper_amount") or {}).get("value") + if not lower_value or not upper_value: + return None + return v + + @field_validator("invoice_date_range", "due_date_range", "payment_date_range", "creation_date_range", mode="before") + @classmethod + def _drop_blank_date_ranges(cls, v): + return _blank_range_to_none(v) + + @field_validator( + "recipient_email", "recipient_first_name", "recipient_last_name", + "recipient_business_name", "invoice_number", "reference", "currency_code", + mode="before", + ) + @classmethod + def _drop_blank_strings(cls, v): + return _blank_str_to_none(v) + + @field_validator("status", mode="before") + @classmethod + def _drop_blank_status(cls, v): + return _blank_list_to_none(v) + + +class RecurringSeriesSearchFilters(BaseModel): + currency_code: Optional[str] = Field(None, description="The three-character ISO-4217 currency code that identifies the currency.") + status: Optional[List[Literal["DRAFT", "ACTIVE", "CANCELLED", "EXPIRED"]]] = Field(None, min_length=1, max_length=5, description="An array of up to 5 unique recurring invoice series status values.") + creation_date_range: Optional[DateTimeRange] = Field(None, description="Filters the search by the date and time PayPal recorded the recurring series as created (a system timestamp). This endpoint has no separate 'series date' field -- for 'series created/started between X and Y' requests, this is the field to use. Mutually exclusive with next_occurrence_date_range -- PayPal supports only one range criterion per search.") + next_occurrence_date_range: Optional[DateRange] = Field(None, description="Filters the search by the date range of the series' next occurrence. Mutually exclusive with creation_date_range -- PayPal supports only one range criterion per search.") + total_amount_range: Optional[AmountRange] = Field(None, description="Filters the search by a range of total amounts.") + + @field_validator("status", mode="before") + @classmethod + def _drop_blank_status(cls, v): + return _blank_list_to_none(v) + + @field_validator("currency_code", mode="before") + @classmethod + def _drop_blank_currency_code(cls, v): + return _blank_str_to_none(v) + + @field_validator("status") + @classmethod + def _check_status_unique(cls, v): + if v is not None and len(set(v)) != len(v): + raise ValueError("status values must be unique.") + return v + + @field_validator("creation_date_range", "next_occurrence_date_range", mode="before") + @classmethod + def _drop_blank_date_ranges(cls, v): + return _blank_range_to_none(v) + + @field_validator("total_amount_range", mode="before") + @classmethod + def _drop_blank_amount_range(cls, v): + if isinstance(v, dict): + lower_value = (v.get("lower_amount") or {}).get("value") + upper_value = (v.get("upper_amount") or {}).get("value") + if not lower_value or not upper_value: + return None + return v + + @model_validator(mode="after") + def _check_date_range_mutually_exclusive(self): + if self.creation_date_range is not None and self.next_occurrence_date_range is not None: + raise ValueError("creation_date_range and next_occurrence_date_range cannot both be set -- PayPal supports only one range criterion per search.") + return self + + +class SearchRecurringSeriesFilters(BaseModel): + search_text: Optional[str] = Field(None, min_length=3, max_length=800, description="Free-text search, checked against the fields listed in search_fields.") + search_fields: Optional[List[Literal[ + "PAYER_REFERENCE_INFO", "BILLING_EMAIL", "BILLING_NAME", "BILLING_BUSINESS_NAME", + "BILLING_PHONE_NUMBER", "SHIPPING_NAME", "SHIPPING_BUSINESS_NAME", "SHIPPING_PHONE_NUMBER", + "ITEM_NAME", "ITEM_TAX_NAME", "ITEM_DISCOUNT_NAME", "INVOICE_DISCOUNT_NAME", "ALL", + ]]] = Field(None, min_length=1, max_length=5, description="The fields search_text is checked against. Use ['ALL'] to search every available field.") + search_filters: Optional[RecurringSeriesSearchFilters] = Field(None, description="Structured filters for the recurring series search.") + + @field_validator("search_text", mode="before") + @classmethod + def _drop_blank_search_text(cls, v): + return _blank_str_to_none(v) + + @field_validator("search_fields", mode="before") + @classmethod + def _drop_blank_search_fields(cls, v): + return _blank_list_to_none(v) + + @field_validator("search_fields") + @classmethod + def _check_search_fields_unique(cls, v): + if v is not None and len(set(v)) != len(v): + raise ValueError("search_fields values must be unique.") + return v + + +class SearchInvoicingParameters(BaseModel): + resource_type: Literal["invoice", "recurring_series"] = Field(..., description="Which kind of resource to search. 'invoice' searches individual invoices; 'recurring_series' searches recurring invoice series.") + page: Optional[int] = Field(1, ge=1, le=1000, description="The page number of the result set to fetch.") + page_size: Optional[int] = Field(20, ge=1, le=100, description="The number of records to return per page (maximum 100).") + total_required: Optional[bool] = Field(False, description="Indicates whether the response should include total_pages and total_items. Only applies when resource_type is 'invoice'.") + invoice_filters: Optional[SearchInvoicesFilters] = Field(None, description="Search filters for invoices. Set only when resource_type is 'invoice'.") + recurring_series_filters: Optional[SearchRecurringSeriesFilters] = Field(None, description="Search filters for recurring invoice series. Set only when resource_type is 'recurring_series'.") + + @model_validator(mode="after") + def _check_filters_match_resource_type(self): + if self.resource_type == "invoice" and not _is_empty_filters(self.recurring_series_filters): + raise ValueError("recurring_series_filters cannot be set when resource_type is 'invoice' -- use invoice_filters instead.") + if self.resource_type == "recurring_series" and not _is_empty_filters(self.invoice_filters): + raise ValueError("invoice_filters cannot be set when resource_type is 'recurring_series' -- use recurring_series_filters instead.") + return self + + +# ---- update_invoicing: one external tool that internally branches to an invoice-update flow ---- +# ---- or a recurring-series-update flow based on resource_type. invoice_update/recurring_series_update ---- +# ---- are full-replacement bodies -- the create models extended with the resource's ID (and, for ---- +# ---- invoices, the two query-param booleans) -- since PayPal's update endpoints are full-body PUTs. ---- + +def _is_empty_update(value): + if value is None: + return True + if isinstance(value, BaseModel): + return _is_empty_update(value.model_dump()) + if isinstance(value, str): + return not value.strip() + if isinstance(value, dict): + return all(_is_empty_update(v) for v in value.values()) + if isinstance(value, list): + return len(value) == 0 + return False + + +class UpdateInvoiceBody(CreateInvoiceParameters): + # Optional at the field level (even though required whenever this flow actually runs) so that a + # client blanking out the unused side of update_invoicing instead of omitting it can still pass + # validation -- the pattern would otherwise reject an empty placeholder before the dispatcher's + # own "is this side really being used" check ever runs. Presence is enforced in the validator below. + invoice_id: Optional[str] = Field(None, pattern=INVOICE_ID_REGEX.pattern, description="The ID of the invoice to update. Required when resource_type is 'invoice'.") + send_to_recipient: Optional[bool] = Field(None, description="Whether to send the invoice update notification to the recipient. PayPal defaults to true if omitted.") + send_to_invoicer: Optional[bool] = Field(None, description="Whether to send the invoice update notification to the merchant (invoicer). PayPal defaults to true if omitted.") + + +class UpdateRecurringSeriesBody(CreateRecurringSeriesParameters): + # Optional for the same reason as invoice_id above; enforced in the validator below. + recurring_series_id: Optional[str] = Field(None, pattern=RECURRING_SERIES_ID_REGEX.pattern, description="The ID of the recurring invoice series to update. Required when resource_type is 'recurring_series'.") + + +class UpdateInvoicingParameters(BaseModel): + resource_type: Literal["invoice", "recurring_series"] = Field(..., description="Which kind of resource to update. 'invoice' updates an individual invoice; 'recurring_series' updates a recurring invoice series.") + invoice_update: Optional[UpdateInvoiceBody] = Field(None, description="Full replacement content for the invoice. Set only when resource_type is 'invoice'.") + recurring_series_update: Optional[UpdateRecurringSeriesBody] = Field(None, description="Full replacement content for the recurring series. Set only when resource_type is 'recurring_series'.") + + @model_validator(mode="after") + def _check_update_matches_resource_type(self): + if self.resource_type == "invoice": + if self.invoice_update is None: + raise ValueError("invoice_update is required when resource_type is 'invoice'.") + if not self.invoice_update.invoice_id: + raise ValueError("invoice_update.invoice_id is required when resource_type is 'invoice'.") + if not _is_empty_update(self.recurring_series_update): + raise ValueError("recurring_series_update cannot be set when resource_type is 'invoice' -- use invoice_update instead.") + else: + if self.recurring_series_update is None: + raise ValueError("recurring_series_update is required when resource_type is 'recurring_series'.") + if not self.recurring_series_update.recurring_series_id: + raise ValueError("recurring_series_update.recurring_series_id is required when resource_type is 'recurring_series'.") + if not _is_empty_update(self.invoice_update): + raise ValueError("invoice_update cannot be set when resource_type is 'recurring_series' -- use recurring_series_update instead.") + return self + + +class RecordPaymentForInvoiceParameters(BaseModel): + invoice_id: str = Field(..., pattern=INVOICE_ID_REGEX.pattern, description="The ID of the invoice to record the payment against.") + payment_id: Optional[str] = Field(None, max_length=22, description="The ID for a PayPal payment transaction. Required for the PAYPAL payment type.") + payment_date: Optional[str] = Field(None, pattern=DATE_NO_TIME_REGEX.pattern, description="The date when the invoicer recorded the payment, in yyyy-MM-dd format.") + payment_date_time: Optional[str] = Field(None, min_length=20, max_length=64, description="The date and time when the invoicer recorded the payment, in Internet date and time format (ISO 8601), for example 2018-05-13T21:20:00Z or 2018-05-13T21:20:00.000-08:00. Seconds are required.") + method: Literal["BANK_TRANSFER", "CASH", "CHECK", "CREDIT_CARD", "DEBIT_CARD", "PAYPAL", "WIRE_TRANSFER", "OTHER"] = Field(..., description="The payment mode or method through which the invoicer can accept the payments.") + note: Optional[str] = Field(None, max_length=2000, description="A note associated with an external cash or check payment.") + amount: Optional[Money] = Field(None, description="The currency and amount for a financial transaction.") + shipping_info: Optional[ShippingInfo] = Field(None, description="The shipping information associated with this payment.") + + @field_validator("payment_id", "payment_date", "payment_date_time", mode="before") + @classmethod + def _empty_string_to_none(cls, v): + return None if v == "" else v + + +class RecordRefundForInvoiceParameters(BaseModel): + invoice_id: str = Field(..., pattern=INVOICE_ID_REGEX.pattern, description="The ID of the invoice to mark as refunded.") + refund_date: Optional[str] = Field(None, pattern=DATE_NO_TIME_REGEX.pattern, description="The date when the invoicer recorded the refund, in yyyy-MM-dd format.") + amount: Optional[Money] = Field(None, description="The currency and amount for a financial transaction.") + method: Literal["BANK_TRANSFER", "CASH", "CHECK", "CREDIT_CARD", "DEBIT_CARD", "PAYPAL", "WIRE_TRANSFER", "OTHER"] = Field(..., description="The payment mode or method through which the invoicer can accept the payments.") + + @field_validator("refund_date", mode="before") + @classmethod + def _empty_string_to_none(cls, v): + return None if v == "" else v + + +class InvoiceConditionalRuleExpiryTerms(BaseModel): + rule_expiry_condition: Literal[ + "SPECIFIC_DATE", + "THREE_DAYS_AFTER_ISSUE_DATE", + "SEVEN_DAYS_AFTER_ISSUE_DATE", + "FIFTEEN_DAYS_AFTER_ISSUE_DATE", + "THIRTY_DAYS_AFTER_ISSUE_DATE", + ] = Field(..., description="When the conditional rule expires: a specific date, or a period relative to the invoice issue date.") + condition_rule_end_date: str = Field(..., pattern=DATE_NO_TIME_REGEX.pattern, description="The date the conditional rule expires, in yyyy-MM-dd format.") + + +class InvoiceConditionalRule(BaseModel): + conditional_rule_type: Literal["EARLY_PAYMENT_DISCOUNT", "AUTO_CANCEL"] = Field(..., description="The type of conditional rule to apply to the invoice.") + conditional_rule_value_type: Optional[Literal["PERCENT", "AMOUNT"]] = Field(None, description="The type of the conditional rule value. Required, and only applicable, when conditional_rule_type is EARLY_PAYMENT_DISCOUNT.") + conditional_rule_value: Optional[str] = Field(None, description="The value of the conditional rule. Required, and only applicable, when conditional_rule_type is EARLY_PAYMENT_DISCOUNT. When conditional_rule_value_type is PERCENT, must be between 1 and 100.") + rule_expiry_terms: InvoiceConditionalRuleExpiryTerms = Field(..., description="The expiry terms for the conditional rule.") + + @model_validator(mode="after") + def _validate_early_payment_discount_fields(self): + if self.conditional_rule_type == "EARLY_PAYMENT_DISCOUNT": + if self.conditional_rule_value_type is None or self.conditional_rule_value is None: + raise ValueError("conditional_rule_value_type and conditional_rule_value are required when conditional_rule_type is EARLY_PAYMENT_DISCOUNT.") + if self.conditional_rule_value_type == "PERCENT" and not (1 <= float(self.conditional_rule_value) <= 100): + raise ValueError("conditional_rule_value must be between 1 and 100 when conditional_rule_value_type is PERCENT.") + return self + + +class CreateConditionalRulesForInvoiceParameters(BaseModel): + invoice_id: str = Field(..., pattern=INVOICE_ID_REGEX.pattern, description="The ID of the invoice for which the conditional rules are to be created.") + rules: List[InvoiceConditionalRule] = Field(..., min_length=1, description="The list of conditional rules to create for the invoice.") + + diff --git a/python/paypal_agent_toolkit/shared/invoices/prompts.py b/python/paypal_agent_toolkit/shared/invoices/prompts.py index f0a62b88..a711e874 100644 --- a/python/paypal_agent_toolkit/shared/invoices/prompts.py +++ b/python/paypal_agent_toolkit/shared/invoices/prompts.py @@ -22,6 +22,24 @@ This function activates a recurring invoice series by its ID, moving it out of DRAFT status. Once activated, PayPal automatically generates and sends invoices to the customer based on the series' configured schedule. Call this after create_recurring_series to make the series active. """ +GET_RECURRING_SERIES_PROMPT = """ +Get a recurring invoice series from PayPal. + +This function retrieves details of a specific recurring invoice series using its ID, including its schedule, status, template, and recipient information. +""" + +CANCEL_RECURRING_SERIES_PROMPT = """ +Cancel a recurring invoice series on PayPal. + +This function cancels a recurring invoice series by its ID. Once cancelled, PayPal stops generating and sending further invoices for the series. This action cannot be undone. +""" + +DELETE_RECURRING_SERIES_PROMPT = """ +Delete a recurring invoice series on PayPal. + +This function permanently deletes a recurring invoice series by its ID. Only series in DRAFT status can be deleted -- for a series that has already been activated, use cancel_recurring_series instead. This action cannot be undone. +""" + LIST_INVOICE_PROMPT = """ List invoices from PayPal. @@ -52,12 +70,24 @@ This function cancels an invoice that has already been sent to the recipient(s). """ +DELETE_INVOICE_PROMPT = """ +Delete a draft or scheduled invoice on PayPal. + +This function permanently deletes an invoice that is in the draft or scheduled state, by ID. It does not work on invoices that have already been sent -- use cancel_sent_invoice for those instead. After deletion, the invoice's details can no longer be retrieved, but its invoice number can be reused. +""" + GENERATE_INVOICE_QRCODE_PROMPT = """ Generate a QR code for an invoice. This function generates a QR code for an invoice, which can be used to pay the invoice using a mobile device or scanning app. """ +GENERATE_INVOICE_NUMBER_PROMPT = """ +Generate the next invoice number available to the merchant. + +This function generates the next invoice number by using the prefix and suffix from the merchant's last invoice number and incrementing the numeric portion by one (e.g. INVOICE-1234 -> INVOICE-1235). +""" + SETUP_INVOICE_AUTO_REMINDER_PROMPT = """ Initialize the invoice auto reminder configuration for the merchant's PayPal account. @@ -68,4 +98,44 @@ Update an existing invoice auto reminder configuration by its configuration ID. This function performs a full update of the reminder configuration's timing interval, repetition count and notification preferences. +""" + +SEARCH_INVOICING_PROMPT = """ +Search for invoices or recurring invoice series on PayPal. + +Use resource_type "invoice" with invoice_filters, or "recurring_series" with recurring_series_filters -- only set the matching filters object. Recurring series search covers only the past 3 years. +""" + +CANCEL_INVOICE_AUTO_REMINDER_PROMPT = """ +Cancel all scheduled automatic reminders for an invoice. + +This function permanently cancels every automatic reminder scheduled for a specific invoice, by invoice ID. This action is irreversible -- once cancelled, automatic reminders cannot be re-enabled for that invoice. +""" + +UPDATE_INVOICING_PROMPT = """ +Update an existing invoice or recurring invoice series on PayPal. + +Use resource_type "invoice" with invoice_update, or "recurring_series" with recurring_series_update -- only set the matching object. This is a full-replacement update: resend the complete invoice/series content, not just the changed fields. + +For invoices, the recipient (primary_recipients) can only be changed 2 times within any 72-hour window -- avoid unnecessary recipient edits. +""" + +RECORD_PAYMENT_FOR_INVOICE_PROMPT = """ +Record a payment for an invoice on PayPal. + +This function records an external or manual payment (for example, cash, check, bank transfer, or a PayPal transaction) against an invoice, by invoice ID. If the recorded amount covers the full amount due, PayPal marks the invoice PAID; otherwise it is marked PARTIALLY_PAID. This does not process a new payment -- it only logs one that was already collected. + +method is required. payment_id applies only to PAYPAL-type payments. +""" + +RECORD_REFUND_FOR_INVOICE_PROMPT = """ +Record a refund for an invoice on PayPal. + +This function records a refund against an invoice, by invoice ID. If all payments on the invoice are refunded, PayPal marks the invoice REFUNDED; otherwise it is marked PARTIALLY_REFUNDED. This does not process a new refund -- it only logs one that was already issued. + +method is required. +""" + +CREATE_CONDITIONAL_RULES_FOR_INVOICE_PROMPT = """ +Create conditional rules for an invoice on PayPal. """ \ No newline at end of file diff --git a/python/paypal_agent_toolkit/shared/invoices/tool_handlers.py b/python/paypal_agent_toolkit/shared/invoices/tool_handlers.py index 624def9d..b1110f96 100644 --- a/python/paypal_agent_toolkit/shared/invoices/tool_handlers.py +++ b/python/paypal_agent_toolkit/shared/invoices/tool_handlers.py @@ -75,6 +75,39 @@ def activate_recurring_series(client, params: dict): return json.dumps({"recurring_series_id": recurring_series_id, "status": "ACTIVATED"}) +def get_recurring_series(client, params: dict): + + validated = GetRecurringSeriesParameters(**params) + recurring_series_id = validated.recurring_series_id + + url = f"/v2/invoicing/recurring-invoices/{recurring_series_id}" + response = client.get(uri=url) + + return json.dumps(response) + + +def cancel_recurring_series(client, params: dict): + + validated = CancelRecurringSeriesParameters(**params) + recurring_series_id = validated.recurring_series_id + + url = f"/v2/invoicing/recurring-invoices/{recurring_series_id}/cancel" + client.post(uri=url, payload={}) + + return json.dumps({"recurring_series_id": recurring_series_id, "status": "CANCELLED"}) + + +def delete_recurring_series(client, params: dict): + + validated = DeleteRecurringSeriesParameters(**params) + recurring_series_id = validated.recurring_series_id + + url = f"/v2/invoicing/recurring-invoices/{recurring_series_id}" + client.delete(uri=url) + + return json.dumps({"recurring_series_id": recurring_series_id, "status": "DELETED"}) + + def list_invoices(client, params: dict): validated = ListInvoicesParameters(**params) @@ -126,6 +159,20 @@ def cancel_sent_invoice(client, params: dict): return json.dumps(response) +def delete_invoice(client, params: dict): + validated = DeleteInvoiceParameters(**params) + invoice_id = validated.invoice_id + url = f"/v2/invoicing/invoices/{invoice_id}" + + response = client.delete(uri=url) + + # PayPal responds with 204 No Content on successful deletion (client.delete returns {} for that) + if not response: + return json.dumps({"success": True, "invoice_id": invoice_id}) + + return json.dumps(response) + + def generate_invoice_qrcode(client, params: dict): validated = GenerateInvoiceQrCodeParameters(**params) @@ -145,6 +192,17 @@ def generate_invoice_qrcode(client, params: dict): return json.dumps(response) +def generate_invoice_number(client, params: dict): + + GenerateInvoiceNumberParameters(**params) + payload = {"fetch_id": False} + + url = "/v2/invoicing/generate-next-invoice-number" + response = client.post(uri=url, payload=payload) + + return json.dumps(response) + + def setup_invoice_auto_reminders(client, params: dict): validated = SetupInvoiceAutoReminderParameters(**params) @@ -170,3 +228,127 @@ def update_invoice_auto_reminder(client, params: dict): response = client.put(uri=url, payload=payload, headers={"Prefer": "return=representation"}) return json.dumps(response) + + +def search_invoicing(client, params: dict): + + validated = SearchInvoicingParameters(**params) + + if validated.resource_type == "invoice": + return _search_invoices(client, validated) + return _search_recurring_series(client, validated) + + +def _search_invoices(client, validated: SearchInvoicingParameters): + + body = (validated.invoice_filters or SearchInvoicesFilters()).model_dump(exclude_none=True) + total_required = "true" if validated.total_required else "false" + uri = f"/v2/invoicing/search-invoices?page={validated.page}&page_size={validated.page_size}&total_required={total_required}" + response = client.post(uri=uri, payload=body) + + return json.dumps(response) + + +def _search_recurring_series(client, validated: SearchInvoicingParameters): + + body = (validated.recurring_series_filters or SearchRecurringSeriesFilters()).model_dump(exclude_none=True) + uri = f"/v2/invoicing/search-recurring-invoices?page={validated.page}&page_size={validated.page_size}" + response = client.post(uri=uri, payload=body) + + return json.dumps(response) + + +def _update_invoice(client, body: UpdateInvoiceBody): + + payload = build_create_invoice_payload( + body.model_dump(exclude_none=True, exclude={"invoice_id", "send_to_recipient", "send_to_invoicer"}) + ) + query = "&".join( + f"{k}={str(v).lower()}" + for k, v in [("send_to_recipient", body.send_to_recipient), ("send_to_invoicer", body.send_to_invoicer)] + if v is not None + ) + url = f"/v2/invoicing/invoices/{body.invoice_id}" + (f"?{query}" if query else "") + response = client.put(uri=url, payload=payload, headers={"Prefer": "return=representation"}) + + return json.dumps(response) + + +def _update_recurring_series(client, body: UpdateRecurringSeriesBody): + + payload = build_create_recurring_series_payload( + body.model_dump(exclude_none=True, exclude={"recurring_series_id"}) + ) + url = f"/v2/invoicing/recurring-invoices/{body.recurring_series_id}" + response = client.put(uri=url, payload=payload, headers={"Prefer": "return=representation"}) + + return json.dumps(response) + + +def update_invoicing(client, params: dict): + + validated = UpdateInvoicingParameters(**params) + + if validated.resource_type == "invoice": + return _update_invoice(client, validated.invoice_update) + return _update_recurring_series(client, validated.recurring_series_update) + + + +def cancel_invoice_auto_reminder(client, params: dict): + validated = CancelInvoiceAutoReminderParameters(**params) + invoice_id = validated.invoice_id + url = f"/v2/invoicing/invoices/{invoice_id}/cancel-reminders" + + response = client.post(uri=url, payload={}) + + # PayPal responds with 204 No Content on success (client.post returns {} for that) + if not response: + return json.dumps({"success": True, "invoice_id": invoice_id}) + + return json.dumps(response) + + +def record_payment_for_invoice(client, params: dict): + + validated = RecordPaymentForInvoiceParameters(**params) + invoice_id = validated.invoice_id + payload = validated.model_dump(exclude_none=True, exclude={"invoice_id"}) + + url = f"/v2/invoicing/invoices/{invoice_id}/payments" + response = client.post(uri=url, payload=payload) + + if not response: + return json.dumps({"success": True, "invoice_id": invoice_id}) + + return json.dumps(response) + + +def record_refund_for_invoice(client, params: dict): + + validated = RecordRefundForInvoiceParameters(**params) + invoice_id = validated.invoice_id + payload = validated.model_dump(exclude_none=True, exclude={"invoice_id"}) + + url = f"/v2/invoicing/invoices/{invoice_id}/refunds" + response = client.post(uri=url, payload=payload) + + if not response: + return json.dumps({"success": True, "invoice_id": invoice_id}) + + return json.dumps(response) + + +def create_conditional_rules_for_invoice(client, params: dict): + + validated = CreateConditionalRulesForInvoiceParameters(**params) + invoice_id = validated.invoice_id + payload = validated.model_dump(exclude_none=True, exclude={"invoice_id"}) + + url = f"/v2/invoicing/invoices/{invoice_id}/conditional-rules" + response = client.post(uri=url, payload=payload) + + if not response: + return json.dumps({"success": True, "invoice_id": invoice_id}) + + return json.dumps(response) diff --git a/python/paypal_agent_toolkit/shared/paypal_client.py b/python/paypal_agent_toolkit/shared/paypal_client.py index 93d9145d..cd4c554f 100644 --- a/python/paypal_agent_toolkit/shared/paypal_client.py +++ b/python/paypal_agent_toolkit/shared/paypal_client.py @@ -117,14 +117,16 @@ def get(self, uri): return json_response - def put(self, uri, payload): - + def put(self, uri, payload, headers=None): + url = f"{self.base_url}{uri}" - headers = self.build_headers() - logRequestPayload(payload, url, headers) + request_headers = self.build_headers() + if headers: + request_headers.update(headers) + logRequestPayload(payload, url, request_headers) try: - response = requests.put(url, headers=headers, json=payload) + response = requests.put(url, headers=request_headers, json=payload) response.raise_for_status() except requests.exceptions.RequestException as e: self.log_request_exception(e, url) @@ -144,5 +146,32 @@ def put(self, uri, payload): return json_response - - + def delete(self, uri): + + url = f"{self.base_url}{uri}" + headers = self.build_headers() + logRequestPayload(None, url, headers) + + try: + response = requests.delete(url, headers=headers) + response.raise_for_status() + except requests.exceptions.RequestException as e: + self.log_request_exception(e, url) + raise + + if response.status_code == 204: + logging.debug("Response Status: 204 No Content") + return {} + + try: + json_response = response.json() + except ValueError: + logging.warning("Response body is not valid JSON or empty, Headers: %s", json.dumps(dict(response.headers), indent=2)) + return {} + + logResponsePayload(response, json_response) + + return json_response + + + diff --git a/python/paypal_agent_toolkit/shared/regex.py b/python/paypal_agent_toolkit/shared/regex.py index 9f166b16..cddf5016 100644 --- a/python/paypal_agent_toolkit/shared/regex.py +++ b/python/paypal_agent_toolkit/shared/regex.py @@ -17,6 +17,7 @@ TRANSACTION_ID_REGEX = re.compile(r"^[A-Za-z0-9_-]{12,255}$") HEX_COLOR_REGEX = re.compile(r"^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$") DATE_NO_TIME_REGEX = re.compile(r"^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])$") +DATE_TIME_REGEX = re.compile(r"^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\.[0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$") RECURRING_SERIES_ID_REGEX = re.compile(r"^RI-[A-Z0-9]{17}$") COUNTRY_CODE_REGEX = re.compile(r"^([A-Z]{2}|C2)$") LANGUAGE_REGEX = re.compile(r"^[a-z]{2}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$") diff --git a/python/paypal_agent_toolkit/shared/tools.py b/python/paypal_agent_toolkit/shared/tools.py index 0b5aea90..4957511e 100644 --- a/python/paypal_agent_toolkit/shared/tools.py +++ b/python/paypal_agent_toolkit/shared/tools.py @@ -19,14 +19,25 @@ CREATE_INVOICE_PROMPT, CREATE_RECURRING_SERIES_PROMPT, ACTIVATE_RECURRING_SERIES_PROMPT, + GET_RECURRING_SERIES_PROMPT, + CANCEL_RECURRING_SERIES_PROMPT, + DELETE_RECURRING_SERIES_PROMPT, LIST_INVOICE_PROMPT, GET_INVOICE_PROMPT, SEND_INVOICE_PROMPT, SEND_INVOICE_REMINDER_PROMPT, CANCEL_SENT_INVOICE_PROMPT, + DELETE_INVOICE_PROMPT, GENERATE_INVOICE_QRCODE_PROMPT, + GENERATE_INVOICE_NUMBER_PROMPT, SETUP_INVOICE_AUTO_REMINDER_PROMPT, UPDATE_INVOICE_AUTO_REMINDER_PROMPT, + SEARCH_INVOICING_PROMPT, + UPDATE_INVOICING_PROMPT, + CANCEL_INVOICE_AUTO_REMINDER_PROMPT, + RECORD_PAYMENT_FOR_INVOICE_PROMPT, + RECORD_REFUND_FOR_INVOICE_PROMPT, + CREATE_CONDITIONAL_RULES_FOR_INVOICE_PROMPT, ) from ..shared.disputes.prompts import ( @@ -72,14 +83,25 @@ CreateInvoiceParameters, CreateRecurringSeriesParameters, ActivateRecurringSeriesParameters, + GetRecurringSeriesParameters, + CancelRecurringSeriesParameters, + DeleteRecurringSeriesParameters, SendInvoiceParameters, ListInvoicesParameters, GetInvoiceParameters, SendInvoiceReminderParameters, CancelSentInvoiceParameters, + DeleteInvoiceParameters, GenerateInvoiceQrCodeParameters, + GenerateInvoiceNumberParameters, SetupInvoiceAutoReminderParameters, UpdateInvoiceAutoReminderParameters, + SearchInvoicingParameters, + UpdateInvoicingParameters, + CancelInvoiceAutoReminderParameters, + RecordPaymentForInvoiceParameters, + RecordRefundForInvoiceParameters, + CreateConditionalRulesForInvoiceParameters, ) from ..shared.disputes.parameters import ( @@ -124,14 +146,25 @@ create_invoice, create_recurring_series, activate_recurring_series, + get_recurring_series, + cancel_recurring_series, + delete_recurring_series, send_invoice, list_invoices, get_invoice, send_invoice_reminder, cancel_sent_invoice, + delete_invoice, generate_invoice_qrcode, + generate_invoice_number, setup_invoice_auto_reminders, update_invoice_auto_reminder, + search_invoicing, + update_invoicing, + cancel_invoice_auto_reminder, + record_payment_for_invoice, + record_refund_for_invoice, + create_conditional_rules_for_invoice, ) @@ -278,6 +311,30 @@ "actions": {"invoices": {"activateRecurringSeries": True}}, "execute": activate_recurring_series, }, + { + "method": "get_recurring_series", + "name": "Get Recurring Invoice Series", + "description": GET_RECURRING_SERIES_PROMPT.strip(), + "args_schema": GetRecurringSeriesParameters, + "actions": {"invoices": {"getRecurringSeries": True}}, + "execute": get_recurring_series, + }, + { + "method": "cancel_recurring_series", + "name": "Cancel Recurring Invoice Series", + "description": CANCEL_RECURRING_SERIES_PROMPT.strip(), + "args_schema": CancelRecurringSeriesParameters, + "actions": {"invoices": {"cancelRecurringSeries": True}}, + "execute": cancel_recurring_series, + }, + { + "method": "delete_recurring_series", + "name": "Delete Recurring Invoice Series", + "description": DELETE_RECURRING_SERIES_PROMPT.strip(), + "args_schema": DeleteRecurringSeriesParameters, + "actions": {"invoices": {"deleteRecurringSeries": True}}, + "execute": delete_recurring_series, + }, { "method": "list_invoices", "name": "List Invoices", @@ -318,6 +375,14 @@ "actions": {"invoices": {"cancel": True}}, "execute": cancel_sent_invoice, }, + { + "method": "delete_invoice", + "name": "Delete Invoice", + "description": DELETE_INVOICE_PROMPT.strip(), + "args_schema": DeleteInvoiceParameters, + "actions": {"invoices": {"delete": True}}, + "execute": delete_invoice, + }, { "method": "generate_invoice_qr_code", "name": "Generate Invoice QR Code", @@ -326,6 +391,14 @@ "actions": {"invoices": {"generateQRC": True}}, "execute": generate_invoice_qrcode, }, + { + "method": "generate_invoice_number", + "name": "Generate Invoice Number", + "description": GENERATE_INVOICE_NUMBER_PROMPT.strip(), + "args_schema": GenerateInvoiceNumberParameters, + "actions": {"invoices": {"generateInvoiceNumber": True}}, + "execute": generate_invoice_number, + }, { "method": "setup_invoice_auto_reminders", "name": "Setup Invoice Auto Reminders", @@ -342,6 +415,54 @@ "actions": {"invoices": {"updateReminder": True}}, "execute": update_invoice_auto_reminder, }, + { + "method": "search_invoicing", + "name": "Search Invoices or Recurring Invoice Series", + "description": SEARCH_INVOICING_PROMPT.strip(), + "args_schema": SearchInvoicingParameters, + "actions": {"invoices": {"search": True}}, + "execute": search_invoicing, + }, + { + "method": "update_invoicing", + "name": "Update Invoice or Recurring Invoice Series", + "description": UPDATE_INVOICING_PROMPT.strip(), + "args_schema": UpdateInvoicingParameters, + "actions": {"invoices": {"update": True}}, + "execute": update_invoicing, + }, + { + "method": "cancel_invoice_auto_reminder", + "name": "Cancel Invoice Auto Reminder", + "description": CANCEL_INVOICE_AUTO_REMINDER_PROMPT.strip(), + "args_schema": CancelInvoiceAutoReminderParameters, + "actions": {"invoices": {"cancelReminders": True}}, + "execute": cancel_invoice_auto_reminder, + }, + { + "method": "record_payment_for_invoice", + "name": "Record Payment For Invoice", + "description": RECORD_PAYMENT_FOR_INVOICE_PROMPT.strip(), + "args_schema": RecordPaymentForInvoiceParameters, + "actions": {"invoices": {"recordPayment": True}}, + "execute": record_payment_for_invoice, + }, + { + "method": "record_refund_for_invoice", + "name": "Record Refund For Invoice", + "description": RECORD_REFUND_FOR_INVOICE_PROMPT.strip(), + "args_schema": RecordRefundForInvoiceParameters, + "actions": {"invoices": {"recordRefund": True}}, + "execute": record_refund_for_invoice, + }, + { + "method": "create_conditional_rules_for_invoice", + "name": "Create Conditional Rules For Invoice", + "description": CREATE_CONDITIONAL_RULES_FOR_INVOICE_PROMPT.strip(), + "args_schema": CreateConditionalRulesForInvoiceParameters, + "actions": {"invoices": {"createConditionalRules": True}}, + "execute": create_conditional_rules_for_invoice, + }, { "method": "list_disputes", "name": "List Disputes", diff --git a/typescript/README.md b/typescript/README.md index 253565d7..1b207815 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -16,6 +16,20 @@ The PayPal Agent toolkit provides the following tools: - `send_invoice_reminder`: Send a reminder for an existing invoice - `cancel_sent_invoice`: Cancel a sent invoice - `generate_invoice_qr_code`: Generate a QR code for an invoice +- `delete_invoice`: Permanently delete a draft or scheduled invoice +- `generate_invoice_number`: Generate the next invoice number available to the merchant +- `search_invoicing`: Search for invoices or recurring invoice series +- `update_invoicing`: Update an existing invoice or recurring invoice series (full-replacement) +- `cancel_invoice_auto_reminder`: Cancel all scheduled automatic reminders for an invoice +- `record_payment_for_invoice`: Record an external or manual payment against an invoice +- `record_refund_for_invoice`: Record a refund against an invoice +- `create_conditional_rules_for_invoice`: Create conditional rules for an invoice, such as an early payment discount or automatic cancellation date + +- `create_recurring_series`: Create a recurring invoice series that automatically generates and sends invoices on a schedule +- `activate_recurring_series`: Activate a draft recurring invoice series +- `get_recurring_series`: Retrieve details of a specific recurring invoice series +- `cancel_recurring_series`: Cancel an active recurring invoice series +- `delete_recurring_series`: Delete a draft recurring invoice series **Payments** @@ -91,7 +105,15 @@ const paypalToolkit = new PayPalAgentToolkit({ send: true, sendReminder: true, cancel: true, + delete: true, generateQRC: true, + generateInvoiceNumber: true, + search: true, + update: true, + cancelReminders: true, + recordPayment: true, + recordRefund: true, + createConditionalRules: true, }, products: { create: true, list: true, update: true }, subscriptionPlans: { create: true, list: true, show: true }, diff --git a/typescript/src/shared/api.ts b/typescript/src/shared/api.ts index c59bb0eb..a37fb8a2 100644 --- a/typescript/src/shared/api.ts +++ b/typescript/src/shared/api.ts @@ -2,13 +2,20 @@ import { createInvoice, createRecurringSeries, activateRecurringSeries, + getRecurringSeries, + cancelRecurringSeries, + deleteRecurringSeries, listInvoices, getInvoice, sendInvoice, sendInvoiceReminder, cancelSentInvoice, + deleteInvoice, setupInvoiceAutoReminder, updateInvoiceAutoReminder, + searchInvoicing, + updateInvoicing, + cancelInvoiceAutoReminder, createProduct, listProducts, createSubscriptionPlan, @@ -17,6 +24,10 @@ import { getShipmentTracking, updateShipmentTracking, generateInvoiceQrCode, + generateInvoiceNumber, + recordPaymentForInvoice, + recordRefundForInvoice, + createConditionalRulesForInvoice, createOrder, getOrder, listDisputes, @@ -95,6 +106,12 @@ class PayPalAPI { return createRecurringSeries(this.paypalClient, this.context, arg); case 'activate_recurring_series': return activateRecurringSeries(this.paypalClient, this.context, arg); + case 'get_recurring_series': + return getRecurringSeries(this.paypalClient, this.context, arg); + case 'cancel_recurring_series': + return cancelRecurringSeries(this.paypalClient, this.context, arg); + case 'delete_recurring_series': + return deleteRecurringSeries(this.paypalClient, this.context, arg); case 'list_invoices': return listInvoices(this.paypalClient, this.context, arg); case 'get_invoice': @@ -105,12 +122,28 @@ class PayPalAPI { return sendInvoiceReminder(this.paypalClient, this.context, arg); case 'cancel_sent_invoice': return cancelSentInvoice(this.paypalClient, this.context, arg); + case 'delete_invoice': + return deleteInvoice(this.paypalClient, this.context, arg); case 'setup_invoice_auto_reminders': return setupInvoiceAutoReminder(this.paypalClient, this.context, arg); case 'update_invoice_auto_reminder': return updateInvoiceAutoReminder(this.paypalClient, this.context, arg); + case 'search_invoicing': + return searchInvoicing(this.paypalClient, this.context, arg); + case 'update_invoicing': + return updateInvoicing(this.paypalClient, this.context, arg); + case 'cancel_invoice_auto_reminder': + return cancelInvoiceAutoReminder(this.paypalClient, this.context, arg); case 'generate_invoice_qr_code': return generateInvoiceQrCode(this.paypalClient, this.context, arg); + case 'generate_invoice_number': + return generateInvoiceNumber(this.paypalClient, this.context, arg); + case 'record_payment_for_invoice': + return recordPaymentForInvoice(this.paypalClient, this.context, arg); + case 'record_refund_for_invoice': + return recordRefundForInvoice(this.paypalClient, this.context, arg); + case 'create_conditional_rules_for_invoice': + return createConditionalRulesForInvoice(this.paypalClient, this.context, arg); case 'create_product': return createProduct(this.paypalClient, this.context, arg); case 'list_products': diff --git a/typescript/src/shared/functions.ts b/typescript/src/shared/functions.ts index e968814e..1d8a248e 100644 --- a/typescript/src/shared/functions.ts +++ b/typescript/src/shared/functions.ts @@ -3,11 +3,16 @@ import type { Context } from './configuration'; import { getInvoicParameters, cancelSentInvoiceParameters, + deleteInvoiceParameters, createInvoiceParameters, createRecurringSeriesParameters, activateRecurringSeriesParameters, + getRecurringSeriesParameters, + cancelRecurringSeriesParameters, + deleteRecurringSeriesParameters, createOrderParameters, generateInvoiceQrCodeParameters, + generateInvoiceNumberParameters, getOrderParameters, listInvoicesParameters, sendInvoiceParameters, @@ -35,7 +40,15 @@ import { updatePlanParameters, getMerchantInsightsParameters, setupInvoiceAutoReminderParameters, - updateInvoiceAutoReminderParameters + updateInvoiceAutoReminderParameters, + recordPaymentForInvoiceParameters, + recordRefundForInvoiceParameters, + createConditionalRulesForInvoiceParameters, + searchInvoicingParameters, + updateInvoicingParameters, + updateInvoiceBodyParameters, + updateRecurringSeriesBodyParameters, + cancelInvoiceAutoReminderParameters } from "./parameters"; import {parseOrderDetails, parseUpdateSubscriptionPayload, buildCreateInvoicePayload, buildCreateRecurringSeriesPayload, toQueryString} from "./payloadUtils"; import { TypeOf } from "zod"; @@ -155,6 +168,167 @@ export async function activateRecurringSeries( } } +// Treats a blanked-out update object (all-undefined fields) as equivalent to "not provided", +// so clients that clear the unused side of update_invoicing by blanking it instead of omitting +// it don't trip the mutual-exclusivity check below. +function isEmptyFilters(value: any): boolean { + if (value === undefined || value === null) return true; + if (typeof value === 'string') return value.trim().length === 0; + if (Array.isArray(value)) return value.length === 0; + if (typeof value === 'object') return Object.values(value).every(isEmptyFilters); + return false; +} + +async function updateInvoice( + client: PayPalClient, + context: Context, + params: TypeOf> +) { + logger('[updateInvoice] Starting to update invoice'); + const headers = await client.getHeaders(); + const { invoice_id, send_to_recipient, send_to_invoicer } = params; + const url = `${client.getBaseUrl()}/v2/invoicing/invoices/${invoice_id}`; + + try { + logger('[updateInvoice] Sending request to PayPal API'); + const invoicePayload = buildCreateInvoicePayload(params); + const response = await axios.put(url, invoicePayload, { + headers: { ...headers, Prefer: 'return=representation' }, + params: { send_to_recipient, send_to_invoicer }, + }); + logger(`[updateInvoice] Invoice updated successfully. Status: ${response.status}`); + return response.data; + } catch (error: any) { + logger('[updateInvoice] Error updating invoice:', error.message); + handleAxiosError(error); + } +} + +export async function getRecurringSeries( + client: PayPalClient, + context: Context, + params: TypeOf> +) { + logger('[getRecurringSeries] Starting to get recurring series'); + + const headers = await client.getHeaders(); + logger('[getRecurringSeries] Headers obtained'); + + const url = `${client.getBaseUrl()}/v2/invoicing/recurring-invoices/${params.recurring_series_id}`; + logger(`[getRecurringSeries] API URL: ${url}`); + + try { + logger('[getRecurringSeries] Sending request to PayPal API'); + const response = await axios.get(url, { headers }); + logger(`[getRecurringSeries] Recurring series retrieved successfully. Status: ${response.status}`); + return response.data; + } catch (error: any) { + logger('[getRecurringSeries] Error getting recurring series:', error.message); + handleAxiosError(error); + } +} + +async function updateRecurringSeries( + client: PayPalClient, + context: Context, + params: TypeOf> +) { + logger('[updateRecurringSeries] Starting to update recurring series'); + const headers = await client.getHeaders(); + const { recurring_series_id } = params; + const url = `${client.getBaseUrl()}/v2/invoicing/recurring-invoices/${recurring_series_id}`; + + try { + logger('[updateRecurringSeries] Sending request to PayPal API'); + const recurringSeriesPayload = buildCreateRecurringSeriesPayload(params); + const response = await axios.put(url, recurringSeriesPayload, { + headers: { ...headers, Prefer: 'return=representation' }, + }); + logger(`[updateRecurringSeries] Recurring series updated successfully. Status: ${response.status}`); + return response.data; + } catch (error: any) { + logger('[updateRecurringSeries] Error updating recurring series:', error.message); + handleAxiosError(error); + } +} + +export async function cancelRecurringSeries( + client: PayPalClient, + context: Context, + params: TypeOf> +) { + logger('[cancelRecurringSeries] Starting recurring series cancellation process'); + + const headers = await client.getHeaders(); + logger('[cancelRecurringSeries] Headers obtained'); + + const url = `${client.getBaseUrl()}/v2/invoicing/recurring-invoices/${params.recurring_series_id}/cancel`; + logger(`[cancelRecurringSeries] API URL: ${url}`); + + try { + logger('[cancelRecurringSeries] Sending request to PayPal API'); + const response = await axios.post(url, {}, { headers }); + logger(`[cancelRecurringSeries] Recurring series cancelled successfully. Status: ${response.status}`); + return { recurring_series_id: params.recurring_series_id, status: response.status }; + } catch (error: any) { + logger('[cancelRecurringSeries] Error cancelling recurring series:', error.message); + handleAxiosError(error); + } +} + +export async function updateInvoicing( + client: PayPalClient, + context: Context, + params: TypeOf> +) { + if (params.resource_type === 'invoice') { + if (!isEmptyFilters(params.recurring_series_update)) { + throw new Error("recurring_series_update cannot be set when resource_type is 'invoice' -- use invoice_update instead."); + } + if (!params.invoice_update) { + throw new Error("invoice_update is required when resource_type is 'invoice'."); + } + if (!params.invoice_update.invoice_id) { + throw new Error("invoice_update.invoice_id is required when resource_type is 'invoice'."); + } + return updateInvoice(client, context, params.invoice_update as TypeOf> & { invoice_id: string }); + } + if (!isEmptyFilters(params.invoice_update)) { + throw new Error("invoice_update cannot be set when resource_type is 'recurring_series' -- use recurring_series_update instead."); + } + if (!params.recurring_series_update) { + throw new Error("recurring_series_update is required when resource_type is 'recurring_series'."); + } + if (!params.recurring_series_update.recurring_series_id) { + throw new Error("recurring_series_update.recurring_series_id is required when resource_type is 'recurring_series'."); + } + return updateRecurringSeries(client, context, params.recurring_series_update as TypeOf> & { recurring_series_id: string }); +} + +export async function deleteRecurringSeries( + client: PayPalClient, + context: Context, + params: TypeOf> +) { + logger('[deleteRecurringSeries] Starting recurring series deletion process'); + + const headers = await client.getHeaders(); + logger('[deleteRecurringSeries] Headers obtained'); + + const url = `${client.getBaseUrl()}/v2/invoicing/recurring-invoices/${params.recurring_series_id}`; + logger(`[deleteRecurringSeries] API URL: ${url}`); + + try { + logger('[deleteRecurringSeries] Sending request to PayPal API'); + const response = await axios.delete(url, { headers }); + logger(`[deleteRecurringSeries] Recurring series deleted successfully. Status: ${response.status}`); + return { recurring_series_id: params.recurring_series_id, status: response.status }; + } catch (error: any) { + logger('[deleteRecurringSeries] Error deleting recurring series:', error.message); + handleAxiosError(error); + } +} + export async function listInvoices( client: PayPalClient, context: Context, @@ -322,6 +496,114 @@ export async function updateInvoiceAutoReminder( } } +// search_invoicing: one external tool that internally branches to invoice search or +// recurring-series search based on resource_type. invoice_filters/recurring_series_filters +// map 1:1 onto PayPal's real request bodies for /v2/invoicing/search-invoices and +// /v2/invoicing/search-recurring-invoices respectively -- no payload reshaping needed. +export async function searchInvoicing( + client: PayPalClient, + context: Context, + params: TypeOf> +) { + const { resource_type, recurring_series_filters, invoice_filters } = params; + + if (resource_type === 'invoice' && !isEmptyFilters(recurring_series_filters)) { + throw new Error("recurring_series_filters cannot be set when resource_type is 'invoice' -- use invoice_filters instead."); + } + if (resource_type === 'recurring_series' && !isEmptyFilters(invoice_filters)) { + throw new Error("invoice_filters cannot be set when resource_type is 'recurring_series' -- use recurring_series_filters instead."); + } + + if (resource_type === 'invoice') { + return searchInvoices(client, params); + } + return searchRecurringSeries(client, params); +} + +async function searchInvoices( + client: PayPalClient, + params: TypeOf> +) { + logger('[searchInvoices] Starting to search invoices'); + + const { page, page_size, total_required, invoice_filters } = params; + + const headers = await client.getHeaders(); + logger('[searchInvoices] Headers obtained'); + + const url = `${client.getBaseUrl()}/v2/invoicing/search-invoices`; + logger(`[searchInvoices] API URL: ${url}`); + + try { + logger('[searchInvoices] Sending request to PayPal API'); + const response = await axios.post(url, invoice_filters ?? {}, { + headers, + params: { page, page_size, total_required }, + }); + logger(`[searchInvoices] Invoices retrieved successfully. Status: ${response.status}`); + return response.data; + } catch (error: any) { + logger('[searchInvoices] Error searching invoices:', error.message); + handleAxiosError(error); + } +} + +async function searchRecurringSeries( + client: PayPalClient, + params: TypeOf> +) { + logger('[searchRecurringSeries] Starting to search recurring invoice series'); + + const { page, page_size, recurring_series_filters } = params; + + const headers = await client.getHeaders(); + logger('[searchRecurringSeries] Headers obtained'); + + const url = `${client.getBaseUrl()}/v2/invoicing/search-recurring-invoices`; + logger(`[searchRecurringSeries] API URL: ${url}`); + + try { + logger('[searchRecurringSeries] Sending request to PayPal API'); + const response = await axios.post(url, recurring_series_filters ?? {}, { + headers, + params: { page, page_size }, + }); + logger(`[searchRecurringSeries] Recurring invoice series retrieved successfully. Status: ${response.status}`); + return response.data; + } catch (error: any) { + logger('[searchRecurringSeries] Error searching recurring invoice series:', error.message); + handleAxiosError(error); + } +} + +export async function cancelInvoiceAutoReminder( + client: PayPalClient, + context: Context, + params: TypeOf> +) { + logger('[cancelInvoiceAutoReminder] Starting to cancel invoice auto reminders'); + const { invoice_id } = params; + + const headers = await client.getHeaders(); + logger('[cancelInvoiceAutoReminder] Headers obtained'); + + const url = `${client.getBaseUrl()}/v2/invoicing/invoices/${invoice_id}/cancel-reminders`; + + try { + logger('[cancelInvoiceAutoReminder] Sending request to PayPal API'); + const response = await axios.post(url, {}, { headers }); + if (response.status === 204) { + logger(`[cancelInvoiceAutoReminder] Invoice auto reminders cancelled successfully. Status: ${response.status}`); + return { success: true, invoice_id }; + } + logger(`[cancelInvoiceAutoReminder] Invoice auto reminders cancellation response received. Status: ${response.status}`); + return response.data; + } catch (error: any) { + logger('[cancelInvoiceAutoReminder] Error cancelling invoice auto reminders:', error.message); + handleAxiosError(error); + } +} + export async function cancelSentInvoice( client: PayPalClient, context: Context, @@ -357,6 +639,33 @@ export async function cancelSentInvoice( } } +export async function deleteInvoice( + client: PayPalClient, + context: Context, + params: TypeOf> +) { + logger('[deleteInvoice] Starting to delete invoice'); + const { invoice_id } = params; + + const headers = await client.getHeaders(); + logger('[deleteInvoice] Headers obtained'); + + const url = `${client.getBaseUrl()}/v2/invoicing/invoices/${invoice_id}`; + + try { + logger('[deleteInvoice] Sending request to PayPal API'); + const response = await axios.delete(url, { headers }); + if (response.status === 204) { + logger(`[deleteInvoice] Invoice deleted successfully. Status: ${response.status}`); + return { success: true, invoice_id }; + } + return response.data; + } catch (error: any) { + logger('[deleteInvoice] Error deleting invoice:', error.message); + handleAxiosError(error); + } +} + export async function generateInvoiceQrCode( client: PayPalClient, context: Context, @@ -386,6 +695,138 @@ export async function generateInvoiceQrCode( } } +export async function generateInvoiceNumber( + client: PayPalClient, + context: Context, + params: TypeOf> +) { + const url = `${client.getBaseUrl()}/v2/invoicing/generate-next-invoice-number`; + const headers = await client.getHeaders(); + try { + const response = await axios.post(url, { fetch_id: false }, { headers }); + return response.data; + } catch (error: any) { + logger('[generateInvoiceNumber] Error generating invoice number:', error.message); + handleAxiosError(error); + } +} + +export async function recordPaymentForInvoice( + client: PayPalClient, + context: Context, + params: TypeOf> +) { + logger('[recordPaymentForInvoice] Starting to record payment for invoice'); + const { + invoice_id, + payment_id, + payment_date, + payment_date_time, + method, + note, + amount, + shipping_info, + } = params; + + const body = { + payment_id, + payment_date, + payment_date_time, + method, + note, + amount, + shipping_info, + }; + + const headers = await client.getHeaders(); + logger('[recordPaymentForInvoice] Headers obtained'); + + const url = `${client.getBaseUrl()}/v2/invoicing/invoices/${invoice_id}/payments`; + + try { + logger('[recordPaymentForInvoice] Sending request to PayPal API'); + const response = await axios.post(url, body, { headers }); + if (response.status === 204) { + logger(`[recordPaymentForInvoice] Payment recorded successfully. Status: ${response.status}`); + return { success: true, invoice_id }; + } + logger(`[recordPaymentForInvoice] Payment record response received. Status: ${response.status}`); + return response.data; + } catch (error: any) { + logger('[recordPaymentForInvoice] Error recording payment for invoice:', error.message); + handleAxiosError(error); + } +} + +export async function recordRefundForInvoice( + client: PayPalClient, + context: Context, + params: TypeOf> +) { + logger('[recordRefundForInvoice] Starting to record refund for invoice'); + const { + invoice_id, + refund_date, + amount, + method, + } = params; + + const body = { + refund_date, + amount, + method, + }; + + const headers = await client.getHeaders(); + logger('[recordRefundForInvoice] Headers obtained'); + + const url = `${client.getBaseUrl()}/v2/invoicing/invoices/${invoice_id}/refunds`; + + try { + logger('[recordRefundForInvoice] Sending request to PayPal API'); + const response = await axios.post(url, body, { headers }); + if (response.status === 204) { + logger(`[recordRefundForInvoice] Refund recorded successfully. Status: ${response.status}`); + return { success: true, invoice_id }; + } + logger(`[recordRefundForInvoice] Refund record response received. Status: ${response.status}`); + return response.data; + } catch (error: any) { + logger('[recordRefundForInvoice] Error recording refund for invoice:', error.message); + handleAxiosError(error); + } +} + +export async function createConditionalRulesForInvoice( + client: PayPalClient, + context: Context, + params: TypeOf> +) { + logger('[createConditionalRulesForInvoice] Starting to create conditional rules for invoice'); + const { invoice_id, rules } = params; + + const body = { rules }; + + const headers = await client.getHeaders(); + logger('[createConditionalRulesForInvoice] Headers obtained'); + + const url = `${client.getBaseUrl()}/v2/invoicing/invoices/${invoice_id}/conditional-rules`; + + try { + logger('[createConditionalRulesForInvoice] Sending request to PayPal API'); + const response = await axios.post(url, body, { headers }); + if (response.status === 204) { + logger(`[createConditionalRulesForInvoice] Conditional rules created successfully. Status: ${response.status}`); + return { success: true, invoice_id }; + } + logger(`[createConditionalRulesForInvoice] Response received. Status: ${response.status}`); + return response.data; + } catch (error: any) { + logger('[createConditionalRulesForInvoice] Error creating conditional rules for invoice:', error.message); + handleAxiosError(error); + } +} + // === PRODUCT FUNCTIONS === export async function createProduct( client: PayPalClient, diff --git a/typescript/src/shared/parameters.ts b/typescript/src/shared/parameters.ts index cffef739..a0499ace 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, HEX_COLOR_REGEX, DATE_NO_TIME_REGEX, RECURRING_SERIES_ID_REGEX, COUNTRY_CODE_REGEX, LANGUAGE_REGEX, DECIMAL_STRING_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, HEX_COLOR_REGEX, DATE_NO_TIME_REGEX, DATE_TIME_REGEX, RECURRING_SERIES_ID_REGEX, COUNTRY_CODE_REGEX, LANGUAGE_REGEX, DECIMAL_STRING_REGEX} from "./regex" // === INVOICE PARAMETERS === // Shared building blocks, reused across create_invoice and create_recurring_series (and @@ -20,6 +20,58 @@ const money = () => z.object({ value: z.string().regex(DECIMAL_STRING_REGEX, "value must be a numeric value, e.g. \"50\" or \"50.00\"").describe("The amount, as a signed decimal string with up to 2 decimal places (e.g. '50.00')."), }); +const amountRange = () => z.object({ + lower_amount: money().describe("The lower bound of the amount range."), + upper_amount: money().describe("The upper bound of the amount range."), +}).describe("amount range object"); + +// Some MCP clients "clear" a filter by blanking out its leaf values (e.g. lower_amount.value: "") +// instead of omitting the field entirely -- treat a range with a blank bound as not provided. +const amountRangeOrUndefinedIfBlank = (description: string) => z.preprocess( + (val) => { + if (val && typeof val === "object") { + const lowerValue = (val as any).lower_amount?.value; + const upperValue = (val as any).upper_amount?.value; + if (!lowerValue || !upperValue) return undefined; + } + return val; + }, + amountRange().optional().describe(description) +); + +const dateRange = () => z.object({ + start: z.string().regex(DATE_NO_TIME_REGEX, "start must be in yyyy-MM-DD format").describe("The start date, in yyyy-MM-DD format."), + end: z.string().regex(DATE_NO_TIME_REGEX, "end must be in yyyy-MM-DD format").describe("The end date, in yyyy-MM-DD format."), +}).describe("date range object"); + +// Some MCP clients "clear" a filter by blanking out a range's leaf values (e.g. start: "") +// instead of omitting the field entirely -- treat a range with a blank bound as not provided. +const dateRangeOrUndefinedIfBlank = (description: string) => z.preprocess( + (val) => { + if (val && typeof val === "object" && (!(val as any).start || !(val as any).end)) return undefined; + return val; + }, + dateRange().optional().describe(description) +); + +const dateTimeBoundary = (defaultTime: string, label: string, description: string) => z.preprocess( + (val) => (typeof val === "string" && DATE_NO_TIME_REGEX.test(val) ? `${val}T${defaultTime}` : val), + z.string().regex(DATE_TIME_REGEX, `${label} must be in ISO8601 format, e.g. 2018-06-01T00:00:00Z`).min(20).max(64).describe(description) +); + +const dateTimeRange = () => z.object({ + start: dateTimeBoundary("00:00:00Z", "start", "The start date and time, in ISO8601 format (for example, 2018-06-01T00:00:00Z). Seconds are required; fractional seconds are optional. A plain date (yyyy-MM-DD) is also accepted and is expanded to the start of that day (00:00:00Z)."), + end: dateTimeBoundary("23:59:59Z", "end", "The end date and time, in ISO8601 format (for example, 2018-06-21T23:59:59Z). Seconds are required; fractional seconds are optional. A plain date (yyyy-MM-DD) is also accepted and is expanded to the end of that day (23:59:59Z)."), +}).describe("date and time range object"); + +const dateTimeRangeOrUndefinedIfBlank = (description: string) => z.preprocess( + (val) => { + if (val && typeof val === "object" && (!(val as any).start || !(val as any).end)) return undefined; + return val; + }, + dateTimeRange().optional().describe(description) +); + const personName = () => z.object({ given_name: z.string().optional().describe("The first name of the person."), surname: z.string().optional().describe("The last name of the person."), @@ -31,7 +83,7 @@ const address = () => z.object({ admin_area_2: z.string().optional().describe("A city, town, or village."), admin_area_1: z.string().optional().describe("The highest-level sub-division in a country, such as a state or province."), postal_code: z.string().optional().describe("The postal code, which is the zip code or equivalent."), - country_code: z.string().regex(COUNTRY_CODE_REGEX, "country_code must be a two-character ISO 3166-1 country code").optional().describe("The two-character ISO 3166-1 country code (for example, US or GB)."), + country_code: z.preprocess((val) => (val === '' ? undefined : val), z.string().regex(COUNTRY_CODE_REGEX, "country_code must be a two-character ISO 3166-1 country code").optional()).describe("The two-character ISO 3166-1 country code (for example, US or GB)."), }).describe("address object"); const phone = () => z.object({ @@ -157,12 +209,58 @@ export const createRecurringSeriesParameters = (context: Context) => z.object({ minimum_partial_payment_amount: z.string().regex(DECIMAL_STRING_REGEX, "minimum_partial_payment_amount must be a numeric value, e.g. \"20.00\"").optional().describe("The minimum amount allowed for a partial payment on each generated invoice, in the series' currency_code. Valid only when allow_partial_payment is true."), }).describe("Simplified create-recurring-series request. The tool implementation builds PayPal's actual nested recurring-invoicing API request from these flat fields."); +// update_invoicing is one external tool that internally branches to an invoice-update flow +// or a recurring-series-update flow based on resource_type. invoice_update/recurring_series_update +// are full-replacement bodies -- the create schemas extended with the resource's ID (and, for +// invoices, the two query-param booleans) -- since PayPal's update endpoints are full-body PUTs. + +export const updateInvoiceBodyParameters = (context: Context) => + createInvoiceParameters(context).extend({ + // Optional at the schema level (even though it's required whenever this flow actually runs) so that + // a client blanking out the unused side of update_invoicing instead of omitting it can still pass + // validation -- the regex would otherwise reject an empty placeholder before the dispatcher's own + // "is this side really being used" check ever runs. Presence is enforced at runtime in updateInvoicing. + invoice_id: z.string().regex(INVOICE_ID_REGEX, "Invalid PayPal Invoice ID").optional().describe("The ID of the invoice to update. Required when resource_type is 'invoice'."), + send_to_recipient: z.boolean().optional().describe("Whether to send the invoice update notification to the recipient. PayPal defaults to true if omitted."), + send_to_invoicer: z.boolean().optional().describe("Whether to send the invoice update notification to the merchant (invoicer). PayPal defaults to true if omitted."), + }); + +export const updateRecurringSeriesBodyParameters = (context: Context) => + createRecurringSeriesParameters(context).extend({ + // Optional for the same reason as invoice_id above; enforced at runtime in updateInvoicing. + recurring_series_id: z.string().regex(RECURRING_SERIES_ID_REGEX, "Invalid PayPal Recurring Series ID").optional().describe("The ID of the recurring invoice series to update. Required when resource_type is 'recurring_series'."), + }); + +export const updateInvoicingParameters = (context: Context) => z.object({ + resource_type: z.enum(["invoice", "recurring_series"]).describe("Which kind of resource to update. 'invoice' updates an individual invoice; 'recurring_series' updates a recurring invoice series."), + invoice_update: updateInvoiceBodyParameters(context).optional().describe("Full replacement content for the invoice. Set only when resource_type is 'invoice'."), + recurring_series_update: updateRecurringSeriesBodyParameters(context).optional().describe("Full replacement content for the recurring series. Set only when resource_type is 'recurring_series'."), +}).describe("Update an existing invoice or recurring invoice series on PayPal, depending on resource_type. This is a full-replacement update -- resend the complete content, not just changed fields."); + export const activateRecurringSeriesParameters = (context: Context) => z.object({ recurring_series_id: z.string() .regex(RECURRING_SERIES_ID_REGEX, "Invalid PayPal Recurring Series ID") .describe("The ID of the recurring invoice series to activate."), }); +export const getRecurringSeriesParameters = (context: Context) => z.object({ + recurring_series_id: z.string() + .regex(RECURRING_SERIES_ID_REGEX, "Invalid PayPal Recurring Series ID") + .describe("The ID of the recurring invoice series to retrieve."), +}); + +export const cancelRecurringSeriesParameters = (context: Context) => z.object({ + recurring_series_id: z.string() + .regex(RECURRING_SERIES_ID_REGEX, "Invalid PayPal Recurring Series ID") + .describe("The ID of the recurring invoice series to cancel."), +}); + +export const deleteRecurringSeriesParameters = (context: Context) => z.object({ + recurring_series_id: z.string() + .regex(RECURRING_SERIES_ID_REGEX, "Invalid PayPal Recurring Series ID") + .describe("The ID of the recurring invoice series to delete. Only series in DRAFT status can be deleted; use cancel_recurring_series for an activated series."), +}); + export const getInvoicParameters = (context: Context) => z.object({ invoice_id: z.string() .regex(INVOICE_ID_REGEX, "Invalid PayPal Invoice ID") @@ -200,12 +298,18 @@ export const cancelSentInvoiceParameters = (context: Context) => additional_recipients: z.array(z.string()).optional().describe('Additional email addresses to which to send the cancellation.'), }); +export const deleteInvoiceParameters = (context: Context) => z.object({ + invoice_id: z.string().regex(INVOICE_ID_REGEX, "Invalid PayPal Invoice ID").describe('The ID of the draft or scheduled invoice to delete.'), +}); + export const generateInvoiceQrCodeParameters = (context: Context) => z.object({ invoice_id: z.string().regex(INVOICE_ID_REGEX, "Invalid PayPal Invoice ID").describe('The invoice id to generate QR code for'), width: z.number().default(300).describe("The QR code width"), height: z.number().default(300).describe("The QR code height") }).describe("generate invoice qr code request payload"); +export const generateInvoiceNumberParameters = (context: Context) => z.object({}).describe("generate next invoice number request payload"); + const invoiceReminderConfiguration = z.object({ type: z.enum(['BEFORE_DUE', 'AFTER_DUE']).describe('The type of reminder. BEFORE_DUE sends a reminder before the invoice due date; AFTER_DUE sends a reminder after the invoice due date.'), interval: z.object({ @@ -228,6 +332,11 @@ export const setupInvoiceAutoReminderParameters = (context: Context) => ), }); +export const cancelInvoiceAutoReminderParameters = (context: Context) => + z.object({ + invoice_id: z.string().regex(INVOICE_ID_REGEX, "Invalid PayPal Invoice ID").describe('The ID of the invoice for which to cancel all scheduled automatic reminders.'), + }); + export const updateInvoiceAutoReminderParameters = (context: Context) => z.object({ reminder_configuration_id: z.string().describe('The ID of the auto reminder configuration to update.'), @@ -242,6 +351,130 @@ export const updateInvoiceAutoReminderParameters = (context: Context) => }).optional().describe('Notification settings for the reminder.'), }).describe('Full replacement configuration for an existing invoice auto reminder. All required fields must be included since this performs a full update.'); +export const recordPaymentForInvoiceParameters = (context: Context) => + z.object({ + invoice_id: z.string().regex(INVOICE_ID_REGEX, "Invalid PayPal Invoice ID").describe('The ID of the invoice to record the payment against.'), + payment_id: z.preprocess((val) => (val === '' ? undefined : val), z.string().max(22).optional()).describe('The ID for a PayPal payment transaction. Required for the PAYPAL payment type.'), + payment_date: z.preprocess((val) => (val === '' ? undefined : val), z.string().regex(DATE_NO_TIME_REGEX, "payment_date must be in yyyy-MM-DD format").optional()).describe('The date when the invoicer recorded the payment, in yyyy-MM-dd format.'), + payment_date_time: z.preprocess((val) => (val === '' ? undefined : val), z.string().min(20).max(64).optional()).describe('The date and time when the invoicer recorded the payment, in Internet date and time format (ISO 8601), for example 2018-05-13T21:20:00Z or 2018-05-13T21:20:00.000-08:00. Seconds are required.'), + method: z.enum(['BANK_TRANSFER', 'CASH', 'CHECK', 'CREDIT_CARD', 'DEBIT_CARD', 'PAYPAL', 'WIRE_TRANSFER', 'OTHER']).describe('The payment mode or method through which the invoicer can accept the payments.'), + note: z.string().max(2000).optional().describe('A note associated with an external cash or check payment.'), + amount: money().optional().describe('The currency and amount for a financial transaction.'), + shipping_info: shippingInfo().optional().describe('The shipping information associated with this payment.'), + }).describe('Record an external or PayPal payment against an invoice.'); + +export const recordRefundForInvoiceParameters = (context: Context) => + z.object({ + invoice_id: z.string().regex(INVOICE_ID_REGEX, "Invalid PayPal Invoice ID").describe('The ID of the invoice to mark as refunded.'), + refund_date: z.preprocess((val) => (val === '' ? undefined : val), z.string().regex(DATE_NO_TIME_REGEX, "refund_date must be in yyyy-MM-DD format").optional()).describe('The date when the invoicer recorded the refund, in yyyy-MM-dd format.'), + amount: money().optional().describe('The currency and amount for a financial transaction.'), + method: z.enum(['BANK_TRANSFER', 'CASH', 'CHECK', 'CREDIT_CARD', 'DEBIT_CARD', 'PAYPAL', 'WIRE_TRANSFER', 'OTHER']).describe('The payment mode or method through which the invoicer can accept the payments.'), + }).describe('Record a refund against an invoice.'); + +const invoiceConditionalRule = z.object({ + conditional_rule_type: z.enum(['EARLY_PAYMENT_DISCOUNT', 'AUTO_CANCEL']).describe('The type of conditional rule to apply to the invoice.'), + conditional_rule_value_type: z.enum(['PERCENT', 'AMOUNT']).optional().describe('The type of the conditional rule value. Required, and only applicable, when conditional_rule_type is EARLY_PAYMENT_DISCOUNT.'), + conditional_rule_value: z.string().optional().describe('The value of the conditional rule. Required, and only applicable, when conditional_rule_type is EARLY_PAYMENT_DISCOUNT. When conditional_rule_value_type is PERCENT, must be between 1 and 100.'), + rule_expiry_terms: z.object({ + rule_expiry_condition: z.enum([ + 'SPECIFIC_DATE', + 'THREE_DAYS_AFTER_ISSUE_DATE', + 'SEVEN_DAYS_AFTER_ISSUE_DATE', + 'FIFTEEN_DAYS_AFTER_ISSUE_DATE', + 'THIRTY_DAYS_AFTER_ISSUE_DATE', + ]).describe('When the conditional rule expires: a specific date, or a period relative to the invoice issue date.'), + condition_rule_end_date: z.string().regex(DATE_NO_TIME_REGEX, "condition_rule_end_date must be in yyyy-MM-DD format").describe('The date the conditional rule expires, in yyyy-MM-dd format.'), + }).describe('The expiry terms for the conditional rule.'), +}).refine( + (rule) => rule.conditional_rule_type !== 'EARLY_PAYMENT_DISCOUNT' || (rule.conditional_rule_value_type !== undefined && rule.conditional_rule_value !== undefined), + { message: 'conditional_rule_value_type and conditional_rule_value are required when conditional_rule_type is EARLY_PAYMENT_DISCOUNT.' } +).refine( + (rule) => rule.conditional_rule_value_type !== 'PERCENT' || (Number(rule.conditional_rule_value) >= 1 && Number(rule.conditional_rule_value) <= 100), + { message: 'conditional_rule_value must be between 1 and 100 when conditional_rule_value_type is PERCENT.' } +); + +export const createConditionalRulesForInvoiceParameters = (context: Context) => + z.object({ + invoice_id: z.string().regex(INVOICE_ID_REGEX, "Invalid PayPal Invoice ID").describe('The ID of the invoice for which the conditional rules are to be created.'), + rules: z.array(invoiceConditionalRule).min(1).describe('The list of conditional rules to create for the invoice.'), + }).describe('Create conditional rules for an invoice.'); + + +// search_invoicing: one external tool that internally branches to invoice search or +// recurring-series search based on resource_type. invoice_filters/recurring_series_filters +// map 1:1 onto PayPal's real request bodies for /v2/invoicing/search-invoices and +// /v2/invoicing/search-recurring-invoices respectively -- no payload reshaping needed. + +// Some MCP clients "clear" an array/string filter by blanking it to [] or "" instead of +// omitting the field entirely -- these treat such blanked-out values as not provided. +const arrayOrUndefinedIfEmpty = (schema: z.ZodTypeAny) => z.preprocess( + (val) => (Array.isArray(val) && val.length === 0 ? undefined : val), + schema +); + +const stringOrUndefinedIfBlank = (schema: z.ZodTypeAny) => z.preprocess( + (val) => (typeof val === "string" && val.trim().length === 0 ? undefined : val), + schema +); + +const searchInvoicesFilters = () => z.object({ + recipient_email: stringOrUndefinedIfBlank(z.string().max(254).optional().describe("Filters invoices by the recipient's email address.")), + recipient_first_name: stringOrUndefinedIfBlank(z.string().max(140).optional().describe("Filters invoices by the recipient's first name.")), + recipient_last_name: stringOrUndefinedIfBlank(z.string().max(140).optional().describe("Filters invoices by the recipient's last name.")), + recipient_business_name: stringOrUndefinedIfBlank(z.string().max(300).optional().describe("Filters invoices by the recipient's business name.")), + invoice_number: stringOrUndefinedIfBlank(z.string().max(25).optional().describe("Filters invoices by invoice number.")), + status: arrayOrUndefinedIfEmpty(z.array(z.enum([ + "DRAFT", "SENT", "SCHEDULED", "PAID", "MARKED_AS_PAID", "CANCELLED", "REFUNDED", + "PARTIALLY_PAID", "PARTIALLY_REFUNDED", "MARKED_AS_REFUNDED", "UNPAID", "PAYMENT_PENDING", + "AUTO_CANCELLED", "PAID_EXTERNAL", "REFUNDED_EXTERNAL", "SHARED", + ])).max(5).optional().describe("Filters invoices by one or more statuses (up to 5).")), + reference: stringOrUndefinedIfBlank(z.string().max(120).optional().describe("Filters invoices by reference value, such as a purchase order number.")), + currency_code: stringOrUndefinedIfBlank(z.string().optional().describe("Filters invoices by the three-character ISO-4217 currency code.")), + total_amount_range: amountRangeOrUndefinedIfBlank("Filters invoices whose total amount falls within this range."), + invoice_date_range: dateRangeOrUndefinedIfBlank("Filters invoices by the invoice's own date (the date shown on the invoice itself, also called the billing date). Use this for most 'invoices dated/created/issued between X and Y' requests -- creation_date_range is for PayPal's internal record-creation timestamp, not the invoice's date."), + due_date_range: dateRangeOrUndefinedIfBlank("Filters invoices whose due date falls within this range."), + payment_date_range: dateTimeRangeOrUndefinedIfBlank("Filters invoices by the date and time PayPal recorded the invoice as paid (a system timestamp)."), + creation_date_range: dateTimeRangeOrUndefinedIfBlank("Filters invoices by the date and time PayPal's system recorded the invoice record as created (an internal system timestamp, NOT the invoice's own date). For 'invoices dated/created between X and Y' requests, prefer invoice_date_range unless the user specifically means when the record was created in PayPal."), +}).describe("Filters for searching individual invoices."); + +const recurringSeriesSearchFilters = () => z.object({ + currency_code: stringOrUndefinedIfBlank(z.string().optional().describe("Filters recurring series by the three-character ISO-4217 currency code.")), + status: arrayOrUndefinedIfEmpty(z.array(z.enum(["DRAFT", "ACTIVE", "CANCELLED", "EXPIRED"])).min(1).max(5).optional().describe("Filters recurring series by one or more statuses (up to 5, must be unique).")), + creation_date_range: dateTimeRangeOrUndefinedIfBlank("Filters recurring series by the date and time PayPal recorded the series as created (a system timestamp). This endpoint has no separate 'series date' field -- for 'series created/started between X and Y' requests, this is the field to use. Mutually exclusive with next_occurrence_date_range -- PayPal supports only one range criterion per search."), + next_occurrence_date_range: dateRangeOrUndefinedIfBlank("Filters recurring series whose next occurrence date falls within this range. Mutually exclusive with creation_date_range -- PayPal supports only one range criterion per search."), + total_amount_range: amountRangeOrUndefinedIfBlank("Filters recurring series whose total amount falls within this range."), +}).refine((val) => val.status === undefined || new Set(val.status).size === val.status.length, { + message: "status values must be unique.", + path: ["status"], +}).refine((val) => val.creation_date_range === undefined || val.next_occurrence_date_range === undefined, { + message: "creation_date_range and next_occurrence_date_range cannot both be set -- PayPal supports only one range criterion per search.", + path: ["next_occurrence_date_range"], +}).describe("Structured filters for searching recurring invoice series."); + +const searchRecurringSeriesFilters = () => z.object({ + search_text: stringOrUndefinedIfBlank(z.string().min(3).max(800).optional().describe("Free-text search across the fields listed in search_fields. Cannot be blank.")), + search_fields: arrayOrUndefinedIfEmpty(z.array(z.enum([ + "PAYER_REFERENCE_INFO", "BILLING_EMAIL", "BILLING_NAME", "BILLING_BUSINESS_NAME", + "BILLING_PHONE_NUMBER", "SHIPPING_NAME", "SHIPPING_BUSINESS_NAME", "SHIPPING_PHONE_NUMBER", + "ITEM_NAME", "ITEM_TAX_NAME", "ITEM_DISCOUNT_NAME", "INVOICE_DISCOUNT_NAME", "ALL", + ])).min(1).max(5).optional().describe("The fields that search_text searches against (1-5 values, must be unique).")), + search_filters: recurringSeriesSearchFilters().optional().describe("Structured filters for the recurring series search."), +}).refine((val) => val.search_fields === undefined || new Set(val.search_fields).size === val.search_fields.length, { + message: "search_fields values must be unique.", + path: ["search_fields"], +}).describe("Filters for searching recurring invoice series."); + +// Note: the resource_type <-> filters pairing is validated at runtime in searchInvoicing() +// (functions.ts), not via .refine() here -- Tool.parameters requires a plain z.ZodObject +// (see tools.ts), and .refine() would wrap this in a ZodEffects that no longer satisfies it. +export const searchInvoicingParameters = (context: Context) => z.object({ + resource_type: z.enum(["invoice", "recurring_series"]).describe("Which resource type to search. 'invoice' searches individual invoices using invoice_filters; 'recurring_series' searches recurring invoice series using recurring_series_filters."), + page: z.number().int().min(1).max(1000).default(1).optional().describe("The page number of the result set to fetch."), + page_size: z.number().int().min(1).max(100).default(20).optional().describe("The number of records to return per page (maximum 100)."), + total_required: z.boolean().default(false).optional().describe("Indicates whether the response should include the total count of matching invoices. Only applies when resource_type is 'invoice'."), + invoice_filters: searchInvoicesFilters().optional().describe("Filters to apply when resource_type is 'invoice'. Must not be set when resource_type is 'recurring_series'."), + recurring_series_filters: searchRecurringSeriesFilters().optional().describe("Filters to apply when resource_type is 'recurring_series'. Must not be set when resource_type is 'invoice'."), +}).describe("Search for invoices or recurring invoice series, depending on resource_type."); export const updateProductParameters = (context: Context) => z.object({ diff --git a/typescript/src/shared/prompts.ts b/typescript/src/shared/prompts.ts index 23fdb2a4..64ea2893 100644 --- a/typescript/src/shared/prompts.ts +++ b/typescript/src/shared/prompts.ts @@ -20,12 +20,36 @@ primary_recipients and items use PayPal's real nested shape (billing_info/shippi A newly created series is in DRAFT status and will not generate invoices until activated -- call activate_recurring_series with the returned series ID to activate it. `; +export const updateInvoicingPrompt = (context: Context) => ` +Update an existing invoice or recurring invoice series on PayPal. + +For invoices, the recipient (primary_recipients) can only be changed 2 times within any 72-hour window -- avoid unnecessary recipient edits. +`; + export const activateRecurringSeriesPrompt = (context: Context) => ` Activate a recurring invoice series on PayPal. This function activates a recurring invoice series by its ID, moving it out of DRAFT status. Once activated, PayPal automatically generates and sends invoices to the customer based on the series' configured schedule. Call this after create_recurring_series to make the series active. `; +export const getRecurringSeriesPrompt = (context: Context) => ` +Get a recurring invoice series from PayPal. + +This function retrieves details of a specific recurring invoice series using its ID, including its schedule, status, template, and recipient information. +`; + +export const cancelRecurringSeriesPrompt = (context: Context) => ` +Cancel a recurring invoice series on PayPal. + +This function cancels a recurring invoice series by its ID. Once cancelled, PayPal stops generating and sending further invoices for the series. This action cannot be undone. +`; + +export const deleteRecurringSeriesPrompt = (context: Context) => ` +Delete a recurring invoice series on PayPal. + +This function permanently deletes a recurring invoice series by its ID. Only series in DRAFT status can be deleted -- for a series that has already been activated, use cancel_recurring_series instead. This action cannot be undone. +`; + export const listInvoicesPrompt = (context: Context) => ` List invoices from PayPal. @@ -56,6 +80,12 @@ Cancel a sent invoice. This function cancels an invoice that has already been sent to the recipient(s). `; +export const deleteInvoicePrompt = (context: Context) => ` +Delete a draft or scheduled invoice on PayPal. + +This function permanently deletes an invoice that is in the draft or scheduled state, by ID. It does not work on invoices that have already been sent -- use cancel_sent_invoice for those instead. After deletion, the invoice's details can no longer be retrieved, but its invoice number can be reused. +`; + export const setupInvoiceAutoReminderPrompt = (context: Context) => ` Initialize the invoice auto reminder configuration for the merchant's PayPal account. @@ -68,12 +98,50 @@ Update an existing invoice auto reminder configuration by its configuration ID. This function performs a full update of the reminder configuration's timing interval, repetition count and notification preferences. `; +export const searchInvoicingPrompt = (context: Context) => ` +Search for invoices or recurring invoice series on PayPal. + +Use resource_type "invoice" with invoice_filters, or "recurring_series" with recurring_series_filters -- only set the matching filters object. Recurring series search covers only the past 3 years. +`; + +export const cancelInvoiceAutoReminderPrompt = (context: Context) => ` +Cancel all scheduled automatic reminders for an invoice. + +This function permanently cancels every automatic reminder scheduled for a specific invoice, by invoice ID. This action is irreversible -- once cancelled, automatic reminders cannot be re-enabled for that invoice. +`; + export const generateInvoiceQrCodePrompt = (context: Context) => ` Generate a QR code for an invoice. This function generates a QR code for an invoice, which can be used to pay the invoice using a mobile device or scanning app. `; +export const generateInvoiceNumberPrompt = (context: Context) => ` +Generate the next invoice number available to the merchant. + +This function generates the next invoice number by using the prefix and suffix from the merchant's last invoice number and incrementing the numeric portion by one (e.g. INVOICE-1234 -> INVOICE-1235). +`; + +export const recordPaymentForInvoicePrompt = (context: Context) => ` +Record a payment for an invoice on PayPal. + +This function records an external or manual payment (for example, cash, check, bank transfer, or a PayPal transaction) against an invoice, by invoice ID. If the recorded amount covers the full amount due, PayPal marks the invoice PAID; otherwise it is marked PARTIALLY_PAID. This does not process a new payment -- it only logs one that was already collected. + +method is required. payment_id applies only to PAYPAL-type payments. +`; + +export const recordRefundForInvoicePrompt = (context: Context) => ` +Record a refund for an invoice on PayPal. + +This function records a refund against an invoice, by invoice ID. If all payments made against the invoice are refunded, PayPal marks the invoice REFUNDED; otherwise it is marked PARTIALLY_REFUNDED. This does not process a new refund -- it only logs one that was already issued. + +method is required. +`; + +export const createConditionalRulesForInvoicePrompt = (context: Context) => ` +Create conditional rules for an invoice on PayPal. +`; + export const createProductPrompt = (context: Context) => ` Create a product in PayPal using product catalog - create products API. This function creates a new product that will be used in subscription plans, subscriptions. diff --git a/typescript/src/shared/regex.ts b/typescript/src/shared/regex.ts index 77b2204a..9623d679 100644 --- a/typescript/src/shared/regex.ts +++ b/typescript/src/shared/regex.ts @@ -9,6 +9,7 @@ export const CAPTURE_ID_REGEX = /^[A-Za-z0-9_-]{15,32}$/; export const TRANSACTION_ID_REGEX = /^[A-Za-z0-9_-]{12,255}$/; export const HEX_COLOR_REGEX = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/; export const DATE_NO_TIME_REGEX = /^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])$/; +export const DATE_TIME_REGEX = /^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\.[0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$/; export const RECURRING_SERIES_ID_REGEX = /^RI-[A-Z0-9]{17}$/; export const COUNTRY_CODE_REGEX = /^([A-Z]{2}|C2)$/; export const LANGUAGE_REGEX = /^[a-z]{2}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$/; diff --git a/typescript/src/shared/tools.ts b/typescript/src/shared/tools.ts index c7b1fee7..1b561b30 100644 --- a/typescript/src/shared/tools.ts +++ b/typescript/src/shared/tools.ts @@ -4,16 +4,27 @@ import { createInvoicePrompt, createRecurringSeriesPrompt, activateRecurringSeriesPrompt, + getRecurringSeriesPrompt, + cancelRecurringSeriesPrompt, + deleteRecurringSeriesPrompt, listInvoicesPrompt, getInvoicePrompt, sendInvoicePrompt, sendInvoiceReminderPrompt, cancelSentInvoicePrompt, + deleteInvoicePrompt, setupInvoiceAutoReminderPrompt, updateInvoiceAutoReminderPrompt, + searchInvoicingPrompt, + updateInvoicingPrompt, + cancelInvoiceAutoReminderPrompt, createShipmentPrompt, getShipmentTrackingPrompt, generateInvoiceQrCodePrompt, + generateInvoiceNumberPrompt, + recordPaymentForInvoicePrompt, + recordRefundForInvoicePrompt, + createConditionalRulesForInvoicePrompt, createOrderPrompt, getOrderPrompt, updateShipmentTrackingPrompt, @@ -43,16 +54,27 @@ import { createInvoiceParameters, createRecurringSeriesParameters, activateRecurringSeriesParameters, + getRecurringSeriesParameters, + cancelRecurringSeriesParameters, + deleteRecurringSeriesParameters, listInvoicesParameters, getInvoicParameters, sendInvoiceParameters, sendInvoiceReminderParameters, cancelSentInvoiceParameters, + deleteInvoiceParameters, setupInvoiceAutoReminderParameters, updateInvoiceAutoReminderParameters, + searchInvoicingParameters, + updateInvoicingParameters, + cancelInvoiceAutoReminderParameters, createShipmentParameters, getShipmentTrackingParameters, generateInvoiceQrCodeParameters, + generateInvoiceNumberParameters, + recordPaymentForInvoiceParameters, + recordRefundForInvoiceParameters, + createConditionalRulesForInvoiceParameters, createOrderParameters, getOrderParameters, updateShipmentTrackingParameters, @@ -126,6 +148,39 @@ const tools = (context: Context): Tool[] => [ }, }, }, + { + method: 'get_recurring_series', + name: 'Get Recurring Invoice Series', + description: getRecurringSeriesPrompt(context), + parameters: getRecurringSeriesParameters(context), + actions: { + invoices: { + getRecurringSeries: true, + }, + }, + }, + { + method: 'cancel_recurring_series', + name: 'Cancel Recurring Invoice Series', + description: cancelRecurringSeriesPrompt(context), + parameters: cancelRecurringSeriesParameters(context), + actions: { + invoices: { + cancelRecurringSeries: true, + }, + }, + }, + { + method: 'delete_recurring_series', + name: 'Delete Recurring Invoice Series', + description: deleteRecurringSeriesPrompt(context), + parameters: deleteRecurringSeriesParameters(context), + actions: { + invoices: { + deleteRecurringSeries: true, + }, + }, + }, { method: 'list_invoices', name: 'List Invoices', @@ -181,6 +236,17 @@ const tools = (context: Context): Tool[] => [ }, }, }, + { + method: 'delete_invoice', + name: 'Delete Invoice', + description: deleteInvoicePrompt(context), + parameters: deleteInvoiceParameters(context), + actions: { + invoices: { + delete: true, + }, + }, + }, { method: 'setup_invoice_auto_reminders', name: 'Setup Invoice Auto Reminders', @@ -203,6 +269,39 @@ const tools = (context: Context): Tool[] => [ }, }, }, + { + method: 'search_invoicing', + name: 'Search Invoices or Recurring Invoice Series', + description: searchInvoicingPrompt(context), + parameters: searchInvoicingParameters(context), + actions: { + invoices: { + search: true, + }, + }, + }, + { + method: 'update_invoicing', + name: 'Update Invoice or Recurring Invoice Series', + description: updateInvoicingPrompt(context), + parameters: updateInvoicingParameters(context), + actions: { + invoices: { + update: true, + }, + }, + }, + { + method: 'cancel_invoice_auto_reminder', + name: 'Cancel Invoice Auto Reminder', + description: cancelInvoiceAutoReminderPrompt(context), + parameters: cancelInvoiceAutoReminderParameters(context), + actions: { + invoices: { + cancelReminders: true, + }, + }, + }, { method: 'generate_invoice_qr_code', name: 'Generate Invoice QR Code', @@ -214,6 +313,50 @@ const tools = (context: Context): Tool[] => [ }, }, }, + { + method: 'generate_invoice_number', + name: 'Generate Invoice Number', + description: generateInvoiceNumberPrompt(context), + parameters: generateInvoiceNumberParameters(context), + actions: { + invoices: { + generateInvoiceNumber: true, + }, + }, + }, + { + method: 'record_payment_for_invoice', + name: 'Record Payment For Invoice', + description: recordPaymentForInvoicePrompt(context), + parameters: recordPaymentForInvoiceParameters(context), + actions: { + invoices: { + recordPayment: true, + }, + }, + }, + { + method: 'record_refund_for_invoice', + name: 'Record Refund For Invoice', + description: recordRefundForInvoicePrompt(context), + parameters: recordRefundForInvoiceParameters(context), + actions: { + invoices: { + recordRefund: true, + }, + }, + }, + { + method: 'create_conditional_rules_for_invoice', + name: 'Create Conditional Rules For Invoice', + description: createConditionalRulesForInvoicePrompt(context), + parameters: createConditionalRulesForInvoiceParameters(context), + actions: { + invoices: { + createConditionalRules: true, + }, + }, + }, { method: 'create_product', name: 'Create Product',