python-myanmar-payments 4.0.0a1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_myanmar_payments-4.0.0a1/.gitignore +16 -0
- python_myanmar_payments-4.0.0a1/CHANGELOG.md +28 -0
- python_myanmar_payments-4.0.0a1/LICENSE.md +21 -0
- python_myanmar_payments-4.0.0a1/PKG-INFO +43 -0
- python_myanmar_payments-4.0.0a1/README.md +12 -0
- python_myanmar_payments-4.0.0a1/pyproject.toml +107 -0
- python_myanmar_payments-4.0.0a1/skills/python-myanmar-payments/SKILL.md +115 -0
- python_myanmar_payments-4.0.0a1/src/python_myanmar_payments/__init__.py +111 -0
- python_myanmar_payments-4.0.0a1/src/python_myanmar_payments/_amount.py +178 -0
- python_myanmar_payments-4.0.0a1/src/python_myanmar_payments/_cache.py +80 -0
- python_myanmar_payments-4.0.0a1/src/python_myanmar_payments/_callback.py +188 -0
- python_myanmar_payments-4.0.0a1/src/python_myanmar_payments/_errors.py +94 -0
- python_myanmar_payments-4.0.0a1/src/python_myanmar_payments/_facade.py +271 -0
- python_myanmar_payments-4.0.0a1/src/python_myanmar_payments/_http.py +201 -0
- python_myanmar_payments-4.0.0a1/src/python_myanmar_payments/_json.py +94 -0
- python_myanmar_payments-4.0.0a1/src/python_myanmar_payments/_results.py +265 -0
- python_myanmar_payments-4.0.0a1/src/python_myanmar_payments/_status.py +66 -0
- python_myanmar_payments-4.0.0a1/src/python_myanmar_payments/_support.py +172 -0
- python_myanmar_payments-4.0.0a1/src/python_myanmar_payments/_validate.py +157 -0
- python_myanmar_payments-4.0.0a1/src/python_myanmar_payments/_values.py +59 -0
- python_myanmar_payments-4.0.0a1/src/python_myanmar_payments/_version.py +1 -0
- python_myanmar_payments-4.0.0a1/src/python_myanmar_payments/aya_pay.py +509 -0
- python_myanmar_payments-4.0.0a1/src/python_myanmar_payments/cyber_source.py +310 -0
- python_myanmar_payments-4.0.0a1/src/python_myanmar_payments/kbz_pay.py +556 -0
- python_myanmar_payments-4.0.0a1/src/python_myanmar_payments/py.typed +0 -0
- python_myanmar_payments-4.0.0a1/src/python_myanmar_payments/wave_money.py +431 -0
- python_myanmar_payments-4.0.0a1/src/python_myanmar_payments/yoma_mmqr.py +526 -0
- python_myanmar_payments-4.0.0a1/tests/__init__.py +0 -0
- python_myanmar_payments-4.0.0a1/tests/conftest.py +26 -0
- python_myanmar_payments-4.0.0a1/tests/fixtures/aya_pay/callback_payload.json +26 -0
- python_myanmar_payments-4.0.0a1/tests/fixtures/kbz_pay/sign_string.json +20 -0
- python_myanmar_payments-4.0.0a1/tests/fixtures/parity/vectors.json +536 -0
- python_myanmar_payments-4.0.0a1/tests/fixtures/wave_money/callback.json +26 -0
- python_myanmar_payments-4.0.0a1/tests/helpers.py +110 -0
- python_myanmar_payments-4.0.0a1/tests/test_amount.py +126 -0
- python_myanmar_payments-4.0.0a1/tests/test_aya_pay.py +336 -0
- python_myanmar_payments-4.0.0a1/tests/test_core.py +490 -0
- python_myanmar_payments-4.0.0a1/tests/test_cyber_source.py +207 -0
- python_myanmar_payments-4.0.0a1/tests/test_kbz_pay.py +504 -0
- python_myanmar_payments-4.0.0a1/tests/test_myanmar_payments.py +210 -0
- python_myanmar_payments-4.0.0a1/tests/test_parity.py +255 -0
- python_myanmar_payments-4.0.0a1/tests/test_wave_money.py +282 -0
- python_myanmar_payments-4.0.0a1/tests/test_yoma_mmqr.py +358 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `python-myanmar-payments` will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## v4.0.0 - Unreleased
|
|
6
|
+
|
|
7
|
+
Initial release. The version number matches the other Laranex Myanmar payments packages (`php-myanmar-payments`, `laravel-myanmar-payments`, `go-myanmar-payments`, `node-myanmar-payments`), and the gateways, flows, validation, signing, status maps, results and errors are a port of them, sharing their test vectors. Pre-releases are tagged `v4.0.0-alpha.N` and published to PyPI as `4.0.0aN`.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Gateways: KBZ Pay (`pwa`, `qr`, `app`, `status`), Wave Money (`initiate`), AYA Payment Gateway (`services`, `initiate`, `status`, `verify_redirect`), Yoma MMQR (`initiate`, `renew_qr`, `status`, `forget_token`) and CyberSource Secure Acceptance (`initiate`); every gateway validates its payment data (`KbzPay.validate()` and so on) and verifies callbacks with `handle_callback`.
|
|
11
|
+
- A sync and an async client for every gateway that calls an API (`KbzPay` / `AsyncKbzPay`, `WaveMoney` / `AsyncWaveMoney`, `AyaPay` / `AsyncAyaPay`, `YomaMmqr` / `AsyncYomaMmqr`), sharing one implementation of signing, validation and response parsing. `CyberSource` makes no network calls and serves both.
|
|
12
|
+
- `httpx` is the only runtime dependency: pass your own `httpx.Client` / `httpx.AsyncClient` as `http_client`, or let the gateway create one with a 30 second `timeout` and close it with `close()` / `aclose()` or a `with` / `async with` block.
|
|
13
|
+
- Exact `Amount` type (`Amount.kyat()`, `Amount.parse()`, `Amount.of()`) kept as decimal text, so amounts never pass through a float; payment data also takes a whole `int`, decimal text or a `Decimal`, and rejects floats. Decimals are accepted only where the gateway documents them (KBZ Pay up to 2 places, CyberSource any); every gateway except CyberSource is MMK only.
|
|
14
|
+
- Typed payment data dataclasses (`KbzPayPaymentData`, `WaveMoneyPaymentData` with `WaveMoneyItem`, `AyaPayPaymentData`, `YomaMmqrPaymentData`, `CyberSourcePaymentData`) and a config class per gateway with `from_env()` (default `os.environ`), reading the same `*_SANDBOX`, credential and URL variables as the PHP, Laravel, Go and Node packages; a missing credential raises `ConfigurationError`. `sandbox` also takes text read like `*_SANDBOX` (`false`, `0`, `f`, `no` or `off` select production). Gateways, `MyanmarPayments` and `AsyncMyanmarPayments` take config objects or mappings of their keyword arguments; the facades build every gateway lazily, also from the environment.
|
|
15
|
+
- Result classes: `RedirectPayment`, `FormPayment` (with `to_html()`), `QrPayment` (with `qr_image_data_uri()`) and `AppPayment` (with `to_dict()`); `PaymentCallback` (with a gateway-independent `PaymentStatus` and the `Acknowledgement` each gateway expects, in its `acknowledgement` attribute) and `PaymentStatusResult`.
|
|
16
|
+
- `CallbackRequest(body=..., headers=..., query=...)` takes the raw body bytes, headers and query string from Django, Flask, FastAPI or any other framework; `CallbackRequest.from_json()` replays a stored callback. JSON numbers in callbacks are read without a float conversion, so signatures and amounts keep the gateway's exact text, and `raw` (like `parsed_body()`, `input()` and error `raw`) holds every JSON number as its exact text, e.g. `"1000.50"`.
|
|
17
|
+
- Errors: `PaymentError` and its subclasses `InvalidPaymentDataError` (per-field `errors`), `ApiError` (`gateway_code`, `gateway_message`, `http_status`, `raw`, network errors chained as `__cause__`), `SignatureVerificationError` and `ConfigurationError`.
|
|
18
|
+
- An injectable `TokenCache` (or `AsyncTokenCache` for the async client) with a thread-safe in-memory default for Yoma's access token; concurrent calls share one token request.
|
|
19
|
+
- Callback verification is the same in every Laranex payments SDK, checked by shared vectors (`tests/fixtures/parity/vectors.json`): numbers sign as the exact text sent, booleans as `true` / `false`, and a nested value (object or list) in a signed field fails verification.
|
|
20
|
+
- `Amount.equals()` and `==` compare by value, ignoring leading zeros and trailing fractional zeros (`"01000"` equals `Amount.kyat(1000)`); text that is not plain digits never equals.
|
|
21
|
+
- CyberSource callbacks trust only the fields listed in `signed_field_names`: `decision` and `req_reference_number` must be signed, and unsigned fields are left out of the result and `raw`.
|
|
22
|
+
- AYA Pay reads a `payload` whose `+` signs became spaces in an unencoded return query string, with or without base64 padding; partial padding, other alphabets and payloads that are not UTF-8 are rejected.
|
|
23
|
+
- Yoma MMQR caches its token under `myanmar-payments.yoma-mmqr.token.<sha256 of base URL|client id>`, the same key as the PHP, Go and Node SDKs, so services in different languages can share one cache.
|
|
24
|
+
- Callback, return and cancel URLs only need to be valid absolute http or https URLs; there is no HTTPS-only rule (gateways may still require HTTPS in production).
|
|
25
|
+
- Wave Money's sandbox is `https://preprodpayments.wavemoney.io:8107`, with checkout at `https://preprodpayments.wavemoney.io/authenticate`.
|
|
26
|
+
- Fully typed (`py.typed`, `mypy --strict`).
|
|
27
|
+
- Agent skill in `skills/python-myanmar-payments` so coding agents use the package correctly; install it with `npx skills add laranex/python-myanmar-payments`.
|
|
28
|
+
- Requires Python 3.10 or higher.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Nay Thu Khant
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: python-myanmar-payments
|
|
3
|
+
Version: 4.0.0a1
|
|
4
|
+
Summary: Python SDK for Myanmar payment gateways: KBZ Pay, Wave Money, AYA Pay, Yoma MMQR and CyberSource. Typed requests and results, exact amounts, sync and async clients.
|
|
5
|
+
Project-URL: Homepage, https://laranex.vercel.app/python-myanmar-payments
|
|
6
|
+
Project-URL: Documentation, https://laranex.vercel.app/python-myanmar-payments
|
|
7
|
+
Project-URL: Repository, https://github.com/laranex/python-myanmar-payments
|
|
8
|
+
Project-URL: Issues, https://github.com/laranex/python-myanmar-payments/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/laranex/python-myanmar-payments/blob/dev/CHANGELOG.md
|
|
10
|
+
Author-email: Nay Thu Khant <naythukhant644@gmail.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE.md
|
|
13
|
+
Keywords: aya-pay,cybersource,kbzpay,laranex,mmqr,myanmar,payment-gateway,payments,python-myanmar-payments,wave-money,wavepay,yoma
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Framework :: AsyncIO
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.10
|
|
29
|
+
Requires-Dist: httpx>=0.25
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# Python Myanmar Payments
|
|
33
|
+
|
|
34
|
+
[](https://pypi.org/project/python-myanmar-payments/)
|
|
35
|
+
[](https://github.com/laranex/python-myanmar-payments/actions/workflows/tests.yml)
|
|
36
|
+
[](https://pypi.org/project/python-myanmar-payments/)
|
|
37
|
+
[](LICENSE.md)
|
|
38
|
+
|
|
39
|
+
Python SDK for Myanmar payment gateways: KBZ Pay, Wave Money, AYA Pay, Yoma MMQR and CyberSource. Typed requests and results, exact amounts, sync and async clients. Built for humans and AI agents.
|
|
40
|
+
|
|
41
|
+
## Documentation
|
|
42
|
+
|
|
43
|
+
Full documentation, including installation, usage and the AI agent skill, lives at **[laranex.vercel.app/python-myanmar-payments](https://laranex.vercel.app/python-myanmar-payments)**.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Python Myanmar Payments
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/python-myanmar-payments/)
|
|
4
|
+
[](https://github.com/laranex/python-myanmar-payments/actions/workflows/tests.yml)
|
|
5
|
+
[](https://pypi.org/project/python-myanmar-payments/)
|
|
6
|
+
[](LICENSE.md)
|
|
7
|
+
|
|
8
|
+
Python SDK for Myanmar payment gateways: KBZ Pay, Wave Money, AYA Pay, Yoma MMQR and CyberSource. Typed requests and results, exact amounts, sync and async clients. Built for humans and AI agents.
|
|
9
|
+
|
|
10
|
+
## Documentation
|
|
11
|
+
|
|
12
|
+
Full documentation, including installation, usage and the AI agent skill, lives at **[laranex.vercel.app/python-myanmar-payments](https://laranex.vercel.app/python-myanmar-payments)**.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "python-myanmar-payments"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Python SDK for Myanmar payment gateways: KBZ Pay, Wave Money, AYA Pay, Yoma MMQR and CyberSource. Typed requests and results, exact amounts, sync and async clients."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE.md"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "Nay Thu Khant", email = "naythukhant644@gmail.com" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"laranex",
|
|
16
|
+
"python-myanmar-payments",
|
|
17
|
+
"myanmar",
|
|
18
|
+
"payments",
|
|
19
|
+
"payment-gateway",
|
|
20
|
+
"kbzpay",
|
|
21
|
+
"wave-money",
|
|
22
|
+
"wavepay",
|
|
23
|
+
"aya-pay",
|
|
24
|
+
"yoma",
|
|
25
|
+
"mmqr",
|
|
26
|
+
"cybersource",
|
|
27
|
+
]
|
|
28
|
+
classifiers = [
|
|
29
|
+
"Development Status :: 4 - Beta",
|
|
30
|
+
"Framework :: AsyncIO",
|
|
31
|
+
"Intended Audience :: Developers",
|
|
32
|
+
"Operating System :: OS Independent",
|
|
33
|
+
"Programming Language :: Python :: 3",
|
|
34
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
35
|
+
"Programming Language :: Python :: 3.10",
|
|
36
|
+
"Programming Language :: Python :: 3.11",
|
|
37
|
+
"Programming Language :: Python :: 3.12",
|
|
38
|
+
"Programming Language :: Python :: 3.13",
|
|
39
|
+
"Programming Language :: Python :: 3.14",
|
|
40
|
+
"Topic :: Office/Business :: Financial",
|
|
41
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
42
|
+
"Typing :: Typed",
|
|
43
|
+
]
|
|
44
|
+
dependencies = ["httpx>=0.25"]
|
|
45
|
+
|
|
46
|
+
[project.urls]
|
|
47
|
+
Homepage = "https://laranex.vercel.app/python-myanmar-payments"
|
|
48
|
+
Documentation = "https://laranex.vercel.app/python-myanmar-payments"
|
|
49
|
+
Repository = "https://github.com/laranex/python-myanmar-payments"
|
|
50
|
+
Issues = "https://github.com/laranex/python-myanmar-payments/issues"
|
|
51
|
+
Changelog = "https://github.com/laranex/python-myanmar-payments/blob/dev/CHANGELOG.md"
|
|
52
|
+
|
|
53
|
+
[dependency-groups]
|
|
54
|
+
dev = [
|
|
55
|
+
"mypy>=1.13",
|
|
56
|
+
"pytest>=8.3",
|
|
57
|
+
"pytest-cov>=6.0",
|
|
58
|
+
"ruff>=0.8",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
[tool.hatch.version]
|
|
62
|
+
path = "src/python_myanmar_payments/_version.py"
|
|
63
|
+
|
|
64
|
+
[tool.hatch.build.targets.sdist]
|
|
65
|
+
include = [
|
|
66
|
+
"/src",
|
|
67
|
+
"/tests",
|
|
68
|
+
"/skills",
|
|
69
|
+
"/CHANGELOG.md",
|
|
70
|
+
"/LICENSE.md",
|
|
71
|
+
"/README.md",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
[tool.hatch.build.targets.wheel]
|
|
75
|
+
packages = ["src/python_myanmar_payments"]
|
|
76
|
+
|
|
77
|
+
[tool.pytest.ini_options]
|
|
78
|
+
testpaths = ["tests"]
|
|
79
|
+
addopts = ["--strict-markers", "--strict-config", "-ra"]
|
|
80
|
+
xfail_strict = true
|
|
81
|
+
filterwarnings = ["error"]
|
|
82
|
+
|
|
83
|
+
[tool.coverage.run]
|
|
84
|
+
branch = true
|
|
85
|
+
source = ["python_myanmar_payments"]
|
|
86
|
+
|
|
87
|
+
[tool.coverage.report]
|
|
88
|
+
show_missing = true
|
|
89
|
+
skip_covered = true
|
|
90
|
+
fail_under = 100
|
|
91
|
+
exclude_also = ["if TYPE_CHECKING:"]
|
|
92
|
+
|
|
93
|
+
[tool.mypy]
|
|
94
|
+
strict = true
|
|
95
|
+
python_version = "3.10"
|
|
96
|
+
files = ["src", "tests"]
|
|
97
|
+
|
|
98
|
+
[tool.ruff]
|
|
99
|
+
line-length = 100
|
|
100
|
+
target-version = "py310"
|
|
101
|
+
src = ["src", "tests"]
|
|
102
|
+
|
|
103
|
+
[tool.ruff.lint]
|
|
104
|
+
select = ["E", "W", "F", "I", "B", "UP", "SIM", "RUF", "C4", "PT", "PIE", "RET", "N"]
|
|
105
|
+
|
|
106
|
+
[tool.ruff.lint.per-file-ignores]
|
|
107
|
+
"tests/**" = ["N802"]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: python-myanmar-payments
|
|
3
|
+
description: >
|
|
4
|
+
Integrate Myanmar payment gateways (KBZ Pay, Wave Money, AYA Pay, Yoma MMQR, CyberSource) in a Python app (Django, Flask, FastAPI or plain Python) with python-myanmar-payments.
|
|
5
|
+
license: MIT
|
|
6
|
+
metadata:
|
|
7
|
+
author: Nay Thu Khant
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Python Myanmar Payments
|
|
11
|
+
|
|
12
|
+
## When to use
|
|
13
|
+
|
|
14
|
+
Use this skill when a Python app takes payments through KBZ Pay, Wave Money, AYA Payment Gateway, Yoma MMQR or CyberSource. Start payments and verify callbacks with the package's typed API; never build gateway signatures by hand. It works the same in Django, Flask, FastAPI or plain Python.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install python-myanmar-payments
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Requires Python 3.10+ and depends only on `httpx`. Import everything from `python_myanmar_payments`. Every gateway that calls an API has a sync class (`KbzPay`, `WaveMoney`, `AyaPay`, `YomaMmqr`) and an async twin (`AsyncKbzPay`, `AsyncWaveMoney`, `AsyncAyaPay`, `AsyncYomaMmqr`) with the same methods, awaited; use the async ones inside an event loop. `CyberSource` makes no network calls and serves both.
|
|
23
|
+
|
|
24
|
+
## Configure
|
|
25
|
+
|
|
26
|
+
`MyanmarPayments.from_env()` reads `KBZ_PAY_*`, `WAVE_MONEY_*`, `AYA_PAY_*` (or `AYA_PGW_*`), `YOMA_MMQR_*` and `CYBER_SOURCE_*` from `os.environ` (the same variables as the PHP, Go and Node SDKs). `sandbox` defaults to `True`; set `*_SANDBOX=false` (or `sandbox=False`) in production.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from python_myanmar_payments import MyanmarPayments
|
|
30
|
+
|
|
31
|
+
payments = MyanmarPayments.from_env() # create once, share across requests
|
|
32
|
+
kbz = payments.kbz_pay() # also wave_money(), aya_pay(), yoma_mmqr(), cyber_source()
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- Or build one gateway: `KbzPay(KbzPayConfig(app_id=..., app_key=..., merchant_code=...))`, `KbzPay.from_env()` or `KbzPay(KbzPayConfig.from_env())`; in async code use `AsyncMyanmarPayments.from_env()` and `AsyncKbzPay`.
|
|
36
|
+
- Or pass the settings directly: gateways and `MyanmarPayments(kbz_pay={"app_id": ..., "app_key": ..., "merchant_code": ...})` take config objects or mappings of their keyword arguments; a string `sandbox` such as `"false"` is read like the variable.
|
|
37
|
+
- Options (keyword arguments): `http_client` (your own `httpx.Client` / `httpx.AsyncClient`) and `timeout` (seconds, default 30); close the clients the package created with `payments.close()` / `await payments.aclose()` or a `with` block. Yoma and the facades also take `token_cache` (any `TokenCache`, default `MemoryTokenCache`; back it with Redis when you run several processes).
|
|
38
|
+
- A missing credential raises `ConfigurationError` (`gateway`, `key`).
|
|
39
|
+
|
|
40
|
+
## Use
|
|
41
|
+
|
|
42
|
+
### Amounts
|
|
43
|
+
|
|
44
|
+
Amounts are `Amount.kyat(1000)`, `Amount.parse("1000.50")`, a whole `int`, decimal text or a `Decimal`; never a float such as `10.5`. Only KBZ Pay (up to 2 decimals) and CyberSource accept decimals; Wave, AYA and Yoma take whole kyat. Invalid data raises `InvalidPaymentDataError` with `errors` per field (`order_id`, `amount`, ...), before any request is sent. Compare a gateway's amount by value with `amount.equals(callback.amount)`.
|
|
45
|
+
|
|
46
|
+
### Start a payment
|
|
47
|
+
|
|
48
|
+
Each gateway takes a data object (`KbzPayPaymentData`, `WaveMoneyPaymentData` with `WaveMoneyItem`s, `AyaPayPaymentData` with an `AyaPayMethod`, `YomaMmqrPaymentData`, `CyberSourcePaymentData` with a `CyberSourceTransactionType`) and returns a typed result:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from python_myanmar_payments import Amount, KbzPayPaymentData
|
|
52
|
+
|
|
53
|
+
payment = kbz.pwa(
|
|
54
|
+
KbzPayPaymentData(
|
|
55
|
+
order_id="ORDER_1",
|
|
56
|
+
amount=Amount.kyat(1000),
|
|
57
|
+
callback_url="https://shop.test/payments/kbz/callback",
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
return redirect(payment.url)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- `RedirectPayment` (`url`) from `kbz.pwa(data)` and `wave.initiate(data)`. Wave fills `data.merchant_reference_id` when empty; store it.
|
|
64
|
+
- `FormPayment` from `aya.initiate(data)` and `cyber_source.initiate(data)` (no network call, never awaited): return `payment.to_html()` as an auto-submitting page, or render `action`, `fields` and `enctype` yourself.
|
|
65
|
+
- `QrPayment` from `kbz.qr(data)` (encode `qr_string`) and `yoma.initiate(data)` (`qr_image_data_uri()`, `expires_at`, `reference`). A Yoma QR lives `YomaMmqr.QR_LIFETIME_SECONDS` (120); renew it with `yoma.renew_qr(order_id)`.
|
|
66
|
+
- `AppPayment` from `kbz.app(data)`: send `payment.to_dict()` (`orderId`, `orderInfo`, `sign`, `signType`) to your mobile app.
|
|
67
|
+
|
|
68
|
+
AYA needs a channel: `aya.services()` lists `AyaPayService` entries (`key`, `supports(method)`), then `aya.initiate(AyaPayPaymentData(order_id="ORDER123", amount=1000, channel="kbz_pay", method=AyaPayMethod.QR))`.
|
|
69
|
+
|
|
70
|
+
### Handle the callback
|
|
71
|
+
|
|
72
|
+
Build a `CallbackRequest` from the raw request, verify it, then reply:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from python_myanmar_payments import CallbackRequest, SignatureVerificationError
|
|
76
|
+
|
|
77
|
+
request = CallbackRequest(
|
|
78
|
+
body=django_request.body, # Flask: request.get_data(); FastAPI: await request.body()
|
|
79
|
+
headers=django_request.headers,
|
|
80
|
+
query=django_request.META["QUERY_STRING"], # Flask: request.query_string
|
|
81
|
+
)
|
|
82
|
+
try:
|
|
83
|
+
callback = kbz.handle_callback(request)
|
|
84
|
+
except SignatureVerificationError:
|
|
85
|
+
return HttpResponse("invalid signature", status=400)
|
|
86
|
+
if callback.is_successful():
|
|
87
|
+
... # compare callback.amount with the order, then fulfill callback.order_id once
|
|
88
|
+
ack = callback.acknowledgement
|
|
89
|
+
return HttpResponse(ack.body, status=ack.status, headers=ack.headers)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- Give the package the raw body bytes, the headers and the query string; `handle_callback()` and `verify_redirect()` are plain methods on the async classes too.
|
|
93
|
+
- Check AYA's browser return with `aya.verify_redirect(request)`.
|
|
94
|
+
- For production, store the verified callback, acknowledge immediately, then process it once in the background.
|
|
95
|
+
|
|
96
|
+
### Check status and handle errors
|
|
97
|
+
|
|
98
|
+
- `kbz.status(order_id)`, `aya.status(order_id)` and `yoma.status(reference)` return `PaymentStatusResult` with `status` and `is_successful()`. Wave Money and CyberSource have no status API.
|
|
99
|
+
- Statuses: `PaymentStatus.SUCCESSFUL`, `PENDING`, `FAILED`, `CANCELED`, `EXPIRED`, `UNKNOWN`; `status.is_final()`.
|
|
100
|
+
- Gateway failures raise `ApiError` (`gateway_code`, `gateway_message`, `http_status`, `raw`; network errors are chained as `__cause__`). All errors extend `PaymentError`.
|
|
101
|
+
|
|
102
|
+
## Test your app
|
|
103
|
+
|
|
104
|
+
- Pass an `httpx.Client(transport=httpx.MockTransport(handler))` (or an `httpx.AsyncClient`) as `http_client`, or mock with `respx`.
|
|
105
|
+
- Replay a stored callback with `CallbackRequest.from_json(payload, headers)` or `CallbackRequest(body=..., headers=..., query=...)`; it is still signature-checked.
|
|
106
|
+
- To test your own fulfillment code, build `PaymentCallback(order_id="ORDER_1", status=PaymentStatus.SUCCESSFUL, gateway_status="PAY_SUCCESS")` yourself.
|
|
107
|
+
|
|
108
|
+
## Avoid
|
|
109
|
+
|
|
110
|
+
- Fulfilling orders from return pages or query strings; fulfill only from a verified callback or a status check.
|
|
111
|
+
- Treating `PENDING` or `UNKNOWN` as paid.
|
|
112
|
+
- Passing floats or `float(price)` as amounts; use `Amount.parse()` or a `Decimal`.
|
|
113
|
+
- Building the `CallbackRequest` from `request.json` / `request.POST` instead of the raw body.
|
|
114
|
+
- Reusing a Wave `merchant_reference_id`, or calling Yoma `initiate()` twice for one order (use `renew_qr()`).
|
|
115
|
+
- Opening a KBZ Pay PWA link outside a phone with the KBZ Pay app.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Python SDK for Myanmar payment gateways.
|
|
2
|
+
|
|
3
|
+
KBZ Pay, Wave Money, AYA Pay, Yoma MMQR and CyberSource. Typed requests and
|
|
4
|
+
results, exact amounts, sync and async clients.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ._amount import Amount, AmountInput
|
|
8
|
+
from ._cache import AsyncTokenCache, MemoryTokenCache, TokenCache
|
|
9
|
+
from ._callback import BodyInput, CallbackRequest, HeadersInput, QueryInput
|
|
10
|
+
from ._errors import (
|
|
11
|
+
ApiError,
|
|
12
|
+
ConfigurationError,
|
|
13
|
+
InvalidPaymentDataError,
|
|
14
|
+
PaymentError,
|
|
15
|
+
SignatureVerificationError,
|
|
16
|
+
)
|
|
17
|
+
from ._facade import AsyncMyanmarPayments, MyanmarPayments
|
|
18
|
+
from ._http import DEFAULT_TIMEOUT
|
|
19
|
+
from ._results import (
|
|
20
|
+
Acknowledgement,
|
|
21
|
+
AppPayment,
|
|
22
|
+
FormField,
|
|
23
|
+
FormPayment,
|
|
24
|
+
PaymentCallback,
|
|
25
|
+
PaymentResult,
|
|
26
|
+
PaymentStatusResult,
|
|
27
|
+
QrPayment,
|
|
28
|
+
RedirectPayment,
|
|
29
|
+
)
|
|
30
|
+
from ._status import PaymentFlow, PaymentStatus, resolve_status
|
|
31
|
+
from ._version import __version__
|
|
32
|
+
from .aya_pay import (
|
|
33
|
+
AsyncAyaPay,
|
|
34
|
+
AyaPay,
|
|
35
|
+
AyaPayConfig,
|
|
36
|
+
AyaPayMethod,
|
|
37
|
+
AyaPayPaymentData,
|
|
38
|
+
AyaPayService,
|
|
39
|
+
)
|
|
40
|
+
from .cyber_source import (
|
|
41
|
+
CyberSource,
|
|
42
|
+
CyberSourceConfig,
|
|
43
|
+
CyberSourcePaymentData,
|
|
44
|
+
CyberSourceTransactionType,
|
|
45
|
+
)
|
|
46
|
+
from .kbz_pay import AsyncKbzPay, KbzPay, KbzPayConfig, KbzPayPaymentData, KbzPaySigner
|
|
47
|
+
from .wave_money import (
|
|
48
|
+
AsyncWaveMoney,
|
|
49
|
+
WaveMoney,
|
|
50
|
+
WaveMoneyConfig,
|
|
51
|
+
WaveMoneyItem,
|
|
52
|
+
WaveMoneyPaymentData,
|
|
53
|
+
)
|
|
54
|
+
from .yoma_mmqr import AsyncYomaMmqr, YomaMmqr, YomaMmqrConfig, YomaMmqrPaymentData
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"DEFAULT_TIMEOUT",
|
|
58
|
+
"Acknowledgement",
|
|
59
|
+
"Amount",
|
|
60
|
+
"AmountInput",
|
|
61
|
+
"ApiError",
|
|
62
|
+
"AppPayment",
|
|
63
|
+
"AsyncAyaPay",
|
|
64
|
+
"AsyncKbzPay",
|
|
65
|
+
"AsyncMyanmarPayments",
|
|
66
|
+
"AsyncTokenCache",
|
|
67
|
+
"AsyncWaveMoney",
|
|
68
|
+
"AsyncYomaMmqr",
|
|
69
|
+
"AyaPay",
|
|
70
|
+
"AyaPayConfig",
|
|
71
|
+
"AyaPayMethod",
|
|
72
|
+
"AyaPayPaymentData",
|
|
73
|
+
"AyaPayService",
|
|
74
|
+
"BodyInput",
|
|
75
|
+
"CallbackRequest",
|
|
76
|
+
"ConfigurationError",
|
|
77
|
+
"CyberSource",
|
|
78
|
+
"CyberSourceConfig",
|
|
79
|
+
"CyberSourcePaymentData",
|
|
80
|
+
"CyberSourceTransactionType",
|
|
81
|
+
"FormField",
|
|
82
|
+
"FormPayment",
|
|
83
|
+
"HeadersInput",
|
|
84
|
+
"InvalidPaymentDataError",
|
|
85
|
+
"KbzPay",
|
|
86
|
+
"KbzPayConfig",
|
|
87
|
+
"KbzPayPaymentData",
|
|
88
|
+
"KbzPaySigner",
|
|
89
|
+
"MemoryTokenCache",
|
|
90
|
+
"MyanmarPayments",
|
|
91
|
+
"PaymentCallback",
|
|
92
|
+
"PaymentError",
|
|
93
|
+
"PaymentFlow",
|
|
94
|
+
"PaymentResult",
|
|
95
|
+
"PaymentStatus",
|
|
96
|
+
"PaymentStatusResult",
|
|
97
|
+
"QrPayment",
|
|
98
|
+
"QueryInput",
|
|
99
|
+
"RedirectPayment",
|
|
100
|
+
"SignatureVerificationError",
|
|
101
|
+
"TokenCache",
|
|
102
|
+
"WaveMoney",
|
|
103
|
+
"WaveMoneyConfig",
|
|
104
|
+
"WaveMoneyItem",
|
|
105
|
+
"WaveMoneyPaymentData",
|
|
106
|
+
"YomaMmqr",
|
|
107
|
+
"YomaMmqrConfig",
|
|
108
|
+
"YomaMmqrPaymentData",
|
|
109
|
+
"__version__",
|
|
110
|
+
"resolve_status",
|
|
111
|
+
]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""The exact money amount type."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
|
|
9
|
+
from ._errors import InvalidPaymentDataError
|
|
10
|
+
|
|
11
|
+
__all__ = ["Amount", "AmountInput"]
|
|
12
|
+
|
|
13
|
+
_PATTERN = re.compile(r"[0-9]+(?:\.[0-9]+)?")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Amount:
|
|
17
|
+
"""An exact, non-negative money amount, kept as decimal text.
|
|
18
|
+
|
|
19
|
+
It never passes through a float, so it is never rounded. Build one with
|
|
20
|
+
:meth:`Amount.kyat` for whole amounts, :meth:`Amount.parse` for decimal text or
|
|
21
|
+
:meth:`Amount.of` for any accepted input. Each gateway then checks it against
|
|
22
|
+
its documented rules (Wave Money, AYA and Yoma MMQR only accept whole kyat,
|
|
23
|
+
KBZ Pay up to 2 decimal places, CyberSource any).
|
|
24
|
+
|
|
25
|
+
Amounts are immutable and ``str(amount)`` is the exact text sent to the
|
|
26
|
+
gateway, e.g. ``"1000.50"``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
__slots__ = ("_value",)
|
|
30
|
+
|
|
31
|
+
_value: str
|
|
32
|
+
|
|
33
|
+
def __init__(self, amount: AmountInput) -> None:
|
|
34
|
+
"""The same as :meth:`Amount.of`: ``Amount(1000)``, ``Amount("1000.50")``."""
|
|
35
|
+
object.__setattr__(self, "_value", Amount.of(amount)._value)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def _make(cls, value: str) -> Amount:
|
|
39
|
+
instance = object.__new__(cls)
|
|
40
|
+
object.__setattr__(instance, "_value", value)
|
|
41
|
+
return instance
|
|
42
|
+
|
|
43
|
+
def __setattr__(self, name: str, value: object) -> None:
|
|
44
|
+
raise AttributeError("Amount is immutable")
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def kyat(cls, amount: int) -> Amount:
|
|
48
|
+
"""A whole amount, e.g. ``Amount.kyat(1000)`` for 1000 MMK.
|
|
49
|
+
|
|
50
|
+
Works for whole units of any currency. Floats, booleans and negative
|
|
51
|
+
values raise an :class:`InvalidPaymentDataError` for the ``amount`` field.
|
|
52
|
+
"""
|
|
53
|
+
if isinstance(amount, bool) or not isinstance(amount, int):
|
|
54
|
+
raise _invalid(
|
|
55
|
+
"The amount field must be a whole number of kyat; use "
|
|
56
|
+
"Amount.parse('10.50') for decimal amounts, "
|
|
57
|
+
f"got {amount!r}."
|
|
58
|
+
)
|
|
59
|
+
if amount < 0:
|
|
60
|
+
raise _invalid("The amount field must not be negative.")
|
|
61
|
+
return cls._make(str(amount))
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def parse(cls, amount: str) -> Amount:
|
|
65
|
+
"""A decimal amount written as plain digits, e.g. ``Amount.parse("1000.50")``.
|
|
66
|
+
|
|
67
|
+
Signs, exponents, spaces and thousands separators are rejected with an
|
|
68
|
+
:class:`InvalidPaymentDataError`. Leading zeros of the whole part are
|
|
69
|
+
removed (``"007.50"`` becomes ``"7.50"``); the fractional digits are kept
|
|
70
|
+
exactly as given.
|
|
71
|
+
"""
|
|
72
|
+
if not isinstance(amount, str) or _PATTERN.fullmatch(amount) is None:
|
|
73
|
+
raise _invalid(
|
|
74
|
+
"The amount field must be a number such as 1000 or 1000.50, "
|
|
75
|
+
f"got {json.dumps(amount, ensure_ascii=False, default=repr)}."
|
|
76
|
+
)
|
|
77
|
+
whole, dot, fraction = amount.partition(".")
|
|
78
|
+
whole = whole.lstrip("0") or "0"
|
|
79
|
+
return cls._make(f"{whole}.{fraction}" if dot else whole)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def of(cls, amount: AmountInput) -> Amount:
|
|
83
|
+
"""Any accepted amount input as an :class:`Amount`.
|
|
84
|
+
|
|
85
|
+
Takes an ``Amount`` (returned as is), a whole ``int`` (as
|
|
86
|
+
:meth:`kyat`), decimal text (as :meth:`parse`) or a finite, non-negative
|
|
87
|
+
:class:`~decimal.Decimal` (its exact digits, e.g. ``Decimal("1000.50")``
|
|
88
|
+
is ``"1000.50"``). Floats are never accepted.
|
|
89
|
+
"""
|
|
90
|
+
if isinstance(amount, Amount):
|
|
91
|
+
return amount
|
|
92
|
+
if isinstance(amount, str):
|
|
93
|
+
return cls.parse(amount)
|
|
94
|
+
if isinstance(amount, Decimal):
|
|
95
|
+
if not amount.is_finite():
|
|
96
|
+
raise _invalid(f"The amount field must be a finite number, got {amount!r}.")
|
|
97
|
+
if amount < 0:
|
|
98
|
+
raise _invalid("The amount field must not be negative.")
|
|
99
|
+
return cls.parse(format(amount.copy_abs(), "f"))
|
|
100
|
+
return cls.kyat(amount)
|
|
101
|
+
|
|
102
|
+
def __str__(self) -> str:
|
|
103
|
+
return self._value
|
|
104
|
+
|
|
105
|
+
def __repr__(self) -> str:
|
|
106
|
+
return f"Amount('{self._value}')"
|
|
107
|
+
|
|
108
|
+
def __eq__(self, other: object) -> bool:
|
|
109
|
+
if isinstance(other, Amount):
|
|
110
|
+
return _normalize(self._value) == _normalize(other._value)
|
|
111
|
+
return NotImplemented
|
|
112
|
+
|
|
113
|
+
def __hash__(self) -> int:
|
|
114
|
+
return hash(_normalize(self._value))
|
|
115
|
+
|
|
116
|
+
def decimal_places(self) -> int:
|
|
117
|
+
"""The number of fractional digits, e.g. ``2`` for ``1000.50``."""
|
|
118
|
+
_, dot, fraction = self._value.partition(".")
|
|
119
|
+
return len(fraction) if dot else 0
|
|
120
|
+
|
|
121
|
+
def whole_part(self) -> str:
|
|
122
|
+
"""The digits before the decimal point, e.g. ``"1000"`` for ``1000.50``."""
|
|
123
|
+
return self._value.partition(".")[0]
|
|
124
|
+
|
|
125
|
+
def is_zero(self) -> bool:
|
|
126
|
+
"""Whether the amount equals zero, e.g. ``0`` or ``0.00``."""
|
|
127
|
+
return self._value.replace(".", "").strip("0") == ""
|
|
128
|
+
|
|
129
|
+
def is_positive(self) -> bool:
|
|
130
|
+
"""Whether the amount is greater than zero."""
|
|
131
|
+
return not self.is_zero()
|
|
132
|
+
|
|
133
|
+
def to_decimal(self) -> Decimal:
|
|
134
|
+
"""The amount as an exact :class:`~decimal.Decimal`, e.g. ``Decimal("1000.50")``."""
|
|
135
|
+
return Decimal(self._value)
|
|
136
|
+
|
|
137
|
+
def equals(self, other: Amount | str | None) -> bool:
|
|
138
|
+
"""Whether both amounts have the same value.
|
|
139
|
+
|
|
140
|
+
Leading zeros of the whole part and trailing zeros of the fraction are
|
|
141
|
+
ignored: ``1000``, ``01000`` and ``1000.00`` are equal. Text such as
|
|
142
|
+
``callback.amount`` must be plain digits with an optional fraction;
|
|
143
|
+
anything else, and ``None``, is never equal, so a gateway amount that was
|
|
144
|
+
not sent never matches.
|
|
145
|
+
"""
|
|
146
|
+
if isinstance(other, Amount):
|
|
147
|
+
return self == other
|
|
148
|
+
if not isinstance(other, str) or _PATTERN.fullmatch(other) is None:
|
|
149
|
+
return False
|
|
150
|
+
return _normalize(self._value) == _normalize(other)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
AmountInput = Amount | int | str | Decimal
|
|
154
|
+
"""What payment data accepts as an amount: an :class:`Amount`, a whole ``int``,
|
|
155
|
+
decimal text such as ``"1000.50"`` or a :class:`~decimal.Decimal`. Never a float."""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _normalize(value: str) -> str:
|
|
159
|
+
whole, _, fraction = value.partition(".")
|
|
160
|
+
whole = whole.lstrip("0") or "0"
|
|
161
|
+
fraction = fraction.rstrip("0")
|
|
162
|
+
return f"{whole}.{fraction}" if fraction else whole
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _invalid(message: str) -> InvalidPaymentDataError:
|
|
166
|
+
return InvalidPaymentDataError({"amount": message})
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def to_amount(value: object) -> Amount | None:
|
|
170
|
+
"""``value`` as an :class:`Amount`, or ``None`` when it is not a valid amount."""
|
|
171
|
+
if isinstance(value, (Amount, str, Decimal)) or (
|
|
172
|
+
isinstance(value, int) and not isinstance(value, bool)
|
|
173
|
+
):
|
|
174
|
+
try:
|
|
175
|
+
return Amount.of(value)
|
|
176
|
+
except InvalidPaymentDataError:
|
|
177
|
+
return None
|
|
178
|
+
return None
|