python-getpaid-core 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,39 @@
1
+ """Getpaid Core -- framework-agnostic payment processing."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from getpaid_core.enums import BackendMethod
6
+ from getpaid_core.enums import ConfirmationMethod
7
+ from getpaid_core.enums import FraudStatus
8
+ from getpaid_core.enums import PaymentStatus
9
+ from getpaid_core.exceptions import ChargeFailure
10
+ from getpaid_core.exceptions import CommunicationError
11
+ from getpaid_core.exceptions import CredentialsError
12
+ from getpaid_core.exceptions import GetPaidException
13
+ from getpaid_core.exceptions import InvalidCallbackError
14
+ from getpaid_core.exceptions import InvalidTransitionError
15
+ from getpaid_core.exceptions import LockFailure
16
+ from getpaid_core.exceptions import RefundFailure
17
+ from getpaid_core.flow import PaymentFlow
18
+ from getpaid_core.processor import BaseProcessor
19
+ from getpaid_core.registry import registry
20
+
21
+
22
+ __all__ = [
23
+ "BackendMethod",
24
+ "BaseProcessor",
25
+ "ChargeFailure",
26
+ "CommunicationError",
27
+ "ConfirmationMethod",
28
+ "CredentialsError",
29
+ "FraudStatus",
30
+ "GetPaidException",
31
+ "InvalidCallbackError",
32
+ "InvalidTransitionError",
33
+ "LockFailure",
34
+ "PaymentFlow",
35
+ "PaymentStatus",
36
+ "RefundFailure",
37
+ "__version__",
38
+ "registry",
39
+ ]
@@ -0,0 +1 @@
1
+ """Built-in payment backends."""
@@ -0,0 +1,95 @@
1
+ """Dummy payment backend for development and testing.
2
+
3
+ Makes zero HTTP calls. Serves as a reference implementation
4
+ for backend authors.
5
+ """
6
+
7
+ from decimal import Decimal
8
+ from typing import ClassVar
9
+
10
+ from getpaid_core.fsm import ALLOWED_CALLBACKS
11
+ from getpaid_core.processor import BaseProcessor
12
+ from getpaid_core.types import ChargeResponse
13
+ from getpaid_core.types import PaymentStatusResponse
14
+ from getpaid_core.types import TransactionResult
15
+
16
+
17
+ class DummyProcessor(BaseProcessor):
18
+ """Dummy processor that simulates all payment operations."""
19
+
20
+ slug: ClassVar[str] = "dummy"
21
+ display_name: ClassVar[str] = "Dummy"
22
+ accepted_currencies: ClassVar[list[str]] = [
23
+ "PLN",
24
+ "EUR",
25
+ "USD",
26
+ "GBP",
27
+ "CHF",
28
+ "CZK",
29
+ ]
30
+
31
+ async def prepare_transaction(self, **kwargs) -> TransactionResult:
32
+ method = self.get_setting("method", "REST")
33
+ if method == "POST":
34
+ return TransactionResult(
35
+ redirect_url="https://dummy.example.com/form",
36
+ form_data={
37
+ "payment_id": self.payment.id,
38
+ "amount": str(self.payment.amount_required),
39
+ "currency": self.payment.currency,
40
+ },
41
+ method="POST",
42
+ headers={},
43
+ )
44
+ elif method == "GET":
45
+ return TransactionResult(
46
+ redirect_url=(
47
+ f"https://dummy.example.com/pay/{self.payment.id}"
48
+ ),
49
+ form_data=None,
50
+ method="GET",
51
+ headers={},
52
+ )
53
+ else:
54
+ return TransactionResult(
55
+ redirect_url=(
56
+ f"https://dummy.example.com/pay/{self.payment.id}"
57
+ ),
58
+ form_data=None,
59
+ method="REST",
60
+ headers={},
61
+ )
62
+
63
+ async def handle_callback(
64
+ self, data: dict, headers: dict, **kwargs
65
+ ) -> None:
66
+ new_status = data.get("new_status")
67
+ if new_status and new_status in ALLOWED_CALLBACKS:
68
+ trigger = getattr(self.payment, new_status, None)
69
+ if trigger and callable(trigger):
70
+ trigger()
71
+
72
+ async def fetch_payment_status(self, **kwargs) -> PaymentStatusResponse:
73
+ status = self.get_setting("confirmation_status", "confirm_payment")
74
+ return PaymentStatusResponse(status=status)
75
+
76
+ async def charge(
77
+ self, amount: Decimal | None = None, **kwargs
78
+ ) -> ChargeResponse:
79
+ charged = amount if amount is not None else self.payment.amount_required
80
+ return ChargeResponse(
81
+ amount_charged=charged,
82
+ success=True,
83
+ async_call=False,
84
+ )
85
+
86
+ async def release_lock(self, **kwargs) -> Decimal:
87
+ return self.payment.amount_locked
88
+
89
+ async def start_refund(
90
+ self, amount: Decimal | None = None, **kwargs
91
+ ) -> Decimal:
92
+ return amount if amount is not None else self.payment.amount_paid
93
+
94
+ async def cancel_refund(self, **kwargs) -> bool:
95
+ return True
getpaid_core/enums.py ADDED
@@ -0,0 +1,44 @@
1
+ """Payment processing enums.
2
+
3
+ Values are kept identical to django-getpaid for backward compatibility.
4
+ """
5
+
6
+ from enum import StrEnum
7
+
8
+
9
+ class PaymentStatus(StrEnum):
10
+ """Internal payment status."""
11
+
12
+ NEW = "new"
13
+ PREPARED = "prepared"
14
+ PRE_AUTH = "pre-auth"
15
+ IN_CHARGE = "charge_started"
16
+ PARTIAL = "partially_paid"
17
+ PAID = "paid"
18
+ FAILED = "failed"
19
+ REFUND_STARTED = "refund_started"
20
+ REFUNDED = "refunded"
21
+
22
+
23
+ class FraudStatus(StrEnum):
24
+ """Fraud verification status."""
25
+
26
+ UNKNOWN = "unknown"
27
+ ACCEPTED = "accepted"
28
+ REJECTED = "rejected"
29
+ CHECK = "check"
30
+
31
+
32
+ class BackendMethod(StrEnum):
33
+ """HTTP method used to initiate payment."""
34
+
35
+ GET = "GET"
36
+ POST = "POST"
37
+ REST = "REST"
38
+
39
+
40
+ class ConfirmationMethod(StrEnum):
41
+ """How the payment gateway confirms payment status."""
42
+
43
+ PUSH = "PUSH"
44
+ PULL = "PULL"
@@ -0,0 +1,37 @@
1
+ """Exception hierarchy for payment processing."""
2
+
3
+
4
+ class GetPaidException(Exception):
5
+ """Base exception for all getpaid errors."""
6
+
7
+ def __init__(self, message: str = "", context: dict | None = None) -> None:
8
+ super().__init__(message)
9
+ self.context = context or {}
10
+
11
+
12
+ class CommunicationError(GetPaidException):
13
+ """Error communicating with payment gateway."""
14
+
15
+
16
+ class ChargeFailure(CommunicationError):
17
+ """Failed to charge payment."""
18
+
19
+
20
+ class LockFailure(CommunicationError):
21
+ """Failed to lock (pre-authorize) payment."""
22
+
23
+
24
+ class RefundFailure(CommunicationError):
25
+ """Failed to process refund."""
26
+
27
+
28
+ class CredentialsError(GetPaidException):
29
+ """Invalid or missing gateway credentials."""
30
+
31
+
32
+ class InvalidCallbackError(GetPaidException):
33
+ """Callback verification failed."""
34
+
35
+
36
+ class InvalidTransitionError(GetPaidException):
37
+ """Attempted invalid state transition."""
getpaid_core/flow.py ADDED
@@ -0,0 +1,140 @@
1
+ """Payment flow orchestrator.
2
+
3
+ The main entry point for framework adapters. Orchestrates the
4
+ interaction between repository, processor, and state machine.
5
+ """
6
+
7
+ from getpaid_core.exceptions import InvalidTransitionError
8
+ from getpaid_core.fsm import ALLOWED_CALLBACKS
9
+ from getpaid_core.fsm import create_fraud_machine
10
+ from getpaid_core.fsm import create_payment_machine
11
+ from getpaid_core.protocols import Order
12
+ from getpaid_core.protocols import Payment
13
+ from getpaid_core.protocols import PaymentRepository
14
+ from getpaid_core.registry import registry
15
+ from getpaid_core.types import TransactionResult
16
+ from getpaid_core.validators import run_validators
17
+
18
+
19
+ class PaymentFlow:
20
+ """Core payment processing orchestrator.
21
+
22
+ Framework adapters create an instance with their repository
23
+ and backend configuration, then delegate to its methods.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ repository: PaymentRepository,
29
+ config: dict | None = None,
30
+ validators: list | None = None,
31
+ ) -> None:
32
+ self.repository = repository
33
+ self.config = config or {}
34
+ self.validators = validators or []
35
+
36
+ async def create_payment(
37
+ self, order: Order, backend_slug: str, **kwargs
38
+ ) -> Payment:
39
+ """Create a new payment for an order."""
40
+ registry.get_by_slug(backend_slug)
41
+ payment = await self.repository.create(
42
+ order=order,
43
+ backend=backend_slug,
44
+ amount_required=order.get_total_amount(),
45
+ currency=order.get_currency(),
46
+ description=order.get_description(),
47
+ **kwargs,
48
+ )
49
+ return payment
50
+
51
+ async def prepare(self, payment: Payment, **kwargs) -> TransactionResult:
52
+ """Prepare a payment for processing.
53
+
54
+ Runs validators, calls the backend's prepare_transaction(),
55
+ transitions to PREPARED, and persists.
56
+ """
57
+ run_validators({"payment": payment}, validators=self.validators)
58
+ create_payment_machine(payment)
59
+ processor = self._get_processor(payment)
60
+ result = await processor.prepare_transaction(**kwargs)
61
+ payment.confirm_prepared()
62
+ await self.repository.save(payment)
63
+ return result
64
+
65
+ async def handle_callback(
66
+ self,
67
+ payment: Payment,
68
+ data: dict,
69
+ headers: dict,
70
+ **kwargs,
71
+ ) -> None:
72
+ """Handle an incoming PUSH callback from the gateway."""
73
+ processor = self._get_processor(payment)
74
+ await processor.verify_callback(data, headers, **kwargs)
75
+ create_payment_machine(payment)
76
+ create_fraud_machine(payment)
77
+ await processor.handle_callback(data, headers, **kwargs)
78
+ await self.repository.save(payment)
79
+
80
+ async def fetch_and_update_status(self, payment: Payment) -> Payment:
81
+ """PULL flow: fetch status from gateway and update."""
82
+ processor = self._get_processor(payment)
83
+ create_payment_machine(payment)
84
+ create_fraud_machine(payment)
85
+ response = await processor.fetch_payment_status()
86
+ if response.get("status"):
87
+ callback = response["status"]
88
+ if callback not in ALLOWED_CALLBACKS:
89
+ raise InvalidTransitionError(
90
+ f"Callback {callback!r} not in ALLOWED_CALLBACKS"
91
+ )
92
+ trigger = getattr(payment, callback, None)
93
+ if trigger and callable(trigger):
94
+ trigger()
95
+ await self.repository.save(payment)
96
+ return payment
97
+
98
+ async def charge(self, payment: Payment, amount=None, **kwargs):
99
+ """Charge a pre-authorized payment."""
100
+ processor = self._get_processor(payment)
101
+ create_payment_machine(payment)
102
+ result = await processor.charge(amount=amount, **kwargs)
103
+ if result["success"]:
104
+ payment.confirm_charge_sent()
105
+ await self.repository.save(payment)
106
+ return result
107
+
108
+ async def release_lock(self, payment: Payment, **kwargs):
109
+ """Release a pre-authorized lock."""
110
+ processor = self._get_processor(payment)
111
+ create_payment_machine(payment)
112
+ amount = await processor.release_lock(**kwargs)
113
+ payment.release_lock()
114
+ await self.repository.save(payment)
115
+ return amount
116
+
117
+ async def start_refund(self, payment: Payment, amount=None, **kwargs):
118
+ """Start a refund."""
119
+ processor = self._get_processor(payment)
120
+ create_payment_machine(payment)
121
+ refund_amount = await processor.start_refund(amount=amount, **kwargs)
122
+ payment.start_refund()
123
+ await self.repository.save(payment)
124
+ return refund_amount
125
+
126
+ async def cancel_refund(self, payment: Payment, **kwargs):
127
+ """Cancel an in-progress refund."""
128
+ processor = self._get_processor(payment)
129
+ create_payment_machine(payment)
130
+ success = await processor.cancel_refund(**kwargs)
131
+ if success:
132
+ payment.cancel_refund()
133
+ await self.repository.save(payment)
134
+ return success
135
+
136
+ def _get_processor(self, payment: Payment):
137
+ """Instantiate the processor for a payment."""
138
+ processor_class = registry.get_by_slug(payment.backend)
139
+ backend_config = self.config.get(payment.backend, {})
140
+ return processor_class(payment, config=backend_config)
getpaid_core/fsm.py ADDED
@@ -0,0 +1,205 @@
1
+ """Payment state machine using the transitions library.
2
+
3
+ Defines all valid payment and fraud status transitions.
4
+ The machine attaches trigger methods directly to payment objects.
5
+ """
6
+
7
+ from transitions import Machine
8
+ from transitions.core import MachineError
9
+
10
+ from getpaid_core.enums import FraudStatus
11
+ from getpaid_core.enums import PaymentStatus
12
+
13
+
14
+ def _require_fully_paid(event_data):
15
+ """Guard that raises MachineError when payment is not fully paid."""
16
+ model = event_data.model
17
+ if not model.is_fully_paid():
18
+ raise MachineError(
19
+ f"Transition '{event_data.event.name}' requires full payment."
20
+ )
21
+
22
+
23
+ def _require_fully_refunded(event_data):
24
+ """Guard that raises MachineError when payment is not fully refunded."""
25
+ model = event_data.model
26
+ if not model.is_fully_refunded():
27
+ raise MachineError(
28
+ f"Transition '{event_data.event.name}' requires full refund."
29
+ )
30
+
31
+
32
+ def _store_locked_amount(event_data):
33
+ """After confirm_lock: store the locked amount on the payment."""
34
+ model = event_data.model
35
+ amount = event_data.kwargs.get("amount", None)
36
+ if amount is None:
37
+ amount = model.amount_required
38
+ model.amount_locked = amount
39
+
40
+
41
+ def _accumulate_paid_amount(event_data):
42
+ """After confirm_payment: accumulate paid amount on the payment."""
43
+ model = event_data.model
44
+ amount = event_data.kwargs.get("amount", None)
45
+ if amount is None:
46
+ if not model.amount_locked:
47
+ model.amount_locked = model.amount_required
48
+ amount = model.amount_locked
49
+ model.amount_paid += amount
50
+
51
+
52
+ PAYMENT_TRANSITIONS = [
53
+ {
54
+ "trigger": "confirm_prepared",
55
+ "source": PaymentStatus.NEW,
56
+ "dest": PaymentStatus.PREPARED,
57
+ },
58
+ {
59
+ "trigger": "confirm_lock",
60
+ "source": [PaymentStatus.NEW, PaymentStatus.PREPARED],
61
+ "dest": PaymentStatus.PRE_AUTH,
62
+ "after": _store_locked_amount,
63
+ },
64
+ {
65
+ "trigger": "confirm_charge_sent",
66
+ "source": PaymentStatus.PRE_AUTH,
67
+ "dest": PaymentStatus.IN_CHARGE,
68
+ },
69
+ {
70
+ "trigger": "confirm_payment",
71
+ "source": [
72
+ PaymentStatus.PRE_AUTH,
73
+ PaymentStatus.PREPARED,
74
+ PaymentStatus.IN_CHARGE,
75
+ PaymentStatus.PARTIAL,
76
+ ],
77
+ "dest": PaymentStatus.PARTIAL,
78
+ "after": _accumulate_paid_amount,
79
+ },
80
+ {
81
+ "trigger": "mark_as_paid",
82
+ "source": PaymentStatus.PARTIAL,
83
+ "dest": PaymentStatus.PAID,
84
+ "before": _require_fully_paid,
85
+ },
86
+ {
87
+ "trigger": "release_lock",
88
+ "source": PaymentStatus.PRE_AUTH,
89
+ "dest": PaymentStatus.REFUNDED,
90
+ },
91
+ {
92
+ "trigger": "start_refund",
93
+ "source": [
94
+ PaymentStatus.PAID,
95
+ PaymentStatus.PARTIAL,
96
+ ],
97
+ "dest": PaymentStatus.REFUND_STARTED,
98
+ },
99
+ {
100
+ "trigger": "cancel_refund",
101
+ "source": PaymentStatus.REFUND_STARTED,
102
+ "dest": PaymentStatus.PARTIAL,
103
+ },
104
+ {
105
+ "trigger": "confirm_refund",
106
+ "source": PaymentStatus.REFUND_STARTED,
107
+ "dest": PaymentStatus.PARTIAL,
108
+ },
109
+ {
110
+ "trigger": "mark_as_refunded",
111
+ "source": PaymentStatus.PARTIAL,
112
+ "dest": PaymentStatus.REFUNDED,
113
+ "before": _require_fully_refunded,
114
+ },
115
+ {
116
+ "trigger": "fail",
117
+ "source": [
118
+ PaymentStatus.NEW,
119
+ PaymentStatus.PRE_AUTH,
120
+ PaymentStatus.PREPARED,
121
+ ],
122
+ "dest": PaymentStatus.FAILED,
123
+ },
124
+ ]
125
+
126
+ FRAUD_TRANSITIONS = [
127
+ {
128
+ "trigger": "flag_as_fraud",
129
+ "source": FraudStatus.UNKNOWN,
130
+ "dest": FraudStatus.REJECTED,
131
+ },
132
+ {
133
+ "trigger": "flag_as_legit",
134
+ "source": FraudStatus.UNKNOWN,
135
+ "dest": FraudStatus.ACCEPTED,
136
+ },
137
+ {
138
+ "trigger": "flag_for_check",
139
+ "source": FraudStatus.UNKNOWN,
140
+ "dest": FraudStatus.CHECK,
141
+ },
142
+ {
143
+ "trigger": "mark_as_fraud",
144
+ "source": FraudStatus.CHECK,
145
+ "dest": FraudStatus.REJECTED,
146
+ },
147
+ {
148
+ "trigger": "mark_as_legit",
149
+ "source": FraudStatus.CHECK,
150
+ "dest": FraudStatus.ACCEPTED,
151
+ },
152
+ ]
153
+
154
+ ALLOWED_CALLBACKS: frozenset[str] = frozenset(
155
+ {
156
+ "confirm_prepared",
157
+ "confirm_lock",
158
+ "confirm_charge_sent",
159
+ "confirm_payment",
160
+ "mark_as_paid",
161
+ "release_lock",
162
+ "start_refund",
163
+ "cancel_refund",
164
+ "confirm_refund",
165
+ "mark_as_refunded",
166
+ "fail",
167
+ }
168
+ )
169
+
170
+
171
+ def create_payment_machine(payment) -> Machine:
172
+ """Attach payment FSM to a payment object.
173
+
174
+ The transitions library adds trigger methods directly to the
175
+ object (confirm_prepared, confirm_lock, fail, etc.).
176
+ """
177
+ return Machine(
178
+ model=payment,
179
+ states=PaymentStatus,
180
+ transitions=PAYMENT_TRANSITIONS,
181
+ initial=payment.status or PaymentStatus.NEW,
182
+ model_attribute="status",
183
+ auto_transitions=False,
184
+ send_event=True,
185
+ )
186
+
187
+
188
+ def _store_fraud_message(event):
189
+ """Before callback: store message kwarg on the payment object."""
190
+ message = event.kwargs.get("message", "")
191
+ event.model.fraud_message = message
192
+
193
+
194
+ def create_fraud_machine(payment) -> Machine:
195
+ """Attach fraud status FSM to a payment object."""
196
+ return Machine(
197
+ model=payment,
198
+ states=FraudStatus,
199
+ transitions=FRAUD_TRANSITIONS,
200
+ initial=payment.fraud_status or FraudStatus.UNKNOWN,
201
+ model_attribute="fraud_status",
202
+ auto_transitions=False,
203
+ send_event=True,
204
+ before_state_change=[_store_fraud_message],
205
+ )
@@ -0,0 +1,87 @@
1
+ """Base payment processor abstract class.
2
+
3
+ All payment backends subclass BaseProcessor and implement at minimum
4
+ prepare_transaction(). Other methods are optional depending on the
5
+ payment gateway's capabilities.
6
+ """
7
+
8
+ from abc import ABC
9
+ from abc import abstractmethod
10
+ from decimal import Decimal
11
+ from typing import ClassVar
12
+
13
+ from getpaid_core.protocols import Payment
14
+ from getpaid_core.types import ChargeResponse
15
+ from getpaid_core.types import PaymentStatusResponse
16
+ from getpaid_core.types import TransactionResult
17
+
18
+
19
+ class BaseProcessor(ABC):
20
+ """Base class for payment backend processors."""
21
+
22
+ slug: ClassVar[str] = ""
23
+ display_name: ClassVar[str] = ""
24
+ accepted_currencies: ClassVar[list[str]] = []
25
+ logo_url: ClassVar[str | None] = None
26
+ sandbox_url: ClassVar[str] = ""
27
+ production_url: ClassVar[str] = ""
28
+
29
+ def __init__(self, payment: Payment, config: dict | None = None) -> None:
30
+ self.payment = payment
31
+ self.config = config or {}
32
+
33
+ def get_setting(self, name: str, default=None):
34
+ """Read a setting from backend config."""
35
+ return self.config.get(name, default)
36
+
37
+ def get_paywall_baseurl(self) -> str:
38
+ """Return sandbox or production URL based on config."""
39
+ sandbox = self.get_setting("sandbox", True)
40
+ return self.sandbox_url if sandbox else self.production_url
41
+
42
+ @abstractmethod
43
+ async def prepare_transaction(self, **kwargs) -> TransactionResult:
44
+ """Prepare data for initiating a payment.
45
+
46
+ Returns a TransactionResult that the framework adapter
47
+ converts into an HTTP response.
48
+ """
49
+ ...
50
+
51
+ async def verify_callback(
52
+ self, data: dict, headers: dict, **kwargs
53
+ ) -> None:
54
+ """Verify callback authenticity.
55
+
56
+ Raise GetPaidException to reject. Default: no-op.
57
+ """
58
+
59
+ async def handle_callback(
60
+ self, data: dict, headers: dict, **kwargs
61
+ ) -> None:
62
+ """Handle async PUSH callback from payment gateway."""
63
+ raise NotImplementedError
64
+
65
+ async def fetch_payment_status(self, **kwargs) -> PaymentStatusResponse:
66
+ """PULL flow: fetch payment status from gateway."""
67
+ raise NotImplementedError
68
+
69
+ async def charge(
70
+ self, amount: Decimal | None = None, **kwargs
71
+ ) -> ChargeResponse:
72
+ """Charge a pre-authorized payment."""
73
+ raise NotImplementedError
74
+
75
+ async def release_lock(self, **kwargs) -> Decimal:
76
+ """Release pre-authorized lock. Return locked amount."""
77
+ raise NotImplementedError
78
+
79
+ async def start_refund(
80
+ self, amount: Decimal | None = None, **kwargs
81
+ ) -> Decimal:
82
+ """Start a refund. Return refund amount."""
83
+ raise NotImplementedError
84
+
85
+ async def cancel_refund(self, **kwargs) -> bool:
86
+ """Cancel in-progress refund. Return True if ok."""
87
+ raise NotImplementedError
@@ -0,0 +1,57 @@
1
+ """Protocols defining framework integration contracts.
2
+
3
+ Framework adapters (django-getpaid, litestar-getpaid, etc.) provide
4
+ concrete implementations. Any object with the right shape satisfies
5
+ the protocol -- no inheritance required.
6
+ """
7
+
8
+ from decimal import Decimal
9
+ from typing import Protocol
10
+ from typing import runtime_checkable
11
+
12
+ from getpaid_core.types import BuyerInfo
13
+ from getpaid_core.types import ItemInfo
14
+
15
+
16
+ @runtime_checkable
17
+ class Order(Protocol):
18
+ """What the core expects from an order object."""
19
+
20
+ def get_total_amount(self) -> Decimal: ...
21
+ def get_buyer_info(self) -> BuyerInfo: ...
22
+ def get_description(self) -> str: ...
23
+ def get_currency(self) -> str: ...
24
+ def get_items(self) -> list[ItemInfo]: ...
25
+ def get_return_url(self, success: bool | None = None) -> str: ...
26
+
27
+
28
+ @runtime_checkable
29
+ class Payment(Protocol):
30
+ """What the core expects from a payment object."""
31
+
32
+ id: str
33
+ order: Order
34
+ amount_required: Decimal
35
+ currency: str
36
+ status: str
37
+ backend: str
38
+ external_id: str
39
+ description: str
40
+ amount_paid: Decimal
41
+ amount_locked: Decimal
42
+ amount_refunded: Decimal
43
+ fraud_status: str
44
+ fraud_message: str
45
+
46
+
47
+ @runtime_checkable
48
+ class PaymentRepository(Protocol):
49
+ """Persistence abstraction. Framework adapters implement this."""
50
+
51
+ async def get_by_id(self, payment_id: str) -> Payment: ...
52
+ async def create(self, **kwargs) -> Payment: ...
53
+ async def save(self, payment: Payment) -> Payment: ...
54
+ async def update_status(
55
+ self, payment_id: str, status: str, **fields
56
+ ) -> Payment: ...
57
+ async def list_by_order(self, order_id: str) -> list[Payment]: ...
getpaid_core/py.typed ADDED
File without changes
@@ -0,0 +1,74 @@
1
+ """Plugin registry for payment backends.
2
+
3
+ Primary discovery via entry_points. Manual registration for
4
+ testing and dynamic scenarios.
5
+ """
6
+
7
+ from importlib.metadata import entry_points
8
+
9
+ from getpaid_core.processor import BaseProcessor
10
+
11
+
12
+ ENTRY_POINT_GROUP = "getpaid.backends"
13
+
14
+
15
+ class PluginRegistry:
16
+ """Discovers and stores payment backend processors."""
17
+
18
+ def __init__(self) -> None:
19
+ self._backends: dict[str, type[BaseProcessor]] = {}
20
+ self._discovered = False
21
+
22
+ def discover(self) -> None:
23
+ """Load all backends registered via entry_points."""
24
+ eps = entry_points(group=ENTRY_POINT_GROUP)
25
+ for ep in eps:
26
+ processor_class = ep.load()
27
+ if isinstance(processor_class, type) and issubclass(
28
+ processor_class, BaseProcessor
29
+ ):
30
+ self._backends[processor_class.slug] = processor_class
31
+ self._discovered = True
32
+
33
+ def register(self, processor_class: type[BaseProcessor]) -> None:
34
+ """Manual registration for testing or dynamic use."""
35
+ self._backends[processor_class.slug] = processor_class
36
+
37
+ def unregister(self, slug: str) -> None:
38
+ """Remove a backend by slug."""
39
+ self._backends.pop(slug, None)
40
+
41
+ def get_for_currency(self, currency: str) -> list[type[BaseProcessor]]:
42
+ """Return all backends supporting the given currency."""
43
+ self._ensure_discovered()
44
+ return [
45
+ b
46
+ for b in self._backends.values()
47
+ if currency in b.accepted_currencies
48
+ ]
49
+
50
+ def get_choices(self, currency: str) -> list[tuple[str, str]]:
51
+ """Return (slug, display_name) pairs for a currency."""
52
+ return [
53
+ (b.slug, b.display_name) for b in self.get_for_currency(currency)
54
+ ]
55
+
56
+ def get_by_slug(self, slug: str) -> type[BaseProcessor]:
57
+ """Return a backend class by slug. Raises KeyError."""
58
+ self._ensure_discovered()
59
+ return self._backends[slug]
60
+
61
+ def get_all_currencies(self) -> set[str]:
62
+ """Return all currencies supported by all backends."""
63
+ self._ensure_discovered()
64
+ currencies: set[str] = set()
65
+ for b in self._backends.values():
66
+ currencies.update(b.accepted_currencies)
67
+ return currencies
68
+
69
+ def _ensure_discovered(self) -> None:
70
+ if not self._discovered:
71
+ self.discover()
72
+
73
+
74
+ registry = PluginRegistry()
getpaid_core/types.py ADDED
@@ -0,0 +1,52 @@
1
+ """Core type definitions for payment processing."""
2
+
3
+ from decimal import Decimal
4
+ from typing import TypedDict
5
+
6
+
7
+ class BuyerInfo(TypedDict, total=False):
8
+ """Buyer/customer information."""
9
+
10
+ email: str
11
+ first_name: str
12
+ last_name: str
13
+ phone: str
14
+
15
+
16
+ class ItemInfo(TypedDict):
17
+ """Single item in an order."""
18
+
19
+ name: str
20
+ quantity: int
21
+ unit_price: Decimal
22
+
23
+
24
+ class ChargeResponse(TypedDict):
25
+ """Response from charging a pre-authorized payment."""
26
+
27
+ amount_charged: Decimal
28
+ success: bool
29
+ async_call: bool
30
+
31
+
32
+ class PaymentStatusResponse(TypedDict, total=False):
33
+ """Response from fetching payment status from gateway."""
34
+
35
+ amount: Decimal | None
36
+ status: str | None
37
+ external_id: str | None
38
+
39
+
40
+ class TransactionResult(TypedDict):
41
+ """Result of preparing a transaction.
42
+
43
+ Framework adapters convert this into framework-specific responses:
44
+ - GET: redirect to redirect_url
45
+ - POST: render form that auto-submits to redirect_url with form_data
46
+ - REST: return JSON or handle internally
47
+ """
48
+
49
+ redirect_url: str | None
50
+ form_data: dict | None
51
+ method: str # 'GET', 'POST', or 'REST'
52
+ headers: dict[str, str]
@@ -0,0 +1,21 @@
1
+ """Pluggable payment validation system.
2
+
3
+ Validators are callables that receive a data dict, optionally
4
+ modify it, and return it. They raise GetPaidException to reject.
5
+ """
6
+
7
+ from collections.abc import Callable
8
+
9
+
10
+ def run_validators(
11
+ data: dict,
12
+ validators: list[Callable] | None = None,
13
+ ) -> dict:
14
+ """Run a chain of validators on payment data.
15
+
16
+ Each validator receives the data dict and must return it
17
+ (possibly modified). Raise GetPaidException to reject.
18
+ """
19
+ for validator in validators or []:
20
+ data = validator(data)
21
+ return data
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-getpaid-core
3
+ Version: 0.1.0
4
+ Summary: Framework-agnostic payment processing core.
5
+ Project-URL: Homepage, https://github.com/django-getpaid/python-getpaid-core
6
+ Project-URL: Repository, https://github.com/django-getpaid/python-getpaid-core
7
+ Project-URL: Documentation, https://getpaid-core.readthedocs.io
8
+ Project-URL: Changelog, https://github.com/django-getpaid/python-getpaid-core/releases
9
+ Author-email: Dominik Kozaczko <dominik@kozaczko.info>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Office/Business :: Financial
20
+ Classifier: Topic :: Office/Business :: Financial :: Point-Of-Sale
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: anyio>=4.0
24
+ Requires-Dist: httpx>=0.27.0
25
+ Requires-Dist: transitions>=0.9.0
26
+ Description-Content-Type: text/markdown
27
+
28
+ # getpaid-core
29
+
30
+ [![PyPI](https://img.shields.io/pypi/v/python-getpaid-core.svg)](https://pypi.org/project/python-getpaid-core/)
31
+ [![Python Version](https://img.shields.io/pypi/pyversions/python-getpaid-core)](https://pypi.org/project/python-getpaid-core/)
32
+ [![License](https://img.shields.io/pypi/l/python-getpaid-core)](https://github.com/django-getpaid/python-getpaid-core/blob/main/LICENSE)
33
+
34
+ Framework-agnostic payment processing library for Python. Provides the core
35
+ abstractions — enums, protocols, FSM, processor base class, plugin registry,
36
+ and exception hierarchy — that framework-specific adapters build on.
37
+
38
+ ## Architecture
39
+
40
+ getpaid-core defines the **what** of payment processing without coupling to
41
+ any web framework:
42
+
43
+ - **Enums** (`PaymentStatus`, `FraudStatus`, `BackendMethod`, `ConfirmationMethod`)
44
+ define all valid states and methods.
45
+ - **Protocols** (`Payment`, `Order`, `PaymentRepository`) define structural
46
+ contracts that framework models must satisfy.
47
+ - **FSM** (`create_payment_machine`, `create_fraud_machine`) attaches
48
+ state-machine triggers to payment objects at runtime using the `transitions`
49
+ library.
50
+ - **BaseProcessor** is an abstract class that payment gateway plugins subclass
51
+ to implement `prepare_transaction`, `handle_callback`, `charge`, etc.
52
+ - **PluginRegistry** discovers and stores payment backend processors via
53
+ entry points or manual registration.
54
+ - **Exceptions** provide a structured hierarchy for payment errors.
55
+
56
+ ## Framework Adapters
57
+
58
+ - **[django-getpaid](https://github.com/django-getpaid/django-getpaid)** —
59
+ Django adapter (models, views, forms, admin)
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ pip install python-getpaid-core
65
+ ```
66
+
67
+ You typically install this as a dependency of a framework adapter rather than
68
+ directly.
69
+
70
+ ## Quick Example
71
+
72
+ ```python
73
+ from getpaid_core.enums import PaymentStatus
74
+ from getpaid_core.fsm import create_payment_machine
75
+
76
+ # Any object satisfying the Payment protocol works
77
+ payment = MyPayment(status=PaymentStatus.NEW, amount_required=100)
78
+ machine = create_payment_machine(payment)
79
+
80
+ # FSM trigger methods are attached directly to the object
81
+ payment.confirm_prepared()
82
+ assert payment.status == PaymentStatus.PREPARED
83
+ ```
84
+
85
+ ## Requirements
86
+
87
+ - Python 3.12+
88
+ - transitions
89
+ - httpx
90
+ - anyio
91
+
92
+ ## License
93
+
94
+ MIT
95
+
96
+ ## Disclaimer
97
+
98
+ This project has nothing in common with the
99
+ [getpaid](http://code.google.com/p/getpaid/) plone project.
100
+
101
+ ## Credits
102
+
103
+ Created by [Dominik Kozaczko](https://github.com/dekoza).
@@ -0,0 +1,17 @@
1
+ getpaid_core/__init__.py,sha256=soSScP8r9yiOz7kv3dO4uS3x6AX8TnIxul_RzkULUSg,1198
2
+ getpaid_core/enums.py,sha256=arfIwqyHDBRXLb_DbLVQMJvMALG5dkN26KGR1w6G5Wg,866
3
+ getpaid_core/exceptions.py,sha256=XJtXvUdH_mNTP66okUvfvJfY13UURVW0_WPXapbjG9c,913
4
+ getpaid_core/flow.py,sha256=IBgMGArjWxUGjW9mZAIA0pl1IqV_EPkLfy3JxluHiTQ,5312
5
+ getpaid_core/fsm.py,sha256=nxAECNyVthxopNjwenbkbMA7wa7bUnS9o_0od3r0gGU,5637
6
+ getpaid_core/processor.py,sha256=5p1AFObhaF9EO5GWt9KNti9Oxq9whMr68-vSsQf8v6A,2870
7
+ getpaid_core/protocols.py,sha256=CnvgEx5P8u3kYuSDKo_XuQm8hG2ze0Us31KZDn5V8G8,1660
8
+ getpaid_core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ getpaid_core/registry.py,sha256=1VMncWbZuK3blrzxLygwWi2YSP67WOYs0X5CAa2r4Pg,2421
10
+ getpaid_core/types.py,sha256=Ougk4LBCvlVPkFWJfp0E3rE1hhv7DPKDFnuulfg3shE,1183
11
+ getpaid_core/validators.py,sha256=TLOvMUG51spEVhm4uvSjismpzOesOjljLmd6VWghuWU,570
12
+ getpaid_core/backends/__init__.py,sha256=7xPzfO9dlCcB8I6ZGW9dk24xYxvSrQMe7WTWdu5Phg0,33
13
+ getpaid_core/backends/dummy.py,sha256=-A4yCP3yZM_ctijoZYaM01MmaRgE_itAReVMZnMqxPQ,3088
14
+ python_getpaid_core-0.1.0.dist-info/METADATA,sha256=4nyRd1AjfDgpjEz0QRqrRjiSJx7eSSkhWEqrwfDDOyY,3621
15
+ python_getpaid_core-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
16
+ python_getpaid_core-0.1.0.dist-info/licenses/LICENSE,sha256=Nds7k4ZmahVulUUqzk6BZ3Lib61U75QHrBzWXqAhjZQ,1072
17
+ python_getpaid_core-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright © 2022 Dominik Kozaczko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.