Skip to main content

tranchepay

Compose payment modes around the gateway you already own: charge the exact amount, gross the amount up so the customer covers the gateway fee, or collect one large amount as sequential tranches of at most ₹1,999. Razorpay, Paytm and PhonePe ship as adapters, and you can register your own.

CI PyPI Python License

  • Composition only. tranchepay never subclasses, monkey-patches, or forks a provider SDK. You build and configure the client, hand over an adapter, and tranchepay calls only its public surface.
  • One narrow interface. Everything above the adapters is written against the PaymentGateway protocol, so the composer, the split flow and the recovery code never name a provider. GatewayFactory picks the adapter by name, JDBC DriverManager style.
  • Integer paise everywhere. Amounts are int paise and fees are decimal.Decimal; a float is rejected rather than rounded.
  • Explicit rounding. Gross-up rounding is a documented config value, not an implementation detail.
  • Resumable splits. Sessions are plain pydantic models in a pluggable store, so a half-finished split survives a deploy.

Documentation

The full guide - initialization, all three modes, abort/resume, webhooks, the exception hierarchy, custom session stores, and compliance - lives in DOCUMENTATION.md. This README is the quick tour.

Install

pip install tranchepay

Requires Python 3.10+ and razorpay>=1.4 (installed automatically). The Paytm and PhonePe adapters need httpx; install the extra when you use them:

pip install "tranchepay[http]"

Working on tranchepay itself? Clone it and install an editable copy with the dev extras:

git clone https://github.com/nonesubham/tranchepay.git
cd tranchepay
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"

Quickstart

from decimal import Decimal

from tranchepay import ChargesConfig, GatewayFactory, PaymentComposer, PaymentMode, SplitConfig

# Ask the factory for an adapter; credentials come from your config, never hardcoded.
gateway = GatewayFactory.get_gateway(
    "razorpay",
    {"key_id": "rzp_test_xxxxxxxx", "key_secret": "your_key_secret"},
)
composer = PaymentComposer(
    gateway,
    charges=ChargesConfig(fee_rate=Decimal("0.0236")),  # 2.36% - your rate, as a Decimal
    split=SplitConfig(),  # tranche ceiling, default 199_900 paise
)

Already have a configured client? Wrap it directly or hand it to the factory: GatewayFactory.get_gateway("razorpay", {"client": client}). Paytm and PhonePe use {"merchant_id": ..., "merchant_key"/"salt_key": ...}.

1. EXACT — the merchant absorbs the charges

order = composer.create_order(50_000, mode=PaymentMode.EXACT)

order.order_id  # "order_XXXXXXXXXXXXXX"
order.amount_paise  # 50_000 - the customer is charged exactly this
order.raw  # untouched Razorpay response, ready for Checkout

2. WITH_CHARGES — the customer covers the gateway fee

order = composer.create_order(50_000, mode=PaymentMode.WITH_CHARGES)

order.amount_paise  # 51_209 - charged to the customer (round(50_000 / (1 - 0.0236)))
order.net_paise  # 50_000 - what the merchant keeps
order.fee_paise  #  1_209 - the fee the customer paid instead

3. SPLIT — sequential tranches, one customer at a time

first = composer.create_order(450_000, mode=PaymentMode.SPLIT)  # ₹4,500 -> ₹1,999 + ₹1,999 + ₹502
session_id = first.session_id
# Send first.raw to Razorpay Checkout.

# In your payment callback / webhook handler, after Checkout returns:
next_order = composer.verify_and_advance(
    session_id=session_id,
    order_id=payload["razorpay_order_id"],
    payment_id=payload["razorpay_payment_id"],
    signature=payload["razorpay_signature"],
)
# next_order is the next tranche's order to check out, or None when the split is complete.

verify_and_advance verifies the signature through the gateway's own verifier, fetches the payment, requires status == "captured" and an amount equal to the tranche, marks the tranche paid, and creates the next order. Each tranche is verified independently, and the whole call is idempotent.

Recovering a split

order = composer.resume(session_id)  # same order if still payable, fresh one if expired
session = composer.abort_and_refund(session_id)  # refund every captured tranche, then ABORTED
session.status  # SessionStatus.ABORTED

Webhooks

from tranchepay import VerificationError, verify_webhook

try:
    verify_webhook(gateway, raw_body, signature_header, webhook_secret)
except VerificationError:
    ...  # return 400

Inspecting a session

session = composer.store.get(session_id)

session.status  # PENDING | IN_PROGRESS | COMPLETE | ABORTED
session.tranches[0].status  # PENDING | PAID | FAILED | REFUNDED
session.tranches[0].payment_id
session.total_paid_paise()
session.model_dump_json()  # persist it wherever you like

Architecture

                         ┌───────────────────────────────────────┐
    your application ───▶│            PaymentComposer            │
                         │  EXACT │ WITH_CHARGES │    SPLIT     │
                         └────┬───────────┬───────────────┬──────┘
                              │           │               │
                money.gross_up│           │               │ split.plan_tranches
                              ▼           ▼               ▼
                         ┌───────────────────────────────────────────┐
                         │ orders: create order, wrap OrderResult    │
                         └───────────────────┬───────────────────────┘
                                             │
                         SplitFlow ──────────┤  (split_flow:     start, verify_and_advance)
                         SplitRecovery ──────┤  (split_recovery: abort_and_refund, resume)
                         verification ───────┘  (signature + captured-payment checks)
                                             │
       PaymentGateway (Protocol - the only surface the code above knows)
       ├── create_order / fetch_order / fetch_payment
       ├── verify_signature / verify_webhook_signature
       └── refund_payment
                                             │
              GatewayFactory ────────────────┤  (get_gateway, register_gateway)
              ├── RazorpayAdapter ───────────┤  (wraps your razorpay.Client)
              ├── PaytmAdapter ──────────────┤  (httpx; execute() left to you)
              └── PhonePeAdapter ────────────┘  (httpx; OAuth v2 + X-VERIFY)
                                             │
                         SessionStore ◀──────┘  (InMemorySessionStore, or your own)
Module Responsibility
models.py Pydantic v2 config, SplitSession/Tranche, OrderResult
money.py Exact paise arithmetic, gross_up, fee-rate validation
split.py Pure tranche planning (plan_tranches, build_session)
orders.py Provider-agnostic order creation and per-tranche bookkeeping
split_flow.py Collection state machine, verify_and_advance
split_recovery.py abort_and_refund, resume
store.py SessionStore protocol + InMemorySessionStore
verification.py Signature and captured-payment checks
session_locks.py Process-global per-session transition locks
protocol.py The PaymentGateway protocol every adapter implements
factory.py GatewayFactory: provider lookup and custom registration
gateways/ RazorpayAdapter, PaytmAdapter, PhonePeAdapter + shared HTTP plumbing

Money rules

  • Every amount is an int number of paise. Passing a float raises TypeError instead of quietly rounding.

  • fee_rate is a Decimal between 0 and 1 (exclusive). Floats are rejected.

  • gross_up(net, fee_rate, rounding) computes round(net / (1 - fee_rate)) as an exact rational and rounds once, at the end, using the configured policy:

    Policy Effect on the merchant's recovery
    ROUND_UP, ROUND_CEILING Never under-recovers; may net up to one paisa extra
    ROUND_HALF_UP (default) Closest paisa; may under-recover by less than half a paisa
    ROUND_HALF_DOWN, ROUND_HALF_EVEN Same, with the tie broken toward zero/even
    ROUND_DOWN, ROUND_FLOOR Customer never overcharged; merchant absorbs up to one paisa

    Whatever the policy, OrderResult always reports the gross charged, the net, and the implied fee, so nothing is hidden.

Splits, idempotency, and concurrency

  • plan_tranches(amount_paise, tranche_paise) is a pure divmod: n full tranches plus a remainder tranche, which is omitted when it is zero. An amount at or below the ceiling yields a single tranche and plan.requires_session is False; in that case create_order(mode=SPLIT) skips the session entirely and places one ordinary order.
  • Transitions are keyed on (session_id, tranche_index). Replaying a verification for an already-paid tranche verifies the signature again, changes nothing, and returns the same next order (or None when the session is already complete).
  • In one process, transitions for a session are serialised by a global per-session lock, so concurrent callbacks cannot advance the same tranche twice. Across processes, atomicity is the store adapter's job: make update a compare-and-swap (Redis WATCH/MULTI or a Lua script, SQL SELECT ... FOR UPDATE).
  • Only the current pending tranche can be advanced, and a replacement order is never created for an order that is already paid, so a replayed callback cannot cause a double charge.
  • abort_and_refund refunds only PAID tranches and persists each one as REFUNDED before touching the next, so no tranche is ever refunded twice - even when an earlier attempt failed halfway and raised PartialPaymentError.

Session stores

InMemorySessionStore (the default) is thread-safe and copy-on-access, but it is process-local: sessions vanish on restart and are invisible to other workers. For production, pass a durable adapter implementing three methods:

class SessionStore(Protocol):
    def save(self, session: SplitSession) -> None: ...
    def get(self, session_id: str) -> SplitSession | None: ...
    def update(self, session: SplitSession) -> None: ...

SplitSession is a pydantic model, so an adapter is usually session.model_dump_json() and SplitSession.model_validate_json(...). get should return a copy, and update should be atomic. Any object with these three methods works - tranchepay validates it structurally and never imports your code.

Exceptions

All errors derive from PaymentComposeError.

Exception Raised when
VerificationError Signature rejected, payment not captured, or payment unfetchable
AmountMismatchError Captured amount is not the tranche amount (subclass of VerificationError)
SessionNotFoundError The session id is unknown to the store
SessionStateError Order not in the session, wrong tranche, or session already closed
PartialPaymentError A split could not complete or unwind; call again to finish
UnsupportedGatewayError GatewayFactory.get_gateway was asked for an unknown provider
GatewayConfigurationError An adapter could not be built: missing credential or missing optional dependency

Development

python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest --cov=tranchepay
ruff check . && ruff format --check .
mypy

Publishing

Before any release, run the pre-publish gate. It builds the sdist and wheel in an isolated environment, verifies the metadata and archive contents, runs twine check --strict and validate-pyproject, then installs the wheel into a throwaway venv and imports it:

./scripts/pre_publish_check.sh                 # verify only
./scripts/pre_publish_check.sh --upload-testpypi  # verify, then upload to TestPyPI

It prints the exact twine commands to dry-run against TestPyPI before you publish to the real index.

Releases then go out from a version tag: pushing v*.*.* runs the checks and the build → publish (PyPI Trusted Publishing / OIDC) → github-release jobs in .github/workflows/ci.yml. A merge to main never publishes on its own.

The default suite makes zero network calls and never touches a real Razorpay account: tests/fakes.py implements the client's resources in-process, including real HMAC-SHA256 signature verification, and tests/test_composer_modes.py asserts that the official razorpay.Client satisfies the protocol tranchepay expects.

Live sandbox integration tests (opt-in)

tests/test_integration_razorpay.py creates real orders in a Razorpay test account, proving the payloads tranchepay builds are accepted by the actual SDK and that what it reports matches what Razorpay stored. It is skipped automatically when credentials are absent, so pytest on a machine without keys stays green and offline; CI runs pytest -m "not integration" so it never needs secrets.

cp .env.example .env   # then fill in your rzp_test_ keys
./run_integration.sh   # pytest tests/test_integration_razorpay.py -v -m integration

Live: order.create, order.fetch, and the SDK's webhook HMAC. Mocked: payment.fetch and verify_payment_signature, because a payment only exists once a human completes checkout in a browser. Keys are read from the environment (never hardcoded) and .env is gitignored.

COMPLIANCE

Not legal advice. Read this before you ship.

  • Why the default tranche ceiling is ₹1,999. NPCI's MDR rules for merchant UPI transactions apply above ₹2,000, so splitting a large collection into tranches of at most ₹1,999 keeps each tranche at or below that threshold. That threshold, and the rules around it, are set by NPCI, RBI, and the networks, are subject to change, and differ by instrument, merchant category, and date. tranchepay ships ₹1,999 as a default constant only: it does not track regulation, and you must confirm the current limit yourself. Configure SplitConfig(tranche_paise=...) to whatever your compliance team requires.
  • Merchants are responsible for their own compliance. Using this library does not make a transaction compliant. You are responsible for the correctness of your fee rate, for how you charge customers, for MDR treatment, for GST and invoicing, for Razorpay's own terms, and for any reporting obligation that follows. tranchepay does not provide a fee rate, does not know your pricing, and does not calculate tax.
  • Surcharging is prohibited on UPI and debit. Charging customers an extra amount to cover your payment costs is not permitted on UPI and debit card transactions. The WITH_CHARGES mode exists for instruments and jurisdictions where recovering a gateway fee is lawful; do not use it to surcharge UPI or debit transactions, and confirm before use that it is permitted for the instrument you are collecting on.
  • No warranty. tranchepay is MIT-licensed and provided "as is", without warranty of any kind. See LICENSE. This project is not affiliated with or endorsed by Razorpay.

Future ideas

Deliberately out of scope for now, kept here so they are not lost:

  • A Redis store adapter (and a SQLAlchemy one) shipped in-tree rather than documented.
  • Per-tranche receipts or notes so every tranche order is trivially reconcilable.
  • Async variants of the composer, flow, and store for asyncio frameworks.
  • Pluggable order-status strategies for resume beyond created/attempted.
  • Partial-capture support on the final tranche for amounts Razorpay rejects.
  • A PaymentComposer.fee_summary() helper that reports realised fees per session.

License

MIT - see LICENSE.

Metadata

Release files for tranchepay 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tranchepay 0.2.0
File Size Uploaded
tranchepay-0.2.0.tar.gz 71.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for tranchepay 0.2.0
File Interpreter ABI Platform
tranchepay-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 127.6 kB

Release files / tranchepay-0.2.0.tar.gz

Download URL tranchepay-0.2.0.tar.gz
Size 71.8 kB
Tags Source
SHA-256 checksum
How to use checksums
90b4b25e29c8a00001cda7a08990611002acc48ac7b6396b7a92e143fe81b599
BLAKE2b-256 checksum
How to use checksums
8f3dd79d0702f4b24a6b8b538cd8cf48c441383c23391a64f14eb13c8d4590ad
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.

Transparency log

Release files / tranchepay-0.2.0-py3-none-any.whl

Download URL tranchepay-0.2.0-py3-none-any.whl
Size 55.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9bdd679b4eac7afdc31f9c87989e3ef2086b8882abd885337d25f88de3328e5b
BLAKE2b-256 checksum
How to use checksums
b546b661772ada87894b874afbe6c3a7fe77c19e58af550774d39f6805834dd8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page