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.
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .coverage
8
+ .coverage.*
9
+ coverage.xml
10
+ htmlcov/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .env
15
+ .DS_Store
16
+ /.idea
@@ -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
@@ -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
+ )