tranchepay
Compose payment modes around the Razorpay client 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.
- Composition only. tranchepay never subclasses, monkey-patches, or forks
razorpay.Client. You build and configure the client; tranchepay calls its publicorder,payment, andutilityresources. - Integer paise everywhere. Amounts are
intpaise and fees aredecimal.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
Not on PyPI yet. The PyPI release is still pending, so
pip install tranchepaydoes not work today. Clone the repository and install from source.
git clone https://github.com/nonesubham/tranchepay.git
cd tranchepay
python -m venv .venv && source .venv/bin/activate
pip install -e .
Or install straight from GitHub without cloning:
pip install "tranchepay @ git+https://github.com/nonesubham/tranchepay.git"
Once the PyPI release ships, pip install tranchepay will be the supported path.
Requires Python 3.10+ and razorpay>=1.4 (installed automatically). Add the dev
extras for tests and type checks: pip install -e ".[dev]".
Quickstart
from decimal import Decimal
import razorpay
from tranchepay import ChargesConfig, PaymentComposer, PaymentMode, SplitConfig
client = razorpay.Client(auth=("rzp_test_xxxxxxxx", "your_key_secret"))
composer = PaymentComposer(
client,
charges=ChargesConfig(fee_rate=Decimal("0.0236")), # 2.36% - your rate, as a Decimal
split=SplitConfig(), # tranche ceiling, default 199_900 paise
)
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 with Razorpay's own
client.utility.verify_payment_signature, 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(client, 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)
│
razorpay.Client (your instance, used as-is, never wrapped)
├── order.create / order.fetch
├── payment.fetch / payment.capture / payment.refund
└── utility.verify_payment_signature / verify_webhook_signature
│
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 |
Order payloads 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 |
Structural typing for the Razorpay client |
Money rules
-
Every amount is an
intnumber of paise. Passing a float raisesTypeErrorinstead of quietly rounding. -
fee_rateis aDecimalbetween0and1(exclusive). Floats are rejected. -
gross_up(net, fee_rate, rounding)computesround(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_CEILINGNever 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_EVENSame, with the tie broken toward zero/even ROUND_DOWN,ROUND_FLOORCustomer never overcharged; merchant absorbs up to one paisa Whatever the policy,
OrderResultalways 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 puredivmod: 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 andplan.requires_session is False; in that casecreate_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 (orNonewhen 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
updatea compare-and-swap (RedisWATCH/MULTIor a Lua script, SQLSELECT ... 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_refundrefunds onlyPAIDtranches and persists each one asREFUNDEDbefore touching the next, so no tranche is ever refunded twice - even when an earlier attempt failed halfway and raisedPartialPaymentError.
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 |
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.
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_CHARGESmode 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
asyncioframeworks. - Pluggable order-status strategies for
resumebeyondcreated/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.
Release files for tranchepay 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| tranchepay-0.1.0.tar.gz | 52.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tranchepay-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 88.4 kB
Release files / tranchepay-0.1.0.tar.gz
| Download URL | tranchepay-0.1.0.tar.gz |
|---|---|
| Size | 52.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4d37653926df26465dfa3955a12eb8e09e9af942b68a3b5a88ea702ebbe0cda3
|
|
BLAKE2b-256 checksum How to use checksums |
d041172a94cdfbf30ffc4e6050498a941da56f4a4a8c6894216647ca20678b0f
|
| 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 Sep 25, 2026.
Transparency logRelease files / tranchepay-0.1.0-py3-none-any.whl
| Download URL | tranchepay-0.1.0-py3-none-any.whl |
|---|---|
| Size | 36.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6edfd075ffafefaa60969d9f59f81d53780279641999da5cd471c30e61106dad
|
|
BLAKE2b-256 checksum How to use checksums |
17aafb6d78d1fe4fdfb7dc92c505b5f0434ca03841cdcc27c646a42b2973a73b
|
| 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 Sep 25, 2026.
Transparency log