ferrox-py-commerce 1.0.0__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.
- ferrox_py_commerce-1.0.0/PKG-INFO +53 -0
- ferrox_py_commerce-1.0.0/README.md +42 -0
- ferrox_py_commerce-1.0.0/docs/controllers.md +35 -0
- ferrox_py_commerce-1.0.0/docs/gateways.md +46 -0
- ferrox_py_commerce-1.0.0/docs/overview.md +40 -0
- ferrox_py_commerce-1.0.0/docs/transaction_state.md +43 -0
- ferrox_py_commerce-1.0.0/docs/webhooks.md +48 -0
- ferrox_py_commerce-1.0.0/ferrox_py_commerce/__init__.py +1 -0
- ferrox_py_commerce-1.0.0/ferrox_py_commerce/controllers/commerce_api_controller.py +61 -0
- ferrox_py_commerce-1.0.0/ferrox_py_commerce/controllers/webhooks_controller.py +105 -0
- ferrox_py_commerce-1.0.0/ferrox_py_commerce/gateways/paypal_gateway.py +23 -0
- ferrox_py_commerce-1.0.0/ferrox_py_commerce/gateways/stripe_gateway.py +44 -0
- ferrox_py_commerce-1.0.0/ferrox_py_commerce/models/events.py +43 -0
- ferrox_py_commerce-1.0.0/ferrox_py_commerce/services/transaction_state.py +47 -0
- ferrox_py_commerce-1.0.0/pyproject.toml +16 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ferrox-py-commerce
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Billing, Subscriptions, and Standardized Webhooks for Ferrox-Py.
|
|
5
|
+
Author: AI-Autistic-Intelligence
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: ferrox-py>=1.0.0
|
|
8
|
+
Requires-Dist: pydantic>=2.0
|
|
9
|
+
Requires-Dist: stripe>=7.0.0
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# Ferrox-Py-Commerce
|
|
13
|
+
|
|
14
|
+
## 1. Overview (What does this do?)
|
|
15
|
+
The `ferrox-py-commerce` package is a standardized extension for the Ferrox ecosystem dedicated to handling billing, subscriptions, and standardized payment webhooks. It abstracts away the complexity of dealing directly with external payment providers (like Stripe or PayPal) and guarantees robust, mathematically idempotent transaction handling.
|
|
16
|
+
|
|
17
|
+
## 2. Philosophy (Why does it exist?)
|
|
18
|
+
Handling real money in software is notoriously difficult. E-commerce integrations frequently suffer from race conditions (double charging a user), out-of-order webhook events (an invoice is marked paid before the charge is completed), and severe vendor lock-in. This module exists to decouple your core business logic from the specific syntax and behavior of third-party payment gateways, enforcing strict state machines that make duplicate charges nearly impossible.
|
|
19
|
+
|
|
20
|
+
## 3. Target Audience (Who is it for?)
|
|
21
|
+
This module is intended for backend engineers building SaaS billing systems, subscription platforms, or general e-commerce architectures where financial data integrity and vendor-agnostic infrastructure are critical.
|
|
22
|
+
|
|
23
|
+
## 4. Architecture (How does it work?)
|
|
24
|
+
The Commerce module leverages three main architectural concepts:
|
|
25
|
+
- **Gateways**: Abstract adapters over official provider SDKs (e.g., `StripeGateway`), allowing the core application to initiate payments using uniform interfaces.
|
|
26
|
+
- **Transaction State Machine**: A strict, Redis-backed state machine that enforces mathematical idempotency for incoming webhook events (e.g., ensuring a transaction can only move from `PENDING` to `COMPLETED` once).
|
|
27
|
+
- **Controllers & Webhooks**: Layer 6 endpoints that securely parse incoming events (verifying HMAC signatures) and translate proprietary JSON payloads into standardized Pydantic Domain Events.
|
|
28
|
+
|
|
29
|
+
## 5. Installation / Setup
|
|
30
|
+
Ensure you are using Python 3.11+ and install the package via pip:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install ferrox-py-commerce
|
|
34
|
+
```
|
|
35
|
+
You will also need to configure a Redis instance, as it is required by the `TransactionStateService` to manage distributed, atomic locks.
|
|
36
|
+
|
|
37
|
+
## 6. Quickstart (Usage)
|
|
38
|
+
```python
|
|
39
|
+
from ferrox_py.core.container import Container
|
|
40
|
+
from ferrox_py_commerce import CommerceModule
|
|
41
|
+
|
|
42
|
+
# 1. Initialize your IoC container
|
|
43
|
+
container = Container()
|
|
44
|
+
|
|
45
|
+
# 2. Register the Commerce Module
|
|
46
|
+
container.register_module(CommerceModule)
|
|
47
|
+
|
|
48
|
+
# The Webhook controllers, Gateways, and State Machines are now
|
|
49
|
+
# wired and ready to process payments safely!
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 7. Ecosystem Integration
|
|
53
|
+
The Commerce module integrates seamlessly with the **Security Component** of the core `ferrox-py` framework for distributed Redis locking, and it relies strictly on the **AuthModule** (`ferrox-py-auth`) to ensure that payment intents and subscriptions are accurately linked to a validated, authenticated `User` context.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Ferrox-Py-Commerce
|
|
2
|
+
|
|
3
|
+
## 1. Overview (What does this do?)
|
|
4
|
+
The `ferrox-py-commerce` package is a standardized extension for the Ferrox ecosystem dedicated to handling billing, subscriptions, and standardized payment webhooks. It abstracts away the complexity of dealing directly with external payment providers (like Stripe or PayPal) and guarantees robust, mathematically idempotent transaction handling.
|
|
5
|
+
|
|
6
|
+
## 2. Philosophy (Why does it exist?)
|
|
7
|
+
Handling real money in software is notoriously difficult. E-commerce integrations frequently suffer from race conditions (double charging a user), out-of-order webhook events (an invoice is marked paid before the charge is completed), and severe vendor lock-in. This module exists to decouple your core business logic from the specific syntax and behavior of third-party payment gateways, enforcing strict state machines that make duplicate charges nearly impossible.
|
|
8
|
+
|
|
9
|
+
## 3. Target Audience (Who is it for?)
|
|
10
|
+
This module is intended for backend engineers building SaaS billing systems, subscription platforms, or general e-commerce architectures where financial data integrity and vendor-agnostic infrastructure are critical.
|
|
11
|
+
|
|
12
|
+
## 4. Architecture (How does it work?)
|
|
13
|
+
The Commerce module leverages three main architectural concepts:
|
|
14
|
+
- **Gateways**: Abstract adapters over official provider SDKs (e.g., `StripeGateway`), allowing the core application to initiate payments using uniform interfaces.
|
|
15
|
+
- **Transaction State Machine**: A strict, Redis-backed state machine that enforces mathematical idempotency for incoming webhook events (e.g., ensuring a transaction can only move from `PENDING` to `COMPLETED` once).
|
|
16
|
+
- **Controllers & Webhooks**: Layer 6 endpoints that securely parse incoming events (verifying HMAC signatures) and translate proprietary JSON payloads into standardized Pydantic Domain Events.
|
|
17
|
+
|
|
18
|
+
## 5. Installation / Setup
|
|
19
|
+
Ensure you are using Python 3.11+ and install the package via pip:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install ferrox-py-commerce
|
|
23
|
+
```
|
|
24
|
+
You will also need to configure a Redis instance, as it is required by the `TransactionStateService` to manage distributed, atomic locks.
|
|
25
|
+
|
|
26
|
+
## 6. Quickstart (Usage)
|
|
27
|
+
```python
|
|
28
|
+
from ferrox_py.core.container import Container
|
|
29
|
+
from ferrox_py_commerce import CommerceModule
|
|
30
|
+
|
|
31
|
+
# 1. Initialize your IoC container
|
|
32
|
+
container = Container()
|
|
33
|
+
|
|
34
|
+
# 2. Register the Commerce Module
|
|
35
|
+
container.register_module(CommerceModule)
|
|
36
|
+
|
|
37
|
+
# The Webhook controllers, Gateways, and State Machines are now
|
|
38
|
+
# wired and ready to process payments safely!
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 7. Ecosystem Integration
|
|
42
|
+
The Commerce module integrates seamlessly with the **Security Component** of the core `ferrox-py` framework for distributed Redis locking, and it relies strictly on the **AuthModule** (`ferrox-py-auth`) to ensure that payment intents and subscriptions are accurately linked to a validated, authenticated `User` context.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Controllers
|
|
2
|
+
|
|
3
|
+
## 1. Overview (What does this do?)
|
|
4
|
+
This module implements Layer 6 (Controller Layer) of the Onion Pipeline specifically for the Commerce domain. It exposes the HTTP endpoints required to interact with front-end clients (initiating checkouts) and third-party servers (receiving webhooks).
|
|
5
|
+
|
|
6
|
+
## 2. Philosophy (Why does it exist?)
|
|
7
|
+
The philosophy of the Controller Layer in `ferrox-py` is to remain as "thin" as possible. Controllers should not contain any domain logic or direct database interactions. This module exists strictly to handle HTTP routing, parse incoming network requests, and delegate the actual heavy lifting to the internal Services (Layer 7), thereby preventing "leaky abstractions".
|
|
8
|
+
|
|
9
|
+
## 3. Target Audience (Who is it for?)
|
|
10
|
+
This module is for developers integrating the frontend application (React, Vue, etc.) with the backend billing system, providing them with predictable REST endpoints to generate checkout sessions and redirect users.
|
|
11
|
+
|
|
12
|
+
## 4. Architecture (How does it work?)
|
|
13
|
+
- **CommerceApiController**: Exposes standard endpoints for the front-end. For example, creating a Checkout Session returns the redirect URL (Success URL / Cancel URL). It invokes the `Gateway` abstraction to generate this URL but *never* saves the actual completion of the order.
|
|
14
|
+
- **WebhooksController**: An unauthenticated (but cryptographically verified) endpoint exposed to the public internet to receive server-to-server calls from providers like Stripe or PayPal.
|
|
15
|
+
|
|
16
|
+
## 5. Installation / Setup
|
|
17
|
+
These controllers are part of the `ferrox-py-commerce` package. No additional setup is required beyond ensuring your `FerroxApp` is configured to route traffic to the registered module controllers.
|
|
18
|
+
|
|
19
|
+
## 6. Quickstart (Usage)
|
|
20
|
+
```python
|
|
21
|
+
# The Controller is automatically registered, but here is a conceptual overview of its usage:
|
|
22
|
+
# POST /api/commerce/checkout
|
|
23
|
+
# Body: { "price_id": "price_12345", "quantity": 1 }
|
|
24
|
+
|
|
25
|
+
# The controller delegates to the internal gateway:
|
|
26
|
+
class CommerceApiController:
|
|
27
|
+
async def create_checkout(self, payload: CheckoutPayload, gateway: BaseGateway):
|
|
28
|
+
# 1. Ask gateway for URL
|
|
29
|
+
session_url = await gateway.create_checkout_session(payload)
|
|
30
|
+
# 2. Return URL to frontend
|
|
31
|
+
return {"redirect_url": session_url}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 7. Ecosystem Integration
|
|
35
|
+
These controllers act as the bridge between the external network and the internal **Gateways** and **Transaction State Machines**. The `WebhooksController` specifically relies heavily on the **Event Dispatcher (CQRS)** from the core ecosystem to broadcast standardized payment events into the system once a payload is verified.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Gateways
|
|
2
|
+
|
|
3
|
+
## 1. Overview (What does this do?)
|
|
4
|
+
The Gateways module provides Adapter pattern implementations over official payment provider SDKs (like Stripe or PayPal). It standardizes how the `ferrox-py` application requests checkout sessions, issues refunds, or verifies webhook signatures.
|
|
5
|
+
|
|
6
|
+
## 2. Philosophy (Why does it exist?)
|
|
7
|
+
If your application uses the `stripe` python package directly within your business logic, you suffer from immediate vendor lock-in. If business requirements mandate a switch to PayPal or Adyen, refactoring becomes a massive endeavor. This module exists to define a strict, generic `BaseGateway` interface. The business logic only talks to this interface, completely isolating it from vendor-specific quirks.
|
|
8
|
+
|
|
9
|
+
## 3. Target Audience (Who is it for?)
|
|
10
|
+
This abstraction is crucial for architects designing future-proof SaaS applications, and for backend developers tasked with implementing new payment methods without breaking the existing core billing logic.
|
|
11
|
+
|
|
12
|
+
## 4. Architecture (How does it work?)
|
|
13
|
+
- **BaseGateway**: An abstract Python class defining the core contract (`create_payment`, `refund`, `verify_webhook_signature`).
|
|
14
|
+
- **StripeGateway**: The concrete implementation for Stripe. It translates Ferrox intents into Stripe Intents and decrypts the `Stripe-Signature` HMAC headers.
|
|
15
|
+
- **PayPalGateway**: A mock/stub implementation ready to be connected to PayPal's REST API v2, following the exact same generic contract.
|
|
16
|
+
Through Inversion of Control, the container injects the correct concrete implementation into the controllers based on the environment configuration.
|
|
17
|
+
|
|
18
|
+
## 5. Installation / Setup
|
|
19
|
+
While the `BaseGateway` is built-in, you must install the specific vendor SDKs you plan to use in production.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install stripe
|
|
23
|
+
# pip install paypalrestsdk (if using PayPal)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## 6. Quickstart (Usage)
|
|
27
|
+
```python
|
|
28
|
+
from ferrox_py_commerce.gateways.base import BaseGateway
|
|
29
|
+
from ferrox_py_commerce.gateways.stripe import StripeGateway
|
|
30
|
+
|
|
31
|
+
# Developers can implement new gateways easily:
|
|
32
|
+
class AdyenGateway(BaseGateway):
|
|
33
|
+
async def verify_webhook_signature(self, payload, signature, secret) -> bool:
|
|
34
|
+
# Custom Adyen HMAC verification logic here
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
async def create_checkout_session(self, items, success_url, cancel_url):
|
|
38
|
+
# Custom Adyen session logic
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
# The IoC container registers the desired implementation
|
|
42
|
+
# container.register("payment_gateway", StripeGateway(api_key="sk_test_..."))
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 7. Ecosystem Integration
|
|
46
|
+
Gateways are injected directly into the **Controllers** via the Core IoC Container. They also interface closely with the **WebhooksController**, providing the necessary cryptographic verification methods required to sanitize incoming Layer 1 HTTP requests before they are parsed by the framework.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Ferrox-Py-Commerce Overview
|
|
2
|
+
|
|
3
|
+
## 1. Overview (What does this do?)
|
|
4
|
+
The `ferrox-py-commerce` module is specifically engineered to safely and efficiently manage billing logic, payment processing, and accounting reconciliation within the Ferrox-Py ecosystem.
|
|
5
|
+
|
|
6
|
+
## 2. Philosophy (Why does it exist?)
|
|
7
|
+
Modern e-commerce integrations suffer from three highly complex issues:
|
|
8
|
+
1. **Out-of-Sequence Webhooks**: Due to network delays, a provider like Stripe might send an `invoice.paid` event *before* the `charge.succeeded` event.
|
|
9
|
+
2. **Double Charges**: A user clicks the "Pay" button twice in rapid succession, launching two concurrent payment intents.
|
|
10
|
+
3. **Vendor Lock-In**: Tying domain logic to Stripe's specific JSON syntax makes switching to PayPal financially prohibitive.
|
|
11
|
+
This module exists to solve all three problems through a rigorous, highly decoupled architecture.
|
|
12
|
+
|
|
13
|
+
## 3. Target Audience (Who is it for?)
|
|
14
|
+
This overview is for backend engineers and technical leads who need a resilient, fault-tolerant infrastructure to handle real money transactions. If you need a billing system that won't randomly credit users twice due to a network glitch, this framework provides the blueprint.
|
|
15
|
+
|
|
16
|
+
## 4. Architecture (How does it work?)
|
|
17
|
+
The solution relies on three foundational pillars:
|
|
18
|
+
- **Redis Transaction State Machine**: Guarantees mathematical idempotency. A transaction ID is locked atomically and can only transition linearly (e.g., from `PENDING` to `COMPLETED`), completely neutralizing duplicate webhooks or concurrent clicks.
|
|
19
|
+
- **Abstract Gateways**: A unified interface (`BaseGateway`) allows the system to trigger `create_payment` without caring if the underlying implementation is Stripe, PayPal, or a mock testing environment.
|
|
20
|
+
- **Standardized Webhook Controllers**: A universal translator that converts proprietary provider payloads (Stripe JSON) into standardized internal Pydantic events (like `PaymentSuccessEvent`).
|
|
21
|
+
|
|
22
|
+
## 5. Installation / Setup
|
|
23
|
+
```bash
|
|
24
|
+
pip install ferrox-py-commerce redis
|
|
25
|
+
```
|
|
26
|
+
A running Redis instance is strictly required to enable the distributed locking and transaction state management mechanisms.
|
|
27
|
+
|
|
28
|
+
## 6. Quickstart (Usage)
|
|
29
|
+
```python
|
|
30
|
+
from ferrox_py.core.container import Container
|
|
31
|
+
from ferrox_py_commerce import CommerceModule
|
|
32
|
+
|
|
33
|
+
# Initializing the billing system requires registering the module.
|
|
34
|
+
container = Container()
|
|
35
|
+
container.register_module(CommerceModule)
|
|
36
|
+
# All controllers, gateways, and state machines are now active.
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 7. Ecosystem Integration
|
|
40
|
+
The commerce logic integrates perfectly with the core **CQRS Bus**. When the Standardized Webhook Controller parses a successful payment, it dispatches an Event to the bus. Other microservices (like a Notification service sending a receipt email, or a provisioning service unlocking premium features) simply subscribe to the Bus, remaining completely uncoupled from the billing module itself.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Transaction State (Idempotency and Redis)
|
|
2
|
+
|
|
3
|
+
## 1. Overview (What does this do?)
|
|
4
|
+
The Transaction State module provides a highly concurrent, distributed State Machine designed specifically for managing the lifecycle of financial transactions. It ensures that incoming payment updates (like webhooks) are processed exactly once and in a logically sound order.
|
|
5
|
+
|
|
6
|
+
## 2. Philosophy (Why does it exist?)
|
|
7
|
+
When handling real money, idempotency is not just a nice-to-have architectural feature; it is a strict legal and operational requirement. The system must never credit a user twice for the same payment, even if Stripe sends the `charge.succeeded` webhook three times due to network retries. This module exists to shift the burden of idempotency off the SQL database and onto a blazing-fast in-memory layer (Redis), preventing database locks and race conditions entirely.
|
|
8
|
+
|
|
9
|
+
## 3. Target Audience (Who is it for?)
|
|
10
|
+
This module is for backend developers and DevOps engineers tasked with scaling e-commerce platforms across multiple server nodes (e.g., Kubernetes pods), where concurrent webhook processing could result in severe data corruption without distributed locking.
|
|
11
|
+
|
|
12
|
+
## 4. Architecture (How does it work?)
|
|
13
|
+
- **Atomic Check-and-Set**: When a `PaymentSuccessEvent` webhook arrives, the service queries Redis. If the transaction ID is already marked as `COMPLETED`, the request is immediately discarded. This neutralizes duplicate webhooks instantly.
|
|
14
|
+
- **Out of Order Resolution**: If Stripe delivers an `invoice.paid` event before a `charge.succeeded` event due to internal network latency, the State Machine queues or recognizes the missing transition. It strictly rejects invalid state jumps (e.g., transitioning from `FAILED` directly to `COMPLETED`).
|
|
15
|
+
- **Redis as a Single Source of Truth**: Because Redis operations are single-threaded and memory-based, collisions between two Kubernetes pods processing the exact same webhook simultaneously are resolved natively at the cache level before touching the SQL database.
|
|
16
|
+
|
|
17
|
+
## 5. Installation / Setup
|
|
18
|
+
This module requires the `ferrox-py-commerce` package, a running Redis server, and the python `redis` client.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install redis
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 6. Quickstart (Usage)
|
|
25
|
+
```python
|
|
26
|
+
from ferrox_py_commerce.services.transaction_state import TransactionStateService
|
|
27
|
+
|
|
28
|
+
# Usually injected by the IoC container
|
|
29
|
+
state_service = TransactionStateService(redis_client)
|
|
30
|
+
|
|
31
|
+
async def handle_payment_success(transaction_id: str):
|
|
32
|
+
# This method attempts to transition the state atomically in Redis.
|
|
33
|
+
# If another pod already did it, it raises an IdempotencyError or returns False.
|
|
34
|
+
success = await state_service.transition_to(transaction_id, new_state="COMPLETED")
|
|
35
|
+
|
|
36
|
+
if success:
|
|
37
|
+
print("Payment applied to user account.")
|
|
38
|
+
else:
|
|
39
|
+
print("Payment already processed. Ignoring duplicate webhook.")
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## 7. Ecosystem Integration
|
|
43
|
+
The Transaction State Machine relies fundamentally on the **Security Component** of the core `ferrox-py` framework, utilizing its `RedisLock` (Redlock algorithm) implementation. It also heavily interacts with the **Data Component** to eventually sync the finalized, deduplicated transaction state back to the persistent SQL database.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Webhooks Controller
|
|
2
|
+
|
|
3
|
+
## 1. Overview (What does this do?)
|
|
4
|
+
The Webhooks Controller is the asynchronous interface between external payment providers (e.g., Stripe, PayPal) and the internal Ferrox application. It acts as a universal translator, securely receiving HTTP POST requests, verifying their authenticity, and converting them into internal domain events.
|
|
5
|
+
|
|
6
|
+
## 2. Philosophy (Why does it exist?)
|
|
7
|
+
Payment providers send JSON payloads that differ vastly in structure. Stripe nests data under `data.object`, while PayPal uses a completely different schema. Writing business logic that depends directly on Stripe's specific JSON syntax tightly couples the core database to a third-party vendor. The philosophy here is absolute decoupling: the business logic must never know which provider sent the payment.
|
|
8
|
+
|
|
9
|
+
## 3. Target Audience (Who is it for?)
|
|
10
|
+
This component is for backend developers who need to expose secure, public-facing endpoints to ingest asynchronous events from third-party APIs without exposing the internal application logic to vendor-specific data structures.
|
|
11
|
+
|
|
12
|
+
## 4. Architecture (How does it work?)
|
|
13
|
+
`ferrox-py-commerce` solves the vendor lock-in problem through a strict 4-step ingestion pipeline:
|
|
14
|
+
1. **HMAC Verification**: The controller extracts the cryptographic signature (e.g., `Stripe-Signature`) from the HTTP headers and validates it using the injected **Gateway** adapter.
|
|
15
|
+
2. **Parsing**: The raw, vendor-specific JSON payload is inspected.
|
|
16
|
+
3. **Mapping**: The proprietary event is "translated" into a pure, standardized Pydantic domain model (e.g., `PaymentSuccessEvent`, `InvoicePaidEvent`) defined in the `models/events.py` file.
|
|
17
|
+
4. **Dispatch**: The standardized Pydantic model is forwarded to the core logic (Layer 7 of the Onion Pipeline). At this point, the business logic only sees a generic `PaymentSuccessEvent` and has no awareness of the original provider.
|
|
18
|
+
|
|
19
|
+
## 5. Installation / Setup
|
|
20
|
+
The Webhooks Controller is built-in. It simply requires configuring the provider-specific webhook signing secrets in your environment variables so the HMAC verification step can function correctly.
|
|
21
|
+
|
|
22
|
+
## 6. Quickstart (Usage)
|
|
23
|
+
```python
|
|
24
|
+
# The Controller is pre-configured, but internally it operates like this:
|
|
25
|
+
from ferrox_py_commerce.gateways.base import BaseGateway
|
|
26
|
+
from ferrox_py_commerce.models.events import PaymentSuccessEvent
|
|
27
|
+
|
|
28
|
+
class WebhooksController:
|
|
29
|
+
async def stripe_webhook(self, request, gateway: BaseGateway, event_bus):
|
|
30
|
+
# 1. Verify Signature
|
|
31
|
+
is_valid = await gateway.verify_webhook_signature(
|
|
32
|
+
payload=request.body,
|
|
33
|
+
signature=request.headers.get("Stripe-Signature"),
|
|
34
|
+
secret=STRIPE_WEBHOOK_SECRET
|
|
35
|
+
)
|
|
36
|
+
if not is_valid:
|
|
37
|
+
raise ForbiddenError("Invalid HMAC signature")
|
|
38
|
+
|
|
39
|
+
# 2 & 3. Parse and Map (Abstracted in real implementation)
|
|
40
|
+
event = PaymentSuccessEvent(transaction_id="txn_123", amount=10.00)
|
|
41
|
+
|
|
42
|
+
# 4. Dispatch generic event
|
|
43
|
+
await event_bus.dispatch(event)
|
|
44
|
+
return {"status": "success"}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 7. Ecosystem Integration
|
|
48
|
+
The Webhooks Controller relies heavily on the **CQRS and Event Dispatcher** core module. Instead of invoking database repositories directly, the controller broadcasts the translated `PaymentSuccessEvent` to the Event Bus. The **Transaction State** service, listening on that bus, then picks up the event to enforce idempotency.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# init
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from fastapi import Request
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from typing import List
|
|
4
|
+
from ferrox_py.core.controllers import BaseController
|
|
5
|
+
from ferrox_py.core.container import Container
|
|
6
|
+
from ferrox_py.integrations.payments import CheckoutRequest, LineItem
|
|
7
|
+
from ferrox_py.databases.redis import RedisCacheService
|
|
8
|
+
from ..gateways.stripe_gateway import StripeGateway
|
|
9
|
+
from ..services.transaction_state import TransactionStateService
|
|
10
|
+
|
|
11
|
+
class CartItemPayload(BaseModel):
|
|
12
|
+
name: str
|
|
13
|
+
amount_cents: int
|
|
14
|
+
quantity: int
|
|
15
|
+
|
|
16
|
+
class CheckoutPayload(BaseModel):
|
|
17
|
+
provider: str
|
|
18
|
+
items: List[CartItemPayload]
|
|
19
|
+
currency: str = "eur"
|
|
20
|
+
success_url: str = "http://localhost:3000/success"
|
|
21
|
+
cancel_url: str = "http://localhost:3000/cancel"
|
|
22
|
+
|
|
23
|
+
class CommerceApiController(BaseController):
|
|
24
|
+
"""
|
|
25
|
+
Frontend-facing API for starting payments and managing subscriptions.
|
|
26
|
+
"""
|
|
27
|
+
def __init__(self, container: Container):
|
|
28
|
+
super().__init__(prefix="/commerce", tags=["Commerce API"])
|
|
29
|
+
|
|
30
|
+
self.stripe_gateway = StripeGateway()
|
|
31
|
+
redis = container.resolve("RedisCacheService", RedisCacheService())
|
|
32
|
+
self.state_service = TransactionStateService(redis)
|
|
33
|
+
|
|
34
|
+
@self.router.post("/checkout")
|
|
35
|
+
async def create_checkout(payload: CheckoutPayload):
|
|
36
|
+
"""Creates a checkout session and returns the redirect URL to the frontend."""
|
|
37
|
+
|
|
38
|
+
# Map frontend payload to internal CheckoutRequest
|
|
39
|
+
req = CheckoutRequest(
|
|
40
|
+
amount_cents=sum(i.amount_cents * i.quantity for i in payload.items),
|
|
41
|
+
currency=payload.currency,
|
|
42
|
+
items=[LineItem(name=i.name, amount_cents=i.amount_cents, quantity=i.quantity) for i in payload.items],
|
|
43
|
+
success_url=payload.success_url,
|
|
44
|
+
cancel_url=payload.cancel_url,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if payload.provider.lower() == "stripe":
|
|
48
|
+
checkout_url = await self.stripe_gateway.create_checkout_session(req)
|
|
49
|
+
|
|
50
|
+
# In a real scenario, you'd extract the Session ID from the Stripe response
|
|
51
|
+
# and initialize its state to PENDING in the State Machine before returning.
|
|
52
|
+
# await self.state_service.transition_state(session_id, "PENDING")
|
|
53
|
+
|
|
54
|
+
return self.ok({"checkout_url": checkout_url}, "Stripe Session Created")
|
|
55
|
+
else:
|
|
56
|
+
return self.bad_request("Unsupported provider")
|
|
57
|
+
|
|
58
|
+
@self.router.get("/invoices")
|
|
59
|
+
async def list_invoices(request: Request):
|
|
60
|
+
"""Returns invoice history (Mock implementation)."""
|
|
61
|
+
return self.ok({"invoices": []}, "Invoices retrieved")
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from fastapi import Request
|
|
2
|
+
from ferrox_py.core.controllers import BaseController
|
|
3
|
+
from ferrox_py.core.container import Container
|
|
4
|
+
from ferrox_py.core.errors import FerroxError
|
|
5
|
+
from ferrox_py.databases.redis import RedisCacheService
|
|
6
|
+
from ..gateways.stripe_gateway import StripeGateway
|
|
7
|
+
from ..gateways.paypal_gateway import PayPalGateway
|
|
8
|
+
from ..models.events import PaymentSuccessEvent, InvoicePaidEvent, InvoiceFailedEvent
|
|
9
|
+
from ..services.transaction_state import TransactionStateService
|
|
10
|
+
|
|
11
|
+
class CommerceWebhookController(BaseController):
|
|
12
|
+
"""
|
|
13
|
+
The Webhook Standardizer.
|
|
14
|
+
Receives raw webhooks from Stripe and PayPal, mathematically verifies the cryptographic
|
|
15
|
+
signatures to prevent fraud, enforces idempotency, updates transaction state,
|
|
16
|
+
and standardizes them into Ferrox Events.
|
|
17
|
+
"""
|
|
18
|
+
def __init__(self, container: Container):
|
|
19
|
+
super().__init__(prefix="/webhooks/commerce", tags=["Commerce Webhooks"])
|
|
20
|
+
|
|
21
|
+
# Resolve dependencies
|
|
22
|
+
self.stripe_gateway = StripeGateway()
|
|
23
|
+
self.paypal_gateway = PayPalGateway()
|
|
24
|
+
|
|
25
|
+
redis = container.resolve("RedisCacheService", RedisCacheService())
|
|
26
|
+
self.state_service = TransactionStateService(redis)
|
|
27
|
+
|
|
28
|
+
@self.router.post("/stripe")
|
|
29
|
+
async def stripe_webhook(request: Request):
|
|
30
|
+
payload = await request.body()
|
|
31
|
+
sig_header = request.headers.get("stripe-signature")
|
|
32
|
+
|
|
33
|
+
if not sig_header:
|
|
34
|
+
raise FerroxError("Missing Stripe signature", 400)
|
|
35
|
+
|
|
36
|
+
# 1. Cryptographic Validation
|
|
37
|
+
event = self.stripe_gateway.verify_webhook_signature(payload, sig_header)
|
|
38
|
+
|
|
39
|
+
# 2. Idempotency Check (Redis)
|
|
40
|
+
is_duplicate = await self.state_service.check_idempotency(event.id)
|
|
41
|
+
if is_duplicate:
|
|
42
|
+
return self.ok(message="Duplicate event ignored")
|
|
43
|
+
|
|
44
|
+
# 3. Standardization & State Machine
|
|
45
|
+
if event.type == "checkout.session.completed":
|
|
46
|
+
session = event.data.object
|
|
47
|
+
# Transition state to PAID
|
|
48
|
+
valid = await self.state_service.transition_state(session.id, "PAID")
|
|
49
|
+
if valid:
|
|
50
|
+
std_event = PaymentSuccessEvent(
|
|
51
|
+
provider="stripe",
|
|
52
|
+
transaction_id=session.id,
|
|
53
|
+
amount_cents=session.amount_total,
|
|
54
|
+
currency=session.currency,
|
|
55
|
+
customer_email=session.customer_details.email if session.customer_details else None,
|
|
56
|
+
)
|
|
57
|
+
print(f"[EVENT BUS] Emitting: {std_event.model_dump_json()}")
|
|
58
|
+
|
|
59
|
+
elif event.type == "invoice.paid":
|
|
60
|
+
invoice = event.data.object
|
|
61
|
+
std_event = InvoicePaidEvent(
|
|
62
|
+
provider="stripe",
|
|
63
|
+
invoice_id=invoice.id,
|
|
64
|
+
subscription_id=invoice.subscription,
|
|
65
|
+
customer_id=invoice.customer,
|
|
66
|
+
amount_cents=invoice.amount_paid,
|
|
67
|
+
currency=invoice.currency
|
|
68
|
+
)
|
|
69
|
+
print(f"[EVENT BUS] Emitting: {std_event.model_dump_json()}")
|
|
70
|
+
|
|
71
|
+
elif event.type == "invoice.payment_failed":
|
|
72
|
+
invoice = event.data.object
|
|
73
|
+
std_event = InvoiceFailedEvent(
|
|
74
|
+
provider="stripe",
|
|
75
|
+
invoice_id=invoice.id,
|
|
76
|
+
subscription_id=invoice.subscription,
|
|
77
|
+
customer_id=invoice.customer
|
|
78
|
+
)
|
|
79
|
+
print(f"[EVENT BUS] Emitting: {std_event.model_dump_json()}")
|
|
80
|
+
|
|
81
|
+
return self.ok(message="Webhook processed successfully")
|
|
82
|
+
|
|
83
|
+
@self.router.post("/paypal")
|
|
84
|
+
async def paypal_webhook(request: Request):
|
|
85
|
+
headers = dict(request.headers)
|
|
86
|
+
body = await request.json()
|
|
87
|
+
|
|
88
|
+
# 1. Cryptographic Validation
|
|
89
|
+
is_valid = self.paypal_gateway.verify_webhook_signature(headers, body)
|
|
90
|
+
if not is_valid:
|
|
91
|
+
raise FerroxError("Invalid PayPal signature", 400)
|
|
92
|
+
|
|
93
|
+
# 2. Standardization
|
|
94
|
+
if body.get("event_type") == "PAYMENT.CAPTURE.COMPLETED":
|
|
95
|
+
resource = body.get("resource", {})
|
|
96
|
+
std_event = PaymentSuccessEvent(
|
|
97
|
+
provider="paypal",
|
|
98
|
+
transaction_id=resource.get("id"),
|
|
99
|
+
amount_cents=int(float(resource.get("amount", {}).get("value", 0)) * 100),
|
|
100
|
+
currency=resource.get("amount", {}).get("currency_code", "USD"),
|
|
101
|
+
customer_email=None,
|
|
102
|
+
)
|
|
103
|
+
print(f"[EVENT BUS] Emitting: {std_event.model_dump_json()}")
|
|
104
|
+
|
|
105
|
+
return self.ok(message="Webhook received")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from ferrox_py.core.provider import injectable
|
|
2
|
+
from ferrox_py.integrations.payments import PaymentGateway, CheckoutRequest
|
|
3
|
+
|
|
4
|
+
@injectable()
|
|
5
|
+
class PayPalGateway(PaymentGateway):
|
|
6
|
+
def __init__(self, client_id: str = "", client_secret: str = ""):
|
|
7
|
+
self.client_id = client_id
|
|
8
|
+
self.client_secret = client_secret
|
|
9
|
+
|
|
10
|
+
async def create_checkout_session(self, request: CheckoutRequest) -> str:
|
|
11
|
+
# Mocking PayPal REST API call for creating an order
|
|
12
|
+
print(f"PayPal: Creating order for {sum(i.amount_cents for i in request.items)} cents")
|
|
13
|
+
return "https://www.sandbox.paypal.com/checkoutnow?token=mock_token_123"
|
|
14
|
+
|
|
15
|
+
def verify_webhook_signature(self, headers: dict, body: dict) -> bool:
|
|
16
|
+
"""
|
|
17
|
+
PayPal requires a complex certificate/signature verification.
|
|
18
|
+
In production, this calls PayPal's /v1/notifications/verify-webhook-signature API.
|
|
19
|
+
"""
|
|
20
|
+
# Mock verification
|
|
21
|
+
if "paypal-transmission-sig" in headers:
|
|
22
|
+
return True
|
|
23
|
+
return False
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import stripe
|
|
2
|
+
from ferrox_py.core.provider import injectable
|
|
3
|
+
from ferrox_py.integrations.payments import PaymentGateway, CheckoutRequest
|
|
4
|
+
from ferrox_py.core.errors import FerroxError
|
|
5
|
+
|
|
6
|
+
@injectable()
|
|
7
|
+
class StripeGateway(PaymentGateway):
|
|
8
|
+
def __init__(self, api_key: str = "", webhook_secret: str = ""):
|
|
9
|
+
stripe.api_key = api_key
|
|
10
|
+
self.webhook_secret = webhook_secret
|
|
11
|
+
|
|
12
|
+
async def create_checkout_session(self, request: CheckoutRequest) -> str:
|
|
13
|
+
line_items = [{
|
|
14
|
+
"price_data": {
|
|
15
|
+
"currency": request.currency.lower(),
|
|
16
|
+
"product_data": {"name": item.name},
|
|
17
|
+
"unit_amount": item.amount_cents,
|
|
18
|
+
},
|
|
19
|
+
"quantity": item.quantity,
|
|
20
|
+
} for item in request.items]
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
session = stripe.checkout.Session.create(
|
|
24
|
+
payment_method_types=["card"],
|
|
25
|
+
line_items=line_items,
|
|
26
|
+
mode="payment",
|
|
27
|
+
success_url=request.success_url,
|
|
28
|
+
cancel_url=request.cancel_url,
|
|
29
|
+
)
|
|
30
|
+
return session.url
|
|
31
|
+
except Exception as e:
|
|
32
|
+
raise FerroxError(f"Stripe Checkout Error: {str(e)}", 500)
|
|
33
|
+
|
|
34
|
+
def verify_webhook_signature(self, payload: bytes, sig_header: str) -> stripe.Event:
|
|
35
|
+
"""
|
|
36
|
+
Cryptographically verifies the HMAC signature of the webhook payload.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
event = stripe.Webhook.construct_event(payload, sig_header, self.webhook_secret)
|
|
40
|
+
return event
|
|
41
|
+
except ValueError:
|
|
42
|
+
raise FerroxError("Invalid payload", 400)
|
|
43
|
+
except stripe.error.SignatureVerificationError:
|
|
44
|
+
raise FerroxError("Invalid signature", 400)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Dict, Any, Optional
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
class PaymentSuccessEvent(BaseModel):
|
|
6
|
+
"""
|
|
7
|
+
Standardized event emitted when ANY provider successfully processes a payment.
|
|
8
|
+
"""
|
|
9
|
+
provider: str # 'stripe', 'paypal'
|
|
10
|
+
transaction_id: str
|
|
11
|
+
amount_cents: int
|
|
12
|
+
currency: str
|
|
13
|
+
customer_email: Optional[str]
|
|
14
|
+
metadata: Dict[str, str] = {}
|
|
15
|
+
timestamp: datetime = datetime.utcnow()
|
|
16
|
+
|
|
17
|
+
class SubscriptionCreatedEvent(BaseModel):
|
|
18
|
+
"""
|
|
19
|
+
Standardized event for new SaaS subscriptions.
|
|
20
|
+
"""
|
|
21
|
+
provider: str
|
|
22
|
+
subscription_id: str
|
|
23
|
+
customer_id: str
|
|
24
|
+
plan_id: str
|
|
25
|
+
status: str
|
|
26
|
+
|
|
27
|
+
class InvoicePaidEvent(BaseModel):
|
|
28
|
+
"""Event for successful recurring SaaS payments."""
|
|
29
|
+
provider: str
|
|
30
|
+
invoice_id: str
|
|
31
|
+
subscription_id: str
|
|
32
|
+
customer_id: str
|
|
33
|
+
amount_cents: int
|
|
34
|
+
currency: str
|
|
35
|
+
timestamp: datetime = datetime.utcnow()
|
|
36
|
+
|
|
37
|
+
class InvoiceFailedEvent(BaseModel):
|
|
38
|
+
"""Event for failed recurring SaaS payments (card declined)."""
|
|
39
|
+
provider: str
|
|
40
|
+
invoice_id: str
|
|
41
|
+
subscription_id: str
|
|
42
|
+
customer_id: str
|
|
43
|
+
timestamp: datetime = datetime.utcnow()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from ferrox_py.core.provider import injectable
|
|
2
|
+
from ferrox_py.core.errors import FerroxError
|
|
3
|
+
from ferrox_py.databases.redis import RedisCacheService
|
|
4
|
+
|
|
5
|
+
@injectable()
|
|
6
|
+
class TransactionStateService:
|
|
7
|
+
def __init__(self, redis: RedisCacheService):
|
|
8
|
+
self.redis = redis
|
|
9
|
+
|
|
10
|
+
async def check_idempotency(self, event_id: str) -> bool:
|
|
11
|
+
"""
|
|
12
|
+
Returns True if the event has already been processed.
|
|
13
|
+
Uses Redis to set a key with a 24-hour expiration.
|
|
14
|
+
"""
|
|
15
|
+
# Ensure redis is connected
|
|
16
|
+
if not self.redis.client:
|
|
17
|
+
await self.redis.connect()
|
|
18
|
+
|
|
19
|
+
key = f"webhook_evt:{event_id}"
|
|
20
|
+
|
|
21
|
+
# setnx returns 1 if key was set (new event), 0 if it already existed
|
|
22
|
+
is_new = await self.redis.client.setnx(key, "processed")
|
|
23
|
+
if is_new:
|
|
24
|
+
# Expire key after 24 hours to free up memory
|
|
25
|
+
await self.redis.client.expire(key, 86400)
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
return True
|
|
29
|
+
|
|
30
|
+
async def transition_state(self, transaction_id: str, new_state: str) -> bool:
|
|
31
|
+
"""
|
|
32
|
+
Simple State Machine: PENDING -> PAID -> FAILED.
|
|
33
|
+
Returns True if transition is valid and executed.
|
|
34
|
+
"""
|
|
35
|
+
state_key = f"tx_state:{transaction_id}"
|
|
36
|
+
|
|
37
|
+
# In a real app, you would use a Redis Transaction (MULTI/EXEC) or Lua script here
|
|
38
|
+
current_state = await self.redis.get(state_key)
|
|
39
|
+
|
|
40
|
+
if current_state == "PAID":
|
|
41
|
+
# Cannot transition out of PAID via standard webhooks (maybe refund later)
|
|
42
|
+
print(f"Transaction {transaction_id} is already PAID. Ignoring {new_state}.")
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
await self.redis.set(state_key, new_state, ex=604800) # Keep state for 7 days
|
|
46
|
+
print(f"Transaction {transaction_id} transitioned to {new_state}")
|
|
47
|
+
return True
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ferrox-py-commerce"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Billing, Subscriptions, and Standardized Webhooks for Ferrox-Py."
|
|
9
|
+
authors = [{ name = "AI-Autistic-Intelligence" }]
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"ferrox-py>=1.0.0",
|
|
14
|
+
"pydantic>=2.0",
|
|
15
|
+
"stripe>=7.0.0"
|
|
16
|
+
]
|