diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..06020f3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,30 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{html,py,json,md}] +charset = utf-8 + +[*.py] +indent_style = space +indent_size = 4 +max_line_length = 119 + +[*.md] +indent_style = space +indent_size = 4 +max_line_length = 119 + +[*.html] +indent_style = space +indent_size = 2 + +[Makefile] +indent_style = tab + +[*.json] +indent_style = space +indent_size = 2 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..f1a2fb2 --- /dev/null +++ b/.flake8 @@ -0,0 +1,15 @@ +[flake8] +max-complexity = 10 +max-line-length = 119 +max-doc-length = 120 +indent-size = 4 +exclude = + .git, + __pycache__, + .github, + docs, + tests +filename = *.py +accept-encodings = utf-8 +inline-quotes = single +multiline-quotes = double diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index f8d1880..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Tests and Coverage - -on: - push: - branches: [ "sandbox" ] - pull_request: - branches: [ "main" ] - -jobs: - tests: - strategy: - fail-fast: false - matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] - poetry-version: ["1.4.2"] - os: [ubuntu-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - name: Set Up Poetry - uses: abatilo/actions-poetry@v2 - with: - poetry-version: ${{ matrix.poetry-version }} - - name: Install merchants - run: poetry install --with dev - - name: Run Tests and coverage - run: poetry run coverage run -m pytest - - name: Coverage report (xml) - run: poetry run coverage xml - - name: Coveralls GitHub Action - uses: coverallsapp/github-action@v2 - - name: Run codacy-coverage-reporter - uses: codacy/codacy-coverage-reporter-action@v1 - with: - project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} - coverage-reports: "coverage.xml" - language: "python" diff --git a/.github/workflows/test_coverage.yml b/.github/workflows/test_coverage.yml new file mode 100644 index 0000000..1ce2358 --- /dev/null +++ b/.github/workflows/test_coverage.yml @@ -0,0 +1,41 @@ +name: Tests&Coverage + +on: + push: + branches: ["sandbox"] + pull_request: + branches: ["main"] + +jobs: + tests: + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + poetry-version: ["1.8.3"] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Set Up Poetry + uses: abatilo/actions-poetry@v2 + with: + poetry-version: ${{ matrix.poetry-version }} + - name: Install merchants + run: poetry install --with dev + - name: Run Tests and coverage + run: poetry run coverage run -m pytest + - name: Coverage report (xml) + run: poetry run coverage xml + - name: Coveralls GitHub Action + uses: coverallsapp/github-action@v2 + - name: Run codacy-coverage-reporter + uses: codacy/codacy-coverage-reporter-action@v1 + with: + project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} + coverage-reports: "coverage.xml" + language: "python" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ae8a134..15ef5eb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,21 +1,40 @@ -# See https://pre-commit.com for more information -# See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.6.0 hooks: - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] - id: end-of-file-fixer - - id: check-yaml - id: check-toml - - id: check-json - id: check-added-large-files + args: ['--maxkb=1024'] + - id: check-case-conflict + - id: mixed-line-ending +- repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + args: ["--profile", "black", "--filter-files"] +- repo: https://github.com/psf/black + rev: 24.4.2 + hooks: + - id: black - repo: https://github.com/asottile/pyupgrade - rev: v3.10.1 + rev: v3.16.0 hooks: - id: pyupgrade -- repo: https://github.com/pycqa/isort - rev: 5.12.0 + args: [--py311-plus] +- repo: https://github.com/pre-commit/mirrors-autopep8 + rev: v2.0.4 hooks: - - id: isort - args: ["--profile", "black", "--filter-files", "--line-length", "100"] + - id: autopep8 +- repo: https://github.com/PyCQA/flake8 + rev: "7.1.0" + hooks: + - id: flake8 + additional_dependencies: + - flake8-comprehensions + - flake8-bugbear + - flake8-polyfill + - toml + exclude: docs\/.* diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..01acc19 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +.PHONY: env-info +env-info: + poetry env info + +# MIG_MSG="new thing" make migrations +.PHONY: migrations +migrations: + poetry run flask db migrate -m "${MIG_MSG}" + +.PHONY: upgrade-db +upgrade-db: + poetry run flask db upgrade head + +.PHONY: pre-commit +pre-commit: + poetry run pre-commit autoupdate -j 2 + poetry run pre-commit run --all-files diff --git a/merchants/core/orm/__init__.py b/examples/peewee/README.md similarity index 100% rename from merchants/core/orm/__init__.py rename to examples/peewee/README.md diff --git a/examples/peewee/example.py b/examples/peewee/example.py new file mode 100644 index 0000000..370925c --- /dev/null +++ b/examples/peewee/example.py @@ -0,0 +1,30 @@ +import peewee + +from merchants import settings +from merchants.orm.peewee import MerchantsAccountSettings, MerchantsBaseModel + +db = peewee.SqliteDatabase("./examples/peewee/example.sqlite3") + + +class Payment(MerchantsBaseModel): + content = peewee.TextField() + + class Meta: + database = db + + +class AccountSettings(MerchantsAccountSettings): + class Meta: + database = db + + +def create_database(): + with db: + db.create_tables([Payment, AccountSettings]) + + +create_database() + +# settings.process_on_save = False +settings.load_variants(config=None) +print(f"{settings=}") diff --git a/examples/peewee/example.sqlite3 b/examples/peewee/example.sqlite3 new file mode 100644 index 0000000..3af0a9d Binary files /dev/null and b/examples/peewee/example.sqlite3 differ diff --git a/merchants/manual.py b/examples/peewee/requirements.txt similarity index 100% rename from merchants/manual.py rename to examples/peewee/requirements.txt diff --git a/examples/sqlalchemy/example.py b/examples/sqlalchemy/example.py index 5ae3566..d82372d 100644 --- a/examples/sqlalchemy/example.py +++ b/examples/sqlalchemy/example.py @@ -1,10 +1,8 @@ -from typing import Literal - -from sqlalchemy import Column, Integer, String, create_engine +from sqlalchemy import Column, Integer, create_engine from sqlalchemy.orm import declarative_base, sessionmaker from sqlalchemy_utils import generic_repr -from merchants.core.orm.sqla import PaymentMixin +from merchants.orm.sqla import PaymentMixin SQLALCHEMY_DATABASE_URL = "sqlite:///./examples/sqlalchemy/db.sqlite3" diff --git a/merchants/__init__.py b/merchants/__init__.py index e69de29..e3df12a 100644 --- a/merchants/__init__.py +++ b/merchants/__init__.py @@ -0,0 +1,3 @@ +from .config import base_settings as settings + +__all__ = ["settings"] diff --git a/merchants/cli.py b/merchants/cli.py index e8d6398..79c8608 100644 --- a/merchants/cli.py +++ b/merchants/cli.py @@ -1,7 +1,7 @@ import typer -from rich import print # pylint: disable=W0622 +from rich import print -from merchants.providers import _provider_list +from merchants.integrations import _provider_list cli = typer.Typer(help="Merchants CLI operations") diff --git a/merchants/config.py b/merchants/config.py new file mode 100644 index 0000000..b913a31 --- /dev/null +++ b/merchants/config.py @@ -0,0 +1,52 @@ +from dataclasses import dataclass, field + + +def _available_integrations() -> list: + return [ + "paypal", + "square", + "stripe", + "payu", + "braintree", # es de paypal + "authorizenet", + "twocheckout", + "verifone", # compró 2checkout + "worldpay", + "fis", # fisglobal.com compro worldpay + "adyen", + "skrill", + "applepay", + "googlepay", + "klarna", + "wepay", + "alipay", + "wix", + ] + + +@dataclass +class MerchantsSettings: + available_integrations: list = field(default_factory=_available_integrations) + enabled_accounts: list = field(default_factory=list) + process_on_save: bool = True + + load_from_database: bool = True + + def __post__init__(self): + pass + + def load_variants(self, config: dict) -> list: + from .integrations.dummy import settings as example_settings + + self.enabled_accounts = [ + { + "example": { + "config": example_settings, + "provider_class": "merchants.integrations.dummy.Provider", + } + }, + ] + return self.enabled_accounts + + +base_settings = MerchantsSettings() diff --git a/merchants/core/schemas.py b/merchants/core/schemas.py new file mode 100644 index 0000000..3a72978 --- /dev/null +++ b/merchants/core/schemas.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + + +@dataclass +class ModelStatus: + CREATED: str = "Created" + PROCESSING: str = "Processing" + ERROR: str = "Error" + REJECTED: str = "Rejected" + COMPLETED: str = "Completed" + REFUNDED: str = "Refunded" + REVERSED: str = "Reversed" diff --git a/merchants/exceptions.py b/merchants/exceptions.py new file mode 100644 index 0000000..492ec70 --- /dev/null +++ b/merchants/exceptions.py @@ -0,0 +1,2 @@ +class ProcessError(Exception): + pass diff --git a/merchants/integrations/__init__.py b/merchants/integrations/__init__.py new file mode 100644 index 0000000..b3b197c --- /dev/null +++ b/merchants/integrations/__init__.py @@ -0,0 +1,15 @@ +from ..config import base_settings + + +def local_factory(slug: str): + if slug not in base_settings.available_providers: + raise NotImplementedError(f"The provider {slug} is not supported.") + + return slug + + +def create_provider(slug: str): + if slug not in base_settings.available_providers: + raise NotImplementedError(f"The provider {slug} is not supported.") + + return slug diff --git a/merchants/integrations/dummy/__init__.py b/merchants/integrations/dummy/__init__.py new file mode 100644 index 0000000..ea974d3 --- /dev/null +++ b/merchants/integrations/dummy/__init__.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass + +__version__ = "0.1.0" +__merchants_name__ = "dummy" +__merchants_slug__ = "dummy" + + +@dataclass +class Settings: + endpoint: str = "https://api.example.com" + api_key: str = "key_123456" + api_secret: str = "secret_123456" + + +settings = Settings() diff --git a/merchants/providers/paypal/__init__.py b/merchants/integrations/paypal/__init__.py similarity index 100% rename from merchants/providers/paypal/__init__.py rename to merchants/integrations/paypal/__init__.py diff --git a/merchants/providers/stripe/__init__.py b/merchants/integrations/payu/__init__.py similarity index 100% rename from merchants/providers/stripe/__init__.py rename to merchants/integrations/payu/__init__.py diff --git a/merchants/providers/square/__init__.py b/merchants/integrations/square/__init__.py similarity index 100% rename from merchants/providers/square/__init__.py rename to merchants/integrations/square/__init__.py diff --git a/merchants/integrations/stripe/__init__.py b/merchants/integrations/stripe/__init__.py new file mode 100644 index 0000000..7dc12a3 --- /dev/null +++ b/merchants/integrations/stripe/__init__.py @@ -0,0 +1,3 @@ +__version__ = "0.1.0" +__merchants_name__ = "Stripe" +__merchants_slug__ = "stripe" diff --git a/merchants/payment.py b/merchants/orm/__init__.py similarity index 100% rename from merchants/payment.py rename to merchants/orm/__init__.py diff --git a/merchants/orm/peewee.py b/merchants/orm/peewee.py new file mode 100644 index 0000000..8cc5e6e --- /dev/null +++ b/merchants/orm/peewee.py @@ -0,0 +1,123 @@ +import datetime +import logging +from uuid import uuid4 + +import peewee + +from ..config import base_settings +from ..core.schemas import ModelStatus +from ..exceptions import ProcessError +from ..integrations import create_provider + + +class MerchantsAccountSettings(peewee.Model): + account_name = peewee.CharField(max_length=64, unique=True, null=False) + account_settings = peewee.TextField( + null=False, help_text="JSON|dict with the Settings for the Integration", verbose_name="Settings" + ) + integration_class = peewee.CharField(max_length=255, null=False) + enabled = peewee.BooleanField(null=False, default=False) + + def __str__(self) -> str: + return self.account_name + + def _export_config(self) -> dict: + return ( + { + "account_name": self.account_name, + "enabled": self.enabled, + "integration_class": self.integration_class, + "account_settings": self.account_settings, + } + if self.enabled + else {} + ) + + +class MerchantsBaseModel(peewee.Model): + variant = peewee.CharField( + max_length=64, + null=False, + help_text="The name of the variant as specified in the config file", + verbose_name="Variant Name", + ) + variant_transaction_id = peewee.TextField( + null=True, help_text="The transaction ID returned by the variant", verbose_name="Variant Transaction ID" + ) + variant_data = peewee.TextField(null=True, help_text="Datapack sent to variant", verbose_name="Variant Data") + variant_response = peewee.TextField( + null=True, help_text="The varant's response data", verbose_name="Variant Response Data" + ) + + status = peewee.CharField( + max_length=16, + default=ModelStatus.CREATED, + null=False, + help_text="See merchants.core.schemas.ModelStatus", + verbose_name="Payment Status", + ) + transaction = peewee.UUIDField( + unique=True, null=False, help_text="Payment Transaction (autogenerated)", verbose_name="Transaction" + ) + customer_name = peewee.CharField( + max_length=128, null=True, help_text="Customer's name", verbose_name="Customer name" + ) + customer_email = peewee.CharField( + max_length=128, null=False, help_text="Customer's email", verbose_name="Customer Email" + ) + description = peewee.CharField( + max_length=255, null=True, help_text="Payment Description/Subject", verbose_name="Description/Subject" + ) + currency = peewee.CharField( + max_length=3, + null=False, + default="USD", + help_text="One alphabetic code as defined in ISO 4217", + verbose_name="Currency", + ) + created = peewee.DateTimeField( + default=datetime.datetime.now, help_text="Creation date", verbose_name="Date Created" + ) + modified = peewee.DateTimeField( + help_text="Last modification date (see variant_response for the different objects)", + verbose_name="Last Modification", + ) + tax = peewee.DecimalField( + decimal_places=2, default=0, help_text="If specified will, be added to the total", verbose_name="Tax Amount" + ) + delivery = peewee.DecimalField( + decimal_places=2, + default=0, + help_text="If specified will, be added to the total", + verbose_name="Delivery Amount", + ) + total = peewee.DecimalField(decimal_places=2, default=0, help_text="Total Amount", verbose_name="Total Amount") + extra_data = peewee.TextField( + null=False, help_text="dict|json with any extra information for the variant.", verbose_name="Extra Information" + ) + + def save(self, *args, **kwargs): + if not self.transaction: + self.transaction = uuid4() + + if not base_settings.process_on_save: + return super().save(*args, **kwargs) + + try: + model = super().save(*args, **kwargs) + except Exception as e: + print(e) + + model.process_payment() + return model + + def __str__(self) -> str: + return f"{self.transaction}" + + def process_payment(self): + variant = create_provider(self.variant) + try: + return variant.create(self) + except ProcessError as error: + logging.error(error) + raise ProcessError("There was a problem with this payment.") diff --git a/merchants/refund.py b/merchants/orm/pony.py similarity index 100% rename from merchants/refund.py rename to merchants/orm/pony.py diff --git a/merchants/core/orm/sqla.py b/merchants/orm/sqla.py similarity index 52% rename from merchants/core/orm/sqla.py rename to merchants/orm/sqla.py index 6cd8ce6..e86a201 100644 --- a/merchants/core/orm/sqla.py +++ b/merchants/orm/sqla.py @@ -1,9 +1,9 @@ -from typing import Any, Literal, Optional +from typing import Literal from sqlalchemy import JSON, Enum, String -from sqlalchemy.orm import DeclarativeBase, Mapped, column_property, declared_attr, mapped_column +from sqlalchemy.orm import Mapped, mapped_column -from merchants.providers import factory +from merchants.integrations import local_factory PaymentStatus = Literal["Created", "Paid", "Rejected"] @@ -18,18 +18,19 @@ class PaymentMixin: nullable=True, default=None, ) - provider_response: Mapped[JSON] = mapped_column(JSON, nullable=True, default=None) - provider_error_response: Mapped[str] = mapped_column(String(255), nullable=True, default=None) + provider_response: Mapped[JSON | None] + provider_error_response: Mapped[str | None] payment_status: Mapped[PaymentStatus] = mapped_column( Enum("Created", "Paid", "Rejected", name="payment_status_choices"), nullable=False, default="Created", + index=True, ) - payment_payload: Mapped[JSON] = mapped_column(JSON, nullable=True, default=None) + payment_payload: Mapped[JSON | None] - name_column: Optional[str] = "name" - email_column: Optional[str] = "email" + name_column: str | None = "name" + email_column: str | None = "email" def make_payment(self): - provider = factory(self.provider) + provider = local_factory(self.provider) return f"{provider}" diff --git a/merchants/orm/tortoise.py b/merchants/orm/tortoise.py new file mode 100644 index 0000000..e69de29 diff --git a/merchants/providers/__init__.py b/merchants/providers/__init__.py deleted file mode 100644 index 651ad19..0000000 --- a/merchants/providers/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -_provider_list = ["dummy", "flow", "khipu", "paypal", "sqare", "stripe", "transbank"] - - -def factory(slug: str): - if slug not in _provider_list: - raise NotImplementedError(f"The provider {slug} is not supported.") - - return slug diff --git a/merchants/providers/bank_transfer/__init__.py b/merchants/providers/bank_transfer/__init__.py deleted file mode 100644 index e41f495..0000000 --- a/merchants/providers/bank_transfer/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -__version__ = "0.1.0" -__merchants_name__ = "Bank Transfer" -__merchants_slug__ = "bank-transfer" diff --git a/merchants/providers/dummy/__init__.py b/merchants/providers/dummy/__init__.py deleted file mode 100644 index 5ef737d..0000000 --- a/merchants/providers/dummy/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -__version__ = "0.1.0" -__merchants_name__ = "dummy" -__merchants_slug__ = "dummy" diff --git a/merchants/providers/flow/__init__.py b/merchants/providers/flow/__init__.py deleted file mode 100644 index 1c252a2..0000000 --- a/merchants/providers/flow/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -__version__ = "0.1.0" -__merchants_name__ = "Flow Chile" -__merchants_slug__ = "flow" diff --git a/merchants/providers/khipu/__init__.py b/merchants/providers/khipu/__init__.py deleted file mode 100644 index 82ee194..0000000 --- a/merchants/providers/khipu/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -__version__ = "0.1.0" -__merchants_name__ = "Khipu" -__merchants_slug__ = "khipu" diff --git a/pyproject.toml b/pyproject.toml index b5cdef1..847391f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "merchants" -version = "0.1.0" +version = "2024.6.0" description = "A gateway platform to process payments" authors = ["Mario Hernandez "] readme = "README.md" @@ -16,51 +16,68 @@ keywords = [ "payment-hub", "payment-platform", ] -packages = [ - { include = "merchants" } -] -classifiers=[ +packages = [{ include = "merchants" }] +classifiers = [ "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Intended Audience :: Developers", "Topic :: Software Development :: Libraries :: Python Modules", ] [tool.poetry.dependencies] -python = "^3.8" -typer = {extras = ["all"], version = "*"} +python = "^3.9" +typer = { version = "^0.9.0", extras = ["all"] } thankyou = "*" +certifi = "^2023.7.22" +requests = "^2.31.0" +pydantic-settings = "^2.0.2" +python-dotenv = "^1.0.0" + + +[tool.poetry.group.sqla] +optional = false + +[tool.poetry.group.sqla.dependencies] sqlalchemy = "2.0.19" sqlalchemy-utils = "^0.41.1" -babel = "^2.12.1" +[tool.poetry.group.peewee] +optional = false + +[tool.poetry.group.peewee.dependencies] +peewee = "^3.16.2" + +[tool.poetry.group.dev] +optional = false [tool.poetry.group.dev.dependencies] -pytest = "*" -coverage = "*" -black = "*" -pre-commit = "*" +pytest = "^7.4.0" +coverage = "^7.2.7" +black = "^23.7.0" +pre-commit = "^3.3.3" +isort = "^5.12.0" + +[tool.poetry.group.docs.dependencies] +mkdocs-git-authors-plugin = "^0.7.2" +mkdocs-material = "^9.1.21" +mkdocs = "^1.5.2" [tool.pytest.ini_options] minversion = "6.0" addopts = "-ra" -testpaths = [ - "tests", -] -python_files =[ - "test*.py" -] +testpaths = ["tests"] +python_files = ["test*.py"] [tool.poetry.scripts] merchants = 'merchants.cli:cli' [tool.black] -line-length = 100 -target-version = ['py38'] +line-length = 119 +target-version = ['py311'] include = '\.pyi?$' diff --git a/tests/test_models.py b/tests/test_models.py index 56b3a5d..2c9065c 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,11 +1,12 @@ from typing import Literal, get_args, get_origin -from merchants.core.orm import sqla +from merchants.orm import sqla def test_Payment_status_type(): assert get_origin(sqla.PaymentStatus) is Literal + def test_Payment_status_values(): expected_values = ["Created", "Paid", "Rejected"] for v in expected_values: