python-getpaid-core 3.0.0a4__tar.gz → 3.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.github/workflows/ci.yml +3 -0
- python_getpaid_core-3.0.1/.github/workflows/release.yml +70 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.gitignore +1 -0
- python_getpaid_core-3.0.1/Makefile +28 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/PKG-INFO +2 -5
- python_getpaid_core-3.0.1/docs/changelog.md +61 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/pyproject.toml +5 -7
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/__init__.py +1 -1
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/flow.py +71 -15
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/fsm.py +29 -9
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/registry.py +5 -1
- python_getpaid_core-3.0.1/tests/test_benchmarks.py +549 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_flow.py +62 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_public_api.py +1 -1
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_registry.py +59 -0
- python_getpaid_core-3.0.1/tests/test_state_engine.py +370 -0
- python_getpaid_core-3.0.0a4/.github/workflows/release.yml +0 -79
- python_getpaid_core-3.0.0a4/docs/changelog.md +0 -22
- python_getpaid_core-3.0.0a4/tests/test_state_engine.py +0 -175
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.cookiecutter.json +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.gitattributes +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.github/dependabot.yml +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.github/labels.yml +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.github/release-drafter.yml +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.github/workflows/constraints.txt +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.github/workflows/labeler.yml +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.github/workflows/tests.yml +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.plans/2026-02-13-getpaid-core-design.md +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.plans/2026-02-13-getpaid-core-implementation.md +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.pre-commit-config.yaml +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.readthedocs.yml +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.sisyphus/evidence/task-22-readme-core.txt +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/CODE_OF_CONDUCT.md +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/CONTRIBUTING.md +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/LICENSE +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/README.md +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/codecov.yml +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/docs/codeofconduct.md +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/docs/concepts.md +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/docs/conf.py +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/docs/contributing.md +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/docs/getting-started.md +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/docs/index.md +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/docs/license.md +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/docs/reference.md +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/docs/requirements.txt +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/backends/__init__.py +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/backends/dummy.py +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/enums.py +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/exceptions.py +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/processor.py +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/protocols.py +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/py.typed +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/types.py +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/validators.py +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/__init__.py +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/conftest.py +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_dummy_backend.py +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_enums.py +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_exceptions.py +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_integration.py +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_processor.py +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_protocols.py +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_types.py +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_validators.py +0 -0
- {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_version.py +0 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
- master
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
release:
|
|
11
|
+
name: Release
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- name: Check out the repository
|
|
15
|
+
uses: actions/checkout@v4
|
|
16
|
+
with:
|
|
17
|
+
fetch-depth: 0 # Full history needed for version detection
|
|
18
|
+
|
|
19
|
+
- name: Set up Python
|
|
20
|
+
uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: "3.12"
|
|
23
|
+
|
|
24
|
+
- name: Install uv
|
|
25
|
+
run: pip install uv
|
|
26
|
+
|
|
27
|
+
# Version is read from __init__.py (dynamic via hatch)
|
|
28
|
+
- name: Detect version from __init__.py
|
|
29
|
+
id: get-version
|
|
30
|
+
run: |
|
|
31
|
+
init_py=$(grep -A2 '\[tool\.hatch\.version\]' pyproject.toml | grep '^path\s*=' | head -1 | sed -E "s/path\s*=\s*['\"]([^'\"]+)['\"].*/\1/")
|
|
32
|
+
version=$(grep '__version__' "$init_py" | head -1 | sed -E "s/.*= ['\"]([^'\"]+)['\"].*/\1/")
|
|
33
|
+
echo "version=$version" >> "$GITHUB_OUTPUT"
|
|
34
|
+
|
|
35
|
+
- name: Check if tag already exists
|
|
36
|
+
id: check-tag
|
|
37
|
+
run: |
|
|
38
|
+
tag="v${{ steps.get-version.outputs.version }}"
|
|
39
|
+
if git rev-parse "$tag" >/dev/null 2>&1; then
|
|
40
|
+
echo "already_tagged=true" >> "$GITHUB_OUTPUT"
|
|
41
|
+
else
|
|
42
|
+
echo "already_tagged=false" >> "$GITHUB_OUTPUT"
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
- name: Tag new version
|
|
46
|
+
if: steps.check-tag.outputs.already_tagged == 'false'
|
|
47
|
+
run: |
|
|
48
|
+
tag="v${{ steps.get-version.outputs.version }}"
|
|
49
|
+
git config user.name "github-actions[bot]"
|
|
50
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
51
|
+
git tag -a "$tag" -m "Release $tag"
|
|
52
|
+
git push origin "$tag"
|
|
53
|
+
|
|
54
|
+
- name: Build package
|
|
55
|
+
run: uv build
|
|
56
|
+
|
|
57
|
+
- name: Publish package on PyPI
|
|
58
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
59
|
+
with:
|
|
60
|
+
password: ${{ secrets.PYPI_TOKEN }}
|
|
61
|
+
|
|
62
|
+
- name: Publish the release notes
|
|
63
|
+
if: steps.check-tag.outputs.already_tagged == 'false'
|
|
64
|
+
uses: release-drafter/release-drafter@v5.20.0
|
|
65
|
+
with:
|
|
66
|
+
publish: true
|
|
67
|
+
name: v${{ steps.get-version.outputs.version }}
|
|
68
|
+
tag: v${{ steps.get-version.outputs.version }}
|
|
69
|
+
env:
|
|
70
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
.PHONY: test test-bench lint type-check audit clean
|
|
2
|
+
|
|
3
|
+
PYTHON ?= .venv/bin/python
|
|
4
|
+
|
|
5
|
+
test: ## Run all tests (unit + benchmarks)
|
|
6
|
+
$(PYTHON) -m pytest
|
|
7
|
+
|
|
8
|
+
test-bench: ## Run benchmarks only
|
|
9
|
+
$(PYTHON) -m pytest tests/test_benchmarks.py --benchmark-only --benchmark-min-rounds=10 --benchmark-sort=mean
|
|
10
|
+
|
|
11
|
+
test-bench-save: ## Run benchmarks and save results
|
|
12
|
+
$(PYTHON) -m pytest tests/test_benchmarks.py --benchmark-only --benchmark-autosave --benchmark-sort=mean
|
|
13
|
+
|
|
14
|
+
test-bench-compare: ## Compare current benchmarks against saved baseline
|
|
15
|
+
$(PYTHON) -m pytest tests/test_benchmarks.py --benchmark-only --benchmark-compare=0
|
|
16
|
+
|
|
17
|
+
lint: ## Run ruff linter
|
|
18
|
+
$(PYTHON) -m ruff check src tests
|
|
19
|
+
|
|
20
|
+
type-check: ## Run type checker
|
|
21
|
+
$(PYTHON) -m ty check src
|
|
22
|
+
|
|
23
|
+
audit: ## Audit dependencies for vulnerabilities
|
|
24
|
+
$(PYTHON) -m pip-audit
|
|
25
|
+
|
|
26
|
+
clean: ## Remove build artifacts and caches
|
|
27
|
+
rm -rf .pytest_cache .benchmarks .coverage htmlcov build dist *.egg-info
|
|
28
|
+
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-getpaid-core
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.1
|
|
4
4
|
Summary: Framework-agnostic payment processing core.
|
|
5
5
|
Project-URL: Homepage, https://github.com/django-getpaid/python-getpaid-core
|
|
6
6
|
Project-URL: Repository, https://github.com/django-getpaid/python-getpaid-core
|
|
@@ -9,7 +9,7 @@ Project-URL: Changelog, https://github.com/django-getpaid/python-getpaid-core/re
|
|
|
9
9
|
Author-email: Dominik Kozaczko <dominik@kozaczko.info>
|
|
10
10
|
License: MIT
|
|
11
11
|
License-File: LICENSE
|
|
12
|
-
Classifier: Development Status ::
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
14
14
|
Classifier: License :: OSI Approved :: MIT License
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
@@ -18,9 +18,6 @@ Classifier: Topic :: Office/Business :: Financial
|
|
|
18
18
|
Classifier: Topic :: Office/Business :: Financial :: Point-Of-Sale
|
|
19
19
|
Classifier: Typing :: Typed
|
|
20
20
|
Requires-Python: >=3.12
|
|
21
|
-
Requires-Dist: anyio>=4.0
|
|
22
|
-
Requires-Dist: httpx>=0.27.0
|
|
23
|
-
Requires-Dist: transitions>=0.9.0
|
|
24
21
|
Description-Content-Type: text/markdown
|
|
25
22
|
|
|
26
23
|
# python-getpaid-core
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## v3.0.1 (2026-06-05)
|
|
4
|
+
|
|
5
|
+
### Notes
|
|
6
|
+
|
|
7
|
+
- Version bump to coordinate with `django-getpaid` v3.0.1, which replaced
|
|
8
|
+
enum inheritance with composition to support Python 3.14's stricter
|
|
9
|
+
`EnumType._check_for_existing_members_` check. No changes to core enums
|
|
10
|
+
themselves — the breaking change was in the Django adapter's wrapper classes.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## v3.0.0 (2026-06-04)
|
|
15
|
+
|
|
16
|
+
Major stable release — framework-agnostic payment processing core.
|
|
17
|
+
|
|
18
|
+
### Breaking Changes
|
|
19
|
+
|
|
20
|
+
- Complete rewrite as a framework-agnostic library, no longer coupled to Django
|
|
21
|
+
- `django-fsm` dependency removed — replaced by runtime FSM via `transitions`
|
|
22
|
+
- Requires Python 3.12+
|
|
23
|
+
- `can_proceed()` replaced by `may_trigger()`
|
|
24
|
+
|
|
25
|
+
### Features
|
|
26
|
+
|
|
27
|
+
- Payment status enum (`PaymentStatus`) with 9 states matching django-getpaid v2 values
|
|
28
|
+
- Fraud status enum (`FraudStatus`) with 4 states
|
|
29
|
+
- Backend method and confirmation method enums
|
|
30
|
+
- `BaseProcessor` abstract class for payment gateway plugins
|
|
31
|
+
- Semantic payment and fraud update engine
|
|
32
|
+
- Transition validation with `InvalidTransitionError`
|
|
33
|
+
- Provider metadata merging and callback idempotency tracking
|
|
34
|
+
- `PluginRegistry` with entry-point discovery and manual registration
|
|
35
|
+
- Runtime-checkable protocols: `Payment`, `Order`, `PaymentRepository`
|
|
36
|
+
- Dataclass response types: `BuyerInfo`, `ItemInfo`, `ChargeResult`,
|
|
37
|
+
`PaymentUpdate`, `RefundResult`, `TransactionResult`
|
|
38
|
+
- Structured exception hierarchy with `context` support
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## v0.1.0 (2026-02-13)
|
|
43
|
+
|
|
44
|
+
Initial release — extracted from django-getpaid v2 and redesigned as a
|
|
45
|
+
framework-agnostic library.
|
|
46
|
+
|
|
47
|
+
### Features
|
|
48
|
+
|
|
49
|
+
- Payment status enum (`PaymentStatus`) with 9 states matching django-getpaid v2
|
|
50
|
+
values for backward compatibility
|
|
51
|
+
- Fraud status enum (`FraudStatus`) with 4 states
|
|
52
|
+
- Backend method and confirmation method enums
|
|
53
|
+
- `BaseProcessor` abstract class for payment gateway plugins
|
|
54
|
+
- Semantic payment and fraud update engine
|
|
55
|
+
- Transition validation with `InvalidTransitionError`
|
|
56
|
+
- Provider metadata merging and callback idempotency tracking
|
|
57
|
+
- `PluginRegistry` with entry-point discovery and manual registration
|
|
58
|
+
- Runtime-checkable protocols: `Payment`, `Order`, `PaymentRepository`
|
|
59
|
+
- Dataclass response types: `BuyerInfo`, `ItemInfo`, `ChargeResult`,
|
|
60
|
+
`PaymentUpdate`, `RefundResult`, `TransactionResult`
|
|
61
|
+
- Structured exception hierarchy with `context` support
|
|
@@ -9,7 +9,7 @@ authors = [
|
|
|
9
9
|
]
|
|
10
10
|
requires-python = '>=3.12'
|
|
11
11
|
classifiers = [
|
|
12
|
-
'Development Status ::
|
|
12
|
+
'Development Status :: 5 - Production/Stable',
|
|
13
13
|
'Intended Audience :: Developers',
|
|
14
14
|
'License :: OSI Approved :: MIT License',
|
|
15
15
|
'Programming Language :: Python :: 3.12',
|
|
@@ -18,11 +18,7 @@ classifiers = [
|
|
|
18
18
|
'Topic :: Office/Business :: Financial :: Point-Of-Sale',
|
|
19
19
|
'Typing :: Typed',
|
|
20
20
|
]
|
|
21
|
-
dependencies = [
|
|
22
|
-
'transitions>=0.9.0',
|
|
23
|
-
'httpx>=0.27.0',
|
|
24
|
-
'anyio>=4.0',
|
|
25
|
-
]
|
|
21
|
+
dependencies = []
|
|
26
22
|
|
|
27
23
|
[dependency-groups]
|
|
28
24
|
dev = [
|
|
@@ -37,6 +33,8 @@ dev = [
|
|
|
37
33
|
"myst-parser>=4.0.1",
|
|
38
34
|
"pre-commit-hooks>=6.0.0",
|
|
39
35
|
"ty>=0.0.16",
|
|
36
|
+
"pip-audit>=2.7.0",
|
|
37
|
+
"pytest-benchmark>=5.0.0",
|
|
40
38
|
]
|
|
41
39
|
|
|
42
40
|
[project.urls]
|
|
@@ -91,7 +89,7 @@ ignore = [
|
|
|
91
89
|
]
|
|
92
90
|
|
|
93
91
|
[tool.ruff.lint.per-file-ignores]
|
|
94
|
-
'tests/**' = ['TC001', 'RUF012']
|
|
92
|
+
'tests/**' = ['TC001', 'RUF012', 'E501']
|
|
95
93
|
|
|
96
94
|
[tool.ruff.lint.isort]
|
|
97
95
|
force-single-line = true
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
"""Payment flow orchestrator."""
|
|
2
2
|
|
|
3
|
+
from collections.abc import Callable
|
|
3
4
|
from decimal import Decimal
|
|
4
5
|
from typing import Any
|
|
5
6
|
|
|
6
7
|
from getpaid_core.enums import PaymentEvent
|
|
8
|
+
from getpaid_core.enums import PaymentStatus
|
|
9
|
+
from getpaid_core.exceptions import InvalidTransitionError
|
|
7
10
|
from getpaid_core.fsm import apply_payment_update
|
|
8
11
|
from getpaid_core.protocols import Order
|
|
9
12
|
from getpaid_core.protocols import Payment
|
|
@@ -17,6 +20,9 @@ from getpaid_core.types import TransactionResult
|
|
|
17
20
|
from getpaid_core.validators import run_validators
|
|
18
21
|
|
|
19
22
|
|
|
23
|
+
OperationValidator = Callable[[dict[str, Any]], dict[str, Any]]
|
|
24
|
+
|
|
25
|
+
|
|
20
26
|
class PaymentFlow:
|
|
21
27
|
"""Core payment processing orchestrator."""
|
|
22
28
|
|
|
@@ -24,16 +30,19 @@ class PaymentFlow:
|
|
|
24
30
|
self,
|
|
25
31
|
repository: PaymentRepository,
|
|
26
32
|
config: dict[str, dict[str, Any]] | None = None,
|
|
27
|
-
validators: list | None = None,
|
|
33
|
+
validators: list[OperationValidator] | None = None,
|
|
28
34
|
registry: PluginRegistry | None = None,
|
|
29
35
|
) -> None:
|
|
30
36
|
self.repository = repository
|
|
31
|
-
self.config = config or {}
|
|
32
|
-
self.validators = validators or []
|
|
37
|
+
self.config: dict[str, dict[str, Any]] = config or {}
|
|
38
|
+
self.validators: list[OperationValidator] = validators or []
|
|
33
39
|
self.registry = registry or default_registry
|
|
34
40
|
|
|
35
41
|
async def create_payment(
|
|
36
|
-
self,
|
|
42
|
+
self,
|
|
43
|
+
order: Order,
|
|
44
|
+
backend_slug: str,
|
|
45
|
+
**kwargs: Any,
|
|
37
46
|
) -> Payment:
|
|
38
47
|
"""Create a new payment for an order."""
|
|
39
48
|
self.registry.get_by_slug(backend_slug)
|
|
@@ -48,7 +57,11 @@ class PaymentFlow:
|
|
|
48
57
|
)
|
|
49
58
|
return payment
|
|
50
59
|
|
|
51
|
-
async def prepare(
|
|
60
|
+
async def prepare(
|
|
61
|
+
self,
|
|
62
|
+
payment: Payment,
|
|
63
|
+
**kwargs: Any,
|
|
64
|
+
) -> TransactionResult:
|
|
52
65
|
"""Prepare a payment for processing."""
|
|
53
66
|
context = self._run_operation_validators(
|
|
54
67
|
operation="prepare",
|
|
@@ -71,9 +84,9 @@ class PaymentFlow:
|
|
|
71
84
|
async def handle_callback(
|
|
72
85
|
self,
|
|
73
86
|
payment: Payment,
|
|
74
|
-
data: dict,
|
|
75
|
-
headers: dict,
|
|
76
|
-
**kwargs,
|
|
87
|
+
data: dict[str, Any],
|
|
88
|
+
headers: dict[str, str],
|
|
89
|
+
**kwargs: Any,
|
|
77
90
|
) -> None:
|
|
78
91
|
"""Handle an incoming PUSH callback from the gateway."""
|
|
79
92
|
context = self._run_operation_validators(
|
|
@@ -93,7 +106,10 @@ class PaymentFlow:
|
|
|
93
106
|
apply_payment_update(payment, update)
|
|
94
107
|
await self.repository.save(payment)
|
|
95
108
|
|
|
96
|
-
async def fetch_and_update_status(
|
|
109
|
+
async def fetch_and_update_status(
|
|
110
|
+
self,
|
|
111
|
+
payment: Payment,
|
|
112
|
+
) -> Payment:
|
|
97
113
|
"""PULL flow: fetch status from gateway and update."""
|
|
98
114
|
context = self._run_operation_validators(
|
|
99
115
|
operation="fetch_status",
|
|
@@ -102,6 +118,8 @@ class PaymentFlow:
|
|
|
102
118
|
)
|
|
103
119
|
processor = self.get_processor(payment)
|
|
104
120
|
update = await processor.fetch_payment_status(**context["kwargs"])
|
|
121
|
+
if update is None:
|
|
122
|
+
return payment
|
|
105
123
|
apply_payment_update(payment, update)
|
|
106
124
|
await self.repository.save(payment)
|
|
107
125
|
return payment
|
|
@@ -110,7 +128,7 @@ class PaymentFlow:
|
|
|
110
128
|
self,
|
|
111
129
|
payment: Payment,
|
|
112
130
|
amount: Decimal | None = None,
|
|
113
|
-
**kwargs,
|
|
131
|
+
**kwargs: Any,
|
|
114
132
|
) -> ChargeResult:
|
|
115
133
|
"""Charge a pre-authorized payment."""
|
|
116
134
|
context = self._run_operation_validators(
|
|
@@ -118,6 +136,16 @@ class PaymentFlow:
|
|
|
118
136
|
payment=payment,
|
|
119
137
|
kwargs={"amount": amount, **kwargs},
|
|
120
138
|
)
|
|
139
|
+
# Validate precondition before calling processor (avoids
|
|
140
|
+
# unnecessary API calls when the payment is not chargeable).
|
|
141
|
+
if payment.status not in {
|
|
142
|
+
PaymentStatus.PRE_AUTH,
|
|
143
|
+
PaymentStatus.IN_CHARGE,
|
|
144
|
+
}:
|
|
145
|
+
raise InvalidTransitionError(
|
|
146
|
+
f"Cannot charge payment in {payment.status!r} status. "
|
|
147
|
+
"Payment must be PRE_AUTH or IN_CHARGE."
|
|
148
|
+
)
|
|
121
149
|
processor = self.get_processor(payment)
|
|
122
150
|
result = await processor.charge(**context["kwargs"])
|
|
123
151
|
if result.success:
|
|
@@ -136,13 +164,22 @@ class PaymentFlow:
|
|
|
136
164
|
await self.repository.save(payment)
|
|
137
165
|
return result
|
|
138
166
|
|
|
139
|
-
async def release_lock(
|
|
167
|
+
async def release_lock(
|
|
168
|
+
self,
|
|
169
|
+
payment: Payment,
|
|
170
|
+
**kwargs: Any,
|
|
171
|
+
) -> Decimal:
|
|
140
172
|
"""Release a pre-authorized lock."""
|
|
141
173
|
context = self._run_operation_validators(
|
|
142
174
|
operation="release_lock",
|
|
143
175
|
payment=payment,
|
|
144
176
|
kwargs=dict(kwargs),
|
|
145
177
|
)
|
|
178
|
+
if payment.status != PaymentStatus.PRE_AUTH:
|
|
179
|
+
raise InvalidTransitionError(
|
|
180
|
+
f"Cannot release lock for payment in {payment.status!r} "
|
|
181
|
+
"status. Payment must be PRE_AUTH."
|
|
182
|
+
)
|
|
146
183
|
processor = self.get_processor(payment)
|
|
147
184
|
amount = await processor.release_lock(**context["kwargs"])
|
|
148
185
|
apply_payment_update(
|
|
@@ -156,7 +193,7 @@ class PaymentFlow:
|
|
|
156
193
|
self,
|
|
157
194
|
payment: Payment,
|
|
158
195
|
amount: Decimal | None = None,
|
|
159
|
-
**kwargs,
|
|
196
|
+
**kwargs: Any,
|
|
160
197
|
) -> RefundResult:
|
|
161
198
|
"""Start a refund."""
|
|
162
199
|
context = self._run_operation_validators(
|
|
@@ -164,6 +201,15 @@ class PaymentFlow:
|
|
|
164
201
|
payment=payment,
|
|
165
202
|
kwargs={"amount": amount, **kwargs},
|
|
166
203
|
)
|
|
204
|
+
if payment.status not in {
|
|
205
|
+
PaymentStatus.PAID,
|
|
206
|
+
PaymentStatus.PARTIAL,
|
|
207
|
+
PaymentStatus.REFUND_STARTED,
|
|
208
|
+
}:
|
|
209
|
+
raise InvalidTransitionError(
|
|
210
|
+
f"Cannot start refund for payment in {payment.status!r} "
|
|
211
|
+
"status. Payment must be PAID, PARTIAL, or REFUND_STARTED."
|
|
212
|
+
)
|
|
167
213
|
processor = self.get_processor(payment)
|
|
168
214
|
result = await processor.start_refund(**context["kwargs"])
|
|
169
215
|
apply_payment_update(
|
|
@@ -176,7 +222,11 @@ class PaymentFlow:
|
|
|
176
222
|
await self.repository.save(payment)
|
|
177
223
|
return result
|
|
178
224
|
|
|
179
|
-
async def cancel_refund(
|
|
225
|
+
async def cancel_refund(
|
|
226
|
+
self,
|
|
227
|
+
payment: Payment,
|
|
228
|
+
**kwargs: Any,
|
|
229
|
+
) -> bool:
|
|
180
230
|
"""Cancel an in-progress refund."""
|
|
181
231
|
context = self._run_operation_validators(
|
|
182
232
|
operation="cancel_refund",
|
|
@@ -193,11 +243,17 @@ class PaymentFlow:
|
|
|
193
243
|
await self.repository.save(payment)
|
|
194
244
|
return success
|
|
195
245
|
|
|
196
|
-
def get_processor(
|
|
246
|
+
def get_processor(
|
|
247
|
+
self,
|
|
248
|
+
payment: Payment,
|
|
249
|
+
) -> Any:
|
|
197
250
|
"""Instantiate the processor for a payment."""
|
|
198
251
|
processor_class = self.registry.get_by_slug(payment.backend)
|
|
199
252
|
backend_config = self.config.get(payment.backend, {})
|
|
200
253
|
return processor_class(payment, config=backend_config)
|
|
201
254
|
|
|
202
|
-
def _run_operation_validators(
|
|
255
|
+
def _run_operation_validators(
|
|
256
|
+
self,
|
|
257
|
+
**context: Any,
|
|
258
|
+
) -> dict[str, Any]:
|
|
203
259
|
return run_validators(context, validators=self.validators)
|
|
@@ -4,7 +4,6 @@ from copy import deepcopy
|
|
|
4
4
|
from dataclasses import dataclass
|
|
5
5
|
from decimal import Decimal
|
|
6
6
|
from typing import Any
|
|
7
|
-
from typing import cast
|
|
8
7
|
|
|
9
8
|
from getpaid_core.enums import FraudEvent
|
|
10
9
|
from getpaid_core.enums import FraudStatus
|
|
@@ -64,9 +63,13 @@ def _merge_provider_data(payment: Payment, provider_data: dict) -> None:
|
|
|
64
63
|
_ensure_provider_data(payment).update(provider_data)
|
|
65
64
|
|
|
66
65
|
|
|
67
|
-
def _set_paid_amount(payment: Payment, paid_amount: Decimal
|
|
68
|
-
if paid_amount
|
|
69
|
-
|
|
66
|
+
def _set_paid_amount(payment: Payment, paid_amount: Decimal) -> None:
|
|
67
|
+
"""Set paid amount. Raises if paid_amount exceeds amount_required."""
|
|
68
|
+
if paid_amount > payment.amount_required:
|
|
69
|
+
raise InvalidTransitionError(
|
|
70
|
+
f"Paid amount {paid_amount} exceeds amount_required "
|
|
71
|
+
f"{payment.amount_required}."
|
|
72
|
+
)
|
|
70
73
|
previous_paid = payment.amount_paid
|
|
71
74
|
next_paid = max(previous_paid, paid_amount)
|
|
72
75
|
increment = next_paid - previous_paid
|
|
@@ -82,6 +85,11 @@ def _set_refunded_amount(
|
|
|
82
85
|
) -> None:
|
|
83
86
|
if refunded_amount is None:
|
|
84
87
|
refunded_amount = payment.amount_paid
|
|
88
|
+
if refunded_amount > payment.amount_paid:
|
|
89
|
+
raise InvalidTransitionError(
|
|
90
|
+
f"Refunded amount {refunded_amount} exceeds amount_paid "
|
|
91
|
+
f"{payment.amount_paid}."
|
|
92
|
+
)
|
|
85
93
|
payment.amount_refunded = max(payment.amount_refunded, refunded_amount)
|
|
86
94
|
|
|
87
95
|
|
|
@@ -125,10 +133,10 @@ def _restore_payment_state(payment: Payment, snapshot: PaymentSnapshot) -> None:
|
|
|
125
133
|
payment.external_id = snapshot.external_id
|
|
126
134
|
payment.fraud_status = snapshot.fraud_status
|
|
127
135
|
payment.fraud_message = snapshot.fraud_message
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
136
|
+
if snapshot.provider_data is None:
|
|
137
|
+
payment.provider_data = {}
|
|
138
|
+
else:
|
|
139
|
+
payment.provider_data = snapshot.provider_data
|
|
132
140
|
|
|
133
141
|
|
|
134
142
|
def _apply_payment_event(payment: Payment, update: PaymentUpdate) -> None:
|
|
@@ -150,6 +158,10 @@ def _apply_payment_event(payment: Payment, update: PaymentUpdate) -> None:
|
|
|
150
158
|
PaymentStatus.PREPARED,
|
|
151
159
|
PaymentStatus.PRE_AUTH,
|
|
152
160
|
}:
|
|
161
|
+
if update.locked_amount is None:
|
|
162
|
+
raise InvalidTransitionError(
|
|
163
|
+
"LOCKED event requires explicit locked_amount."
|
|
164
|
+
)
|
|
153
165
|
_set_locked_amount(payment, update.locked_amount)
|
|
154
166
|
payment.status = PaymentStatus.PRE_AUTH
|
|
155
167
|
return
|
|
@@ -170,6 +182,10 @@ def _apply_payment_event(payment: Payment, update: PaymentUpdate) -> None:
|
|
|
170
182
|
raise InvalidTransitionError(
|
|
171
183
|
f"Cannot capture payment in {status.value!r} status."
|
|
172
184
|
)
|
|
185
|
+
if update.paid_amount is None:
|
|
186
|
+
raise InvalidTransitionError(
|
|
187
|
+
"PAYMENT_CAPTURED event requires explicit paid_amount."
|
|
188
|
+
)
|
|
173
189
|
_set_paid_amount(payment, update.paid_amount)
|
|
174
190
|
payment.status = _active_paid_status(payment)
|
|
175
191
|
return
|
|
@@ -215,6 +231,10 @@ def _apply_payment_event(payment: Payment, update: PaymentUpdate) -> None:
|
|
|
215
231
|
raise InvalidTransitionError(
|
|
216
232
|
f"Cannot confirm refund for payment in {status.value!r} status."
|
|
217
233
|
)
|
|
234
|
+
if update.refunded_amount is None:
|
|
235
|
+
raise InvalidTransitionError(
|
|
236
|
+
"REFUND_CONFIRMED event requires explicit refunded_amount."
|
|
237
|
+
)
|
|
218
238
|
_set_refunded_amount(payment, update.refunded_amount)
|
|
219
239
|
if (
|
|
220
240
|
payment.amount_refunded >= payment.amount_paid
|
|
@@ -238,7 +258,7 @@ def _apply_payment_event(payment: Payment, update: PaymentUpdate) -> None:
|
|
|
238
258
|
)
|
|
239
259
|
|
|
240
260
|
if event is PaymentEvent.LOCK_RELEASED:
|
|
241
|
-
if status
|
|
261
|
+
if status is PaymentStatus.PRE_AUTH:
|
|
242
262
|
payment.amount_locked = Decimal("0.00")
|
|
243
263
|
payment.status = PaymentStatus.REFUNDED
|
|
244
264
|
return
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Plugin registry for payment backends."""
|
|
2
2
|
|
|
3
|
+
import threading
|
|
3
4
|
from importlib.metadata import entry_points
|
|
4
5
|
|
|
5
6
|
from getpaid_core.processor import BaseProcessor
|
|
@@ -14,6 +15,7 @@ class PluginRegistry:
|
|
|
14
15
|
def __init__(self) -> None:
|
|
15
16
|
self._backends: dict[str, type[BaseProcessor]] = {}
|
|
16
17
|
self._discovered = False
|
|
18
|
+
self._lock = threading.Lock()
|
|
17
19
|
|
|
18
20
|
def discover(self) -> None:
|
|
19
21
|
"""Load all backends registered via entry points."""
|
|
@@ -64,7 +66,9 @@ class PluginRegistry:
|
|
|
64
66
|
|
|
65
67
|
def _ensure_discovered(self) -> None:
|
|
66
68
|
if not self._discovered:
|
|
67
|
-
self.
|
|
69
|
+
with self._lock:
|
|
70
|
+
if not self._discovered:
|
|
71
|
+
self.discover()
|
|
68
72
|
|
|
69
73
|
def _register_backend(self, processor_class: type[BaseProcessor]) -> None:
|
|
70
74
|
slug = processor_class.slug
|