From a735da9bbe6c4280a6872ae4c05700f737fa29dc Mon Sep 17 00:00:00 2001 From: Dustin Date: Mon, 4 Aug 2025 10:05:34 +0700 Subject: [PATCH 1/7] chore: add gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3c8987 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.pypirc +*.pyc +test.py +venv \ No newline at end of file From c17eec0dda5028940c969095dce18919179a638f Mon Sep 17 00:00:00 2001 From: Dustin Date: Mon, 4 Aug 2025 11:53:08 +0700 Subject: [PATCH 2/7] refactor: update build process --- .gitignore | 3 ++- MANIFEST.in | 3 +-- README.md | 5 ++--- fasterpay/config.py | 4 ++-- fasterpay/signature.py | 27 +++++++++++++++++++-------- fasterpay/tests/test-pingback.py | 1 - pypi_description.rst | 5 +++++ setup.cfg | 3 --- setup.py | 17 ++++++++++++----- 9 files changed, 43 insertions(+), 25 deletions(-) create mode 100644 pypi_description.rst delete mode 100644 setup.cfg diff --git a/.gitignore b/.gitignore index e3c8987..8946169 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .pypirc *.pyc test.py -venv \ No newline at end of file +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/config.py b/fasterpay/config.py index c73b819..3f25ac7 100644 --- a/fasterpay/config.py +++ b/fasterpay/config.py @@ -3,8 +3,8 @@ class Config: 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" + if is_test is False: + self.API_BASE_URL = "https://pay.fasterpay.com" else: self.API_BASE_URL = "https://pay.sandbox.fasterpay.com" diff --git a/fasterpay/signature.py b/fasterpay/signature.py index 9eb8ad2..118fcad 100644 --- a/fasterpay/signature.py +++ b/fasterpay/signature.py @@ -1,5 +1,5 @@ +from urllib.parse import urlencode -import urllib import hashlib, hmac @@ -11,16 +11,27 @@ def __init__(self, gateway): def ksort(self, params): return [(k, params[k]) for k in sorted(params.keys())] - 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() + def calculate_hash(self, params, scheme="v1"): + if scheme == "v1": + urlencoded_params = ( + urlencode(self.ksort(params)) + + self.gateway.config.get_private_key() + ) return hashlib.sha256(urlencoded_params.encode()).hexdigest() else: - encoded_string = '' + 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() + 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, 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.get_private_key().encode(), + pingbackdata.encode(), + digestmod=hashlib.sha256, + ).hexdigest() 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("", "", True) diff --git a/pypi_description.rst b/pypi_description.rst new file mode 100644 index 0000000..7f201a3 --- /dev/null +++ b/pypi_description.rst @@ -0,0 +1,5 @@ +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) \ No newline at end of file diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 9d5f797..0000000 --- a/setup.cfg +++ /dev/null @@ -1,3 +0,0 @@ -# Inside of setup.cfg -[metadata] -description-file = README.md \ No newline at end of file diff --git a/setup.py b/setup.py index 7cbccac..5ebd11c 100644 --- a/setup.py +++ b/setup.py @@ -1,16 +1,23 @@ -from distutils.core import setup +import os +from setuptools import setup + +def read(fname): + return open(os.path.join(os.path.dirname(__file__), fname)).read() + setup( name='fasterpay-python3', - packages=['fasterpay'], version='1.2', + packages=['fasterpay'], + description='Faster Python Library', + long_description=read('pypi_description.rst'), license='MIT', - description='Integrate FasterPay into your application using fasterpay-python SDK', - author='FasterPay Integrations Team', - author_email='integration@fasterpay.com', url='https://github.com/FasterPay/fasterpay-python3', download_url='https://github.com/FasterPay/fasterpay-python3/releases', + author='FasterPay Integrations Team', + author_email='integration@fasterpay.com', keywords=['FASTERPAY', 'PAYMENTS', 'CARD PROCESSING'], install_requires=['requests'], + python_requires=">=3.6", classifiers=[ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', From 50b046da1b9bb130a5741ccdc1b805115254ed44 Mon Sep 17 00:00:00 2001 From: Dustin Date: Wed, 6 Aug 2025 11:18:41 +0700 Subject: [PATCH 3/7] refactor: update codebase for modern typing, add validation and error handling --- fasterpay/config.py | 48 ++++++++++++++++++++---------------- fasterpay/gateway.py | 35 +++++++++++++++----------- fasterpay/paymentform.py | 37 ++++++++++++++++------------ fasterpay/pingback.py | 23 ++++++++--------- fasterpay/refund.py | 14 ----------- fasterpay/signature.py | 52 ++++++++++++++++++--------------------- fasterpay/subscription.py | 23 +++++++++++------ fasterpay/transaction.py | 36 +++++++++++++++------------ pypi_description.rst | 10 +++++--- 9 files changed, 145 insertions(+), 133 deletions(-) delete mode 100644 fasterpay/refund.py diff --git a/fasterpay/config.py b/fasterpay/config.py index 3f25ac7..432717f 100644 --- a/fasterpay/config.py +++ b/fasterpay/config.py @@ -1,26 +1,32 @@ 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 - def __init__(self, privateKey, publicKey, is_test = False, apiVersion = None): - self.publicKey = publicKey - self.privateKey = privateKey - if is_test is False: - 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 diff --git a/fasterpay/gateway.py b/fasterpay/gateway.py index feea942..632d830 100644 --- a/fasterpay/gateway.py +++ b/fasterpay/gateway.py @@ -2,33 +2,38 @@ 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 - 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, + private_key: str, + public_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) diff --git a/fasterpay/paymentform.py b/fasterpay/paymentform.py index 45d70af..3130536 100644 --- a/fasterpay/paymentform.py +++ b/fasterpay/paymentform.py @@ -1,26 +1,31 @@ -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()}) - - if "sign_version" in payload: - hash = self.gateway.signature().calculate_hash(payload, payload.get("sign_version")) - else: - hash = self.gateway.signature().calculate_hash(payload) + def build_form(self, parameters: dict) -> str: + payload = parameters.get("payload", {}) + payload["api_key"] = self.gateway.config.public_key - payload.update({"hash": hash}) + sign_version = payload.get("sign_version", "v1") + payload["hash"] = self.gateway.signature().calculate_hash(payload, sign_version) - form = '
' - for param in payload: - form += '' + form_fields = "\n".join( + f'' + for k, v in payload.items() + ) - form += '
' + form = f''' +
+ {form_fields} + +
+ '''.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/pingback.py b/fasterpay/pingback.py index c41ae70..b5a4ac7 100644 --- a/fasterpay/pingback.py +++ b/fasterpay/pingback.py @@ -1,21 +1,18 @@ 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 118fcad..f97bdc2 100644 --- a/fasterpay/signature.py +++ b/fasterpay/signature.py @@ -1,37 +1,33 @@ from urllib.parse import urlencode - -import hashlib, hmac - +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, scheme="v1"): + def calculate_hash(self, params: dict, scheme: str = "v1") -> str: if scheme == "v1": - urlencoded_params = ( - 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() + 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_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/transaction.py b/fasterpay/transaction.py index 6fa86b1..cb3b246 100644 --- a/fasterpay/transaction.py +++ b/fasterpay/transaction.py @@ -1,26 +1,30 @@ -#!/usr/bin/python import requests class Transaction: - def __init__(self, gateway): self.gateway = gateway + self.api_url = gateway.config.api_url + self.api_key = gateway.config.private_key + + def refund(self, order_id: str, amount: float): + """Process a refund for a given order ID.""" + if not order_id or amount < 0: + raise ValueError("order_id is required and amount must be non-negative.") - def refund(self, order_id=None, amount=None): - refund_response = self.do_http_post_request( - self.gateway.config.get_api_url() + "/payment/" + str(order_id) + "/refund", - {"amount": amount}, - {"X-ApiKey": self.gateway.config.get_private_key()}) - return refund_response + url = f"{self.api_url}/payment/{order_id}/refund" + data = {"amount": amount} + return self._post_json(url, data) - def deliver(self, delivery_info): - delivery_response = self.do_http_post_request(self.gateway.config.get_api_url() + "/api/v1/deliveries", - delivery_info, - {"X-ApiKey": self.gateway.config.get_private_key()}) - return delivery_response + def deliver(self, delivery_info: dict): + """Send delivery confirmation.""" + url = f"{self.api_url}/api/v1/deliveries" + return self._post_json(url, delivery_info) - @staticmethod - def do_http_post_request(url, data, headers): - response = requests.post(url, data=data, headers=headers) + def _post_json(self, url: str, data: dict): + """Helper to POST JSON with auth header.""" + headers = {"X-ApiKey": self.api_key, "Content-Type": "application/json"} + response = requests.post(url, json=data, headers=headers) + response.raise_for_status() return response.json() + \ No newline at end of file diff --git a/pypi_description.rst b/pypi_description.rst index 7f201a3..7630fc1 100644 --- a/pypi_description.rst +++ b/pypi_description.rst @@ -1,5 +1,9 @@ -FasterPay Python SDK enables you to integrate the FasterPay's Checkout Page seamlessly without having the hassle of integrating everything from Scratch. +FasterPay Python SDK +===================== -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. +Integrate FasterPay's Checkout Page into your application with ease. -For more information on the API Parameters, refer to our entire API Documentation [here](https://docs.fasterpay.com/api#section-custom-integration) \ No newline at end of file +Once your customer is ready to pay, FasterPay will handle the payment, notify your system, and redirect the customer back to your thank-you page. + +For full API documentation, visit: +https://docs.fasterpay.com/api#section-custom-integration From 6aba719d7a83dda29d8f5194116b4d9106ecc586 Mon Sep 17 00:00:00 2001 From: Dustin Date: Wed, 6 Aug 2025 14:35:02 +0700 Subject: [PATCH 4/7] feat: add address and contact api --- fasterpay/address.py | 18 +++++ fasterpay/config.py | 5 ++ fasterpay/contact.py | 152 +++++++++++++++++++++++++++++++++++++++ fasterpay/gateway.py | 5 ++ fasterpay/paymentform.py | 39 ++++++++++ fasterpay/pingback.py | 1 + fasterpay/transaction.py | 25 ++++++- 7 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 fasterpay/address.py create mode 100644 fasterpay/contact.py 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 432717f..094fd5b 100644 --- a/fasterpay/config.py +++ b/fasterpay/config.py @@ -14,6 +14,7 @@ def __init__( "https://pay.fasterpay.com" ) self._api_version = api_version + self._external_api_url = "https://business.fasterpay.com" @property def public_key(self) -> str: @@ -30,3 +31,7 @@ def api_url(self) -> str: @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/gateway.py b/fasterpay/gateway.py index 632d830..5094f31 100644 --- a/fasterpay/gateway.py +++ b/fasterpay/gateway.py @@ -4,6 +4,7 @@ from fasterpay.paymentform import PaymentForm from fasterpay.subscription import Subscription from fasterpay.transaction import Transaction +from fasterpay.address import Address class Gateway: def __init__( @@ -37,3 +38,7 @@ def subscription(self) -> Subscription: def transaction(self) -> Transaction: return Transaction(self) + + def address(self) -> Address: + return Address(self) + \ No newline at end of file diff --git a/fasterpay/paymentform.py b/fasterpay/paymentform.py index 3130536..9bcea7c 100644 --- a/fasterpay/paymentform.py +++ b/fasterpay/paymentform.py @@ -5,6 +5,45 @@ def __init__(self, gateway): self.gateway = gateway 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 `
` content that posts to FasterPay checkout endpoint. + + Docs: + https://docs.fasterpay.com/api#section-custom-integration + """ payload = parameters.get("payload", {}) payload["api_key"] = self.gateway.config.public_key diff --git a/fasterpay/pingback.py b/fasterpay/pingback.py index b5a4ac7..6145485 100644 --- a/fasterpay/pingback.py +++ b/fasterpay/pingback.py @@ -16,3 +16,4 @@ def validate(self, pingbackdata: str, headers: dict) -> bool: # 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/transaction.py b/fasterpay/transaction.py index cb3b246..020a7b6 100644 --- a/fasterpay/transaction.py +++ b/fasterpay/transaction.py @@ -17,10 +17,33 @@ def refund(self, order_id: str, amount: float): return self._post_json(url, data) def deliver(self, delivery_info: dict): - """Send delivery confirmation.""" + """ + Send delivery confirmation for a completed payment. + + This confirms to FasterPay that the purchased goods or services have been delivered. + Typically used for digital goods or subscriptions after successful payment. + + Parameters: + delivery_info (dict): A dictionary containing delivery details. Example fields: + - payment_id (str): Required. The FasterPay payment ID. + - merchant_reference_id (str): Optional. Your internal reference ID. + - status (str): Required. Delivery status. Example: "delivered". + - type (str): Optional. Type of delivery. Example: "digital". + - estimated_delivery_datetime (str): Optional. ISO 8601 timestamp of estimated delivery time. + + Endpoint: + POST https://pay.fasterpay.com/api/v1/deliveries + + Docs: + https://docs.fasterpay.com/api#section-delivery-confirmation (update with actual link) + + Returns: + dict: API response confirming delivery has been recorded. + """ url = f"{self.api_url}/api/v1/deliveries" return self._post_json(url, delivery_info) + def _post_json(self, url: str, data: dict): """Helper to POST JSON with auth header.""" headers = {"X-ApiKey": self.api_key, "Content-Type": "application/json"} From 418de515815de455a74a739d5506dc619d522e23 Mon Sep 17 00:00:00 2001 From: Dustin Date: Wed, 6 Aug 2025 16:42:03 +0700 Subject: [PATCH 5/7] feat: mass payout api --- fasterpay/gateway.py | 6 +++- fasterpay/payout.py | 85 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 fasterpay/payout.py diff --git a/fasterpay/gateway.py b/fasterpay/gateway.py index 5094f31..c3817f0 100644 --- a/fasterpay/gateway.py +++ b/fasterpay/gateway.py @@ -5,7 +5,8 @@ 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 class Gateway: def __init__( self, @@ -41,4 +42,7 @@ def transaction(self) -> Transaction: def address(self) -> Address: return Address(self) + + def contact(self) -> Contact: + return Contact(self) \ 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() From 06622a619061a033ebb4439387f3c81825173373 Mon Sep 17 00:00:00 2001 From: Dustin Date: Wed, 6 Aug 2025 16:53:58 +0700 Subject: [PATCH 6/7] feat: add einvoice api --- fasterpay/einvoice.py | 64 +++++++++++++++++++++++++++++++++++++++++++ fasterpay/gateway.py | 7 +++++ 2 files changed, 71 insertions(+) create mode 100644 fasterpay/einvoice.py diff --git a/fasterpay/einvoice.py b/fasterpay/einvoice.py new file mode 100644 index 0000000..dfb1d75 --- /dev/null +++ b/fasterpay/einvoice.py @@ -0,0 +1,64 @@ +import requests + +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. + + Required headers: + - X-ApiKey: Your private key (added automatically). + + Parameters: + - invoice_template_id (str): Optional. ID of an existing invoice template. + - template (dict): Optional. Object containing template data. Only one of `invoice_template_id` or `template` should be provided. + - number (str): Optional. Unique invoice number (max 50 chars). + - summary (str): Optional. Description (max 255 chars). + - contact_id (str): Required. ID of the associated contact. + - currency (str): Required. ISO-4217 currency code (e.g., 'USD'). + - due_date (str): Optional. Due date in 'YYYY-MM-DD' format. + - discount_id or discount (str or dict): Optional. Provide only one. + - tax_id or tax (str or dict): Optional. Provide only one. + - items (list of dict): Required. List of item objects. Each item may include: + - name (str, optional): Item name (max 255 chars) + - product_id or product (str or dict): Only one required + - price (float): Required. Unit price of the item + - quantity (int): Required. Quantity (min 1) + - discount_id or discount (str or dict): Optional. Only one + - tax_id or tax (str or dict): Optional. Only one + - deleted_at (str, optional): Set to true to delete + - include (str): Optional. e.g. 'prices' to show custom prices in response + + Endpoint: + POST https://business.fasterpay.com/api/external/einvoices + + 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.") + + # Enforce template OR invoice_template_id (but not both) + 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/einvoices" + 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() diff --git a/fasterpay/gateway.py b/fasterpay/gateway.py index c3817f0..9b12327 100644 --- a/fasterpay/gateway.py +++ b/fasterpay/gateway.py @@ -7,6 +7,7 @@ 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, @@ -45,4 +46,10 @@ def address(self) -> Address: 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 From b0547557931ae70e136d739e6a51d3a6c0b3bfe7 Mon Sep 17 00:00:00 2001 From: Dustin Date: Fri, 8 Aug 2025 10:16:55 +0700 Subject: [PATCH 7/7] feat: add einvoice --- fasterpay/einvoice.py | 759 ++++++++++++++++++++++++++++++++++++++++-- fasterpay/gateway.py | 8 +- 2 files changed, 734 insertions(+), 33 deletions(-) diff --git a/fasterpay/einvoice.py b/fasterpay/einvoice.py index dfb1d75..1112320 100644 --- a/fasterpay/einvoice.py +++ b/fasterpay/einvoice.py @@ -1,4 +1,6 @@ import requests +import json + class Einvoice: def __init__(self, gateway): @@ -10,31 +12,7 @@ def create_invoice(self, params: dict) -> dict: """ Create a new E-Invoice. - Required headers: - - X-ApiKey: Your private key (added automatically). - - Parameters: - - invoice_template_id (str): Optional. ID of an existing invoice template. - - template (dict): Optional. Object containing template data. Only one of `invoice_template_id` or `template` should be provided. - - number (str): Optional. Unique invoice number (max 50 chars). - - summary (str): Optional. Description (max 255 chars). - - contact_id (str): Required. ID of the associated contact. - - currency (str): Required. ISO-4217 currency code (e.g., 'USD'). - - due_date (str): Optional. Due date in 'YYYY-MM-DD' format. - - discount_id or discount (str or dict): Optional. Provide only one. - - tax_id or tax (str or dict): Optional. Provide only one. - - items (list of dict): Required. List of item objects. Each item may include: - - name (str, optional): Item name (max 255 chars) - - product_id or product (str or dict): Only one required - - price (float): Required. Unit price of the item - - quantity (int): Required. Quantity (min 1) - - discount_id or discount (str or dict): Optional. Only one - - tax_id or tax (str or dict): Optional. Only one - - deleted_at (str, optional): Set to true to delete - - include (str): Optional. e.g. 'prices' to show custom prices in response - - Endpoint: - POST https://business.fasterpay.com/api/external/einvoices + 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 @@ -48,17 +26,740 @@ def create_invoice(self, params: dict) -> dict: 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.") - - # Enforce template OR invoice_template_id (but not both) 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/einvoices" + 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" } - response = requests.post(url, json=params, headers=headers) + 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 9b12327..e602455 100644 --- a/fasterpay/gateway.py +++ b/fasterpay/gateway.py @@ -7,12 +7,12 @@ from fasterpay.address import Address from fasterpay.contact import Contact from fasterpay.payout import Payout -from fasterpay.einvoice import EInvoice +from fasterpay.einvoice import Einvoice class Gateway: def __init__( self, - private_key: str, public_key: str, + private_key: str, is_test: bool = False, api_version: str = "1.0.0", ): @@ -50,6 +50,6 @@ def contact(self) -> Contact: def payout(self) -> Payout: return Payout(self) - def einvoice(self) -> EInvoice: - return EInvoice(self) + def einvoice(self) -> Einvoice: + return Einvoice(self) \ No newline at end of file