A production-oriented Python SDK for common EPay-style payment gateways.
The project focuses on the full payment lifecycle rather than only assembling order parameters.
- MD5 signing and signature verification
- Create-order URL generation and raw gateway submission
- Order query requests and response parsing
- Callback signature verification
- Optional query reconciliation after callbacks
- Atomic, idempotent repository protocol
- Optional SQLAlchemy repository implementation
- Decimal-based amount normalization
- Library-friendly logging defaults
- Django integration template that keeps framework code outside the core package
Core package:
uv add epay-sdkWith the optional SQLAlchemy integration:
uv add 'epay-sdk[sqlalchemy]'With the Django example dependencies:
uv add 'epay-sdk[django]'For local development in this repository:
uv sync --extra devIf you are not using uv, the package is also available on PyPI:
pip install epay-sdkfrom epay_sdk import EPayClient, EPayConfig, PayType, PaymentService
client = EPayClient(
EPayConfig(
pid="1001",
key="your_secret_key",
base_url="https://your-epay.com",
environment="production",
)
)
service = PaymentService(client)
pay_url = service.create_order(
pay_type=PayType.ALIPAY,
order_no="ORDER_10001",
amount="9.90",
subject="Membership top-up",
notify_url="https://api.example.com/pay/notify",
return_url="https://www.example.com/pay/success",
)
print(pay_url)PaymentService also keeps convenience helpers:
create_alipay_order(...)create_wxpay_order(...)create_qqpay_order(...)
PaymentService.create_order(...)— the recommended business-facing entry point. It normalizes the amount and returns a ready-to-use payment URL.EPayClient.create_order_url(CreateOrderRequest)— lower level; use it when you already buildCreateOrderRequestyourself.EPayClient.create_order(CreateOrderRequest)— sends the request to the gateway and returns the raw upstream response body.
If your use case is simply “generate a payment URL”, prefer PaymentService.create_order(...) or one of its convenience helpers.
A production-friendly callback flow looks like this:
- Receive the gateway callback form payload.
- Call
client.verify_callback()to verify the signature and validatepid,sign_type, required fields, and amount format. - Call
CallbackProcessor.process()to load the local order and validate the amount. - If
verify_with_query=True, the processor performsquery_order()+parse_query_result()for upstream reconciliation. - Persist the paid transition through
mark_paid_if_unpaid(). - Record callback audit stages through
record_callback_attempt(..., stage=...). - Let the application decide whether to commit or roll back the surrounding transaction.
- Return
successfor accepted callbacks andfailfor rejected/retryable callbacks.
Current callback audit stages:
RECEIVEDRECONCILEDAPPLIEDREJECTED
You are expected to implement your own order repository, but it should satisfy this protocol:
class OrderRepository(Protocol):
def get_order(self, order_no: str) -> Optional[OrderRecord]: ...
def mark_paid_if_unpaid(self, order_no: str, gateway_trade_no: str, raw_payload: str) -> bool: ...
def record_callback_attempt(
self,
order_no: str,
accepted: bool,
reason: Optional[str],
raw_payload: str,
*,
stage: str,
) -> None: ...Important behavior notes:
mark_paid_if_unpaid()must be atomic, typically via a conditional database update.record_callback_attempt()must persist thestagefield — it is part of the public contract.- If your repository supports transaction control, it may additionally expose
commit()/rollback(). CallbackProcessor.process()defaults to not committing or rolling back external transactions.- If you explicitly want the processor to manage the repository transaction boundary, use
CallbackProcessor(..., manage_transaction=True).
Install the SQLAlchemy extra with:
uv add 'epay-sdk[sqlalchemy]'The top-level package lazily exposes these symbols when SQLAlchemy is installed:
BasePaymentOrderModelCallbackAuditModelSQLAlchemyOrderRepositorycreate_sqlite_engine
Example:
from sqlalchemy.orm import Session
from epay_sdk import Base, PaymentOrderModel, SQLAlchemyOrderRepository, create_sqlite_engine
engine = create_sqlite_engine("sqlite:///./epay.db")
Base.metadata.create_all(engine)
with Session(engine) as session:
session.add(PaymentOrderModel(order_no="A001", amount="9.90", status="UNPAID"))
session.commit()
repo = SQLAlchemyOrderRepository(session)
if repo.mark_paid_if_unpaid("A001", "G100", '{"trade_no":"G100"}'):
repo.record_callback_attempt("A001", True, None, '{"trade_no":"G100"}', stage="APPLIED")
repo.commit()If SQLAlchemy is not installed, import epay_sdk still works. Only accessing SQLAlchemy-specific exports requires the extra.
The Django strategy is intentionally app-layer oriented:
src/epay_sdk/stays framework-agnostic- Django models, repositories, views, URLs, and transaction boundaries live in the Django app layer
- the repository ships
examples/django_app/as a ready-to-copy integration template
This keeps the SDK generic while still providing a concrete Django reference implementation.
Install the example dependency set with:
uv add 'epay-sdk[django]'Or, when working from this repository:
uv sync --extra djangoFor Django, the recommended transaction pattern is:
- keep
CallbackProcessor(..., manage_transaction=False)(the default) - wrap callback processing in
transaction.atomic() - let Django own the commit/rollback behavior
Example:
from django.db import transaction
with transaction.atomic():
callback = client.verify_callback(request.POST.dict())
repo = DjangoOrderRepository()
processor = CallbackProcessor(client, repo, verify_with_query=True)
result = processor.process(callback)
return HttpResponse(result.response_text, content_type="text/plain")In Django terms, the repository usually maps like this:
get_order(order_no)— load a Django model and map it toOrderRecordmark_paid_if_unpaid(order_no, gateway_trade_no, raw_payload)— use a single conditional updaterecord_callback_attempt(...)— write a callback audit row
The most important part is keeping mark_paid_if_unpaid() atomic. In Django, that typically means:
updated = PaymentOrder.objects.filter(
order_no=order_no,
status="UNPAID",
).update(
status="PAID",
gateway_trade_no=gateway_trade_no,
raw_payload=raw_payload,
paid_at=timezone.now(),
)
first_success = updated == 1That mirrors the core SDK requirement that only UNPAID -> PAID is allowed for the idempotent paid transition.
examples/django_app/ currently shows:
- Django model definitions
- a
DjangoOrderRepositoryimplementation via duck typing - an order-creation view using
PaymentService.create_order(...) - callback handling through
client.verify_callback(...) + CallbackProcessor.process(...) transaction.atomic()for transaction control- app/project URL wiring and Django tests
- The signing protocol follows the common EPay convention: sorted non-empty parameters plus the merchant key, hashed with MD5. This is for gateway compatibility, not a modern standalone transport-security mechanism.
- Use HTTPS in production and keep
verify_ssl=True. The SDK rejectsverify_ssl=Falsewhenenvironment="production". - SQLite is fine for local development and examples, but production callback processing is better served by PostgreSQL/MySQL or another database with stronger concurrency semantics.
- Always verify callbacks before processing them.
- The
environmentfield is currently a safety/configuration signal. It influences validation and warnings, but does not automatically switch gateway endpoints.
examples/flask_app.py— minimal Flask integration with an in-memory repositoryexamples/fastapi_app.py— FastAPI + SQLAlchemy integration with blocking work pushed into the thread poolexamples/django_app/— Django integration template that keeps framework-specific code outside the core package
Run the test suite with uv:
uv run pytest tests
uv run python examples/django_app/manage.py test paymentsBuild release artifacts with:
uv build