fastapi-myanmar-payments 0.1.0.dev0__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.
- fastapi_myanmar_payments-0.1.0.dev0/.gitignore +16 -0
- fastapi_myanmar_payments-0.1.0.dev0/CHANGELOG.md +18 -0
- fastapi_myanmar_payments-0.1.0.dev0/LICENSE.md +21 -0
- fastapi_myanmar_payments-0.1.0.dev0/PKG-INFO +302 -0
- fastapi_myanmar_payments-0.1.0.dev0/README.md +269 -0
- fastapi_myanmar_payments-0.1.0.dev0/pyproject.toml +106 -0
- fastapi_myanmar_payments-0.1.0.dev0/skills/fastapi-myanmar-payments/SKILL.md +101 -0
- fastapi_myanmar_payments-0.1.0.dev0/src/fastapi_myanmar_payments/__init__.py +48 -0
- fastapi_myanmar_payments-0.1.0.dev0/src/fastapi_myanmar_payments/_version.py +1 -0
- fastapi_myanmar_payments-0.1.0.dev0/src/fastapi_myanmar_payments/callbacks.py +47 -0
- fastapi_myanmar_payments-0.1.0.dev0/src/fastapi_myanmar_payments/dependencies.py +83 -0
- fastapi_myanmar_payments-0.1.0.dev0/src/fastapi_myanmar_payments/lifespan.py +44 -0
- fastapi_myanmar_payments-0.1.0.dev0/src/fastapi_myanmar_payments/py.typed +0 -0
- fastapi_myanmar_payments-0.1.0.dev0/src/fastapi_myanmar_payments/responses.py +21 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `fastapi-myanmar-payments` will be documented in this file. Releases are tagged `fastapi/v<version>`.
|
|
4
|
+
|
|
5
|
+
## fastapi/v0.1.0 - Unreleased
|
|
6
|
+
|
|
7
|
+
Initial release, for `python-myanmar-payments` 4.0.0a16 or later (below 5) and FastAPI 0.115 or later.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- `payments_lifespan()`: one `AsyncMyanmarPayments` per application on `app.state`, created from a factory and closed at shutdown, or an instance you own and close yourself.
|
|
11
|
+
- Dependencies `get_payments`, `get_kbz_pay`, `get_wave_pay`, `get_aya_pay`, `get_yoma_mmqr` and `get_cyber_source`, with `Annotated` aliases (`PaymentsDep`, `KbzPayDep`, ...).
|
|
12
|
+
- `callback_request` / `CallbackRequestDep`: the core `CallbackRequest` from the raw body bytes, headers and query string.
|
|
13
|
+
- `verify_callback()`: the gateway's `handle_callback()`, answering a `SignatureVerificationError` with a generic 400.
|
|
14
|
+
- `acknowledgement_response()`: the gateway's `Acknowledgement` as a response with its exact status, body and headers.
|
|
15
|
+
- An agent skill, `skills/fastapi-myanmar-payments/SKILL.md`, describing how to use the adapter in an application.
|
|
16
|
+
|
|
17
|
+
### Notes
|
|
18
|
+
- `python-myanmar-payments` 4.0.0 is still a pre-release; its pre-releases may contain breaking changes. The adapter is tested against 4.0.0a16 and the newest published core.
|
|
@@ -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,302 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fastapi-myanmar-payments
|
|
3
|
+
Version: 0.1.0.dev0
|
|
4
|
+
Summary: FastAPI integration for python-myanmar-payments: lifespan, dependencies, verified callbacks and gateway acknowledgements.
|
|
5
|
+
Project-URL: Homepage, https://github.com/laranex/python-myanmar-payments/tree/dev/adapters/fastapi
|
|
6
|
+
Project-URL: Documentation, https://github.com/laranex/python-myanmar-payments/tree/dev/adapters/fastapi#readme
|
|
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/adapters/fastapi/CHANGELOG.md
|
|
10
|
+
Author-email: Soe Thura <thixpin@gmail.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE.md
|
|
13
|
+
Keywords: fastapi,laranex,myanmar,payment-gateway,payments,python-myanmar-payments,webhooks
|
|
14
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
15
|
+
Classifier: Framework :: AsyncIO
|
|
16
|
+
Classifier: Framework :: FastAPI
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
26
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
27
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
28
|
+
Classifier: Typing :: Typed
|
|
29
|
+
Requires-Python: >=3.10
|
|
30
|
+
Requires-Dist: fastapi>=0.115
|
|
31
|
+
Requires-Dist: python-myanmar-payments<5,>=4.0.0a16
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# FastAPI Myanmar Payments
|
|
35
|
+
|
|
36
|
+
Optional FastAPI integration for [python-myanmar-payments](https://pypi.org/project/python-myanmar-payments/): a lifespan that owns one payments facade, dependencies for its gateways, raw callback requests, verification that answers bad signatures with a 400, and the gateway's acknowledgement as a response.
|
|
37
|
+
|
|
38
|
+
> **You don't need this package to use FastAPI.** The core SDK already has async clients (`AsyncKbzPay`, `AsyncWavePay`, ...) that work in any FastAPI endpoint. This adapter only removes the framework glue. Payments, signing, callback verification, amounts and status mapping all stay in `python-myanmar-payments`; see its [documentation](https://laranex.vercel.app/python-myanmar-payments) for each gateway.
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
Requires Python 3.10+, FastAPI 0.115+ and `python-myanmar-payments` 4.0.0a16 or later (below 5).
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install fastapi-myanmar-payments
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
uv add fastapi-myanmar-payments
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The version range names a pre-release, so pip and uv install the core's pre-releases without extra flags.
|
|
53
|
+
|
|
54
|
+
> **Pre-release core.** `python-myanmar-payments` 4.0.0 is still in alpha, and its pre-releases may contain breaking changes (4.0.0a16, for example, renamed Wave Money to Wave Pay). This adapter is tested against the oldest supported core (4.0.0a16) and the newest published one. Pin both packages in your application's lock file and upgrade them together.
|
|
55
|
+
|
|
56
|
+
## Quick start
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from fastapi import FastAPI, Response
|
|
60
|
+
from python_myanmar_payments import AsyncMyanmarPayments
|
|
61
|
+
|
|
62
|
+
from fastapi_myanmar_payments import (
|
|
63
|
+
CallbackRequestDep,
|
|
64
|
+
KbzPayDep,
|
|
65
|
+
acknowledgement_response,
|
|
66
|
+
payments_lifespan,
|
|
67
|
+
verify_callback,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
app = FastAPI(lifespan=payments_lifespan(AsyncMyanmarPayments.from_env))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.post("/payments/kbz/callback")
|
|
74
|
+
async def kbz_callback(kbz: KbzPayDep, request: CallbackRequestDep) -> Response:
|
|
75
|
+
callback = verify_callback(kbz, request) # 400 on a bad signature
|
|
76
|
+
... # validate and persist it: see "Secure webhooks"
|
|
77
|
+
return acknowledgement_response(callback.acknowledgement)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
The adapter adds no settings. Configure the facade exactly as the core SDK documents ([Configuration](https://laranex.vercel.app/python-myanmar-payments/configuration)):
|
|
83
|
+
|
|
84
|
+
- `AsyncMyanmarPayments.from_env` reads `KBZ_PAY_*`, `WAVE_PAY_*`, `AYA_PAY_*`, `YOMA_MMQR_*`, `CYBER_SOURCE_*` and `MYANMAR_PAYMENTS_HTTP_TIMEOUT` when each gateway is first used.
|
|
85
|
+
- Or pass a factory that builds it from your own settings:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from python_myanmar_payments import AsyncMyanmarPayments
|
|
89
|
+
|
|
90
|
+
def build_payments() -> AsyncMyanmarPayments:
|
|
91
|
+
return AsyncMyanmarPayments(
|
|
92
|
+
kbz_pay={"app_id": settings.kbz_app_id, ...},
|
|
93
|
+
wave_pay={"merchant_id": settings.wave_merchant_id, ...},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
app = FastAPI(lifespan=payments_lifespan(build_payments))
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Only the gateways you use need to be configured. Using one that is not raises the core `ConfigurationError` (`gateway`, `key`), which FastAPI answers with a 500.
|
|
100
|
+
|
|
101
|
+
## Lifespan
|
|
102
|
+
|
|
103
|
+
`payments_lifespan(...)` stores one `AsyncMyanmarPayments` on `app.state` for the life of the application, so every request reuses the same gateways, HTTP connections and Yoma MMQR token cache. Nothing is created per request.
|
|
104
|
+
|
|
105
|
+
Who closes what:
|
|
106
|
+
|
|
107
|
+
| You pass | At shutdown |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| A factory, e.g. `AsyncMyanmarPayments.from_env` | The lifespan calls `aclose()`, which closes only the HTTP clients the gateways created. |
|
|
110
|
+
| An instance, e.g. `payments_lifespan(my_payments)` | Nothing is closed; it is yours. |
|
|
111
|
+
| An `http_client` or `token_cache` inside the facade | Never closed by the adapter or the core SDK; close it yourself. |
|
|
112
|
+
|
|
113
|
+
Combine it with your own lifespan:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from contextlib import asynccontextmanager
|
|
117
|
+
|
|
118
|
+
payments = payments_lifespan(AsyncMyanmarPayments.from_env)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@asynccontextmanager
|
|
122
|
+
async def lifespan(app: FastAPI):
|
|
123
|
+
async with payments(app):
|
|
124
|
+
# your own startup
|
|
125
|
+
yield
|
|
126
|
+
# your own shutdown
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
app = FastAPI(lifespan=lifespan)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Share your own `httpx.AsyncClient` (proxies, tracing, limits):
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
import httpx
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@asynccontextmanager
|
|
139
|
+
async def lifespan(app: FastAPI):
|
|
140
|
+
async with httpx.AsyncClient(timeout=30) as http_client: # you close it
|
|
141
|
+
factory = lambda: AsyncMyanmarPayments.from_env(http_client=http_client)
|
|
142
|
+
async with payments_lifespan(factory)(app):
|
|
143
|
+
yield
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Dependency injection
|
|
147
|
+
|
|
148
|
+
| Dependency | Annotated alias | Gives |
|
|
149
|
+
| --- | --- | --- |
|
|
150
|
+
| `get_payments` | `PaymentsDep` | `AsyncMyanmarPayments` |
|
|
151
|
+
| `get_kbz_pay` | `KbzPayDep` | `AsyncKbzPay` |
|
|
152
|
+
| `get_wave_pay` | `WavePayDep` | `AsyncWavePay` |
|
|
153
|
+
| `get_aya_pay` | `AyaPayDep` | `AsyncAyaPay` |
|
|
154
|
+
| `get_yoma_mmqr` | `YomaMmqrDep` | `AsyncYomaMmqr` |
|
|
155
|
+
| `get_cyber_source` | `CyberSourceDep` | `CyberSource` |
|
|
156
|
+
| `callback_request` | `CallbackRequestDep` | the core `CallbackRequest` (raw body, headers and query string) |
|
|
157
|
+
|
|
158
|
+
Every gateway dependency reads the facade through `get_payments`, so overriding `get_payments` replaces them all. Without a lifespan (and without an override), `get_payments` raises a `RuntimeError` that says so.
|
|
159
|
+
|
|
160
|
+
## Async payment initiation
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from fastapi import HTTPException
|
|
164
|
+
from fastapi.responses import RedirectResponse
|
|
165
|
+
from python_myanmar_payments import Amount, ApiError, InvalidPaymentDataError, KbzPayPaymentData
|
|
166
|
+
|
|
167
|
+
from fastapi_myanmar_payments import KbzPayDep
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.post("/orders/{order_id}/pay")
|
|
171
|
+
async def pay(order_id: str, kbz: KbzPayDep) -> RedirectResponse:
|
|
172
|
+
order = await orders.get(order_id) # your storage
|
|
173
|
+
try:
|
|
174
|
+
payment = await kbz.pwa(
|
|
175
|
+
KbzPayPaymentData(
|
|
176
|
+
order_id=order.id,
|
|
177
|
+
amount=Amount.parse(order.amount),
|
|
178
|
+
callback_url="https://shop.example/payments/kbz/callback",
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
except InvalidPaymentDataError as error:
|
|
182
|
+
raise HTTPException(422, detail=dict(error.errors)) from error
|
|
183
|
+
except ApiError as error:
|
|
184
|
+
raise HTTPException(502, detail="The payment gateway is unavailable.") from error
|
|
185
|
+
return RedirectResponse(payment.url, status_code=303)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Don't retry a failed initiation automatically: the gateway may have created the payment before the error reached you. Check `kbz.status(order_id)` (KBZ Pay, AYA Pay; Yoma MMQR by QR reference) or let the customer try again with a new attempt where the gateway requires one (Wave Pay's `merchant_reference_id`).
|
|
189
|
+
|
|
190
|
+
`AyaPay.initiate()` and `CyberSource.initiate()` make no network call and return a `FormPayment`; return `HTMLResponse(payment.to_html())`.
|
|
191
|
+
|
|
192
|
+
## Secure webhooks
|
|
193
|
+
|
|
194
|
+
Use this order for every gateway callback: **verify → validate against the stored order → persist → acknowledge → process once.**
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
from fastapi import Response
|
|
198
|
+
from python_myanmar_payments import Amount
|
|
199
|
+
|
|
200
|
+
from fastapi_myanmar_payments import (
|
|
201
|
+
CallbackRequestDep,
|
|
202
|
+
WavePayDep,
|
|
203
|
+
acknowledgement_response,
|
|
204
|
+
verify_callback,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@app.post("/payments/wave/callback")
|
|
209
|
+
async def wave_callback(wave: WavePayDep, request: CallbackRequestDep) -> Response:
|
|
210
|
+
# 1. Verify. A bad signature or a malformed signed callback becomes a 400;
|
|
211
|
+
# nothing from an unverified payload is trusted, stored or echoed.
|
|
212
|
+
callback = verify_callback(wave, request)
|
|
213
|
+
|
|
214
|
+
# 2. Validate against your stored order, never against the callback alone.
|
|
215
|
+
# An unknown order is still recorded (step 3), never created or marked paid.
|
|
216
|
+
order = await orders.get(callback.order_id)
|
|
217
|
+
# Not every gateway sends an amount (Yoma MMQR doesn't): compare it when present.
|
|
218
|
+
amount_matches = order is not None and (
|
|
219
|
+
callback.amount is None or Amount.parse(order.amount).equals(callback.amount)
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# 3. Persist durably and idempotently, e.g. a unique key on
|
|
223
|
+
# (gateway, order_id, gateway_status, gateway_reference). A duplicate
|
|
224
|
+
# delivery hits the key and is simply acknowledged again. If this raises,
|
|
225
|
+
# the request fails with a 5xx and nothing is acknowledged.
|
|
226
|
+
await webhook_events.insert_once(
|
|
227
|
+
gateway="wave_pay",
|
|
228
|
+
order_id=callback.order_id,
|
|
229
|
+
order_known=order is not None,
|
|
230
|
+
status=callback.status,
|
|
231
|
+
gateway_status=callback.gateway_status,
|
|
232
|
+
gateway_reference=callback.gateway_reference,
|
|
233
|
+
amount_matches=amount_matches,
|
|
234
|
+
raw=dict(callback.raw),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# 4. Acknowledge only after step 3 has committed, with the gateway's exact response.
|
|
238
|
+
return acknowledgement_response(callback.acknowledgement)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# 5. Process once, outside the request: a worker claims each stored event
|
|
242
|
+
# (e.g. UPDATE ... SET processed_at = now() WHERE id = ? AND processed_at IS NULL),
|
|
243
|
+
# fulfills the order only when it is known, PaymentStatus.SUCCESSFUL and
|
|
244
|
+
# amount_matches, and flags unknown orders and mismatches for review.
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Failure and retry behavior:
|
|
248
|
+
|
|
249
|
+
- **Verification fails** → 400. The gateway may retry; a forged or corrupted callback keeps failing. Log `error.__cause__` (a `SignatureVerificationError`) if you need it; its message never contains your secrets, but its `raw` payload is unverified.
|
|
250
|
+
- **Unknown order** (a verified callback for an order you don't have, e.g. from another environment or a deleted order) → record it durably as an unknown-order event, then return the gateway's acknowledgement so it stops retrying. Never create the order or mark anything paid from it; review it by hand.
|
|
251
|
+
- **Persisting fails** (database down) → let the exception produce a 5xx. Don't return the acknowledgement: the gateway retries later, so nothing is lost.
|
|
252
|
+
- **Persisted, but processing fails later** → the event stays unprocessed in your store; retry it from your worker. The gateway already has its acknowledgement and must not be relied on to resend.
|
|
253
|
+
- **Duplicates** are normal: gateways retry until they receive the acknowledgement. The idempotent insert and the once-only claim make them harmless.
|
|
254
|
+
- FastAPI `BackgroundTasks` run in the same process after the response; they are lost on a crash or deploy. Use them only for work you can redo from the persisted event.
|
|
255
|
+
- Never fulfill from a browser return or redirect; it is not proof of payment. For AYA Pay's signed return, `aya.verify_redirect(request)` shows the right page; fulfill from the callback or `status()`.
|
|
256
|
+
- Yoma MMQR also checks the `X-Webhook-Secret` header, which `CallbackRequestDep` passes through unchanged.
|
|
257
|
+
|
|
258
|
+
`callback_request` hands the core SDK the raw body bytes, the headers and the raw query string: it never decodes and re-encodes JSON or forms, so numbers like `1000.50` and form encoding reach the signature check exactly as sent. Don't read the body with `await request.json()` or `await request.form()` and rebuild it.
|
|
259
|
+
|
|
260
|
+
## Testing
|
|
261
|
+
|
|
262
|
+
No credentials or network are needed. Run the app in-process with `httpx.ASGITransport` and its own lifespan, and give the facade an `http_client` that answers from `httpx.MockTransport`. (FastAPI's `TestClient` works too, though recent Starlette versions ask for `httpx2` with it.)
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
import httpx
|
|
266
|
+
import pytest
|
|
267
|
+
from python_myanmar_payments import AsyncMyanmarPayments
|
|
268
|
+
|
|
269
|
+
from fastapi_myanmar_payments import get_payments
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
273
|
+
return httpx.Response(200, json={"message": "success", "transaction_id": "T1"})
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@pytest.mark.anyio
|
|
277
|
+
async def test_pay() -> None:
|
|
278
|
+
fake = AsyncMyanmarPayments(
|
|
279
|
+
wave_pay={...}, # test credentials
|
|
280
|
+
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
|
281
|
+
)
|
|
282
|
+
app.dependency_overrides[get_payments] = lambda: fake
|
|
283
|
+
async with (
|
|
284
|
+
app.router.lifespan_context(app),
|
|
285
|
+
httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client,
|
|
286
|
+
):
|
|
287
|
+
response = await client.post("/orders/100/pay")
|
|
288
|
+
assert response.status_code == 303
|
|
289
|
+
app.dependency_overrides.clear()
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
- Override one gateway with `app.dependency_overrides[get_kbz_pay] = ...`.
|
|
293
|
+
- Test your fulfillment code with a `PaymentCallback(order_id=..., status=PaymentStatus.SUCCESSFUL, gateway_status=...)` you build yourself.
|
|
294
|
+
- Callbacks you post through `TestClient` are still signature-checked; sign them with your test secret (see the core SDK's [Testing](https://laranex.vercel.app/python-myanmar-payments/testing) guide).
|
|
295
|
+
|
|
296
|
+
## What this package does not do
|
|
297
|
+
|
|
298
|
+
No webhook router, no storage, no background processing, no retries and no automatic fulfillment: those decisions stay in your application. Request body size limits belong to your server or proxy.
|
|
299
|
+
|
|
300
|
+
## License
|
|
301
|
+
|
|
302
|
+
MIT. See [LICENSE](LICENSE.md).
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# FastAPI Myanmar Payments
|
|
2
|
+
|
|
3
|
+
Optional FastAPI integration for [python-myanmar-payments](https://pypi.org/project/python-myanmar-payments/): a lifespan that owns one payments facade, dependencies for its gateways, raw callback requests, verification that answers bad signatures with a 400, and the gateway's acknowledgement as a response.
|
|
4
|
+
|
|
5
|
+
> **You don't need this package to use FastAPI.** The core SDK already has async clients (`AsyncKbzPay`, `AsyncWavePay`, ...) that work in any FastAPI endpoint. This adapter only removes the framework glue. Payments, signing, callback verification, amounts and status mapping all stay in `python-myanmar-payments`; see its [documentation](https://laranex.vercel.app/python-myanmar-payments) for each gateway.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Requires Python 3.10+, FastAPI 0.115+ and `python-myanmar-payments` 4.0.0a16 or later (below 5).
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install fastapi-myanmar-payments
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uv add fastapi-myanmar-payments
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The version range names a pre-release, so pip and uv install the core's pre-releases without extra flags.
|
|
20
|
+
|
|
21
|
+
> **Pre-release core.** `python-myanmar-payments` 4.0.0 is still in alpha, and its pre-releases may contain breaking changes (4.0.0a16, for example, renamed Wave Money to Wave Pay). This adapter is tested against the oldest supported core (4.0.0a16) and the newest published one. Pin both packages in your application's lock file and upgrade them together.
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from fastapi import FastAPI, Response
|
|
27
|
+
from python_myanmar_payments import AsyncMyanmarPayments
|
|
28
|
+
|
|
29
|
+
from fastapi_myanmar_payments import (
|
|
30
|
+
CallbackRequestDep,
|
|
31
|
+
KbzPayDep,
|
|
32
|
+
acknowledgement_response,
|
|
33
|
+
payments_lifespan,
|
|
34
|
+
verify_callback,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
app = FastAPI(lifespan=payments_lifespan(AsyncMyanmarPayments.from_env))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.post("/payments/kbz/callback")
|
|
41
|
+
async def kbz_callback(kbz: KbzPayDep, request: CallbackRequestDep) -> Response:
|
|
42
|
+
callback = verify_callback(kbz, request) # 400 on a bad signature
|
|
43
|
+
... # validate and persist it: see "Secure webhooks"
|
|
44
|
+
return acknowledgement_response(callback.acknowledgement)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Configuration
|
|
48
|
+
|
|
49
|
+
The adapter adds no settings. Configure the facade exactly as the core SDK documents ([Configuration](https://laranex.vercel.app/python-myanmar-payments/configuration)):
|
|
50
|
+
|
|
51
|
+
- `AsyncMyanmarPayments.from_env` reads `KBZ_PAY_*`, `WAVE_PAY_*`, `AYA_PAY_*`, `YOMA_MMQR_*`, `CYBER_SOURCE_*` and `MYANMAR_PAYMENTS_HTTP_TIMEOUT` when each gateway is first used.
|
|
52
|
+
- Or pass a factory that builds it from your own settings:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from python_myanmar_payments import AsyncMyanmarPayments
|
|
56
|
+
|
|
57
|
+
def build_payments() -> AsyncMyanmarPayments:
|
|
58
|
+
return AsyncMyanmarPayments(
|
|
59
|
+
kbz_pay={"app_id": settings.kbz_app_id, ...},
|
|
60
|
+
wave_pay={"merchant_id": settings.wave_merchant_id, ...},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
app = FastAPI(lifespan=payments_lifespan(build_payments))
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Only the gateways you use need to be configured. Using one that is not raises the core `ConfigurationError` (`gateway`, `key`), which FastAPI answers with a 500.
|
|
67
|
+
|
|
68
|
+
## Lifespan
|
|
69
|
+
|
|
70
|
+
`payments_lifespan(...)` stores one `AsyncMyanmarPayments` on `app.state` for the life of the application, so every request reuses the same gateways, HTTP connections and Yoma MMQR token cache. Nothing is created per request.
|
|
71
|
+
|
|
72
|
+
Who closes what:
|
|
73
|
+
|
|
74
|
+
| You pass | At shutdown |
|
|
75
|
+
| --- | --- |
|
|
76
|
+
| A factory, e.g. `AsyncMyanmarPayments.from_env` | The lifespan calls `aclose()`, which closes only the HTTP clients the gateways created. |
|
|
77
|
+
| An instance, e.g. `payments_lifespan(my_payments)` | Nothing is closed; it is yours. |
|
|
78
|
+
| An `http_client` or `token_cache` inside the facade | Never closed by the adapter or the core SDK; close it yourself. |
|
|
79
|
+
|
|
80
|
+
Combine it with your own lifespan:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from contextlib import asynccontextmanager
|
|
84
|
+
|
|
85
|
+
payments = payments_lifespan(AsyncMyanmarPayments.from_env)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@asynccontextmanager
|
|
89
|
+
async def lifespan(app: FastAPI):
|
|
90
|
+
async with payments(app):
|
|
91
|
+
# your own startup
|
|
92
|
+
yield
|
|
93
|
+
# your own shutdown
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
app = FastAPI(lifespan=lifespan)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Share your own `httpx.AsyncClient` (proxies, tracing, limits):
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
import httpx
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@asynccontextmanager
|
|
106
|
+
async def lifespan(app: FastAPI):
|
|
107
|
+
async with httpx.AsyncClient(timeout=30) as http_client: # you close it
|
|
108
|
+
factory = lambda: AsyncMyanmarPayments.from_env(http_client=http_client)
|
|
109
|
+
async with payments_lifespan(factory)(app):
|
|
110
|
+
yield
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Dependency injection
|
|
114
|
+
|
|
115
|
+
| Dependency | Annotated alias | Gives |
|
|
116
|
+
| --- | --- | --- |
|
|
117
|
+
| `get_payments` | `PaymentsDep` | `AsyncMyanmarPayments` |
|
|
118
|
+
| `get_kbz_pay` | `KbzPayDep` | `AsyncKbzPay` |
|
|
119
|
+
| `get_wave_pay` | `WavePayDep` | `AsyncWavePay` |
|
|
120
|
+
| `get_aya_pay` | `AyaPayDep` | `AsyncAyaPay` |
|
|
121
|
+
| `get_yoma_mmqr` | `YomaMmqrDep` | `AsyncYomaMmqr` |
|
|
122
|
+
| `get_cyber_source` | `CyberSourceDep` | `CyberSource` |
|
|
123
|
+
| `callback_request` | `CallbackRequestDep` | the core `CallbackRequest` (raw body, headers and query string) |
|
|
124
|
+
|
|
125
|
+
Every gateway dependency reads the facade through `get_payments`, so overriding `get_payments` replaces them all. Without a lifespan (and without an override), `get_payments` raises a `RuntimeError` that says so.
|
|
126
|
+
|
|
127
|
+
## Async payment initiation
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from fastapi import HTTPException
|
|
131
|
+
from fastapi.responses import RedirectResponse
|
|
132
|
+
from python_myanmar_payments import Amount, ApiError, InvalidPaymentDataError, KbzPayPaymentData
|
|
133
|
+
|
|
134
|
+
from fastapi_myanmar_payments import KbzPayDep
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.post("/orders/{order_id}/pay")
|
|
138
|
+
async def pay(order_id: str, kbz: KbzPayDep) -> RedirectResponse:
|
|
139
|
+
order = await orders.get(order_id) # your storage
|
|
140
|
+
try:
|
|
141
|
+
payment = await kbz.pwa(
|
|
142
|
+
KbzPayPaymentData(
|
|
143
|
+
order_id=order.id,
|
|
144
|
+
amount=Amount.parse(order.amount),
|
|
145
|
+
callback_url="https://shop.example/payments/kbz/callback",
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
except InvalidPaymentDataError as error:
|
|
149
|
+
raise HTTPException(422, detail=dict(error.errors)) from error
|
|
150
|
+
except ApiError as error:
|
|
151
|
+
raise HTTPException(502, detail="The payment gateway is unavailable.") from error
|
|
152
|
+
return RedirectResponse(payment.url, status_code=303)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Don't retry a failed initiation automatically: the gateway may have created the payment before the error reached you. Check `kbz.status(order_id)` (KBZ Pay, AYA Pay; Yoma MMQR by QR reference) or let the customer try again with a new attempt where the gateway requires one (Wave Pay's `merchant_reference_id`).
|
|
156
|
+
|
|
157
|
+
`AyaPay.initiate()` and `CyberSource.initiate()` make no network call and return a `FormPayment`; return `HTMLResponse(payment.to_html())`.
|
|
158
|
+
|
|
159
|
+
## Secure webhooks
|
|
160
|
+
|
|
161
|
+
Use this order for every gateway callback: **verify → validate against the stored order → persist → acknowledge → process once.**
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from fastapi import Response
|
|
165
|
+
from python_myanmar_payments import Amount
|
|
166
|
+
|
|
167
|
+
from fastapi_myanmar_payments import (
|
|
168
|
+
CallbackRequestDep,
|
|
169
|
+
WavePayDep,
|
|
170
|
+
acknowledgement_response,
|
|
171
|
+
verify_callback,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.post("/payments/wave/callback")
|
|
176
|
+
async def wave_callback(wave: WavePayDep, request: CallbackRequestDep) -> Response:
|
|
177
|
+
# 1. Verify. A bad signature or a malformed signed callback becomes a 400;
|
|
178
|
+
# nothing from an unverified payload is trusted, stored or echoed.
|
|
179
|
+
callback = verify_callback(wave, request)
|
|
180
|
+
|
|
181
|
+
# 2. Validate against your stored order, never against the callback alone.
|
|
182
|
+
# An unknown order is still recorded (step 3), never created or marked paid.
|
|
183
|
+
order = await orders.get(callback.order_id)
|
|
184
|
+
# Not every gateway sends an amount (Yoma MMQR doesn't): compare it when present.
|
|
185
|
+
amount_matches = order is not None and (
|
|
186
|
+
callback.amount is None or Amount.parse(order.amount).equals(callback.amount)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# 3. Persist durably and idempotently, e.g. a unique key on
|
|
190
|
+
# (gateway, order_id, gateway_status, gateway_reference). A duplicate
|
|
191
|
+
# delivery hits the key and is simply acknowledged again. If this raises,
|
|
192
|
+
# the request fails with a 5xx and nothing is acknowledged.
|
|
193
|
+
await webhook_events.insert_once(
|
|
194
|
+
gateway="wave_pay",
|
|
195
|
+
order_id=callback.order_id,
|
|
196
|
+
order_known=order is not None,
|
|
197
|
+
status=callback.status,
|
|
198
|
+
gateway_status=callback.gateway_status,
|
|
199
|
+
gateway_reference=callback.gateway_reference,
|
|
200
|
+
amount_matches=amount_matches,
|
|
201
|
+
raw=dict(callback.raw),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# 4. Acknowledge only after step 3 has committed, with the gateway's exact response.
|
|
205
|
+
return acknowledgement_response(callback.acknowledgement)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# 5. Process once, outside the request: a worker claims each stored event
|
|
209
|
+
# (e.g. UPDATE ... SET processed_at = now() WHERE id = ? AND processed_at IS NULL),
|
|
210
|
+
# fulfills the order only when it is known, PaymentStatus.SUCCESSFUL and
|
|
211
|
+
# amount_matches, and flags unknown orders and mismatches for review.
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Failure and retry behavior:
|
|
215
|
+
|
|
216
|
+
- **Verification fails** → 400. The gateway may retry; a forged or corrupted callback keeps failing. Log `error.__cause__` (a `SignatureVerificationError`) if you need it; its message never contains your secrets, but its `raw` payload is unverified.
|
|
217
|
+
- **Unknown order** (a verified callback for an order you don't have, e.g. from another environment or a deleted order) → record it durably as an unknown-order event, then return the gateway's acknowledgement so it stops retrying. Never create the order or mark anything paid from it; review it by hand.
|
|
218
|
+
- **Persisting fails** (database down) → let the exception produce a 5xx. Don't return the acknowledgement: the gateway retries later, so nothing is lost.
|
|
219
|
+
- **Persisted, but processing fails later** → the event stays unprocessed in your store; retry it from your worker. The gateway already has its acknowledgement and must not be relied on to resend.
|
|
220
|
+
- **Duplicates** are normal: gateways retry until they receive the acknowledgement. The idempotent insert and the once-only claim make them harmless.
|
|
221
|
+
- FastAPI `BackgroundTasks` run in the same process after the response; they are lost on a crash or deploy. Use them only for work you can redo from the persisted event.
|
|
222
|
+
- Never fulfill from a browser return or redirect; it is not proof of payment. For AYA Pay's signed return, `aya.verify_redirect(request)` shows the right page; fulfill from the callback or `status()`.
|
|
223
|
+
- Yoma MMQR also checks the `X-Webhook-Secret` header, which `CallbackRequestDep` passes through unchanged.
|
|
224
|
+
|
|
225
|
+
`callback_request` hands the core SDK the raw body bytes, the headers and the raw query string: it never decodes and re-encodes JSON or forms, so numbers like `1000.50` and form encoding reach the signature check exactly as sent. Don't read the body with `await request.json()` or `await request.form()` and rebuild it.
|
|
226
|
+
|
|
227
|
+
## Testing
|
|
228
|
+
|
|
229
|
+
No credentials or network are needed. Run the app in-process with `httpx.ASGITransport` and its own lifespan, and give the facade an `http_client` that answers from `httpx.MockTransport`. (FastAPI's `TestClient` works too, though recent Starlette versions ask for `httpx2` with it.)
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
import httpx
|
|
233
|
+
import pytest
|
|
234
|
+
from python_myanmar_payments import AsyncMyanmarPayments
|
|
235
|
+
|
|
236
|
+
from fastapi_myanmar_payments import get_payments
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
240
|
+
return httpx.Response(200, json={"message": "success", "transaction_id": "T1"})
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@pytest.mark.anyio
|
|
244
|
+
async def test_pay() -> None:
|
|
245
|
+
fake = AsyncMyanmarPayments(
|
|
246
|
+
wave_pay={...}, # test credentials
|
|
247
|
+
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
|
248
|
+
)
|
|
249
|
+
app.dependency_overrides[get_payments] = lambda: fake
|
|
250
|
+
async with (
|
|
251
|
+
app.router.lifespan_context(app),
|
|
252
|
+
httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client,
|
|
253
|
+
):
|
|
254
|
+
response = await client.post("/orders/100/pay")
|
|
255
|
+
assert response.status_code == 303
|
|
256
|
+
app.dependency_overrides.clear()
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
- Override one gateway with `app.dependency_overrides[get_kbz_pay] = ...`.
|
|
260
|
+
- Test your fulfillment code with a `PaymentCallback(order_id=..., status=PaymentStatus.SUCCESSFUL, gateway_status=...)` you build yourself.
|
|
261
|
+
- Callbacks you post through `TestClient` are still signature-checked; sign them with your test secret (see the core SDK's [Testing](https://laranex.vercel.app/python-myanmar-payments/testing) guide).
|
|
262
|
+
|
|
263
|
+
## What this package does not do
|
|
264
|
+
|
|
265
|
+
No webhook router, no storage, no background processing, no retries and no automatic fulfillment: those decisions stay in your application. Request body size limits belong to your server or proxy.
|
|
266
|
+
|
|
267
|
+
## License
|
|
268
|
+
|
|
269
|
+
MIT. See [LICENSE](LICENSE.md).
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fastapi-myanmar-payments"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "FastAPI integration for python-myanmar-payments: lifespan, dependencies, verified callbacks and gateway acknowledgements."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE.md"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "Soe Thura", email = "thixpin@gmail.com" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"laranex",
|
|
16
|
+
"python-myanmar-payments",
|
|
17
|
+
"fastapi",
|
|
18
|
+
"myanmar",
|
|
19
|
+
"payments",
|
|
20
|
+
"payment-gateway",
|
|
21
|
+
"webhooks",
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
25
|
+
"Framework :: AsyncIO",
|
|
26
|
+
"Framework :: FastAPI",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"Operating System :: OS Independent",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
31
|
+
"Programming Language :: Python :: 3.10",
|
|
32
|
+
"Programming Language :: Python :: 3.11",
|
|
33
|
+
"Programming Language :: Python :: 3.12",
|
|
34
|
+
"Programming Language :: Python :: 3.13",
|
|
35
|
+
"Programming Language :: Python :: 3.14",
|
|
36
|
+
"Topic :: Office/Business :: Financial",
|
|
37
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
38
|
+
"Typing :: Typed",
|
|
39
|
+
]
|
|
40
|
+
# 4.0.0a16 is the first published core with every API the adapter uses (AsyncWavePay).
|
|
41
|
+
dependencies = [
|
|
42
|
+
"fastapi>=0.115",
|
|
43
|
+
"python-myanmar-payments>=4.0.0a16,<5",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.urls]
|
|
47
|
+
Homepage = "https://github.com/laranex/python-myanmar-payments/tree/dev/adapters/fastapi"
|
|
48
|
+
Documentation = "https://github.com/laranex/python-myanmar-payments/tree/dev/adapters/fastapi#readme"
|
|
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/adapters/fastapi/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/fastapi_myanmar_payments/_version.py"
|
|
63
|
+
|
|
64
|
+
[tool.hatch.build.targets.sdist]
|
|
65
|
+
include = [
|
|
66
|
+
"/src",
|
|
67
|
+
"/skills",
|
|
68
|
+
"/CHANGELOG.md",
|
|
69
|
+
"/LICENSE.md",
|
|
70
|
+
"/README.md",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
[tool.hatch.build.targets.wheel]
|
|
74
|
+
packages = ["src/fastapi_myanmar_payments"]
|
|
75
|
+
|
|
76
|
+
[tool.pytest.ini_options]
|
|
77
|
+
testpaths = ["tests"]
|
|
78
|
+
addopts = ["--strict-markers", "--strict-config", "-ra"]
|
|
79
|
+
xfail_strict = true
|
|
80
|
+
filterwarnings = ["error"]
|
|
81
|
+
|
|
82
|
+
[tool.coverage.run]
|
|
83
|
+
branch = true
|
|
84
|
+
source = ["fastapi_myanmar_payments"]
|
|
85
|
+
|
|
86
|
+
[tool.coverage.report]
|
|
87
|
+
show_missing = true
|
|
88
|
+
skip_covered = true
|
|
89
|
+
fail_under = 100
|
|
90
|
+
exclude_also = ["if TYPE_CHECKING:"]
|
|
91
|
+
|
|
92
|
+
[tool.mypy]
|
|
93
|
+
strict = true
|
|
94
|
+
python_version = "3.10"
|
|
95
|
+
files = ["src", "tests"]
|
|
96
|
+
|
|
97
|
+
[tool.ruff]
|
|
98
|
+
line-length = 100
|
|
99
|
+
target-version = "py310"
|
|
100
|
+
src = ["src", "tests"]
|
|
101
|
+
|
|
102
|
+
[tool.ruff.lint]
|
|
103
|
+
select = ["E", "W", "F", "I", "B", "UP", "SIM", "RUF", "C4", "PT", "PIE", "RET", "N"]
|
|
104
|
+
|
|
105
|
+
[tool.ruff.lint.per-file-ignores]
|
|
106
|
+
"tests/**" = ["N802"]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: fastapi-myanmar-payments
|
|
3
|
+
description: >
|
|
4
|
+
Wire Myanmar payment gateways (KBZ Pay, Wave Pay, AYA Pay, Yoma MMQR, CyberSource) into a FastAPI app with fastapi-myanmar-payments: lifespan, gateway dependencies, verified callbacks and gateway acknowledgements.
|
|
5
|
+
license: MIT
|
|
6
|
+
metadata:
|
|
7
|
+
author: Soe Thura
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# FastAPI Myanmar Payments
|
|
11
|
+
|
|
12
|
+
## When to use
|
|
13
|
+
|
|
14
|
+
Use this skill when a FastAPI app takes payments with `python-myanmar-payments` and the project depends on `fastapi-myanmar-payments`. The adapter only connects FastAPI to the core SDK; payment data, amounts, gateway methods, statuses and errors are the core SDK's (see its `python-myanmar-payments` skill). Without the adapter, the core's async clients work in FastAPI directly.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install fastapi-myanmar-payments
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Requires Python 3.10+, FastAPI 0.115+ and `python-myanmar-payments` 4.0.0a16 or later (below 5). The core is a pre-release whose versions may break each other: pin both packages and upgrade them together. Import the adapter from `fastapi_myanmar_payments` and everything else from `python_myanmar_payments`.
|
|
23
|
+
|
|
24
|
+
## Configure
|
|
25
|
+
|
|
26
|
+
Create the facade once per app with `payments_lifespan`; never build gateways or HTTP clients per request.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from fastapi import FastAPI
|
|
30
|
+
from python_myanmar_payments import AsyncMyanmarPayments
|
|
31
|
+
|
|
32
|
+
from fastapi_myanmar_payments import payments_lifespan
|
|
33
|
+
|
|
34
|
+
app = FastAPI(lifespan=payments_lifespan(AsyncMyanmarPayments.from_env))
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- A factory (`AsyncMyanmarPayments.from_env`, or a function returning `AsyncMyanmarPayments(kbz_pay={...}, ...)`) is created at startup and `aclose()`d at shutdown, which closes only the HTTP clients the core created. An instance you pass is never closed. An `http_client` or `token_cache` you give the facade is never closed by the adapter: close it yourself.
|
|
38
|
+
- With your own lifespan, nest it: `async with payments_lifespan(factory)(app): yield`.
|
|
39
|
+
- Settings and environment variables are the core's (`KBZ_PAY_*`, `WAVE_PAY_*`, ...). An unconfigured gateway raises the core `ConfigurationError`; a missing lifespan makes `get_payments` raise `RuntimeError`.
|
|
40
|
+
|
|
41
|
+
## Use
|
|
42
|
+
|
|
43
|
+
### Dependencies
|
|
44
|
+
|
|
45
|
+
`PaymentsDep` (`AsyncMyanmarPayments`), `KbzPayDep` (`AsyncKbzPay`), `WavePayDep` (`AsyncWavePay`), `AyaPayDep` (`AsyncAyaPay`), `YomaMmqrDep` (`AsyncYomaMmqr`) and `CyberSourceDep` (`CyberSource`) are `Annotated` aliases of `get_payments`, `get_kbz_pay`, `get_wave_pay`, `get_aya_pay`, `get_yoma_mmqr` and `get_cyber_source`. Await the async gateways' methods:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from fastapi_myanmar_payments import KbzPayDep
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.post("/orders/{order_id}/pay")
|
|
52
|
+
async def pay(order_id: str, kbz: KbzPayDep):
|
|
53
|
+
payment = await kbz.pwa(data) # data: the core's KbzPayPaymentData
|
|
54
|
+
return RedirectResponse(payment.url, status_code=303)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Never retry a failed payment initiation automatically; check the status or start a new attempt.
|
|
58
|
+
|
|
59
|
+
### Handle the callback
|
|
60
|
+
|
|
61
|
+
Verify, validate against the stored order, persist, acknowledge, then process once:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from fastapi import Response
|
|
65
|
+
|
|
66
|
+
from fastapi_myanmar_payments import (
|
|
67
|
+
CallbackRequestDep,
|
|
68
|
+
KbzPayDep,
|
|
69
|
+
acknowledgement_response,
|
|
70
|
+
verify_callback,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.post("/payments/kbz/callback")
|
|
75
|
+
async def kbz_callback(kbz: KbzPayDep, request: CallbackRequestDep) -> Response:
|
|
76
|
+
callback = verify_callback(kbz, request) # HTTPException(400) on a bad signature
|
|
77
|
+
order = await orders.get(callback.order_id)
|
|
78
|
+
await events.insert_once(callback, order_known=order is not None) # durable, idempotent
|
|
79
|
+
return acknowledgement_response(callback.acknowledgement)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- `CallbackRequestDep` (or `await callback_request(request)`) passes the raw body bytes, headers and query string to the core. Never rebuild the body from `await request.json()` or `await request.form()`.
|
|
83
|
+
- `verify_callback` calls the gateway's `handle_callback()` (sync or async clients, or `CyberSource`) and turns `SignatureVerificationError` into a generic 400 with the error chained as `__cause__`. Don't catch it to return 200.
|
|
84
|
+
- `acknowledgement_response` returns the gateway's exact status, body and headers (KBZ Pay's plain `success`, an empty `text/plain` 200 for most). Never write your own acknowledgement.
|
|
85
|
+
- Acknowledge only after the event is stored. If storing fails, let it raise a 5xx so the gateway retries.
|
|
86
|
+
- An unknown order: store it as an unknown-order event, then acknowledge. Never create the order or mark it paid.
|
|
87
|
+
- Not every gateway sends an amount (Yoma MMQR doesn't): compare `callback.amount` with the stored order when present, and fulfill only `callback.is_successful()` callbacks once, outside the request. FastAPI `BackgroundTasks` are lost on a crash.
|
|
88
|
+
|
|
89
|
+
## Test your app
|
|
90
|
+
|
|
91
|
+
- Override `app.dependency_overrides[get_payments]` with an `AsyncMyanmarPayments` whose `http_client` is `httpx.AsyncClient(transport=httpx.MockTransport(handler))`, or override one gateway, e.g. `app.dependency_overrides[get_kbz_pay]`.
|
|
92
|
+
- Run the app in-process: `async with app.router.lifespan_context(app)` and `httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test")`.
|
|
93
|
+
- Posted callbacks are still signature-checked; sign them with test secrets.
|
|
94
|
+
|
|
95
|
+
## Avoid
|
|
96
|
+
|
|
97
|
+
- Fulfilling orders from return pages or redirects.
|
|
98
|
+
- Creating `AsyncMyanmarPayments` or gateways inside an endpoint.
|
|
99
|
+
- Parsing and re-serializing callback bodies before verification.
|
|
100
|
+
- Acknowledging before the callback is durably stored.
|
|
101
|
+
- Adding signing, verification or status mapping to the app or the adapter: they belong to the core SDK.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""FastAPI integration for python-myanmar-payments.
|
|
2
|
+
|
|
3
|
+
A lifespan that owns one ``AsyncMyanmarPayments``, dependencies for its gateways,
|
|
4
|
+
raw callback requests, verification that answers bad signatures with a 400, and
|
|
5
|
+
the gateway's acknowledgement as a response. Payments, signing and verification
|
|
6
|
+
stay in ``python_myanmar_payments``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ._version import __version__
|
|
10
|
+
from .callbacks import CallbackHandler, CallbackRequestDep, callback_request, verify_callback
|
|
11
|
+
from .dependencies import (
|
|
12
|
+
AyaPayDep,
|
|
13
|
+
CyberSourceDep,
|
|
14
|
+
KbzPayDep,
|
|
15
|
+
PaymentsDep,
|
|
16
|
+
WavePayDep,
|
|
17
|
+
YomaMmqrDep,
|
|
18
|
+
get_aya_pay,
|
|
19
|
+
get_cyber_source,
|
|
20
|
+
get_kbz_pay,
|
|
21
|
+
get_payments,
|
|
22
|
+
get_wave_pay,
|
|
23
|
+
get_yoma_mmqr,
|
|
24
|
+
)
|
|
25
|
+
from .lifespan import payments_lifespan
|
|
26
|
+
from .responses import acknowledgement_response
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AyaPayDep",
|
|
30
|
+
"CallbackHandler",
|
|
31
|
+
"CallbackRequestDep",
|
|
32
|
+
"CyberSourceDep",
|
|
33
|
+
"KbzPayDep",
|
|
34
|
+
"PaymentsDep",
|
|
35
|
+
"WavePayDep",
|
|
36
|
+
"YomaMmqrDep",
|
|
37
|
+
"__version__",
|
|
38
|
+
"acknowledgement_response",
|
|
39
|
+
"callback_request",
|
|
40
|
+
"get_aya_pay",
|
|
41
|
+
"get_cyber_source",
|
|
42
|
+
"get_kbz_pay",
|
|
43
|
+
"get_payments",
|
|
44
|
+
"get_wave_pay",
|
|
45
|
+
"get_yoma_mmqr",
|
|
46
|
+
"payments_lifespan",
|
|
47
|
+
"verify_callback",
|
|
48
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0.dev0"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Turns a FastAPI request into the core ``CallbackRequest`` and verifies it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Protocol
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends, HTTPException, Request
|
|
8
|
+
from python_myanmar_payments import CallbackRequest, PaymentCallback, SignatureVerificationError
|
|
9
|
+
|
|
10
|
+
__all__ = ["CallbackHandler", "CallbackRequestDep", "callback_request", "verify_callback"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def callback_request(request: Request) -> CallbackRequest:
|
|
14
|
+
"""The request exactly as the gateway sent it: raw body bytes, headers and query string.
|
|
15
|
+
|
|
16
|
+
Nothing is parsed or re-encoded here, so JSON and form callbacks reach the
|
|
17
|
+
core's signature check byte for byte. Use it as a dependency
|
|
18
|
+
(:data:`CallbackRequestDep`) or await it in an endpoint.
|
|
19
|
+
"""
|
|
20
|
+
return CallbackRequest(
|
|
21
|
+
body=await request.body(),
|
|
22
|
+
headers=request.headers,
|
|
23
|
+
query=request.scope["query_string"],
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
CallbackRequestDep = Annotated[CallbackRequest, Depends(callback_request)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CallbackHandler(Protocol):
|
|
31
|
+
"""Any gateway that verifies callbacks: sync or async clients, or ``CyberSource``."""
|
|
32
|
+
|
|
33
|
+
def handle_callback(self, request: CallbackRequest) -> PaymentCallback: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def verify_callback(gateway: CallbackHandler, request: CallbackRequest) -> PaymentCallback:
|
|
37
|
+
"""Verifies a callback with the gateway's ``handle_callback()``.
|
|
38
|
+
|
|
39
|
+
A ``SignatureVerificationError`` (a bad signature or a malformed signed
|
|
40
|
+
callback) becomes an ``HTTPException(400)`` with a generic detail; the original
|
|
41
|
+
error is chained as ``__cause__`` and its unverified payload is never sent back.
|
|
42
|
+
Other errors propagate.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
return gateway.handle_callback(request)
|
|
46
|
+
except SignatureVerificationError as error:
|
|
47
|
+
raise HTTPException(status_code=400, detail="Callback verification failed.") from error
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""FastAPI dependencies that hand out the application's payments facade and gateways."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends, Request
|
|
8
|
+
from python_myanmar_payments import (
|
|
9
|
+
AsyncAyaPay,
|
|
10
|
+
AsyncKbzPay,
|
|
11
|
+
AsyncMyanmarPayments,
|
|
12
|
+
AsyncWavePay,
|
|
13
|
+
AsyncYomaMmqr,
|
|
14
|
+
CyberSource,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AyaPayDep",
|
|
19
|
+
"CyberSourceDep",
|
|
20
|
+
"KbzPayDep",
|
|
21
|
+
"PaymentsDep",
|
|
22
|
+
"WavePayDep",
|
|
23
|
+
"YomaMmqrDep",
|
|
24
|
+
"get_aya_pay",
|
|
25
|
+
"get_cyber_source",
|
|
26
|
+
"get_kbz_pay",
|
|
27
|
+
"get_payments",
|
|
28
|
+
"get_wave_pay",
|
|
29
|
+
"get_yoma_mmqr",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
# The dependencies are `async def` so FastAPI runs them on the event loop rather
|
|
33
|
+
# than in its thread pool, where two requests could build the same gateway twice.
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def get_payments(request: Request) -> AsyncMyanmarPayments:
|
|
37
|
+
"""The ``AsyncMyanmarPayments`` that :func:`payments_lifespan` stored on the app.
|
|
38
|
+
|
|
39
|
+
Override it with ``app.dependency_overrides[get_payments]`` in tests; the
|
|
40
|
+
gateway dependencies use it too.
|
|
41
|
+
"""
|
|
42
|
+
payments = getattr(request.app.state, "myanmar_payments", None)
|
|
43
|
+
if not isinstance(payments, AsyncMyanmarPayments):
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
"No AsyncMyanmarPayments is configured for this app: pass "
|
|
46
|
+
"payments_lifespan(...) as the FastAPI lifespan or override get_payments."
|
|
47
|
+
)
|
|
48
|
+
return payments
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
PaymentsDep = Annotated[AsyncMyanmarPayments, Depends(get_payments)]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def get_kbz_pay(payments: PaymentsDep) -> AsyncKbzPay:
|
|
55
|
+
"""The KBZ Pay gateway. Raises the core ``ConfigurationError`` when not configured."""
|
|
56
|
+
return payments.kbz_pay()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def get_wave_pay(payments: PaymentsDep) -> AsyncWavePay:
|
|
60
|
+
"""The Wave Pay gateway. Raises the core ``ConfigurationError`` when not configured."""
|
|
61
|
+
return payments.wave_pay()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def get_aya_pay(payments: PaymentsDep) -> AsyncAyaPay:
|
|
65
|
+
"""The AYA Payment Gateway. Raises the core ``ConfigurationError`` when not configured."""
|
|
66
|
+
return payments.aya_pay()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def get_yoma_mmqr(payments: PaymentsDep) -> AsyncYomaMmqr:
|
|
70
|
+
"""The Yoma MMQR gateway. Raises the core ``ConfigurationError`` when not configured."""
|
|
71
|
+
return payments.yoma_mmqr()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def get_cyber_source(payments: PaymentsDep) -> CyberSource:
|
|
75
|
+
"""The CyberSource gateway. Raises the core ``ConfigurationError`` when not configured."""
|
|
76
|
+
return payments.cyber_source()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
KbzPayDep = Annotated[AsyncKbzPay, Depends(get_kbz_pay)]
|
|
80
|
+
WavePayDep = Annotated[AsyncWavePay, Depends(get_wave_pay)]
|
|
81
|
+
AyaPayDep = Annotated[AsyncAyaPay, Depends(get_aya_pay)]
|
|
82
|
+
YomaMmqrDep = Annotated[AsyncYomaMmqr, Depends(get_yoma_mmqr)]
|
|
83
|
+
CyberSourceDep = Annotated[CyberSource, Depends(get_cyber_source)]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Creates the payments facade once per application and closes what it created."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Callable
|
|
6
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
from python_myanmar_payments import AsyncMyanmarPayments
|
|
10
|
+
|
|
11
|
+
__all__ = ["payments_lifespan"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def payments_lifespan(
|
|
15
|
+
payments: AsyncMyanmarPayments | Callable[[], AsyncMyanmarPayments],
|
|
16
|
+
) -> Callable[[FastAPI], AbstractAsyncContextManager[None]]:
|
|
17
|
+
"""A FastAPI lifespan that stores one ``AsyncMyanmarPayments`` on ``app.state``.
|
|
18
|
+
|
|
19
|
+
Pass a factory such as ``AsyncMyanmarPayments.from_env`` to let the lifespan
|
|
20
|
+
create the facade at startup and ``aclose()`` it at shutdown; that closes only
|
|
21
|
+
the HTTP clients the facade created, never an ``http_client`` or token cache
|
|
22
|
+
you gave it. Pass an instance to keep it under your control: it is never
|
|
23
|
+
closed.
|
|
24
|
+
|
|
25
|
+
.. code-block:: python
|
|
26
|
+
|
|
27
|
+
app = FastAPI(lifespan=payments_lifespan(AsyncMyanmarPayments.from_env))
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
@asynccontextmanager
|
|
31
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
32
|
+
if isinstance(payments, AsyncMyanmarPayments):
|
|
33
|
+
instance, owned = payments, False
|
|
34
|
+
else:
|
|
35
|
+
instance, owned = payments(), True
|
|
36
|
+
app.state.myanmar_payments = instance
|
|
37
|
+
try:
|
|
38
|
+
yield
|
|
39
|
+
finally:
|
|
40
|
+
del app.state.myanmar_payments
|
|
41
|
+
if owned:
|
|
42
|
+
await instance.aclose()
|
|
43
|
+
|
|
44
|
+
return lifespan
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Turns the core ``Acknowledgement`` into a Starlette response."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import Response
|
|
6
|
+
from python_myanmar_payments import Acknowledgement
|
|
7
|
+
|
|
8
|
+
__all__ = ["acknowledgement_response"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def acknowledgement_response(acknowledgement: Acknowledgement) -> Response:
|
|
12
|
+
"""The response the gateway expects: its status, body and headers, unchanged.
|
|
13
|
+
|
|
14
|
+
No media type is added, so the ``Content-Type`` is exactly the gateway's
|
|
15
|
+
(e.g. ``text/plain`` without a charset).
|
|
16
|
+
"""
|
|
17
|
+
return Response(
|
|
18
|
+
content=acknowledgement.body,
|
|
19
|
+
status_code=acknowledgement.status,
|
|
20
|
+
headers=dict(acknowledgement.headers),
|
|
21
|
+
)
|