Metadata-Version: 2.4
Name: merchants-sdk
Version: 2026.3.1
Summary: Payments for people who have better things to do.
License: MIT License
         
         Copyright (c) 2024 merchants contributors
         
         Permission is hereby granted, free of charge, to any person obtaining a copy
         of this software and associated documentation files (the "Software"), to deal
         in the Software without restriction, including without limitation the rights
         to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
         copies of the Software, and to permit persons to whom the Software is
         furnished to do so, subject to the following conditions:
         
         The above copyright notice and this permission notice shall be included in all
         copies or substantial portions of the Software.
         
         THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
         IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
         FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
         AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
         LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
         OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
         SOFTWARE.
License-File: LICENSE
Requires-Python: >=3.10
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Provides-Extra: cli
Provides-Extra: dev
Provides-Extra: flow
Provides-Extra: khipu
Requires-Dist: khipu-tools (>=2024.1) ; extra == "khipu"
Requires-Dist: mkdocs (>=1.6) ; extra == "dev"
Requires-Dist: mkdocs-material (>=9.5) ; extra == "dev"
Requires-Dist: mkdocstrings[python] (>=0.25) ; extra == "dev"
Requires-Dist: pre-commit ; extra == "dev"
Requires-Dist: pydantic (>=2.0)
Requires-Dist: pyflowcl (>=2024.1) ; extra == "flow"
Requires-Dist: pytest (>=7.4) ; extra == "dev"
Requires-Dist: pytest-cov ; extra == "dev"
Requires-Dist: requests (>=2.28)
Requires-Dist: responses (>=0.25) ; extra == "dev"
Requires-Dist: ruff ; extra == "dev"
Requires-Dist: typer (>=0.9) ; extra == "cli"
Requires-Dist: typer (>=0.9) ; extra == "dev"
Project-URL: Documentation, https://mariofix.github.io/merchants/
Project-URL: Homepage, https://github.com/mariofix/merchants/
Project-URL: Repository, https://github.com/mariofix/merchants/
Description-Content-Type: text/markdown

# merchants

Payments for people who have better things to do.

## Features

- **Hosted checkout only** – redirect users to a provider-hosted payment page; no card data ever touches your server.
- **Built-in providers** – Stripe, PayPal, [Flow.cl](https://www.flow.cl) (`pip install merchants-sdk[flow]` or `pip install pyflowcl`), [Khipu](https://khipu.com) (`pip install merchants-sdk[khipu]` or `pip install khipu-tools`), and a `DummyProvider` for local dev.
- **Pluggable transport** – default `requests.Session` backend; inject any `Transport` (e.g. httpx) for testing or custom HTTP clients.
- **Flexible auth** – API-key header auth and token (Bearer) auth strategies.

## Installation

```bash
pip install merchants-sdk              # core (Stripe, PayPal, Flow, Khipu)
```

## Quick Start

```python
import merchants
from merchants.providers.stripe import StripeProvider

# 1. Create a provider
stripe = StripeProvider(api_key="sk_test_…")

# 2. Create a client (accepts provider instance or registered key string)
client = merchants.Client(provider=stripe)

# 3. Create a hosted checkout session – raises UserError on failure
try:
    session = client.payments.create_checkout(
        amount="19.99",
        currency="USD",
        success_url="https://example.com/success",
        cancel_url="https://example.com/cancel",
        metadata={"order_id": "ord_123"},
    )
    print(session.redirect_url)  # redirect your user here
except merchants.UserError as e:
    print("Payment error:", e)
```

## Providers

| Provider | Key | Install extra | Notes |
|---|---|---|---|
| `StripeProvider` | `"stripe"` | – | Minor-unit amounts (cents) |
| `PayPalProvider` | `"paypal"` | – | Decimal-string amounts |
| `FlowProvider` | `"flow"` | `merchants[flow]` | Flow.cl (Chile) via `pyflowcl` |
| `KhipuProvider` | `"khipu"` | `merchants[khipu]` | Khipu (Chile) via `khipu-tools` |
| `GenericProvider` | `"generic"` | – | Configurable REST endpoints |
| `DummyProvider` | `"dummy"` | – | Random data, no API calls |

```python
# Stripe
from merchants.providers.stripe import StripeProvider
client = Client(provider=StripeProvider(api_key="sk_test_…"))

# PayPal
from merchants.providers.paypal import PayPalProvider
client = Client(provider=PayPalProvider(access_token="token_…"))

# Flow.cl  (pip install merchants-sdk[flow])
from merchants.providers.flow import FlowProvider
client = Client(provider=FlowProvider(api_key="…", api_secret="…"))

# Khipu  (pip install merchants-sdk[khipu])
from merchants.providers.khipu import KhipuProvider
client = Client(provider=KhipuProvider(api_key="…"))

# Dummy – no credentials, random data for local dev
from merchants.providers.dummy import DummyProvider
client = Client(provider=DummyProvider())
```

## Provider Selection

### By instance

```python
from merchants import Client
from merchants.providers.paypal import PayPalProvider

client = Client(provider=PayPalProvider(access_token="token_…"))
```

### By string key (registry)

```python
from merchants import Client, register_provider
from merchants.providers.stripe import StripeProvider

# Register once at startup
register_provider(StripeProvider(api_key="sk_test_…"))

# Later, select by key
client = Client(provider="stripe")
```

### List registered providers

```python
from merchants import list_providers

print(list_providers())   # ['stripe', 'paypal', ...]
```

### Custom provider

See `examples/03_custom_provider.py` for a full example.

```python
from merchants.providers import Provider, UserError
from merchants.models import CheckoutSession, PaymentStatus, PaymentState, WebhookEvent

class MyProvider(Provider):
    key = "my_gateway"
    name = "My Gateway"
    author = "acme"
    version = "1.0.0"
    description = "Custom in-house payment gateway"
    url = "https://my-gateway.example.com"

    def create_checkout(self, amount, currency, success_url, cancel_url, metadata=None):
        # Call your gateway here; raise UserError on failure
        return CheckoutSession(
            session_id="sess_1",
            redirect_url="https://pay.my-gateway.com/sess_1",
            provider=self.key,
            amount=amount,
            currency=currency,
        )

    def get_payment(self, payment_id):
        return PaymentStatus(payment_id=payment_id, state=PaymentState.PENDING, provider=self.key)

    def parse_webhook(self, payload, headers):
        from merchants.webhooks import parse_event
        return parse_event(payload, provider=self.key)
```

## Provider Metadata

Every provider exposes structured metadata through the `ProviderInfo` Pydantic model.
Downstream applications can inspect the registry, serialise it to JSON, or drive
routing logic without knowing provider implementation details.

### Required fields for new providers

| Field | Type | Description |
|---|---|---|
| `key` | `str` | Short machine-readable identifier (e.g. `"stripe"`) |
| `name` | `str` | Human-readable name (e.g. `"Stripe"`) |
| `author` | `str` | Author/maintainer of the integration |
| `version` | `str` | Version string for this integration |
| `description` | `str` | Short description *(optional, defaults to `""`)* |
| `url` | `str` | Homepage or docs URL *(optional, defaults to `""`)* |

### Inspecting a single provider

```python
from merchants.providers.dummy import DummyProvider
import merchants

provider = DummyProvider()
info = provider.get_info()   # returns a ProviderInfo pydantic model

print(info.key)          # "dummy"
print(info.name)         # "Dummy"
print(info.author)       # "merchants team"
print(info.model_dump()) # {'key': 'dummy', 'name': 'Dummy', ...}
print(info.model_dump_json(indent=2))  # JSON string
```

### Inspecting all registered providers

```python
from merchants import register_provider, describe_providers
from merchants.providers.dummy import DummyProvider
from merchants.providers.stripe import StripeProvider

register_provider(DummyProvider())
register_provider(StripeProvider(api_key="sk_test_…"))

for info in describe_providers():
    print(f"{info.key}: {info.name} v{info.version}")
# dummy: Dummy v1.0.0
# stripe: Stripe v1.0.0

# Serialise the entire registry to JSON
import json
print(json.dumps([i.model_dump() for i in describe_providers()], indent=2))
```

## Checkout Creation

```python
try:
    session = client.payments.create_checkout(
        amount="99.00",
        currency="EUR",
        success_url="https://shop.example.com/thank-you",
        cancel_url="https://shop.example.com/cart",
    )
    return redirect(session.redirect_url)
except merchants.UserError as e:
    return f"Payment setup failed: {e}", 400
```

## Payment Status

```python
status = client.payments.get("pi_3LHpu2…")

print(status.state)        # e.g. PaymentState.SUCCEEDED
print(status.is_final)     # True once payment is terminal
print(status.is_success)   # True only when SUCCEEDED
```

## Webhook Verification & Parsing

```python
import merchants

# 1. Verify signature (constant-time HMAC-SHA256)
try:
    merchants.verify_signature(
        payload=request.body,          # raw bytes
        secret="whsec_…",
        signature=request.headers["Stripe-Signature"],
    )
except merchants.WebhookVerificationError:
    return 400  # reject

# 2. Parse and normalise the event
event = merchants.parse_event(request.body, provider="stripe")

print(event.event_type)  # e.g. "payment_intent.succeeded"
print(event.state)       # e.g. PaymentState.SUCCEEDED
print(event.payment_id)  # e.g. "pi_3LHpu2…"
```

## Amount Format Notes

| Helper | Example | Use case |
|---|---|---|
| `to_decimal_string("19.99")` | `"19.99"` | PayPal, most REST APIs |
| `to_minor_units("19.99")` | `1999` | Stripe (cents/pence) |
| `from_minor_units(1999)` | `Decimal("19.99")` | Converting Stripe amounts back |

```python
from merchants import to_decimal_string, to_minor_units, from_minor_units
from decimal import Decimal

to_decimal_string(Decimal("9.5"))   # "9.50"
to_minor_units("19.99")             # 1999
to_minor_units("1000", decimals=0)  # 1000  (JPY, no cents)
from_minor_units(1999)              # Decimal("19.99")
```

## Auth Strategies

```python
from merchants import Client, ApiKeyAuth, TokenAuth
from merchants.providers.generic import GenericProvider

# API key header
client = Client(
    provider=GenericProvider("https://api.example.com/checkout", "https://api.example.com/payments/{payment_id}"),
    auth=ApiKeyAuth("my-key", header="X-API-Key"),
)

# Bearer token
client = Client(
    provider=...,
    auth=TokenAuth("my-token"),   # Authorization: Bearer my-token
)
```

## Custom Transport

```python
from merchants import Client, RequestsTransport

# Inject a pre-configured requests.Session (e.g. with retries)
import requests
from requests.adapters import HTTPAdapter, Retry

session = requests.Session()
retry = Retry(total=3, backoff_factor=0.5)
session.mount("https://", HTTPAdapter(max_retries=retry))

client = Client(
    provider="stripe",
    transport=RequestsTransport(session=session),
)
```

## Low-level Escape Hatch

```python
response = client.request("GET", "https://api.stripe.com/v1/balance")
print(response.status_code, response.body)
```

## Examples

The `examples/` directory contains runnable scripts:

| File | Description |
|---|---|
| `01_simple_client.py` | Basic client setup with DummyProvider and Stripe |
| `02_custom_httpx_transport.py` | Custom httpx-backed transport |
| `03_custom_provider.py` | Building your own provider |

