Metadata-Version: 2.4
Name: proxy-checkout-stripe
Version: 0.1.0a1
Summary: Stripe SUB-SI server adapter for Proxy Checkout Python integrations.
Project-URL: Documentation, https://docs.proxycheckout.com/guides/stripe-direct-subscriptions-python/
Project-URL: Homepage, https://proxycheckout.com
Author: Linus Labs, Inc.
Maintainer: Linus Labs, Inc.
License-Expression: MIT
License-File: LICENSE
Keywords: django,fastapi,proxy-checkout,stripe,subscriptions
Classifier: Development Status :: 3 - Alpha
Classifier: License :: OSI Approved :: MIT 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
Classifier: Typing :: Typed
Requires-Python: >=3.10
Provides-Extra: stripe
Requires-Dist: stripe==8.8.0; extra == 'stripe'
Description-Content-Type: text/markdown

# proxy-checkout-stripe

Focused Python server adapter for Proxy Checkout's Stripe `SUB-SI` path
(`subscription.direct_setup_intent`). It ports the acquisition protocol behind
`@proxy-checkout/stripe-server-js` `openSetupIntent` and the SetupIntent branch of
`openSubscription`; it does not port Checkout, PaymentIntent, or saved-PaymentMethod paths.

The `0.1.0a1` package is synchronous and works in conventional Python request/service code. It
uses an injected
[`stripe.StripeClient`](https://github.com/stripe/stripe-python/tree/v8.8.0), keeps Stripe writes in
the merchant process, and has no required runtime dependencies. Proxy supplies the acquisition
reservation, contract metadata, and deterministic Stripe idempotency keys. A returned SetupIntent
or Subscription is never evidence that Proxy has granted or provisioned access.

## Install

Install the public alpha with either package manager:

```bash
python -m pip install "proxy-checkout-stripe[stripe]==0.1.0a1"
uv add "proxy-checkout-stripe[stripe]==0.1.0a1"
```

The `stripe` extra pins the currently tested `stripe==8.8.0` release. That release defaults to
request API `2023-10-16`; construct the client with the version explicitly and select the legacy
initial-Invoice shape below. The SDK accepts a structurally compatible injected client when the
extra is installed by the host application instead.

## Compatibility and support policy

| Surface | Validated support | Notes |
| --- | --- | --- |
| Python | 3.10 through 3.14 | CI runs the full package test suite on every listed interpreter. |
| Stripe Python | 8.8.0 | The optional `stripe` extra installs the currently tested version. |
| Stripe request API | 2023-10-16 | Use `legacy_payment_intent` for the initial-Invoice action shape. |
| Newer Stripe clients | Not yet supported | `confirmation_secret` is tested, but newer Stripe Python versions need their own compatibility tests. |
| Synchronous web frameworks | Direct request/service use | The worked example uses Django, but the SDK is framework-independent. |
| Async web frameworks | Complete-call worker-thread offload | Native async orchestration is not implemented in this alpha. |

`StripeCompatibility` describes the merchant's configured client; it does not mutate or detect the
Stripe version. Omission remains available for dependency-injected test clients, matching the
TypeScript contract, but production integrations should always supply both values. A reported
version below the supported floor fails before provider I/O. Per-request `stripe_version`
overrides are rejected: configure the API version once on `StripeClient` so every request uses the
response shape this SDK was tested against.

The public `AsyncProxyClientLike` and `AsyncStripeClientLike` protocols reserve the native-async
transport contract. Future native entry points will be named `aopen_setup_intent` and
`aopen_subscription`. They are intentionally absent until a tested Stripe Python version with native
`*_async` service methods is tested; this package does not disguise worker-thread offload as native
async I/O.

## Django endpoint example

Build both Stripe requests from the immutable Proxy cart. The same builders run during preparation
and Subscription creation so their canonical fingerprint remains identical across retries and
concurrent callers.

```python
import stripe
from django.conf import settings
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from billing.models import CheckoutOrder  # Replace with your application's order model.
from proxy_checkout_stripe import (
    ActionRequired,
    AlreadyPaid,
    ProxyClient,
    ReadySetupIntent,
    ReadySubscription,
    StripeCompatibility,
    Unavailable,
    open_setup_intent,
    open_subscription,
)

proxy = ProxyClient(settings.PROXY_SECRET_KEY, timeout=15.0)
stripe_client = stripe.StripeClient(
    settings.STRIPE_SECRET_KEY,
    stripe_version="2023-10-16",
    max_network_retries=2,
    http_client=stripe.http_client.RequestsClient(timeout=20),
)
compatibility = StripeCompatibility(
    api_version="2023-10-16",
    server_sdk_version="8.8.0",
)


def build_setup_intent_params(_context):
    return {"payment_method_types": ["card"], "usage": "off_session"}


def build_subscription_params(context):
    params: dict[str, object] = {
        "items": [
            {"price": item["stripe_price_id"], "quantity": item["quantity"]}
            for item in context.cart["items"]
        ],
    }
    trial_days = context.cart.get("trial_days")
    if trial_days is not None:
        params["trial_period_days"] = trial_days
    return params


def prepare_payment_method(request, proxy_session_id):
    # Authorize the untrusted route identifier before any Proxy reservation or Stripe mutation.
    order = get_object_or_404(
        CheckoutOrder,
        proxy_session_id=proxy_session_id,
        user=request.user,
    )
    result = open_setup_intent(
        proxy=proxy,
        stripe=stripe_client,
        proxy_session_id=proxy_session_id,
        customer_id=request.user.account.stripe_customer_id,
        build_setup_intent_params=build_setup_intent_params,
        build_subscription_params=build_subscription_params,
        initial_invoice_action_shape="legacy_payment_intent",
        compatibility=compatibility,
    )
    if isinstance(result, ReadySetupIntent):
        order.proxy_acquisition_attempt_id = result.acquisition_attempt_id
        order.stripe_setup_intent_id = result.setup_intent_id
        order.save(
            update_fields=["proxy_acquisition_attempt_id", "stripe_setup_intent_id"]
        )
        # Send presentation material only to this authorized payer. Never log the secret.
        return JsonResponse(
            {
                "setup_intent_client_secret": result.client_secret,
            }
        )
    return render_proxy_outcome(result)


def create_subscription(request, proxy_session_id):
    # Call after the browser confirms the Payment Element SetupIntent. The SDK retrieves and
    # revalidates its status, Proxy metadata, Customer, and attached PaymentMethod server-side.
    order = get_object_or_404(
        CheckoutOrder,
        proxy_session_id=proxy_session_id,
        user=request.user,
    )
    result = open_subscription(
        proxy=proxy,
        stripe=stripe_client,
        proxy_session_id=proxy_session_id,
        acquisition_attempt_id=order.proxy_acquisition_attempt_id,
        setup_intent_id=order.stripe_setup_intent_id,
        build_setup_intent_params=build_setup_intent_params,
        build_subscription_params=build_subscription_params,
        initial_invoice_action_shape="legacy_payment_intent",
        compatibility=compatibility,
    )
    if isinstance(result, ReadySubscription):
        action = result.initial_invoice_action
        if action.kind == "client_secret":
            # Confirm this positive initial Invoice with Stripe.js before waiting for webhooks.
            return JsonResponse({"invoice_client_secret": action.client_secret})
        # A trial or other exact-zero initial Invoice normally reaches this branch.
        # Render pending/current state; do not fulfill from this response.
        return JsonResponse({"state": "pending_proxy_lifecycle"}, status=202)
    return render_proxy_outcome(result)


def render_proxy_outcome(result):
    if isinstance(result, AlreadyPaid):
        return JsonResponse({"state": "already_paid"})
    if isinstance(result, ActionRequired):
        return JsonResponse({"state": "merchant_action_required"}, status=409)
    if isinstance(result, Unavailable):
        return JsonResponse({"state": "unavailable"}, status=409)
    raise AssertionError("Unhandled Proxy outcome")
```

In the browser, confirm `setup_intent_client_secret` with the existing Stripe Payment Element.
After `stripe.confirmSetup` succeeds, call the Subscription endpoint. Keep the Proxy acquisition ID
and SetupIntent ID server-associated with the authenticated order. The SDK requires them to match
both Stripe metadata and the current Proxy reservation.

Fulfillment waits for Proxy's signed merchant lifecycle notification and current-state resolver.
SetupIntent success only means the payment method is prepared. A Subscription response, including
a trial or zero initial Invoice, also does not grant access by itself.

For a newer pinned Stripe API that returns `latest_invoice.confirmation_secret`, pass
`initial_invoice_action_shape="confirmation_secret"`. The choice is part of the acquisition
fingerprint and expansion contract; the SDK does not retry a request under a different API shape.
An exact Proxy `acquisition_provider_options_conflict` is returned to the caller; do not mutate the
builders to work around it.

## FastAPI and other ASGI applications

Stripe Python 8.8.0 and this alpha's Proxy client perform blocking network I/O. A normal FastAPI
`def` route is safe because FastAPI runs it in its worker thread pool:

```python
from fastapi.responses import JSONResponse
from proxy_checkout_stripe import (
    ActionRequired,
    AlreadyPaid,
    ReadySubscription,
    Unavailable,
)


def render_fastapi_subscription_outcome(result):
    if isinstance(result, ReadySubscription):
        action = result.initial_invoice_action
        if action.kind == "client_secret":
            return {"invoice_client_secret": action.client_secret}
        return JSONResponse({"state": "pending_proxy_lifecycle"}, status_code=202)
    if isinstance(result, AlreadyPaid):
        return {"state": "already_paid"}
    if isinstance(result, ActionRequired):
        return JSONResponse({"state": "merchant_action_required"}, status_code=409)
    if isinstance(result, Unavailable):
        return JSONResponse({"state": "unavailable"}, status_code=409)
    raise AssertionError("Unhandled Proxy outcome")


@app.post("/proxy-sessions/{proxy_session_id}/subscription")
def create_subscription(proxy_session_id: str):
    order = load_order(proxy_session_id)
    return render_fastapi_subscription_outcome(
        open_subscription(
            proxy=proxy,
            stripe=stripe_client,
            proxy_session_id=proxy_session_id,
            acquisition_attempt_id=order.acquisition_attempt_id,
            setup_intent_id=order.setup_intent_id,
            build_setup_intent_params=build_setup_intent_params,
            build_subscription_params=build_subscription_params,
            initial_invoice_action_shape="legacy_payment_intent",
            compatibility=compatibility,
        )
    )
```

An `async def` route must offload the complete synchronous orchestration explicitly. Calling
`open_subscription` directly would block the event loop:

```python
from functools import partial

from starlette.concurrency import run_in_threadpool


@app.post("/proxy-sessions/{proxy_session_id}/subscription")
async def create_subscription_async(proxy_session_id: str):
    order = await load_order_async(proxy_session_id)
    result = await run_in_threadpool(
        partial(
            open_subscription,
            proxy=proxy,
            stripe=stripe_client,
            proxy_session_id=proxy_session_id,
            acquisition_attempt_id=order.acquisition_attempt_id,
            setup_intent_id=order.setup_intent_id,
            build_setup_intent_params=build_setup_intent_params,
            build_subscription_params=build_subscription_params,
            initial_invoice_action_shape="legacy_payment_intent",
            compatibility=compatibility,
        )
    )
    return render_fastapi_subscription_outcome(result)
```

Thread offload is appropriate for bounded pilot traffic. It occupies one worker for several
sequential Proxy and Stripe calls, and cancelling the awaiting coroutine does not stop an already
running blocking request. Use capacity limits and request deadlines appropriate to the host.

## Timeouts, retries, and safe diagnostics

`ProxyClient(timeout=30.0)` applies a positive finite timeout to each Proxy HTTP request, not to the
whole orchestration. For Stripe 8.8, configure the injected client's request timeout with
`stripe.http_client.RequestsClient(timeout=...)` and its retries with `max_network_retries` on the
injected client. Proxy supplies deterministic Stripe idempotency keys; after an ambiguous
create error the SDK reconciles Proxy state and can issue one identical create retry inside the
persisted replay window. Stripe's internal network retries therefore multiply the total latency
budget even though they cannot create a second physical object under the same key.

`ProxyApiError` retains Proxy's request ID and status. `ProxyStripeAcquisitionError` retains the
caller request ID, bounded acquisition/provider identifiers, operation name, and any Stripe request
IDs found on underlying exceptions. `diagnostic_context()` returns only those bounded fields for
structured logs. Do not log exception causes, raw Stripe objects, API keys, or client secrets.

## Supported and rejected behavior

The package supports fixed licensed Prices, fixed quantities, optional positive trials,
and stable fixed discounts with an explicit `validate_fixed_price` attestation. It rejects metered
billing, transformed Price quantities, `send_invoice`, automatic tax, Connect routing, schedules,
provider-finalized pricing, arbitrary proration or pending-invoice behavior, and payer-selected
promotion codes.

The implemented Python API uses synchronous snake_case functions and frozen dataclass outcomes in
place of Promise-returning option objects. Synchronous execution is an operational limitation for
async frameworks, not merely a naming difference. This focused package omits the TypeScript SDK's
saved-PaymentMethod branch and custom zod-style schema objects; pass a Python callable as
`cart_validator` instead. Acquisition, metadata, fingerprint, idempotency, validation, retry,
attachment, Invoice-action, and terminal-outcome behavior remains aligned with the TypeScript
implementation.

An attached-Subscription replay never creates a replacement merely because its PaymentMethod was
later detached. The SDK still verifies the Subscription's recorded Customer and PaymentMethod. If
Stripe's mutable `latest_invoice` pointer has advanced to a renewal, the response reports no
initial-Invoice action; renewal action and fulfillment remain owned by Proxy lifecycle state.

One deliberate replay refinement differs from the current TypeScript SDK: Python treats Price
activeness as a creation-only check, so an exact matching Subscription already attached in Proxy
can still be retrieved after its Price is archived. New acquisitions still reject an inactive
Price before reservation. Applying the same rule to TypeScript without weakening its additional
saved-PaymentMethod path requires a separate lifecycle refactor.

## Release artifacts and publication

Pull requests build, inspect, clean-install, and upload an installable wheel and source distribution
without registry credentials. Production releases require an explicit
`stripe-server-python-v<version>` tag at a commit on `main`. A credential-free job repeats the
complete checks and uploads the exact artifacts; a separate checkout-free job publishes only those
artifacts through PyPI Trusted Publishing and the protected `pypi-release` environment. No
long-lived PyPI token is used.

PyPI has no npm-style dist-tags and published versions cannot be overwritten, so pull-request
previews remain workflow artifacts rather than public development releases. A defective public
release is replaced by a new version and may be yanked; it is never republished in place. PyPI is
the registry, while `uv` remains the build, development, and one supported consumer installation
client.

## Development

```bash
uv sync --locked --all-extras
uv run ruff format --check src tests scripts typecheck
uv run ruff check src tests scripts typecheck
uv run mypy src
uv run pyright
uv run pytest
uv build --no-sources
uv run python scripts/verify_artifacts.py
```
