python-getpaid-core 3.0.0a3__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.0a3 → 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.0a3 → python_getpaid_core-3.0.1}/.gitignore +1 -0
- python_getpaid_core-3.0.1/Makefile +28 -0
- {python_getpaid_core-3.0.0a3 → 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.0a3 → python_getpaid_core-3.0.1}/pyproject.toml +5 -7
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/__init__.py +1 -1
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/flow.py +71 -15
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/fsm.py +83 -13
- {python_getpaid_core-3.0.0a3 → 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.0a3 → python_getpaid_core-3.0.1}/tests/test_flow.py +62 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/test_public_api.py +1 -1
- {python_getpaid_core-3.0.0a3 → 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.0a3/.github/workflows/release.yml +0 -79
- python_getpaid_core-3.0.0a3/docs/changelog.md +0 -22
- python_getpaid_core-3.0.0a3/tests/test_state_engine.py +0 -122
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.cookiecutter.json +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.gitattributes +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.github/dependabot.yml +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.github/labels.yml +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.github/release-drafter.yml +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.github/workflows/constraints.txt +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.github/workflows/labeler.yml +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.github/workflows/tests.yml +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.plans/2026-02-13-getpaid-core-design.md +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.plans/2026-02-13-getpaid-core-implementation.md +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.pre-commit-config.yaml +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.readthedocs.yml +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.sisyphus/evidence/task-22-readme-core.txt +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/CODE_OF_CONDUCT.md +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/CONTRIBUTING.md +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/LICENSE +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/README.md +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/codecov.yml +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/docs/codeofconduct.md +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/docs/concepts.md +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/docs/conf.py +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/docs/contributing.md +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/docs/getting-started.md +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/docs/index.md +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/docs/license.md +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/docs/reference.md +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/docs/requirements.txt +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/backends/__init__.py +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/backends/dummy.py +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/enums.py +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/exceptions.py +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/processor.py +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/protocols.py +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/py.typed +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/types.py +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/validators.py +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/__init__.py +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/conftest.py +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/test_dummy_backend.py +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/test_enums.py +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/test_exceptions.py +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/test_integration.py +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/test_processor.py +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/test_protocols.py +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/test_types.py +0 -0
- {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/test_validators.py +0 -0
- {python_getpaid_core-3.0.0a3 → 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)
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"""State engine for payment and fraud lifecycle transitions."""
|
|
2
2
|
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from dataclasses import dataclass
|
|
3
5
|
from decimal import Decimal
|
|
6
|
+
from typing import Any
|
|
4
7
|
|
|
5
8
|
from getpaid_core.enums import FraudEvent
|
|
6
9
|
from getpaid_core.enums import FraudStatus
|
|
@@ -11,6 +14,18 @@ from getpaid_core.protocols import Payment
|
|
|
11
14
|
from getpaid_core.types import PaymentUpdate
|
|
12
15
|
|
|
13
16
|
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class PaymentSnapshot:
|
|
19
|
+
status: str
|
|
20
|
+
amount_paid: Decimal
|
|
21
|
+
amount_locked: Decimal
|
|
22
|
+
amount_refunded: Decimal
|
|
23
|
+
external_id: str | None
|
|
24
|
+
fraud_status: str
|
|
25
|
+
fraud_message: str
|
|
26
|
+
provider_data: dict[str, Any] | None
|
|
27
|
+
|
|
28
|
+
|
|
14
29
|
def _ensure_provider_data(payment: Payment) -> dict:
|
|
15
30
|
provider_data = getattr(payment, "provider_data", None)
|
|
16
31
|
if provider_data is None:
|
|
@@ -48,9 +63,13 @@ def _merge_provider_data(payment: Payment, provider_data: dict) -> None:
|
|
|
48
63
|
_ensure_provider_data(payment).update(provider_data)
|
|
49
64
|
|
|
50
65
|
|
|
51
|
-
def _set_paid_amount(payment: Payment, paid_amount: Decimal
|
|
52
|
-
if paid_amount
|
|
53
|
-
|
|
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
|
+
)
|
|
54
73
|
previous_paid = payment.amount_paid
|
|
55
74
|
next_paid = max(previous_paid, paid_amount)
|
|
56
75
|
increment = next_paid - previous_paid
|
|
@@ -66,6 +85,11 @@ def _set_refunded_amount(
|
|
|
66
85
|
) -> None:
|
|
67
86
|
if refunded_amount is None:
|
|
68
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
|
+
)
|
|
69
93
|
payment.amount_refunded = max(payment.amount_refunded, refunded_amount)
|
|
70
94
|
|
|
71
95
|
|
|
@@ -88,6 +112,33 @@ def _active_paid_status(payment: Payment) -> PaymentStatus:
|
|
|
88
112
|
return PaymentStatus.PREPARED
|
|
89
113
|
|
|
90
114
|
|
|
115
|
+
def _snapshot_payment_state(payment: Payment) -> PaymentSnapshot:
|
|
116
|
+
return PaymentSnapshot(
|
|
117
|
+
status=payment.status,
|
|
118
|
+
amount_paid=payment.amount_paid,
|
|
119
|
+
amount_locked=payment.amount_locked,
|
|
120
|
+
amount_refunded=payment.amount_refunded,
|
|
121
|
+
external_id=payment.external_id,
|
|
122
|
+
fraud_status=payment.fraud_status,
|
|
123
|
+
fraud_message=payment.fraud_message,
|
|
124
|
+
provider_data=deepcopy(getattr(payment, "provider_data", None)),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _restore_payment_state(payment: Payment, snapshot: PaymentSnapshot) -> None:
|
|
129
|
+
payment.status = snapshot.status
|
|
130
|
+
payment.amount_paid = snapshot.amount_paid
|
|
131
|
+
payment.amount_locked = snapshot.amount_locked
|
|
132
|
+
payment.amount_refunded = snapshot.amount_refunded
|
|
133
|
+
payment.external_id = snapshot.external_id
|
|
134
|
+
payment.fraud_status = snapshot.fraud_status
|
|
135
|
+
payment.fraud_message = snapshot.fraud_message
|
|
136
|
+
if snapshot.provider_data is None:
|
|
137
|
+
payment.provider_data = {}
|
|
138
|
+
else:
|
|
139
|
+
payment.provider_data = snapshot.provider_data
|
|
140
|
+
|
|
141
|
+
|
|
91
142
|
def _apply_payment_event(payment: Payment, update: PaymentUpdate) -> None:
|
|
92
143
|
event = update.payment_event
|
|
93
144
|
if event is None:
|
|
@@ -107,6 +158,10 @@ def _apply_payment_event(payment: Payment, update: PaymentUpdate) -> None:
|
|
|
107
158
|
PaymentStatus.PREPARED,
|
|
108
159
|
PaymentStatus.PRE_AUTH,
|
|
109
160
|
}:
|
|
161
|
+
if update.locked_amount is None:
|
|
162
|
+
raise InvalidTransitionError(
|
|
163
|
+
"LOCKED event requires explicit locked_amount."
|
|
164
|
+
)
|
|
110
165
|
_set_locked_amount(payment, update.locked_amount)
|
|
111
166
|
payment.status = PaymentStatus.PRE_AUTH
|
|
112
167
|
return
|
|
@@ -127,6 +182,10 @@ def _apply_payment_event(payment: Payment, update: PaymentUpdate) -> None:
|
|
|
127
182
|
raise InvalidTransitionError(
|
|
128
183
|
f"Cannot capture payment in {status.value!r} status."
|
|
129
184
|
)
|
|
185
|
+
if update.paid_amount is None:
|
|
186
|
+
raise InvalidTransitionError(
|
|
187
|
+
"PAYMENT_CAPTURED event requires explicit paid_amount."
|
|
188
|
+
)
|
|
130
189
|
_set_paid_amount(payment, update.paid_amount)
|
|
131
190
|
payment.status = _active_paid_status(payment)
|
|
132
191
|
return
|
|
@@ -172,6 +231,10 @@ def _apply_payment_event(payment: Payment, update: PaymentUpdate) -> None:
|
|
|
172
231
|
raise InvalidTransitionError(
|
|
173
232
|
f"Cannot confirm refund for payment in {status.value!r} status."
|
|
174
233
|
)
|
|
234
|
+
if update.refunded_amount is None:
|
|
235
|
+
raise InvalidTransitionError(
|
|
236
|
+
"REFUND_CONFIRMED event requires explicit refunded_amount."
|
|
237
|
+
)
|
|
175
238
|
_set_refunded_amount(payment, update.refunded_amount)
|
|
176
239
|
if (
|
|
177
240
|
payment.amount_refunded >= payment.amount_paid
|
|
@@ -195,7 +258,7 @@ def _apply_payment_event(payment: Payment, update: PaymentUpdate) -> None:
|
|
|
195
258
|
)
|
|
196
259
|
|
|
197
260
|
if event is PaymentEvent.LOCK_RELEASED:
|
|
198
|
-
if status
|
|
261
|
+
if status is PaymentStatus.PRE_AUTH:
|
|
199
262
|
payment.amount_locked = Decimal("0.00")
|
|
200
263
|
payment.status = PaymentStatus.REFUNDED
|
|
201
264
|
return
|
|
@@ -247,15 +310,22 @@ def apply_payment_update(
|
|
|
247
310
|
if update is None:
|
|
248
311
|
return payment
|
|
249
312
|
|
|
250
|
-
|
|
251
|
-
|
|
313
|
+
snapshot = _snapshot_payment_state(payment)
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
if not _record_provider_event(payment, update.provider_event_id):
|
|
317
|
+
return payment
|
|
318
|
+
|
|
319
|
+
if update.external_id is not None:
|
|
320
|
+
payment.external_id = update.external_id
|
|
321
|
+
if update.fraud_message is not None:
|
|
322
|
+
payment.fraud_message = update.fraud_message
|
|
252
323
|
|
|
253
|
-
|
|
254
|
-
payment
|
|
255
|
-
|
|
256
|
-
|
|
324
|
+
_merge_provider_data(payment, update.provider_data)
|
|
325
|
+
_apply_payment_event(payment, update)
|
|
326
|
+
_apply_fraud_event(payment, update)
|
|
327
|
+
except Exception:
|
|
328
|
+
_restore_payment_state(payment, snapshot)
|
|
329
|
+
raise
|
|
257
330
|
|
|
258
|
-
_merge_provider_data(payment, update.provider_data)
|
|
259
|
-
_apply_payment_event(payment, update)
|
|
260
|
-
_apply_fraud_event(payment, update)
|
|
261
331
|
return payment
|
|
@@ -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
|