qpay-client 0.1.0__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,21 @@
1
+ Metadata-Version: 2.3
2
+ Name: qpay-client
3
+ Version: 0.1.0
4
+ Summary: Async qpay payment API client
5
+ Author: Amraa1
6
+ Author-email: Amraa1 <amarsanaaganbaatar0409@gmail.com>
7
+ Requires-Dist: httpx>=0.28.1
8
+ Requires-Dist: pydantic>=2.11.7
9
+ Requires-Python: >=3.12
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Package description
13
+ Features I like to add:
14
+ * Async and sync client
15
+ * Provide ways for users to quickly create a client and start testing
16
+ * Provide ways for users to optimize and prepare for prod env
17
+ * qpay logs
18
+ *
19
+
20
+ # QPAY developer doc
21
+ https://developer.qpay.mn/
@@ -0,0 +1,10 @@
1
+ # Package description
2
+ Features I like to add:
3
+ * Async and sync client
4
+ * Provide ways for users to quickly create a client and start testing
5
+ * Provide ways for users to optimize and prepare for prod env
6
+ * qpay logs
7
+ *
8
+
9
+ # QPAY developer doc
10
+ https://developer.qpay.mn/
@@ -0,0 +1,17 @@
1
+ [project]
2
+ name = "qpay-client"
3
+ version = "0.1.0"
4
+ description = "Async qpay payment API client"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Amraa1", email = "amarsanaaganbaatar0409@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "httpx>=0.28.1",
12
+ "pydantic>=2.11.7",
13
+ ]
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.8.14,<0.9.0"]
17
+ build-backend = "uv_build"
@@ -0,0 +1,4 @@
1
+ from .v2.qpay_client import QPayClient
2
+
3
+
4
+ __all__ = ["QPayClient"]
File without changes
@@ -0,0 +1,23 @@
1
+ class QPayError(Exception):
2
+ """
3
+ Raised when Qpay server returns error
4
+ """
5
+
6
+ def __init__(self, *, status_code: int, error_key: str) -> None:
7
+ self.exception_message = f"status_code: {status_code}, error_key: {error_key}"
8
+ super().__init__(self.exception_message)
9
+ self.status_code = status_code
10
+ self.error_key = error_key
11
+
12
+ def __repr__(self) -> str:
13
+ return self.exception_message
14
+
15
+
16
+ class ClientConfigError(Exception):
17
+ """
18
+ Raised when the client is configured wrong.
19
+ """
20
+
21
+ def __init__(self, *attr) -> None:
22
+ self.exception_message = f"incorrect attributes: {attr}"
23
+ super().__init__(self.exception_message)
@@ -0,0 +1,273 @@
1
+ import os
2
+ from httpx import AsyncClient, BasicAuth, Response
3
+ import time
4
+ from .schemas import (
5
+ InvoiceCreateRequest,
6
+ InvoiceCreateSimpleRequest,
7
+ Payment,
8
+ PaymentCheckRequest,
9
+ PaymentCheckResponse,
10
+ CreateInvoiceResponse,
11
+ TokenResponse,
12
+ PaymentListRequest,
13
+ EbarimtCreateRequest,
14
+ Ebarimt,
15
+ )
16
+ from .error import QPayError, ClientConfigError
17
+ from typing import Optional
18
+ import enum
19
+ import logging
20
+
21
+
22
+ class Environment(enum.Enum):
23
+ sandbox = 1
24
+ production = 2
25
+
26
+
27
+ logger = logging.getLogger("qpay")
28
+
29
+
30
+ QPAY_USERNAME = os.getenv("QPAY_USERNAME", "TEST_MERCHANT")
31
+ QPAY_PASSWORD = os.getenv("QPAY_PASSWORD", "123456")
32
+ QPAY_ENV = os.getenv("QPAY_ENV", "sandbox")
33
+
34
+ if QPAY_ENV == "production":
35
+ is_sandbox = False
36
+ elif QPAY_ENV == "sandbox":
37
+ is_sandbox = True
38
+ else:
39
+ raise ValueError("QPAY_ENV must either sandbox or production")
40
+
41
+
42
+ # Env based settings
43
+ if is_sandbox:
44
+ BASE_URL = "https://merchant-sandbox.qpay.mn/v2"
45
+ else:
46
+ BASE_URL = "https://merchant.qpay.mn/v2"
47
+
48
+ is_prod = not is_sandbox
49
+
50
+
51
+ class QPayClient:
52
+ """
53
+ Async QPay v2 client
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ *,
59
+ username: Optional[str] = None,
60
+ password: Optional[str] = None,
61
+ base_url: Optional[str] = None,
62
+ is_sandbox: Optional[bool] = None,
63
+ token_leeway=60,
64
+ timeout=30,
65
+ logger=logger,
66
+ ):
67
+ if is_sandbox is None:
68
+ is_sandbox = not is_prod
69
+
70
+ if is_sandbox:
71
+ self._env = Environment.sandbox
72
+ elif not is_sandbox:
73
+ self._env = Environment.production
74
+
75
+ self._base_url = base_url or BASE_URL
76
+ self._client = AsyncClient(timeout=timeout)
77
+ self._auth_credentials = BasicAuth(
78
+ username=username or QPAY_USERNAME,
79
+ password=password or QPAY_PASSWORD,
80
+ )
81
+ self._access_token = ""
82
+ self._access_token_expiry = 0
83
+ self._refresh_token = ""
84
+ self._refresh_token_expiry = 0
85
+ self._scope = ""
86
+ self._not_before_policy = ""
87
+ self._session_state = ""
88
+ self._timeout = timeout or 30
89
+ self._token_leeway = token_leeway or 60
90
+ self._logger = logger
91
+
92
+ @property
93
+ async def headers(self):
94
+ token = await self.get_token()
95
+ return {
96
+ "Content-Type": "APP_JSON",
97
+ "Authorization": f"Bearer {token}",
98
+ }
99
+
100
+ def _check_error(self, response: Response):
101
+ if response.is_error:
102
+ print(response.json())
103
+ error_data = response.json()
104
+ raise QPayError(
105
+ status_code=response.status_code, error_key=error_data["message"]
106
+ )
107
+
108
+ # Auth
109
+ async def authenticate(self):
110
+ response = await self._client.post(
111
+ BASE_URL + "/auth/token",
112
+ auth=self._auth_credentials,
113
+ timeout=self._timeout,
114
+ )
115
+ # Raises status error if there is error
116
+ self._check_error(response)
117
+
118
+ data = TokenResponse.model_validate(response.json())
119
+
120
+ self._access_token = data.access_token
121
+ self._refresh_token = data.refresh_token
122
+ self._access_token_expiry = data.expires_in - self._token_leeway
123
+ self._refresh_token_expiry = data.refresh_expires_in - self._token_leeway
124
+ self._scope = data.scope
125
+ self._not_before_policy = data.not_before_policy
126
+ self._session_state = data.session_state
127
+
128
+ async def refresh_access_token(self):
129
+ if not self._refresh_token or time.time() >= self._refresh_token_expiry:
130
+ await self.authenticate()
131
+ return
132
+
133
+ response = await self._client.post(
134
+ BASE_URL + "/auth/refresh",
135
+ headers={"Authorization": f"Bearer {self._refresh_token}"},
136
+ timeout=self._timeout,
137
+ )
138
+
139
+ self._check_error(response)
140
+
141
+ if response.is_success:
142
+ data = TokenResponse.model_validate(response.json())
143
+
144
+ self._access_token = data.access_token
145
+ self._refresh_token = data.refresh_token
146
+ self._access_token_expiry = data.expires_in - self._token_leeway
147
+ self._refresh_token_expiry = data.refresh_expires_in - self._token_leeway
148
+ else:
149
+ await self.authenticate()
150
+
151
+ async def get_token(self):
152
+ if not self._access_token:
153
+ await self.authenticate()
154
+ elif time.time() >= self._access_token_expiry:
155
+ await self.refresh_access_token()
156
+ return self._access_token
157
+
158
+ # Invoice
159
+ async def invoice_create(
160
+ self, create_invoice_request: InvoiceCreateRequest | InvoiceCreateSimpleRequest
161
+ ):
162
+ response = await self._client.post(
163
+ BASE_URL + "/invoice",
164
+ headers=await self.headers,
165
+ data=create_invoice_request.model_dump(),
166
+ timeout=self._timeout,
167
+ )
168
+
169
+ self._check_error(response)
170
+
171
+ data = CreateInvoiceResponse.model_validate_json(response.json())
172
+ return data
173
+
174
+ async def invoice_cancel(
175
+ self,
176
+ invoice_id: str,
177
+ ):
178
+ response = await self._client.delete(
179
+ BASE_URL + "/invoice/" + invoice_id,
180
+ headers=await self.headers,
181
+ timeout=self._timeout,
182
+ )
183
+
184
+ self._check_error(response)
185
+
186
+ return response.json()
187
+
188
+ # Payment
189
+ async def payment_get(self, payment_id: str):
190
+ response = await self._client.get(
191
+ BASE_URL + "/payment/" + payment_id,
192
+ headers=await self.headers,
193
+ timeout=self._timeout,
194
+ )
195
+
196
+ self._check_error(response)
197
+
198
+ validated_response = Payment.model_validate(response.json())
199
+ return validated_response
200
+
201
+ async def payment_check(self, payment_check_request: PaymentCheckRequest):
202
+ response = await self._client.post(
203
+ BASE_URL + "/payment/check",
204
+ data=payment_check_request.model_dump(),
205
+ headers=await self.headers,
206
+ timeout=self._timeout,
207
+ )
208
+
209
+ self._check_error(response)
210
+
211
+ validated_response = PaymentCheckResponse.model_validate_json(response.json())
212
+ return validated_response
213
+
214
+ async def payment_cancel(self, payment_id: str):
215
+ response = await self._client.delete(
216
+ BASE_URL + "/payment/cancel/" + payment_id,
217
+ headers=await self.headers,
218
+ timeout=self._timeout,
219
+ )
220
+
221
+ self._check_error(response)
222
+
223
+ return response.json()
224
+
225
+ async def payment_refund(self, payment_id: str):
226
+ response = await self._client.delete(
227
+ BASE_URL + "/payment/refund/" + payment_id,
228
+ headers=await self.headers,
229
+ timeout=self._timeout,
230
+ )
231
+
232
+ self._check_error(response)
233
+
234
+ return response.json()
235
+
236
+ async def payment_list(self, payment_list_request: PaymentListRequest):
237
+ response = await self._client.post(
238
+ BASE_URL + "/payment/list",
239
+ data=payment_list_request.model_dump(),
240
+ headers=await self.headers,
241
+ timeout=self._timeout,
242
+ )
243
+
244
+ self._check_error(response)
245
+
246
+ validated_response = PaymentCheckResponse.model_validate_json(response.json())
247
+ return validated_response
248
+
249
+ # ebarimt
250
+ async def ebarimt_create(self, ebarimt_create_request: EbarimtCreateRequest):
251
+ response = await self._client.post(
252
+ BASE_URL + "/ebarimt/create",
253
+ data=ebarimt_create_request.model_dump(),
254
+ headers=await self.headers,
255
+ timeout=self._timeout,
256
+ )
257
+
258
+ self._check_error(response)
259
+
260
+ validated_response = Ebarimt.model_validate_json(response.json())
261
+ return validated_response
262
+
263
+ async def ebarimt_get(self, barimt_id: str):
264
+ response = await self._client.get(
265
+ BASE_URL + "/ebarimt/" + barimt_id,
266
+ headers=await self.headers,
267
+ timeout=self._timeout,
268
+ )
269
+
270
+ self._check_error(response)
271
+
272
+ validated_response = Ebarimt.model_validate_json(response.json())
273
+ return validated_response
@@ -0,0 +1,306 @@
1
+ from typing import Optional, Dict, List
2
+ from pydantic import BaseModel, Field, ConfigDict
3
+ from datetime import date, datetime
4
+ from decimal import Decimal
5
+ from enum import StrEnum
6
+
7
+
8
+ class Currency(StrEnum):
9
+ mnt = "MNT"
10
+ usd = "USD"
11
+ cny = "CNY"
12
+ jpy = "JPY"
13
+ rub = "RUB"
14
+ eur = "EUR"
15
+
16
+
17
+ class PaymentStatus(StrEnum):
18
+ new = "NEW"
19
+ failed = "FAILED"
20
+ paid = "PAID"
21
+ partial = "PARTIAL"
22
+ refunded = "REFUNDED"
23
+
24
+
25
+ class BankCode(StrEnum):
26
+ bank_of_mongolia = "010000"
27
+ capital_bank = "020000"
28
+ trade_and_development_bank_of_mongolia = "040000"
29
+ khan_bank = "050000"
30
+ golomt_bank = "150000"
31
+ trans_bank = "190000"
32
+ arig_bank = "210000"
33
+ credit_bank = "220000"
34
+ nib_bank = "290000"
35
+ capitron_bank = "300000"
36
+ khas_bank = "320000"
37
+ chingiskhan_bank = "330000"
38
+ state_bank = "340000"
39
+ national_development_bank = "360000"
40
+ bogd_bank = "380000"
41
+ state_fund = "900000"
42
+ mobi_finance = "500000"
43
+ m_bank = "390000"
44
+ invescore = "993000"
45
+ test_bank = "100000"
46
+
47
+
48
+ class ObjectTypeNum(StrEnum):
49
+ invoice = "INVOICE"
50
+ qr = "QR"
51
+ item = "ITEM"
52
+
53
+
54
+ class TokenResponse(BaseModel):
55
+ token_type: str
56
+ access_token: str
57
+ expires_in: float
58
+ refresh_token: str
59
+ refresh_expires_in: float
60
+ scope: str
61
+ not_before_policy: str = Field(..., alias="not-before-policy")
62
+ session_state: str
63
+
64
+
65
+ class QPayDeeplink(BaseModel):
66
+ name: str
67
+ description: str
68
+ logo: str
69
+ link: str
70
+
71
+
72
+ class Address(BaseModel):
73
+ city: Optional[str] = Field(default=None, max_length=100)
74
+ district: Optional[str] = Field(default=None, max_length=100)
75
+ street: Optional[str] = Field(default=None, max_length=100)
76
+ building: Optional[str] = Field(default=None, max_length=100)
77
+ address: Optional[str] = Field(default=None, max_length=100)
78
+ zipcode: Optional[str] = Field(default=None, max_length=20)
79
+ longitude: Optional[str] = Field(default=None, max_length=20)
80
+ latitude: Optional[str] = Field(default=None, max_length=20)
81
+
82
+
83
+ class SenderTerminalData(BaseModel):
84
+ name: Optional[str] = Field(default=None, max_length=100)
85
+
86
+
87
+ class InvoiceReceiverData(BaseModel):
88
+ model_config = ConfigDict(populate_by_name=True)
89
+
90
+ registration_number: Optional[str] = Field(
91
+ default=None, alias="register", max_length=20
92
+ )
93
+ name: Optional[str] = Field(default=None, max_length=100)
94
+ email: Optional[str] = Field(default=None, max_length=255)
95
+ phone: Optional[str] = Field(default=None, max_length=20)
96
+ address: Optional[Address] = None
97
+
98
+
99
+ class SenderBranchData(BaseModel):
100
+ model_config = ConfigDict(populate_by_name=True)
101
+
102
+ registration_number: Optional[str] = Field(
103
+ default=None, alias="register", max_length=20
104
+ )
105
+ name: Optional[str] = Field(default=None, max_length=100)
106
+ email: Optional[str] = Field(default=None, max_length=255)
107
+ phone: Optional[str] = Field(default=None, max_length=20)
108
+ address: Optional[Address] = None
109
+
110
+
111
+ class Discount(BaseModel):
112
+ discount_code: Optional[str] = Field(default=None, max_length=45)
113
+ description: str = Field(max_length=100)
114
+ amount: Decimal = Field(max_digits=20)
115
+ note: Optional[str] = Field(default=None, max_length=255)
116
+
117
+
118
+ class Surcharge(BaseModel):
119
+ surcharge_code: Optional[str] = Field(default=None, max_length=45)
120
+ description: str = Field(max_length=100)
121
+ amount: Decimal = Field(max_digits=20)
122
+ note: Optional[str] = Field(default=None, max_length=255)
123
+
124
+
125
+ class Tax(BaseModel):
126
+ tax_code: Optional[str] = Field(default=None, max_length=20)
127
+ description: Optional[str] = Field(default=None, max_length=100)
128
+ amount: Decimal
129
+ note: Optional[str] = Field(default=None, max_length=255)
130
+
131
+
132
+ class Line(BaseModel):
133
+ sender_product_code: Optional[str]
134
+ tax_product_code: Optional[str]
135
+ line_description: str = Field(max_length=255)
136
+ line_quantity: Decimal = Field(max_digits=20)
137
+ line_unit_price: Decimal = Field(max_digits=20)
138
+ note: Optional[str] = Field(default=None, max_length=100)
139
+ discounts: Optional[List[Discount]] = None
140
+ surcharges: Optional[List[Surcharge]] = None
141
+ taxes: Optional[List[Tax]] = None
142
+
143
+
144
+ class SenderStaffData(BaseModel):
145
+ name: Optional[str] = Field(default=None, max_length=100)
146
+ email: Optional[str] = Field(default=None, max_length=255)
147
+ phone: Optional[str] = Field(default=None, max_length=20)
148
+
149
+
150
+ class InvoiceCreateSimpleRequest(BaseModel):
151
+ invoice_code: str = Field(examples=["TEST_INVOICE"], max_length=45)
152
+ sender_invoice_no: str = Field(examples=["123"], max_length=45)
153
+ invoice_receiver_code: str = Field(max_length=45)
154
+ invoice_description: str = Field(max_length=255)
155
+ sender_branch_code: Optional[str] = Field(default=None, max_length=45)
156
+ amount: Decimal = Field(gt=0)
157
+ callback_url: str = Field(max_length=255)
158
+
159
+
160
+ class InvoiceCreateRequest(BaseModel):
161
+ invoice_code: str = Field(max_length=45)
162
+ sender_invoice_no: str = Field(max_length=45)
163
+ sender_branch_code: Optional[str] = Field(default=None, max_length=45)
164
+ sender_branch_data: Optional[SenderBranchData] = None
165
+ sender_staff_code: Optional[str] = Field(default=None, max_length=100)
166
+ sender_staff_data: Optional[SenderStaffData] = None
167
+ sender_terminal_code: Optional[str] = Field(default=None, max_length=45)
168
+ sender_terminal_data: Optional[SenderTerminalData] = None
169
+ invoice_receiver_code: str = Field(max_length=45)
170
+ invoice_receiver_data: Optional[InvoiceReceiverData] = None
171
+ invoice_description: str = Field(max_length=255)
172
+ invoice_due_date: Optional[date] = None
173
+ enable_expiry: Optional[bool] = None
174
+ expiry_date: Optional[date] = None
175
+ calculate_vat: Optional[bool] = Field(default=None)
176
+ tax_customer_code: Optional[str] = None
177
+ line_tax_code: Optional[str] = Field(default=None)
178
+ allow_partial: Optional[bool] = Field(default=None)
179
+ minimum_amount: Optional[Decimal] = None
180
+ allow_exceed: Optional[bool] = Field(default=None)
181
+ maximum_amount: Optional[Decimal] = None
182
+ amount: Optional[Decimal] = Field(default=None)
183
+ callback_url: str = Field(max_length=255)
184
+ note: Optional[str] = Field(default=None, max_length=1000)
185
+ lines: Optional[List[Line]] = None
186
+ transactions: Optional[List] = None
187
+
188
+
189
+ class CreateInvoiceResponse(BaseModel):
190
+ invoice_id: str
191
+ qr_text: str
192
+ qr_image: str
193
+ qPay_shortUrl: str
194
+ urls: List[QPayDeeplink]
195
+
196
+
197
+ class CardTransaction(BaseModel):
198
+ card_type: str
199
+ is_cross_border: bool
200
+ amount: Decimal
201
+ currency: Currency
202
+ date: datetime
203
+ status: str
204
+ settlement_status: str
205
+ settlement_status_date: datetime
206
+
207
+
208
+ class P2PTransaction(BaseModel):
209
+ transaction_bank_code: BankCode
210
+ account_bank_code: BankCode
211
+ account_bank_name: str
212
+ account_number: str
213
+ status: str
214
+ amount: Decimal
215
+ currency: Currency
216
+ settlement_status: str
217
+
218
+
219
+ class Payment(BaseModel):
220
+ payment_id: Decimal
221
+ payment_status: PaymentStatus
222
+ payment_amount: Decimal
223
+ trx_fee: Decimal
224
+ payment_currency: Currency
225
+ payment_wallet: str
226
+ payment_type: str
227
+ next_payment_date: Optional[date] = None
228
+ next_payment_datetime: Optional[datetime] = None
229
+ card_transactions: List[CardTransaction]
230
+ p2p_transactions: List[P2PTransaction]
231
+
232
+
233
+ class Offset(BaseModel):
234
+ page_number: Decimal = Field(default=Decimal(1), ge=1, le=100)
235
+ page_limit: Decimal = Field(default=Decimal(10), ge=1, le=100)
236
+
237
+
238
+ class PaymentCheckResponse(BaseModel):
239
+ count: Optional[Decimal] = None
240
+ paid_amount: Optional[Decimal] = None
241
+ rows: Optional[List[Payment]] = None
242
+
243
+
244
+ class PaymentCheckRequest(BaseModel):
245
+ object_type: ObjectTypeNum
246
+ object_id: str = Field(max_length=50)
247
+ offset: Optional[Offset] = Field(default_factory=Offset)
248
+
249
+
250
+ class CancelPaymentRequest(Payment):
251
+ callback_url: str
252
+ note: str
253
+
254
+
255
+ class EbarimtCreateRequest(BaseModel):
256
+ payment_id: str
257
+ ebarimt_receiver_type: str
258
+ ebarimt_receiver: Optional[str] = None
259
+ callback_url: Optional[str] = None
260
+
261
+
262
+ class Ebarimt(BaseModel):
263
+ id: str
264
+ ebarimt_by: str
265
+ g_wallet_id: str
266
+ g_wallet_customer_id: str
267
+ ebarim_receiver_type: str
268
+ ebarimt_receiver: str
269
+ ebarimt_district_code: str
270
+ ebarimt_bill_type: str
271
+ g_merchant_id: str
272
+ merchant_branch_code: str
273
+ merchant_terminal_code: str
274
+ merchant_staff_code: str
275
+ merchant_register: Decimal
276
+ g_payment_id: Decimal
277
+ paid_by: str
278
+ object_type: str
279
+ object_id: str
280
+ amount: Decimal
281
+ vat_amount: Decimal
282
+ city_tax_amount: Decimal
283
+ ebarimt_qr_data: str
284
+ ebarimt_lottery: str
285
+ note: str
286
+ ebarimt_status: str
287
+ ebarimt_status_date: datetime
288
+ tax_type: str
289
+ created_by: str
290
+ created_date: datetime
291
+ updated_by: str
292
+ updated_date: datetime
293
+ status: bool
294
+
295
+
296
+ class PaymentListRequest(BaseModel):
297
+ object_type: str
298
+ object_id: str
299
+ start_date: datetime
300
+ end_date: datetime
301
+ offset: Offset
302
+
303
+
304
+ class PaymentCancelRequest(BaseModel):
305
+ callback_url: Optional[str] = None
306
+ note: Optional[str] = None