diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8946169 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.pypirc +*.pyc +test.py +venv +build \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index 9203697..e5a4727 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1 @@ -include *.txt -recursive-include docs *.txt \ No newline at end of file +include LICENSE *.rst *.txt *.md \ No newline at end of file diff --git a/README.md b/README.md index 8540782..d6a7953 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ FasterPay Python SDK enables you to integrate the FasterPay's Checkout Page seamlessly without having the hassle of integrating everything from Scratch. Once your customer is ready to pay, FasterPay will take care of the payment, notify your system about the payment and return the customer back to your Thank You page. +For more information on the API Parameters, refer to our entire API Documentation [here](https://docs.fasterpay.com/api#section-custom-integration) ## Downloading the FasterPay Python SDK @@ -12,7 +13,7 @@ $ git clone https://github.com/FasterPay/fasterpay-python3.git ## Installing the FasterPay Python SDK. ```sh $ cd fasterpay-python3 -$ sudo python setup.py install +$ pip install -e . ``` ## Initiating Payment Request using Python SDK @@ -40,8 +41,6 @@ if __name__ == "__main__": print paymentForm ``` -For more information on the API Parameters, refer to our entire API Documentation [here](https://docs.fasterpay.com/api#section-custom-integration) - ## Handling FasterPay Pingbacks ```python diff --git a/fasterpay/address.py b/fasterpay/address.py new file mode 100644 index 0000000..25772c7 --- /dev/null +++ b/fasterpay/address.py @@ -0,0 +1,18 @@ +import requests + + +class Address: + def __init__(self, gateway): + self.gateway = gateway + self.api_url = gateway.config.external_api_url + + def get_address(self, country_code: str) -> dict: + """Retrieve the address fields required for the specified country.""" + if not country_code: + raise ValueError("country_code is required to retrieve address fields.") + + url = f"{self.api_url}/api/external/address/fields/{country_code}" + + response = requests.get(url) + response.raise_for_status() + return response.json() diff --git a/fasterpay/config.py b/fasterpay/config.py index c73b819..094fd5b 100644 --- a/fasterpay/config.py +++ b/fasterpay/config.py @@ -1,26 +1,37 @@ class Config: + def __init__( + self, + private_key: str, + public_key: str, + is_test: bool = False, + api_version: str = "1.0.0", + ): + self._private_key = private_key + self._public_key = public_key + self._api_base_url = ( + "https://pay.sandbox.fasterpay.com" + if is_test else + "https://pay.fasterpay.com" + ) + self._api_version = api_version + self._external_api_url = "https://business.fasterpay.com" - def __init__(self, privateKey, publicKey, is_test = False, apiVersion = None): - self.publicKey = publicKey - self.privateKey = privateKey - if is_test is True : - self.API_BASE_URL = "https://pay.fasterpay.com" - else: - self.API_BASE_URL = "https://pay.sandbox.fasterpay.com" + @property + def public_key(self) -> str: + return self._public_key - if apiVersion is not None : - self.VERSION = apiVersion - else: - self.VERSION = "1.0.0" + @property + def private_key(self) -> str: + return self._private_key - def get_public_key(self): - return self.publicKey + @property + def api_url(self) -> str: + return self._api_base_url - def get_private_key(self): - return self.privateKey - - def get_api_url(self): - return self.API_BASE_URL - - def get_api_version(self): - return self.VERSION + @property + def api_version(self) -> str: + return self._api_version + + @property + def external_api_url(self) -> str: + return self._external_api_url diff --git a/fasterpay/contact.py b/fasterpay/contact.py new file mode 100644 index 0000000..fbccb23 --- /dev/null +++ b/fasterpay/contact.py @@ -0,0 +1,152 @@ +import requests + +class Contact: + def __init__(self, gateway): + self.gateway = gateway + self.api_url = gateway.config.external_api_url + self.api_key = gateway.config.private_key + + def create_contact(self, params: dict) -> dict: + """ + Create a new contact. + + Parameters (at least one of `email` or `phone` is required): + - email (str): Email address of the contact. + - phone (str): Phone number of the contact. + - phone_country_code (str): Required if phone is provided. ISO 3166-1 alpha-2 format (e.g., 'US'). + - first_name (str, optional): Max 90 characters. + - last_name (str, optional): Max 90 characters. + - country (str, optional): ISO 3166-1 alpha-2 code. + - favorite (bool, optional): Mark this contact as favorite. + + Endpoint: + POST https://business.fasterpay.com/api/external/contacts + + Docs: + https://docs.fasterpay.com/api#section-create-contact + + Returns: + dict: Contact creation result. + """ + email = params.get("email") + phone = params.get("phone") + phone_code = params.get("phone_country_code") + + if not email and not phone: + raise ValueError("Either 'email' or 'phone' is required.") + + if phone and not phone_code: + raise ValueError("'phone_country_code' is required when 'phone' is provided.") + + url = f"{self.api_url}/api/external/contacts" + response = requests.post(url, json=params) + response.raise_for_status() + return response.json() + + def list_contacts(self, params: dict = None) -> dict: + """ + Retrieve a list of contacts with optional filtering and sorting. + + Optional Parameters: + - prefer_favorite (bool): Show favorite contacts first. + - fasterpay_account_only (bool): Show only contacts with FasterPay accounts. + - name (str): Filter by full name (first or last name). + - email (str): Filter by email prefix. + - phone (str): Filter by phone number. + - country (str): Filter by ISO 3166-1 alpha-2 country code. + - sort_by (str): Required if `order_by` is present. One of: first_name, last_name, favorite, updated_at, last_transfer_at. + - order_by (str): Required if `sort_by` is present. One of: asc, desc. + - page (int): Page number to retrieve (default 1). + - per_page (int): Max records per page (max 1000). + + Endpoint: + GET https://business.fasterpay.com/api/external/contacts + + Docs: + https://docs.fasterpay.com/api#section-contact-list + + Returns: + dict: Paginated list of contacts. + """ + params = params or {} + + if "sort_by" in params and "order_by" not in params: + raise ValueError("'order_by' is required when 'sort_by' is provided.") + if "order_by" in params and "sort_by" not in params: + raise ValueError("'sort_by' is required when 'order_by' is provided.") + + url = f"{self.api_url}/api/external/contacts" + response = requests.get(url, params=params) + response.raise_for_status() + return response.json() + + def get_contact(self, contact_id: str) -> dict: + """ + Retrieve details of a specific contact by ID. + + Parameters: + - contact_id (str): ID of the contact (e.g., CT-250527-AZARCIJE) + + Endpoint: + GET https://business.fasterpay.com/api/external/contacts/{contact_id} + + Returns: + dict: Contact detail response. + """ + if not contact_id: + raise ValueError("contact_id is required to retrieve a contact.") + + url = f"{self.api_url}/api/external/contacts/{contact_id}" + response = requests.get(url) + response.raise_for_status() + return response.json() + + def update_contact(self, contact_id: str, params: dict) -> dict: + """ + Update a contact's details. + + Parameters: + - contact_id (str): ID of the contact to update. + - params (dict): Fields to update: + - email (str, optional) + - phone (str, optional) + - phone_country_code (str): Required if phone is present. + - first_name (str, optional) + - last_name (str, optional) + - country (str, optional) + - favorite (bool, optional) + + Endpoint: + PUT https://business.fasterpay.com/api/external/contacts/{contact_id} + + Returns: + dict: Updated contact info. + """ + if not contact_id: + raise ValueError("contact_id is required to update a contact.") + + url = f"{self.api_url}/api/external/contacts/{contact_id}" + response = requests.put(url, json=params) + response.raise_for_status() + return response.json() + + def delete_contact(self, contact_id: str) -> dict: + """ + Delete a contact by ID. + + Parameters: + - contact_id (str): ID of the contact to delete. + + Endpoint: + DELETE https://business.fasterpay.com/api/external/contacts/{contact_id} + + Returns: + dict: API response with success status. + """ + if not contact_id: + raise ValueError("contact_id is required to delete a contact.") + + url = f"{self.api_url}/api/external/contacts/{contact_id}" + response = requests.delete(url) + response.raise_for_status() + return response.json() diff --git a/fasterpay/einvoice.py b/fasterpay/einvoice.py new file mode 100644 index 0000000..1112320 --- /dev/null +++ b/fasterpay/einvoice.py @@ -0,0 +1,765 @@ +import requests +import json + + +class Einvoice: + def __init__(self, gateway): + self.gateway = gateway + self.api_url = gateway.config.external_api_url + self.api_key = gateway.config.private_key + + def create_invoice(self, params: dict) -> dict: + """ + Create a new E-Invoice. + + Automatically switches to multipart/form-data if `template.logo` or `items.product.image` contains a file object. + + Docs: + https://docs.fasterpay.com/api#section-create-invoice + + Returns: + dict: API response containing invoice data. + """ + if not params.get("contact_id"): + raise ValueError("'contact_id' is required.") + if not params.get("currency"): + raise ValueError("'currency' is required.") + if not params.get("items") or not isinstance(params["items"], list): + raise ValueError("'items' must be a non-empty list of item objects.") + if params.get("template") and params.get("invoice_template_id"): + raise ValueError("Provide either 'template' or 'invoice_template_id', not both.") + + url = f"{self.api_url}/api/external/invoices" + headers = {"X-ApiKey": self.api_key} + + # Check for file uploads + files = {} + file_keys = [] + + if isinstance(params.get("template", {}).get("logo"), (bytes, tuple)): + files["template.logo"] = params["template"]["logo"] + params["template"]["logo"] = None # Replace file with placeholder + file_keys.append("template.logo") + + for i, item in enumerate(params.get("items", [])): + product = item.get("product") + if isinstance(product, dict) and isinstance(product.get("image"), (bytes, tuple)): + field_name = f"items[{i}].product.image" + files[field_name] = product["image"] + product["image"] = None # Replace with placeholder + file_keys.append(field_name) + + if files: + # Send as multipart/form-data + headers.pop("Content-Type", None) + form_data = { + "json": json.dumps(params) + } + response = requests.post(url, headers=headers, data=form_data, files=files) + else: + # Send as JSON + headers["Content-Type"] = "application/json" + response = requests.post(url, headers=headers, json=params) + + response.raise_for_status() + return response.json() + + def list_invoices(self, params: dict = None) -> dict: + """ + List E-Invoices with optional filters. + + Returns: + dict: Response data from the API. + """ + url = f"{self.api_url}/api/external/invoices" + headers = { + "X-ApiKey": self.api_key, + "Content-Type": "application/json" + } + response = requests.get(url, params=params or {}, headers=headers) + response.raise_for_status() + return response.json() + + def get_invoice(self, invoice_id: str, params: dict = None) -> dict: + """ + Retrieve details of a specific E-Invoice by ID. + + Returns: + dict: Invoice details. + """ + if not invoice_id: + raise ValueError("invoice_id is required.") + + url = f"{self.api_url}/api/external/invoices/{invoice_id}" + headers = { + "X-ApiKey": self.api_key, + "Content-Type": "application/json" + } + response = requests.get(url, params=params or {}, headers=headers) + response.raise_for_status() + return response.json() + + def update_invoice(self, invoice_id: str, params: dict) -> dict: + """ + Update an existing E-Invoice. + + Automatically switches to multipart/form-data if `template.logo` or `items.product.image` contains a file object. + + Docs: + https://docs.fasterpay.com/api#section-update-invoice + + Args: + invoice_id (str): The ID of the invoice to update. + params (dict): Fields to update. + + Returns: + dict: API response. + """ + if not invoice_id: + raise ValueError("invoice_id is required.") + if not isinstance(params, dict): + raise ValueError("params must be a dictionary.") + + url = f"{self.api_url}/api/external/invoices/{invoice_id}" + headers = {"X-ApiKey": self.api_key} + + # Check for file uploads + files = {} + file_keys = [] + + if isinstance(params.get("template", {}).get("logo"), (bytes, tuple)): + files["template.logo"] = params["template"]["logo"] + params["template"]["logo"] = None + file_keys.append("template.logo") + + for i, item in enumerate(params.get("items", [])): + product = item.get("product") + if isinstance(product, dict) and isinstance(product.get("image"), (bytes, tuple)): + field_name = f"items[{i}].product.image" + files[field_name] = product["image"] + product["image"] = None + file_keys.append(field_name) + + if files: + # multipart/form-data (use POST method) + form_data = { + "json": json.dumps(params) + } + response = requests.post(url, headers=headers, data=form_data, files=files) + else: + # Standard PUT request + headers["Content-Type"] = "application/json" + response = requests.put(url, headers=headers, json=params) + + response.raise_for_status() + return response.json() + + def update_invoice_status(self, invoice_id: str, status: str) -> dict: + """ + Update the status of an existing E-Invoice. + + Allowed status values: + - void + - uncollectible + + Docs: + https://docs.fasterpay.com/api#section-update-invoice-status + + Args: + invoice_id (str): ID of the invoice to update. + status (str): New status value. Must be one of "void" or "uncollectible". + + Returns: + dict: API response with updated invoice data. + """ + if not invoice_id: + raise ValueError("invoice_id is required.") + if status not in ["void", "uncollectible"]: + raise ValueError("Invalid status. Must be one of: 'void', 'uncollectible'.") + + url = f"{self.api_url}/api/external/invoices/{invoice_id}/status" + headers = { + "X-ApiKey": self.api_key, + "Content-Type": "application/json" + } + + payload = {"status": status} + response = requests.put(url, headers=headers, json=payload) + response.raise_for_status() + return response.json() + + def preview_invoice_pdf(self, invoice_id: str) -> str: + """ + Generate an HTML preview of the invoice's PDF version. + + Docs: + https://docs.fasterpay.com/api#section-preview-invoice + + Args: + invoice_id (str): The ID of the invoice to preview. + + Returns: + str: Raw HTML content of the invoice preview. + """ + if not invoice_id: + raise ValueError("invoice_id is required.") + + url = f"{self.api_url}/api/external/invoices/{invoice_id}/pdf" + headers = {"X-ApiKey": self.api_key} + + response = requests.get(url, headers=headers) + response.raise_for_status() + return response.text # Returns HTML content + + + def send_invoice(self, invoice_id: str, test: bool = False) -> dict: + """ + Send the invoice to the customer's email address. + + Optionally sends a test invoice to the merchant’s email. + + Args: + invoice_id (str): The ID of the invoice to send. + test (bool): If True, sends a test invoice to the merchant email. + + Returns: + dict: API response containing the invoice data. + """ + if not invoice_id: + raise ValueError("invoice_id is required.") + + url = f"{self.api_url}/api/external/invoices/{invoice_id}/send" + headers = { + "X-ApiKey": self.api_key, + "Content-Type": "application/json" + } + + data = {"test": test} if test else {} + + response = requests.post(url, json=data, headers=headers) + response.raise_for_status() + return response.json() + + def create_invoice_template(self, template: dict) -> dict: + """ + Create a new invoice template. + + Automatically switches to multipart/form-data if `logo` is a file. + + Args: + template (dict): Dictionary containing template parameters: + - name (str): Required. Template name. + - country_code (str): Required. 2-letter country code. + - footer (str): Optional. Footer text. + - address (str) or localized_address (dict): Only one allowed. + - colors (dict): Required. Must include 'primary' and 'secondary' hex colors. + - logo (file-like or bytes, optional): Logo file if provided. + + Returns: + dict: API response containing created template details. + """ + if not template.get("name"): + raise ValueError("'name' is required.") + if not template.get("country_code"): + raise ValueError("'country_code' is required.") + if not template.get("colors"): + raise ValueError("'colors' is required and must contain 'primary' and 'secondary'.") + + if template.get("address") and template.get("localized_address"): + raise ValueError("Provide either 'address' or 'localized_address', not both.") + + url = f"{self.api_url}/api/external/invoices/templates" + headers = {"X-ApiKey": self.api_key} + + files = {} + logo = template.get("logo") + if isinstance(logo, (bytes, tuple)): + files["logo"] = logo + template["logo"] = None # Remove file object from JSON + + if files: + # Multipart form-data required + headers.pop("Content-Type", None) + form_data = { + "json": json.dumps(template) + } + response = requests.post(url, headers=headers, data=form_data, files=files) + else: + # Pure JSON + headers["Content-Type"] = "application/json" + response = requests.post(url, headers=headers, json=template) + + response.raise_for_status() + return response.json() + + def list_invoice_templates(self, params: dict = None) -> dict: + """ + Retrieve a list of all invoice templates with optional filters. + + Args: + params (dict, optional): Query parameters: + - page (int): Page number (starting from 1) + - per_page (int): Number of records per page (max 1000) + - filter (dict): Optional nested filter, e.g.: + - name (str): Filter by template name + + Returns: + dict: Response data containing list of invoice templates + """ + url = f"{self.api_url}/api/external/invoices/templates" + headers = {"X-ApiKey": self.api_key} + + response = requests.get(url, headers=headers, params=params or {}) + response.raise_for_status() + return response.json() + + def get_invoice_template(self, template_id: str) -> dict: + """ + Retrieve details of a specific invoice template by ID. + + Args: + template_id (str): The ID of the invoice template to retrieve. + + Returns: + dict: API response containing the template details. + """ + if not template_id: + raise ValueError("template_id is required.") + + url = f"{self.api_url}/api/external/invoices/templates/{template_id}" + headers = {"X-ApiKey": self.api_key} + + response = requests.get(url, headers=headers) + response.raise_for_status() + return response.json() + + def update_invoice_template(self, template_id: str, params: dict) -> dict: + """ + Update an existing invoice template. + + - Uses POST with `_method: PUT` if a logo file is included. + - Otherwise, uses a standard PUT request. + + Endpoint: + PUT (or POST with _method=PUT if files present) + https://business.fasterpay.com/api/external/invoices/templates/{template_id} + + Args: + template_id (str): ID of the template to update. + params (dict): Fields to update. Possible keys: + - name (str), address (str), country_code (str), + - footer (str), localized_address (dict), colors (dict), + - logo (file-like object, optional) + + Returns: + dict: API response with updated template data. + """ + if not template_id: + raise ValueError("template_id is required.") + + url = f"{self.api_url}/api/external/invoices/templates/{template_id}" + headers = {"X-ApiKey": self.api_key} + + logo = params.get("logo") + use_multipart = isinstance(logo, (bytes, tuple)) + + if use_multipart: + # Force _method override to PUT + params["_method"] = "PUT" + + files = {} + if logo: + files["logo"] = logo + params["logo"] = None + + form_data = {"json": json.dumps(params)} + response = requests.post(url, headers=headers, data=form_data, files=files) + else: + headers["Content-Type"] = "application/json" + response = requests.put(url, headers=headers, json=params) + + response.raise_for_status() + return response.json() + + def delete_invoice_template(self, template_id: str) -> dict: + """ + Delete an invoice template by ID. + + Args: + template_id (str): The ID of the invoice template to delete. + + Returns: + dict: API response confirming deletion. + """ + if not template_id: + raise ValueError("template_id is required.") + + url = f"{self.api_url}/api/external/invoices/templates/{template_id}" + headers = {"X-ApiKey": self.api_key} + + response = requests.delete(url, headers=headers) + response.raise_for_status() + return response.json() + + def create_invoice_product(self, params: dict) -> dict: + """ + Create a new product for use in invoices. + + Automatically switches to multipart/form-data if `image` is a file-like object. + + Args: + params (dict): Product data with possible keys: + - name (str, required) + - sku (str, optional) + - type (str, required): "physical" or "digital" + - description (str, optional) + - image (file-like object, optional) + - prices (list of dicts, optional) + + Returns: + dict: Created product data from the API. + """ + url = f"{self.api_url}/api/external/invoices/products" + headers = {"X-ApiKey": self.api_key} + + image = params.get("image") + use_multipart = isinstance(image, (bytes, tuple)) + + if use_multipart: + files = {"image": image} + params["image"] = None # Remove file reference from params + form_data = {"json": json.dumps(params)} + response = requests.post(url, headers=headers, data=form_data, files=files) + else: + headers["Content-Type"] = "application/json" + response = requests.post(url, headers=headers, json=params) + + response.raise_for_status() + return response.json() + + def list_invoice_products(self, params: dict = None) -> dict: + """ + Retrieve a list of invoice products with optional filters. + + Docs: + https://docs.fasterpay.com/api#section-list-invoice-products + + Args: + params (dict, optional): Query parameters for filtering and pagination. + - include (str): Use 'prices' to include price data in the response. + - page (int): Page number to retrieve (starting from 1). + - per_page (int): Number of items per page (max 1000). + - filter (dict): Object with possible keys: + - name (str): Filter by product name. + - type (str): Filter by type ('physical' or 'digital'). + - sku (str): Filter by SKU. + - prices (dict): Object with possible key: + - prices.currency (str): Currency code to filter product prices. + + Returns: + dict: API response with product list and metadata. + """ + url = f"{self.api_url}/api/external/invoices/products" + response = requests.get(url, headers={"X-ApiKey": self.api_key}, params=params or {}) + response.raise_for_status() + return response.json() + + def get_invoice_product(self, product_id: str) -> dict: + """ + Retrieve details of a specific invoice product by ID. + + Args: + product_id (str): The ID of the product to retrieve. + + Returns: + dict: Product details from the API. + """ + if not product_id: + raise ValueError("product_id is required.") + + url = f"{self.api_url}/api/external/invoices/products/{product_id}" + headers = {"X-ApiKey": self.api_key} + + response = requests.get(url, headers=headers) + response.raise_for_status() + return response.json() + + def update_invoice_product(self, product_id: str, data: dict, files: dict = None) -> dict: + """ + Update an invoice product. + + Docs: + https://docs.fasterpay.com/api#section-update-invoice-product + + Args: + product_id (str): ID of the product to update (e.g. 'PD-250528-L5CC'). + data (dict): Fields to update: + - name (str) + - sku (str) + - type (str): 'physical' or 'digital' + - description (str) + - prices (list of dict): [{price: float, currency: str}] + files (dict, optional): Use {'image': (filename, fileobj, mimetype)} if an image file is included. + + Returns: + dict: API response with updated product data. + """ + url = f"{self.api_url}/api/external/invoices/products/{product_id}" + + headers = {"X-ApiKey": self.api_key} + + if files: + data["_method"] = "PUT" + response = requests.post(url, data=data, files=files, headers=headers) + else: + response = requests.put(url, json=data, headers=headers) + + response.raise_for_status() + return response.json() + + def delete_invoice_product(self, product_id: str) -> dict: + """ + Delete an invoice product by ID. + + Args: + product_id (str): The ID of the product to delete. + + Returns: + dict: API response confirming deletion. + """ + if not product_id: + raise ValueError("product_id is required.") + + url = f"{self.api_url}/api/external/invoices/products/{product_id}" + headers = {"X-ApiKey": self.api_key} + + response = requests.delete(url, headers=headers) + response.raise_for_status() + return response.json() + + def delete_invoice_product_price(self, product_id: str, currency: str) -> dict: + """ + Delete an invoice product price by product ID and currency. + + Args: + product_id (str): The ID of the product to delete. + currency (str): The currency code of the price to delete (e.g. 'USD'). + + Returns: + dict: API response confirming deletion. + """ + if not product_id or not currency: + raise ValueError("product_id and currency is required.") + + url = f"{self.api_url}/api/external/invoices/products/{product_id}/prices/{currency}" + headers = {"X-ApiKey": self.api_key} + + response = requests.delete(url, headers=headers) + response.raise_for_status() + return response.json() + + def create_invoice_tax(self, data: dict) -> dict: + """ + Create an invoice tax. + + Args: + data (dict): Tax fields: + - name (str): Required + - value (float): Required, min 0.01, max 1000 + - description (str): Optional + - deleted_at (str): Optional, set to 'true' to permanently delete + + Returns: + dict: API response with created tax object + """ + url = f"{self.api_url}/api/external/invoices/taxes" + headers = {"X-ApiKey": self.api_key} + response = requests.post(url, json=data, headers=headers) + response.raise_for_status() + return response.json() + + def list_invoice_taxes(self, params: dict = None) -> dict: + """ + Retrieve a list of all invoice taxes, with optional filters and pagination. + + Args: + params (dict, optional): Query parameters. Can include: + - page (int) + - per_page (int, max 1000) + - filter[name] (str) + - filter[type] (str) + - filter[active] (bool) + + Returns: + dict: API response with list of tax objects + """ + url = f"{self.api_url}/api/external/invoices/taxes" + headers = {"X-ApiKey": self.api_key} + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + return response.json() + + def get_invoice_tax(self, tax_id: str) -> dict: + """ + Retrieve details of a specific invoice tax by ID. + + Args: + tax_id (str): The ID of the tax to retrieve. + + Returns: + dict: API response with tax details. + """ + if not tax_id: + raise ValueError("tax_id is required.") + + url = f"{self.api_url}/api/external/invoices/taxes/{tax_id}" + headers = {"X-ApiKey": self.api_key} + + response = requests.get(url, headers=headers) + response.raise_for_status() + return response.json() + + def update_invoice_tax(self, tax_id: str, data: dict) -> dict: + """ + Update an existing invoice tax by ID. + + Args: + tax_id (str): ID of the tax to update (e.g. "TX-250527-2E9N") + data (dict): Tax data to update. Must include: + - name (str): Required + - value (float): Required + - description (str): Optional + + Returns: + dict: API response with updated tax data + """ + url = f"{self.api_url}/api/external/invoices/taxes/{tax_id}" + headers = {"X-ApiKey": self.api_key, "Content-Type": "application/json"} + response = requests.put(url, headers=headers, json=data) + response.raise_for_status() + return response.json() + + def delete_invoice_tax(self, tax_id: str) -> dict: + """ + Delete an invoice tax by ID. + + Args: + tax_id (str): The ID of the tax to delete. + + Returns: + dict: API response confirming deletion. + """ + if not tax_id: + raise ValueError("tax_id is required.") + + url = f"{self.api_url}/api/external/invoices/taxes/{tax_id}" + headers = {"X-ApiKey": self.api_key} + + response = requests.delete(url, headers=headers) + response.raise_for_status() + return response.json() + + def create_invoice_discount(self, data: dict) -> dict: + """ + Create a new invoice discount. + + Args: + data (dict): Discount details, must include: + - name (str): Required, max 191 characters + - type (str): Required, "flat" or "percentage" + - value (float): Required, 0.01–1000 + - currency (str): Required if type is "flat" (ISO 4217 format) + - description (str): Optional, max 191 characters + + Returns: + dict: API response with created discount data + """ + url = f"{self.api_url}/api/external/invoices/discounts" + headers = {"X-ApiKey": self.api_key, "Content-Type": "application/json"} + response = requests.post(url, headers=headers, json=data) + response.raise_for_status() + return response.json() + + def list_invoice_discounts(self, params: dict = None) -> dict: + """ + Retrieve a list of all invoice discounts, with optional filters and pagination. + + Args: + params (dict, optional): Query parameters. Can include: + - page (int) + - per_page (int, max 1000) + - filter[name] (str) + - filter[type] (str) + - filter[active] (bool) + - filter[currency] (str): ISO 4217 format for flat discounts + + Returns: + dict: API response with list of discount objects + """ + url = f"{self.api_url}/api/external/invoices/discounts" + headers = {"X-ApiKey": self.api_key} + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + return response.json() + + def get_invoice_discount(self, discount_id: str) -> dict: + """ + Retrieve details of a specific invoice discount by ID. + + Args: + discount_id (str): The ID of the discount to retrieve. + + Returns: + dict: API response with discount details. + """ + if not discount_id: + raise ValueError("discount_id is required.") + + url = f"{self.api_url}/api/external/invoices/discounts/{discount_id}" + headers = {"X-ApiKey": self.api_key} + + response = requests.get(url, headers=headers) + response.raise_for_status() + return response.json() + + def update_invoice_discount(self, discount_id: str, data: dict) -> dict: + """ + Update an existing invoice tax by ID. + + Args: + discount_id (str): ID of the discount to update (e.g. "TX-250527-2E9N") + data (dict): Tax data to update. + - name (str): Optional + - type (enum): Optional, "flat" or "percentage" + - value (float): Optional + - currency (str): Optional, required if type is "flat" (ISO 4217 format) + - description (str): Optional + + Returns: + dict: API response with updated tax data + """ + url = f"{self.api_url}/api/external/invoices/discounts/{discount_id}" + headers = {"X-ApiKey": self.api_key, "Content-Type": "application/json"} + response = requests.put(url, headers=headers, json=data) + response.raise_for_status() + return response.json() + + def delete_invoice_discount(self, discount_id: str) -> dict: + """ + Delete an invoice discount by ID. + + Args: + discount_id (str): The ID of the discount to delete. + + Returns: + dict: API response confirming deletion. + """ + if not discount_id: + raise ValueError("discount_id is required.") + + url = f"{self.api_url}/api/external/invoices/discounts/{discount_id}" + headers = {"X-ApiKey": self.api_key} + + response = requests.delete(url, headers=headers) + response.raise_for_status() + return response.json() \ No newline at end of file diff --git a/fasterpay/gateway.py b/fasterpay/gateway.py index feea942..e602455 100644 --- a/fasterpay/gateway.py +++ b/fasterpay/gateway.py @@ -2,33 +2,54 @@ from fasterpay.signature import Signature from fasterpay.pingback import Pingback from fasterpay.paymentform import PaymentForm -from fasterpay.refund import Refund from fasterpay.subscription import Subscription from fasterpay.transaction import Transaction - - +from fasterpay.address import Address +from fasterpay.contact import Contact +from fasterpay.payout import Payout +from fasterpay.einvoice import Einvoice class Gateway: - - def __init__(self, private_key, public_key, api_url=None, api_version=None): - self.config = Config(private_key, public_key, api_url, api_version) - - def payment_form(self): + def __init__( + self, + public_key: str, + private_key: str, + is_test: bool = False, + api_version: str = "1.0.0", + ): + self.config = Config( + private_key=private_key, + public_key=public_key, + is_test=is_test, + api_version=api_version, + ) + + def payment_form(self) -> PaymentForm: return PaymentForm(self) - def signature(self): + def signature(self) -> Signature: return Signature(self) - def pingback(self): + def pingback(self) -> Pingback: return Pingback(self) - def get_config(self): + def get_config(self) -> Config: return self.config - def refund(self): - return Refund(self) - - def subscription(self): + def subscription(self) -> Subscription: return Subscription(self) - def transaction(self): + def transaction(self) -> Transaction: return Transaction(self) + + def address(self) -> Address: + return Address(self) + + def contact(self) -> Contact: + return Contact(self) + + def payout(self) -> Payout: + return Payout(self) + + def einvoice(self) -> Einvoice: + return Einvoice(self) + \ No newline at end of file diff --git a/fasterpay/paymentform.py b/fasterpay/paymentform.py index 45d70af..9bcea7c 100644 --- a/fasterpay/paymentform.py +++ b/fasterpay/paymentform.py @@ -1,26 +1,70 @@ -class PaymentForm: +import html +class PaymentForm: def __init__(self, gateway): self.gateway = gateway - def build_form(self, parameters): - payload = parameters.get("payload") - payload.update({"api_key": self.gateway.config.get_public_key()}) + def build_form(self, parameters: dict) -> str: + """ + Build a FasterPay Checkout Form to redirect the customer to the payment widget. + + Parameters: + parameters (dict): A dictionary with the following structure: + + Required keys inside `parameters["payload"]`: + - amount (str): Payment amount in "0000.00" format. + - currency (str): ISO 4217 currency code (e.g., 'USD'). + - description (str): Description of the product. + - api_key (str): Automatically added using your FasterPay public key. + - merchant_order_id (str): Unique merchant-side order ID. + + Optional keys inside `payload`: + - sign_version (str): Signature version to use ('v1' or 'v2'). Default is 'v1'. + - email (str): Customer email. + - first_name (str): Customer first name. + - last_name (str): Customer last name. + - city (str): Customer city. + - zip (str): Customer zip/postal code. + - payment_flow (str): Set to "user_qr" to request a QR code. + - success_url (str): URL to redirect to after successful payment. + - pingback_url (str): Overrides default pingback URL. + - hash (str): Automatically calculated by the SDK using your private key. + + Top-level keys in `parameters`: + - auto_submit_form (bool): If True, auto-submits the form via JavaScript after rendering. + + Notes: + - `api_key` and `hash` are automatically added by this method. + - If `sign_version` is not provided, version "v1" is used. + - To use QR code flow, ensure `payment_flow` = "user_qr" and add header Accept: application/json (not handled here). + + Returns: + str: A string of HTML `
' + form = f''' + + '''.strip() - if "auto_submit_form" in parameters: - form += "" + if parameters.get("auto_submit_form"): + form += '\n' return form + + \ No newline at end of file diff --git a/fasterpay/payout.py b/fasterpay/payout.py new file mode 100644 index 0000000..8449cd7 --- /dev/null +++ b/fasterpay/payout.py @@ -0,0 +1,85 @@ +import requests + +class Payout: + def __init__(self, gateway): + self.gateway = gateway + self.api_url = gateway.config.external_api_url + self.api_key = gateway.config.private_key + + def create_payout(self, params: dict) -> dict: + """ + Create one or more payouts in a single request. + + Required headers: + - X-ApiKey: Your private API key (set automatically). + + Parameters (in `params`): + - source_currency (str): Required. ISO-4217 currency of your balance (e.g. 'EUR'). + - template (str): Required. Payout destination type: 'wallet' or 'bank_account'. + - payouts (list): Required. List of payout objects. Each object includes: + - amount (str): Required. Amount to transfer. + - amount_currency (str): Required. Must be same as source_currency or target_currency. + - target_currency (str): Required. ISO-4217 currency to send. + - receiver_type (str): Required. 'private' or 'business'. + - receiver_full_name (str): Required. Full name of recipient. + - receiver_email (str): Optional. Recipient's FasterPay email. + - bank_beneficiary_country (str): Required if template=bank_account. + - bank_beneficiary_address (str): Required if template=bank_account. + - bank_account_number (str): Required if template=bank_account. + - bank_swift_code (str): Required if template=bank_account. + - bank_name (str): Required if template=bank_account. + - corresponding_bank_swift_code (str, optional) + - additional_information (str, optional) + - reference_id (str, optional): Unique ID in your system. + + Endpoint: + POST https://business.fasterpay.com/api/external/payouts + + Docs: + https://docs.fasterpay.com/api#section-create-payout + + Returns: + dict: API response with created payout data. + """ + if "source_currency" not in params: + raise ValueError("'source_currency' is required") + if "template" not in params: + raise ValueError("'template' is required") + if "payouts" not in params or not isinstance(params["payouts"], list): + raise ValueError("'payouts' must be a list of payout objects") + + url = f"{self.api_url}/api/external/payouts" + headers = { + "X-ApiKey": self.api_key, + "Content-Type": "application/json" + } + + response = requests.post(url, json=params, headers=headers) + response.raise_for_status() + return response.json() + + def list_payouts(self) -> dict: + """Retrieve a list of payouts.""" + url = f"{self.api_url}/api/external/payouts" + headers = { + "X-ApiKey": self.api_key, + "Content-Type": "application/json" + } + response = requests.get(url, headers=headers) + response.raise_for_status() + return response.json() + + def get_payout(self, payout_id: str) -> dict: + """Retrieve details of a specific payout by ID.""" + if not payout_id: + raise ValueError("payout_id is required to retrieve a payout.") + + url = f"{self.api_url}/api/external/payouts/{payout_id}" + headers = { + "X-ApiKey": self.api_key, + "Content-Type": "application/json" + } + + response = requests.get(url, headers=headers) + response.raise_for_status() + return response.json() diff --git a/fasterpay/pingback.py b/fasterpay/pingback.py index c41ae70..6145485 100644 --- a/fasterpay/pingback.py +++ b/fasterpay/pingback.py @@ -1,21 +1,19 @@ class Pingback: - def __init__(self, gateway): self.gateway = gateway - def validate(self, pingbackdata, headers): - if len(headers) == 0: + def validate(self, pingbackdata: str, headers: dict) -> bool: + if not headers or not pingbackdata: return False - if len(pingbackdata) == 0: - return False + signature_version = headers.get("X-Fasterpay-Signature-Version", "v1") - if headers.get("X-Fasterpay-Signature-Version") == "v2": - generated_hash = self.gateway.signature().calculate_pingback_hash(pingbackdata) - if generated_hash == headers.get("X-Fasterpay-Signature"): - return True - else: - if headers.get("X-ApiKey") == self.gateway.config.get_private_key(): - return True + if signature_version == "v2": + expected_signature = self.gateway.signature().calculate_pingback_hash(pingbackdata) + received_signature = headers.get("X-Fasterpay-Signature") + return expected_signature == received_signature - return False + # Fallback to v1: compare API keys (legacy) + api_key = headers.get("X-ApiKey") + return api_key == self.gateway.config.private_key + diff --git a/fasterpay/refund.py b/fasterpay/refund.py deleted file mode 100644 index 088c608..0000000 --- a/fasterpay/refund.py +++ /dev/null @@ -1,14 +0,0 @@ -import requests -import json - -class Refund: - - def __init__(self, gateway): - self.gateway = gateway - - def process(self, order_id=None, amount=None): - response = requests.post(self.gateway.config.get_api_url() + "/payment/" + str(order_id) + "/refund", - data=json.dumps({"amount": amount}), - headers={"X-ApiKey": self.gateway.config.get_private_key()}) - - return response diff --git a/fasterpay/signature.py b/fasterpay/signature.py index 9eb8ad2..f97bdc2 100644 --- a/fasterpay/signature.py +++ b/fasterpay/signature.py @@ -1,26 +1,33 @@ - -import urllib -import hashlib, hmac - +from urllib.parse import urlencode +import hashlib +import hmac class Signature: - def __init__(self, gateway): self.gateway = gateway - def ksort(self, params): - return [(k, params[k]) for k in sorted(params.keys())] + def _sorted_items(self, params: dict) -> list[tuple[str, str]]: + return sorted(params.items()) + + def calculate_hash(self, params: dict, scheme: str = "v1") -> str: + if scheme == "v1": + query_string = urlencode(self._sorted_items(params)) + self.gateway.config.private_key + return hashlib.sha256(query_string.encode()).hexdigest() + + # v2: build manually + encoded = "".join(f"{k}={v};" for k, v in self._sorted_items(params)) + return hmac.new( + self.gateway.config.private_key.encode(), + encoded.encode(), + digestmod=hashlib.sha256, + ).hexdigest() - def calculate_hash(self, params, scheme = "v1"): - if scheme is "v1": - urlencoded_params = urllib.parse.urlencode(self.ksort(params)) + self.gateway.config.get_private_key() - return hashlib.sha256(urlencoded_params.encode()).hexdigest() - else: - encoded_string = '' - for k, v in self.ksort(params): - encoded_string += k+"="+v+";" - return hmac.new(self.gateway.config.get_private_key().encode(), encoded_string.encode(), digestmod=hashlib.sha256).hexdigest() + def calculate_pingback_hash(self, pingback_data: str, is_string: bool = True) -> str: + if not is_string: + raise NotImplementedError("Only string pingback data is currently supported") - def calculate_pingback_hash(self, pingbackdata, is_string=True): - if is_string is True: - return hmac.new(self.gateway.config.get_private_key().encode(), pingbackdata.encode(), digestmod=hashlib.sha256).hexdigest() + return hmac.new( + self.gateway.config.private_key.encode(), + pingback_data.encode(), + digestmod=hashlib.sha256, + ).hexdigest() diff --git a/fasterpay/subscription.py b/fasterpay/subscription.py index da5dd57..bfb2c71 100644 --- a/fasterpay/subscription.py +++ b/fasterpay/subscription.py @@ -1,14 +1,23 @@ import requests -import json class Subscription: - def __init__(self, gateway): self.gateway = gateway + self.api_url = gateway.config.api_url + self.api_key = gateway.config.private_key + + def cancel(self, order_id: str) -> dict: + """Cancel a subscription for the given order ID.""" + if not order_id: + raise ValueError("order_id is required to cancel a subscription.") + + url = f"{self.api_url}/api/subscription/{order_id}/cancel" + headers = { + "X-ApiKey": self.api_key, + "Content-Type": "application/json" + } - def cancel(self, order_id=None): - response = requests.post(self.gateway.config.get_api_url() + "/api/subscription/" + str(order_id) + "/cancel", - data=json.dumps({}), - headers={"X-ApiKey": self.gateway.config.get_private_key()}) - return response + response = requests.post(url, json={}, headers=headers) + response.raise_for_status() + return response.json() diff --git a/fasterpay/tests/test-pingback.py b/fasterpay/tests/test-pingback.py index f584944..c48d7bd 100644 --- a/fasterpay/tests/test-pingback.py +++ b/fasterpay/tests/test-pingback.py @@ -1,4 +1,3 @@ -from urllib.parse import urlparse from fasterpay.gateway import Gateway gateway = Gateway("