Metadata-Version: 2.4
Name: payplus-python
Version: 0.3.0
Summary: Python SDK for PayPlus payment gateway with subscription management for SaaS apps
Author-email: Two Solutions <dev@two-solutions.com>
License: MIT
Project-URL: Homepage, https://github.com/Two-Solutions/payplus-python
Project-URL: Documentation, https://github.com/Two-Solutions/payplus-python#readme
Project-URL: Repository, https://github.com/Two-Solutions/payplus-python
Project-URL: Issues, https://github.com/Two-Solutions/payplus-python/issues
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.25.0
Requires-Dist: email-validator>=2.0.0
Requires-Dist: pydantic>=2.0.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
Requires-Dist: black>=23.0.0; extra == "dev"
Requires-Dist: ruff>=0.1.0; extra == "dev"
Requires-Dist: mypy>=1.0.0; extra == "dev"
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.100.0; extra == "fastapi"
Provides-Extra: flask
Requires-Dist: flask>=2.0.0; extra == "flask"
Provides-Extra: mongodb
Requires-Dist: motor>=3.0.0; extra == "mongodb"
Requires-Dist: pymongo>=4.0.0; extra == "mongodb"
Dynamic: license-file

# PayPlus Python SDK (Unofficial)

> **Note:** This is an unofficial SDK and is not affiliated with or endorsed by PayPlus.

A Python SDK for [PayPlus](https://www.payplus.co.il/) payment gateway with built-in subscription management for SaaS applications.

[![PyPI version](https://badge.fury.io/py/payplus-python.svg)](https://badge.fury.io/py/payplus-python)
[![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

## Features

- Full PayPlus API coverage — payment pages, transactions, recurring payments, customers
- Subscription management — payment-link-based recurring billing for SaaS apps
- Database integration — MongoDB and in-memory storage backends
- Webhook handling — IPN/webhook integration with HMAC signature verification
- Async support — full async/await for modern Python apps
- Type safe — Pydantic models with full type hints

## Installation

```bash
pip install payplus-python
```

With optional dependencies:

```bash
pip install payplus-python[fastapi]    # FastAPI webhook integration
pip install payplus-python[mongodb]    # MongoDB storage
```

## Implementation Steps

A step-by-step guide covering the full subscription lifecycle in your app.

### Step 1: Initialize the SDK

```python
from decimal import Decimal
from payplus import PayPlus, SubscriptionManager
from payplus.models.subscription import BillingCycle
from payplus.subscriptions.storage import MongoDBStorage
from payplus.webhooks import WebhookHandler
from motor.motor_asyncio import AsyncIOMotorClient

client = PayPlus(
    api_key="your_api_key",
    secret_key="your_secret_key",
    sandbox=True,
)
mongo = AsyncIOMotorClient("mongodb://localhost:27017")
storage = MongoDBStorage(mongo.your_database)
manager = SubscriptionManager(client, storage)
webhook_handler = WebhookHandler(client)
```

### Step 2: Define your plans (run once on app setup)

```python
await manager.create_tier(
    tier_id="basic",
    name="Basic",
    price=Decimal("29"),
    billing_cycle=BillingCycle.MONTHLY,
    trial_days=7,
)

await manager.create_tier(
    tier_id="pro",
    name="Pro",
    price=Decimal("79"),
    billing_cycle=BillingCycle.MONTHLY,
    trial_days=14,
)
```

### Step 3: User signs up

```python
customer = await manager.create_customer(
    email="user@example.com",
    name="John Doe",
    phone="050-1234567",
)
# Save customer.id in your user record
```

### Step 4: User subscribes to a plan

```python
subscription = await manager.create_subscription(
    customer_id=customer.id,
    tier_id="pro",
    callback_url="https://yourapp.com/webhooks/payplus",
    payment_page_uid="your-payplus-payment-page-uid",  # required by PayPlus
    success_url="https://yourapp.com/subscription/success",
    failure_url="https://yourapp.com/subscription/failure",
)

# Redirect user to complete payment
redirect(subscription.payment_page_link)

# Save subscription.id in your user record
```

Behind the scenes this:

1. Creates the customer on PayPlus (`POST /Customers/Add`) if not already created
2. Generates a payment link with `charge_method=3` and `recurring_settings` derived from the tier
3. Saves the subscription locally with `status=INCOMPLETE`

The user fills in their card details on the PayPlus hosted page. You never touch card data.

### Step 5: Set up the webhook endpoint

```python
from fastapi import FastAPI, Request, HTTPException
from payplus.exceptions import WebhookSignatureError

app = FastAPI()

@app.post("/webhooks/payplus")
async def payplus_webhook(request: Request):
    payload = await request.body()
    signature = request.headers.get("hash")  # PayPlus signs the callback in the `hash` header
    try:
        event = await webhook_handler.handle_async(payload, signature)
        await manager.handle_webhook_event(event)
        return {"received": True}
    except WebhookSignatureError:
        raise HTTPException(status_code=400, detail="Invalid signature")
```

This single endpoint handles every subscription event automatically:

| Webhook event | What happens |
|---|---|
| First payment succeeds | `INCOMPLETE` -> `ACTIVE`, `recurring_uid` stored |
| Recurring charge succeeds | Billing period advanced, status stays `ACTIVE` |
| Recurring charge fails | Status -> `PAST_DUE` (-> `UNPAID` after 4 failures) |
| Recurring canceled | Status -> `CANCELED` |
| Cancel at period end flagged | After last charge, cancels on PayPlus and sets `CANCELED` |

### Step 6: Check access in your app

```python
sub = await manager.get_subscription(subscription_id)
if sub and sub.is_active:
    # User has access
    ...
```

### Step 7: User upgrades plan

```python
await manager.change_tier(subscription.id, new_tier_id="enterprise")
```

For an active subscription this creates a **new** PayPlus recurring order for the new tier (starting at the current period end, with no immediate charge) and cancels the old one. The card token is saved automatically from the first payment webhook.

### Step 8: User pauses subscription

```python
await manager.pause_subscription(subscription.id)

# Later, resume it
await manager.resume_subscription(subscription.id)
```

### Step 9: User cancels subscription

```python
# Cancel at end of billing period (user keeps access until then)
await manager.cancel_subscription(
    subscription.id,
    at_period_end=True,
    reason="Customer requested",
)

# Or cancel immediately
await manager.cancel_subscription(subscription.id, at_period_end=False)
```

### Step 10: React to lifecycle events (optional)

Register hooks to trigger your own business logic:

```python
manager.on("subscription.activated", lambda sub: send_welcome_email(sub))
manager.on("subscription.renewed", lambda sub: log_renewal(sub))
manager.on("subscription.payment_failed", lambda sub: send_dunning_email(sub))
manager.on("subscription.canceled", lambda sub: handle_offboarding(sub))
```

### Trials

If a tier has `trial_days` set, the subscription flow changes:

- `create_subscription()` sets `jump_payments` in `recurring_settings`, telling PayPlus to wait N days before the first charge
- The subscription starts as `INCOMPLETE` (waiting for the user to enter card details on the payment page)
- When the user completes the payment page, PayPlus validates the card but doesn't charge yet
- The webhook activates the subscription as `TRIALING` (since `trial_end` is in the future)
- After the trial period, PayPlus charges automatically and sends a `recurring.charged` webhook
- `is_active` returns `True` for both `ACTIVE` and `TRIALING` statuses

```python
# Tier with a 14-day trial
await manager.create_tier(
    tier_id="pro",
    name="Pro",
    price=Decimal("79"),
    trial_days=14,  # 14 free days before first charge
)

# After subscription is created and user completes payment page:
# sub.status == "trialing"
# sub.is_active == True
# sub.trial_end == ~14 days from now
```

### How it all fits together

```
User clicks "Subscribe to Pro"
        |
        v
create_subscription()
  - Creates customer on PayPlus
  - Generates payment link with recurring settings
  - Subscription status: INCOMPLETE
        |
        v
User redirected to PayPlus payment page
User enters card details and pays
        |
        v
PayPlus sends webhook to callback_url
        |
        v
handle_webhook_event()
  - Matches webhook to subscription via page_request_uid
  - Saves card token and recurring_uid
  - Sets status: ACTIVE (or TRIALING if trial_days > 0)
        |
        v
Every billing cycle, PayPlus charges automatically
  - recurring.charged  -> period advanced, still ACTIVE
  - recurring.failed   -> PAST_DUE (-> UNPAID after 4 failures)

Lifecycle actions (from your app):
  - change_tier()      -> new recurring order on PayPlus + cancels the old one
  - pause/resume       -> updates local status
  - cancel(at_period_end=True)  -> flags locally, cancels on PayPlus after last charge
  - cancel(at_period_end=False) -> cancels on PayPlus immediately, status: CANCELED
```

## Direct API Usage

You can also use the PayPlus API directly without the subscription manager:

### Payment Link

```python
result = client.payment_pages.generate_link(
    amount=100.00,
    currency="ILS",
    payment_page_uid="your-payplus-payment-page-uid",  # required
    description="One-time payment",
    customer_email="customer@example.com",
    success_url="https://yourapp.com/success",
    callback_url="https://yourapp.com/webhooks/payplus",
)
print(result["data"]["payment_page_link"])
```

### Payment Link with Recurring

```python
from payplus.api.payment_pages import build_recurring_settings

result = client.payment_pages.generate_link(
    amount=79.00,
    currency="ILS",
    payment_page_uid="your-payplus-payment-page-uid",  # required
    charge_method=3,  # Recurring
    customer_uid="payplus-customer-uid",
    callback_url="https://yourapp.com/webhooks/payplus",
    recurring_settings=build_recurring_settings(
        billing_cycle="monthly",
        trial_days=14,
        number_of_charges=0,  # Unlimited
    ),
)
```

### Create Customer

```python
result = client.customers.add(
    customer_name="John Doe",
    email="john@example.com",
    phone="050-1234567",
)
customer_uid = result["data"]["customer_uid"]
```

### Transactions

```python
# Charge a saved card token (needs terminal_uid + cashier_uid on the client)
result = client.transactions.charge(
    99.00,
    token="card_token",
    customer_uid="payplus-customer-uid",
    currency="ILS",
)

# Refund a transaction by its UID
client.transactions.refund(
    transaction_uid=result["data"]["transaction_uid"],
    amount=99.00,
)
```

### Recurring Payments

```python
# Create a recurring order (monthly) from a saved token
result = client.recurring.add(
    customer_uid="payplus-customer-uid",
    card_token="card_token",
    start_date="2026-02-01",
    items=[{"name": "Pro plan", "price": 49.00, "quantity": 1}],
    recurring_type=2,   # 0=Daily, 1=Weekly, 2=Monthly
    recurring_range=1,
)

# Cancel (deactivate) a recurring order
client.recurring.cancel(result["data"]["recurring_uid"])
```

## Storage Backends

### MongoDB

```python
from motor.motor_asyncio import AsyncIOMotorClient
from payplus.subscriptions.storage import MongoDBStorage

mongo = AsyncIOMotorClient("mongodb://localhost:27017")
storage = MongoDBStorage(mongo.your_database)
await storage.create_indexes()  # Run once
```

### In-Memory (development/testing)

```python
# Used automatically when no storage is provided
manager = SubscriptionManager(client)
```

## API Reference

### PayPlus Client

| Module | Methods |
|--------|---------|
| `client.customers` | `add()` |
| `client.payment_pages` | `generate_link()`, `get_status()` |
| `client.transactions` | `charge()`, `get()`, `refund()`, `list()` |
| `client.recurring` | `add()`, `update()`, `charge()`, `cancel()`, `get()`, `list()` |
| `client.payments` | `check_card()`, `tokenize()`, `get_token()` |

### Subscription Manager

| Method | Description |
|--------|-------------|
| `create_customer()` | Create a new customer |
| `get_customer()` | Get a customer by ID |
| `create_tier()` | Create a pricing tier |
| `get_tier()` | Get a tier by ID |
| `list_tiers()` | List all tiers |
| `create_subscription()` | Create subscription and generate payment link |
| `get_subscription()` | Get a subscription by ID |
| `change_tier()` | Upgrade/downgrade (new PayPlus recurring order + cancels old) |
| `pause_subscription()` | Pause a subscription |
| `resume_subscription()` | Resume a paused subscription |
| `cancel_subscription()` | Cancel immediately or at period end |
| `handle_webhook_event()` | Process webhook and update subscription state |

## Configuration

```bash
PAYPLUS_API_KEY=your_api_key
PAYPLUS_SECRET_KEY=your_secret_key
PAYPLUS_TERMINAL_UID=your_terminal_uid  # Optional
PAYPLUS_SANDBOX=true  # For testing
```

```python
# Sandbox (restapidev.payplus.co.il)
client = PayPlus(api_key="...", secret_key="...", sandbox=True)

# Production (restapi.payplus.co.il)
client = PayPlus(api_key="...", secret_key="...", sandbox=False)
```

## Error Handling

```python
from payplus.exceptions import (
    PayPlusError,
    PayPlusAPIError,
    PayPlusAuthError,
    SubscriptionError,
    WebhookSignatureError,
)

try:
    result = client.transactions.charge(100, token="...", customer_uid="...")
except PayPlusAuthError:
    print("Invalid API credentials")
except PayPlusAPIError as e:
    print(f"API error [{e.status_code}]: {e.message}")
except PayPlusError as e:
    print(f"General error: {e}")
```

## License

MIT License - see [LICENSE](LICENSE) for details.

## Links

- [PayPlus Documentation](https://docs.payplus.co.il/)
- [GitHub Repository](https://github.com/Two-Solutions/payplus-python)
- [Issue Tracker](https://github.com/Two-Solutions/payplus-python/issues)
