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.
Files changed (66) hide show
  1. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.github/workflows/ci.yml +3 -0
  2. python_getpaid_core-3.0.1/.github/workflows/release.yml +70 -0
  3. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.gitignore +1 -0
  4. python_getpaid_core-3.0.1/Makefile +28 -0
  5. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/PKG-INFO +2 -5
  6. python_getpaid_core-3.0.1/docs/changelog.md +61 -0
  7. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/pyproject.toml +5 -7
  8. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/__init__.py +1 -1
  9. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/flow.py +71 -15
  10. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/fsm.py +29 -9
  11. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/registry.py +5 -1
  12. python_getpaid_core-3.0.1/tests/test_benchmarks.py +549 -0
  13. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_flow.py +62 -0
  14. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_public_api.py +1 -1
  15. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_registry.py +59 -0
  16. python_getpaid_core-3.0.1/tests/test_state_engine.py +370 -0
  17. python_getpaid_core-3.0.0a4/.github/workflows/release.yml +0 -79
  18. python_getpaid_core-3.0.0a4/docs/changelog.md +0 -22
  19. python_getpaid_core-3.0.0a4/tests/test_state_engine.py +0 -175
  20. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.cookiecutter.json +0 -0
  21. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.gitattributes +0 -0
  22. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.github/dependabot.yml +0 -0
  23. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.github/labels.yml +0 -0
  24. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.github/release-drafter.yml +0 -0
  25. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.github/workflows/constraints.txt +0 -0
  26. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.github/workflows/labeler.yml +0 -0
  27. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.github/workflows/tests.yml +0 -0
  28. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.plans/2026-02-13-getpaid-core-design.md +0 -0
  29. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.plans/2026-02-13-getpaid-core-implementation.md +0 -0
  30. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.pre-commit-config.yaml +0 -0
  31. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.readthedocs.yml +0 -0
  32. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/.sisyphus/evidence/task-22-readme-core.txt +0 -0
  33. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/CODE_OF_CONDUCT.md +0 -0
  34. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/CONTRIBUTING.md +0 -0
  35. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/LICENSE +0 -0
  36. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/README.md +0 -0
  37. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/codecov.yml +0 -0
  38. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/docs/codeofconduct.md +0 -0
  39. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/docs/concepts.md +0 -0
  40. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/docs/conf.py +0 -0
  41. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/docs/contributing.md +0 -0
  42. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/docs/getting-started.md +0 -0
  43. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/docs/index.md +0 -0
  44. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/docs/license.md +0 -0
  45. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/docs/reference.md +0 -0
  46. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/docs/requirements.txt +0 -0
  47. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/backends/__init__.py +0 -0
  48. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/backends/dummy.py +0 -0
  49. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/enums.py +0 -0
  50. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/exceptions.py +0 -0
  51. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/processor.py +0 -0
  52. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/protocols.py +0 -0
  53. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/py.typed +0 -0
  54. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/types.py +0 -0
  55. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/src/getpaid_core/validators.py +0 -0
  56. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/__init__.py +0 -0
  57. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/conftest.py +0 -0
  58. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_dummy_backend.py +0 -0
  59. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_enums.py +0 -0
  60. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_exceptions.py +0 -0
  61. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_integration.py +0 -0
  62. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_processor.py +0 -0
  63. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_protocols.py +0 -0
  64. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_types.py +0 -0
  65. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_validators.py +0 -0
  66. {python_getpaid_core-3.0.0a4 → python_getpaid_core-3.0.1}/tests/test_version.py +0 -0
@@ -29,5 +29,8 @@ jobs:
29
29
  - name: Lint with ruff
30
30
  run: uv run ruff check .
31
31
 
32
+ - name: Audit dependencies
33
+ run: uv run pip-audit --strict
34
+
32
35
  - name: Run tests
33
36
  run: uv run pytest --tb=short
@@ -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 }}
@@ -6,6 +6,7 @@
6
6
  /.pytype/
7
7
  /dist/
8
8
  /docs/_build/
9
+ /_build/
9
10
  /src/*.egg-info/
10
11
  __pycache__/
11
12
  /.idea/
@@ -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.0a4
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 :: 3 - Alpha
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 :: 3 - Alpha',
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,6 +1,6 @@
1
1
  """Getpaid Core -- framework-agnostic payment processing."""
2
2
 
3
- __version__ = "3.0.0a4"
3
+ __version__ = "3.0.1"
4
4
 
5
5
  from getpaid_core.enums import BackendMethod
6
6
  from getpaid_core.enums import ConfirmationMethod
@@ -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, order: Order, backend_slug: str, **kwargs
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(self, payment: Payment, **kwargs) -> TransactionResult:
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(self, payment: Payment) -> Payment:
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(self, payment: Payment, **kwargs) -> Decimal:
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(self, payment: Payment, **kwargs) -> bool:
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(self, payment: Payment):
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(self, **context):
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 | None) -> None:
68
- if paid_amount is None:
69
- paid_amount = payment.amount_required
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
- payment.provider_data = cast(
129
- "dict[str, Any]",
130
- {} if snapshot.provider_data is None else snapshot.provider_data,
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 in {PaymentStatus.PRE_AUTH, PaymentStatus.REFUNDED}:
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.discover()
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