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.
Files changed (66) hide show
  1. {python_getpaid_core-3.0.0a3 → 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.0a3 → 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.0a3 → 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.0a3 → python_getpaid_core-3.0.1}/pyproject.toml +5 -7
  8. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/__init__.py +1 -1
  9. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/flow.py +71 -15
  10. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/fsm.py +83 -13
  11. {python_getpaid_core-3.0.0a3 → 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.0a3 → python_getpaid_core-3.0.1}/tests/test_flow.py +62 -0
  14. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/test_public_api.py +1 -1
  15. {python_getpaid_core-3.0.0a3 → 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.0a3/.github/workflows/release.yml +0 -79
  18. python_getpaid_core-3.0.0a3/docs/changelog.md +0 -22
  19. python_getpaid_core-3.0.0a3/tests/test_state_engine.py +0 -122
  20. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.cookiecutter.json +0 -0
  21. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.gitattributes +0 -0
  22. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.github/dependabot.yml +0 -0
  23. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.github/labels.yml +0 -0
  24. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.github/release-drafter.yml +0 -0
  25. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.github/workflows/constraints.txt +0 -0
  26. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.github/workflows/labeler.yml +0 -0
  27. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.github/workflows/tests.yml +0 -0
  28. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.plans/2026-02-13-getpaid-core-design.md +0 -0
  29. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.plans/2026-02-13-getpaid-core-implementation.md +0 -0
  30. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.pre-commit-config.yaml +0 -0
  31. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.readthedocs.yml +0 -0
  32. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/.sisyphus/evidence/task-22-readme-core.txt +0 -0
  33. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/CODE_OF_CONDUCT.md +0 -0
  34. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/CONTRIBUTING.md +0 -0
  35. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/LICENSE +0 -0
  36. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/README.md +0 -0
  37. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/codecov.yml +0 -0
  38. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/docs/codeofconduct.md +0 -0
  39. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/docs/concepts.md +0 -0
  40. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/docs/conf.py +0 -0
  41. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/docs/contributing.md +0 -0
  42. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/docs/getting-started.md +0 -0
  43. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/docs/index.md +0 -0
  44. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/docs/license.md +0 -0
  45. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/docs/reference.md +0 -0
  46. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/docs/requirements.txt +0 -0
  47. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/backends/__init__.py +0 -0
  48. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/backends/dummy.py +0 -0
  49. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/enums.py +0 -0
  50. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/exceptions.py +0 -0
  51. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/processor.py +0 -0
  52. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/protocols.py +0 -0
  53. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/py.typed +0 -0
  54. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/types.py +0 -0
  55. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/src/getpaid_core/validators.py +0 -0
  56. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/__init__.py +0 -0
  57. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/conftest.py +0 -0
  58. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/test_dummy_backend.py +0 -0
  59. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/test_enums.py +0 -0
  60. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/test_exceptions.py +0 -0
  61. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/test_integration.py +0 -0
  62. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/test_processor.py +0 -0
  63. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/test_protocols.py +0 -0
  64. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/test_types.py +0 -0
  65. {python_getpaid_core-3.0.0a3 → python_getpaid_core-3.0.1}/tests/test_validators.py +0 -0
  66. {python_getpaid_core-3.0.0a3 → 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.0a3
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.0a3"
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)
@@ -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 | None) -> None:
52
- if paid_amount is None:
53
- 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
+ )
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 in {PaymentStatus.PRE_AUTH, PaymentStatus.REFUNDED}:
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
- if not _record_provider_event(payment, update.provider_event_id):
251
- return payment
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
- if update.external_id is not None:
254
- payment.external_id = update.external_id
255
- if update.fraud_message is not None:
256
- payment.fraud_message = update.fraud_message
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.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