taiwan-payment-skill 1.0.0 → 1.0.1
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,451 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
NewebPay 藍新金流 Python 完整範例
|
|
4
|
+
|
|
5
|
+
依照 taiwan-payment-skill 最高規範撰寫
|
|
6
|
+
支援: MPG 整合支付 (信用卡、ATM、超商代碼、LINE Pay、Apple Pay 等)
|
|
7
|
+
|
|
8
|
+
API 文件: https://www.newebpay.com
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import json
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import Dict, Literal, Optional
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from Crypto.Cipher import AES
|
|
20
|
+
from Crypto.Util.Padding import pad, unpad
|
|
21
|
+
HAS_CRYPTO = True
|
|
22
|
+
except ImportError:
|
|
23
|
+
HAS_CRYPTO = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class MPGOrderData:
|
|
28
|
+
"""NewebPay MPG 付款訂單資料"""
|
|
29
|
+
merchant_order_no: str
|
|
30
|
+
amt: int
|
|
31
|
+
item_desc: str
|
|
32
|
+
email: str
|
|
33
|
+
return_url: str
|
|
34
|
+
notify_url: Optional[str] = None
|
|
35
|
+
client_back_url: Optional[str] = None
|
|
36
|
+
enable_credit: bool = True
|
|
37
|
+
enable_vacc: bool = False
|
|
38
|
+
enable_cvs: bool = False
|
|
39
|
+
enable_barcode: bool = False
|
|
40
|
+
enable_linepay: bool = False
|
|
41
|
+
enable_applepay: bool = False
|
|
42
|
+
login_type: int = 0
|
|
43
|
+
order_comment: Optional[str] = None
|
|
44
|
+
trade_limit: int = 900
|
|
45
|
+
exp_date: Optional[str] = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class MPGOrderResponse:
|
|
50
|
+
"""NewebPay MPG 付款訂單回應"""
|
|
51
|
+
success: bool
|
|
52
|
+
merchant_order_no: str
|
|
53
|
+
form_action: str = ''
|
|
54
|
+
trade_info: str = ''
|
|
55
|
+
trade_sha: str = ''
|
|
56
|
+
merchant_id: str = ''
|
|
57
|
+
version: str = ''
|
|
58
|
+
error_message: str = ''
|
|
59
|
+
raw: Dict[str, str] = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class MPGCallbackData:
|
|
64
|
+
"""NewebPay MPG 付款回傳資料"""
|
|
65
|
+
status: str
|
|
66
|
+
message: str
|
|
67
|
+
merchant_order_no: str
|
|
68
|
+
amt: int
|
|
69
|
+
trade_no: str
|
|
70
|
+
merchant_id: str
|
|
71
|
+
payment_type: str
|
|
72
|
+
pay_time: str
|
|
73
|
+
ip: str
|
|
74
|
+
esc_row_bank_acount: Optional[str] = None
|
|
75
|
+
code_no: Optional[str] = None
|
|
76
|
+
barcode_1: Optional[str] = None
|
|
77
|
+
barcode_2: Optional[str] = None
|
|
78
|
+
barcode_3: Optional[str] = None
|
|
79
|
+
expire_date: Optional[str] = None
|
|
80
|
+
raw: Dict = field(default_factory=dict)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class NewebPayMPGService:
|
|
84
|
+
"""
|
|
85
|
+
NewebPay 藍新金流 MPG 整合支付服務
|
|
86
|
+
|
|
87
|
+
認證方式: AES-256-CBC + SHA256
|
|
88
|
+
加密方式: 雙層加密 (AES 加密 + SHA256 驗證)
|
|
89
|
+
|
|
90
|
+
支援付款方式:
|
|
91
|
+
- CREDIT: 信用卡
|
|
92
|
+
- VACC: ATM 轉帳
|
|
93
|
+
- CVS: 超商代碼
|
|
94
|
+
- BARCODE: 超商條碼
|
|
95
|
+
- LINEPAY: LINE Pay
|
|
96
|
+
- APPLEPAY: Apple Pay
|
|
97
|
+
|
|
98
|
+
測試環境說明:
|
|
99
|
+
- 需至藍新金流申請測試帳號
|
|
100
|
+
- 測試網址: https://ccore.newebpay.com
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
# 測試環境
|
|
104
|
+
TEST_API_URL = 'https://ccore.newebpay.com/MPG/mpg_gateway'
|
|
105
|
+
TEST_QUERY_URL = 'https://ccore.newebpay.com/API/QueryTradeInfo'
|
|
106
|
+
|
|
107
|
+
# 正式環境
|
|
108
|
+
PROD_API_URL = 'https://core.newebpay.com/MPG/mpg_gateway'
|
|
109
|
+
PROD_QUERY_URL = 'https://core.newebpay.com/API/QueryTradeInfo'
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
merchant_id: str,
|
|
114
|
+
hash_key: str,
|
|
115
|
+
hash_iv: str,
|
|
116
|
+
is_production: bool = False
|
|
117
|
+
):
|
|
118
|
+
"""
|
|
119
|
+
初始化 NewebPay MPG 服務
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
merchant_id: 商店代號
|
|
123
|
+
hash_key: HashKey (32 字元)
|
|
124
|
+
hash_iv: HashIV (16 字元)
|
|
125
|
+
is_production: 是否為正式環境 (預設 False)
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
ImportError: 缺少 pycryptodome 套件
|
|
129
|
+
"""
|
|
130
|
+
if not HAS_CRYPTO:
|
|
131
|
+
raise ImportError('需要安裝 pycryptodome: pip install pycryptodome')
|
|
132
|
+
|
|
133
|
+
self.merchant_id = merchant_id
|
|
134
|
+
self.hash_key = hash_key.encode('utf-8')
|
|
135
|
+
self.hash_iv = hash_iv.encode('utf-8')
|
|
136
|
+
self.api_url = self.PROD_API_URL if is_production else self.TEST_API_URL
|
|
137
|
+
self.query_url = self.PROD_QUERY_URL if is_production else self.TEST_QUERY_URL
|
|
138
|
+
|
|
139
|
+
def encrypt_trade_info(self, data: Dict[str, any]) -> str:
|
|
140
|
+
"""
|
|
141
|
+
加密 TradeInfo (AES-256-CBC)
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
data: 交易資料字典
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
str: AES 加密後的 hex 字串
|
|
148
|
+
|
|
149
|
+
Example:
|
|
150
|
+
>>> data = {'MerchantID': 'MS123', 'Amt': 100}
|
|
151
|
+
>>> encrypted = service.encrypt_trade_info(data)
|
|
152
|
+
>>> len(encrypted) > 0
|
|
153
|
+
True
|
|
154
|
+
"""
|
|
155
|
+
# 步驟 1: 轉換為查詢字串
|
|
156
|
+
query_string = urllib.parse.urlencode(data)
|
|
157
|
+
|
|
158
|
+
# 步驟 2: AES-256-CBC 加密
|
|
159
|
+
cipher = AES.new(self.hash_key, AES.MODE_CBC, self.hash_iv)
|
|
160
|
+
padded = pad(query_string.encode('utf-8'), AES.block_size)
|
|
161
|
+
encrypted = cipher.encrypt(padded)
|
|
162
|
+
|
|
163
|
+
# 步驟 3: 轉換為 hex
|
|
164
|
+
return encrypted.hex()
|
|
165
|
+
|
|
166
|
+
def decrypt_trade_info(self, encrypted_data: str) -> Dict[str, any]:
|
|
167
|
+
"""
|
|
168
|
+
解密 TradeInfo (AES-256-CBC)
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
encrypted_data: AES 加密的 hex 字串
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Dict: 解密後的資料字典
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
ValueError: 解密失敗
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
# 步驟 1: hex 轉 bytes
|
|
181
|
+
encrypted_bytes = bytes.fromhex(encrypted_data)
|
|
182
|
+
|
|
183
|
+
# 步驟 2: AES-256-CBC 解密
|
|
184
|
+
decipher = AES.new(self.hash_key, AES.MODE_CBC, self.hash_iv)
|
|
185
|
+
decrypted = decipher.decrypt(encrypted_bytes)
|
|
186
|
+
unpadded = unpad(decrypted, AES.block_size)
|
|
187
|
+
|
|
188
|
+
# 步驟 3: 解析查詢字串
|
|
189
|
+
query_string = unpadded.decode('utf-8')
|
|
190
|
+
params = urllib.parse.parse_qs(query_string)
|
|
191
|
+
|
|
192
|
+
# 步驟 4: 轉換為單值字典
|
|
193
|
+
result = {k: v[0] if len(v) == 1 else v for k, v in params.items()}
|
|
194
|
+
return result
|
|
195
|
+
except Exception as e:
|
|
196
|
+
raise ValueError(f'解密失敗: {str(e)}')
|
|
197
|
+
|
|
198
|
+
def generate_trade_sha(self, trade_info: str) -> str:
|
|
199
|
+
"""
|
|
200
|
+
產生 TradeSha (SHA256)
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
trade_info: 加密後的 TradeInfo
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
str: SHA256 雜湊值 (大寫)
|
|
207
|
+
|
|
208
|
+
Example:
|
|
209
|
+
>>> trade_sha = service.generate_trade_sha('abcd1234')
|
|
210
|
+
>>> len(trade_sha)
|
|
211
|
+
64
|
|
212
|
+
"""
|
|
213
|
+
# 組合字串: HashKey=xxx&TradeInfo=xxx&HashIV=xxx
|
|
214
|
+
raw = f"HashKey={self.hash_key.decode('utf-8')}&{trade_info}&HashIV={self.hash_iv.decode('utf-8')}"
|
|
215
|
+
|
|
216
|
+
# SHA256 雜湊並轉大寫
|
|
217
|
+
return hashlib.sha256(raw.encode('utf-8')).hexdigest().upper()
|
|
218
|
+
|
|
219
|
+
def verify_trade_sha(self, trade_info: str, trade_sha: str) -> bool:
|
|
220
|
+
"""
|
|
221
|
+
驗證 TradeSha
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
trade_info: 加密後的 TradeInfo
|
|
225
|
+
trade_sha: 接收到的 TradeSha
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
bool: 驗證是否通過
|
|
229
|
+
"""
|
|
230
|
+
calculated_sha = self.generate_trade_sha(trade_info)
|
|
231
|
+
return calculated_sha == trade_sha.upper()
|
|
232
|
+
|
|
233
|
+
def create_order(
|
|
234
|
+
self,
|
|
235
|
+
data: MPGOrderData,
|
|
236
|
+
) -> MPGOrderResponse:
|
|
237
|
+
"""
|
|
238
|
+
建立 MPG 整合支付訂單
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
data: MPG 訂單資料 (MPGOrderData)
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
MPGOrderResponse: MPG 訂單回應
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
ValueError: 參數驗證失敗
|
|
248
|
+
|
|
249
|
+
Example:
|
|
250
|
+
>>> order_data = MPGOrderData(
|
|
251
|
+
... merchant_order_no=f'MPG{int(time.time())}',
|
|
252
|
+
... amt=2500,
|
|
253
|
+
... item_desc='測試商品',
|
|
254
|
+
... email='test@example.com',
|
|
255
|
+
... return_url='https://your-site.com/callback',
|
|
256
|
+
... enable_credit=True,
|
|
257
|
+
... enable_vacc=True,
|
|
258
|
+
... enable_cvs=True,
|
|
259
|
+
... )
|
|
260
|
+
>>> result = service.create_order(order_data)
|
|
261
|
+
>>> print(result.form_action)
|
|
262
|
+
"""
|
|
263
|
+
# 參數驗證
|
|
264
|
+
if data.amt < 1:
|
|
265
|
+
raise ValueError('金額必須大於 0')
|
|
266
|
+
if len(data.merchant_order_no) > 30:
|
|
267
|
+
raise ValueError('訂單編號不可超過 30 字元')
|
|
268
|
+
|
|
269
|
+
# 準備 API 參數
|
|
270
|
+
trade_info_data = {
|
|
271
|
+
'MerchantID': self.merchant_id,
|
|
272
|
+
'RespondType': 'JSON',
|
|
273
|
+
'TimeStamp': str(int(datetime.now().timestamp())),
|
|
274
|
+
'Version': '2.0',
|
|
275
|
+
'MerchantOrderNo': data.merchant_order_no,
|
|
276
|
+
'Amt': data.amt,
|
|
277
|
+
'ItemDesc': data.item_desc,
|
|
278
|
+
'Email': data.email,
|
|
279
|
+
'ReturnURL': data.return_url,
|
|
280
|
+
'LoginType': data.login_type,
|
|
281
|
+
'TradeLimit': data.trade_limit,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
# 啟用付款方式
|
|
285
|
+
if data.enable_credit:
|
|
286
|
+
trade_info_data['CREDIT'] = 1
|
|
287
|
+
if data.enable_vacc:
|
|
288
|
+
trade_info_data['VACC'] = 1
|
|
289
|
+
if data.enable_cvs:
|
|
290
|
+
trade_info_data['CVS'] = 1
|
|
291
|
+
if data.enable_barcode:
|
|
292
|
+
trade_info_data['BARCODE'] = 1
|
|
293
|
+
if data.enable_linepay:
|
|
294
|
+
trade_info_data['LINEPAY'] = 1
|
|
295
|
+
if data.enable_applepay:
|
|
296
|
+
trade_info_data['APPLEPAY'] = 1
|
|
297
|
+
|
|
298
|
+
# 可選參數
|
|
299
|
+
if data.notify_url:
|
|
300
|
+
trade_info_data['NotifyURL'] = data.notify_url
|
|
301
|
+
if data.client_back_url:
|
|
302
|
+
trade_info_data['ClientBackURL'] = data.client_back_url
|
|
303
|
+
if data.order_comment:
|
|
304
|
+
trade_info_data['OrderComment'] = data.order_comment
|
|
305
|
+
if data.exp_date:
|
|
306
|
+
trade_info_data['ExpDate'] = data.exp_date
|
|
307
|
+
|
|
308
|
+
# 加密 TradeInfo
|
|
309
|
+
trade_info = self.encrypt_trade_info(trade_info_data)
|
|
310
|
+
|
|
311
|
+
# 產生 TradeSha
|
|
312
|
+
trade_sha = self.generate_trade_sha(trade_info)
|
|
313
|
+
|
|
314
|
+
# 回傳表單資料
|
|
315
|
+
return MPGOrderResponse(
|
|
316
|
+
success=True,
|
|
317
|
+
merchant_order_no=data.merchant_order_no,
|
|
318
|
+
form_action=self.api_url,
|
|
319
|
+
trade_info=trade_info,
|
|
320
|
+
trade_sha=trade_sha,
|
|
321
|
+
merchant_id=self.merchant_id,
|
|
322
|
+
version='2.0',
|
|
323
|
+
raw={'TradeInfo': trade_info, 'TradeSha': trade_sha},
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def parse_callback(self, callback_data: Dict[str, str]) -> MPGCallbackData:
|
|
327
|
+
"""
|
|
328
|
+
解析 MPG 付款回傳資料
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
callback_data: POST 回傳的參數字典
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
MPGCallbackData: 解析後的回傳資料
|
|
335
|
+
|
|
336
|
+
Raises:
|
|
337
|
+
ValueError: TradeSha 驗證失敗或解密失敗
|
|
338
|
+
|
|
339
|
+
Example:
|
|
340
|
+
>>> callback = request.form.to_dict()
|
|
341
|
+
>>> result = service.parse_callback(callback)
|
|
342
|
+
>>> if result.status == 'SUCCESS':
|
|
343
|
+
... print(f"付款成功: {result.trade_no}")
|
|
344
|
+
"""
|
|
345
|
+
# 驗證 TradeSha
|
|
346
|
+
trade_info = callback_data.get('TradeInfo', '')
|
|
347
|
+
trade_sha = callback_data.get('TradeSha', '')
|
|
348
|
+
|
|
349
|
+
if not self.verify_trade_sha(trade_info, trade_sha):
|
|
350
|
+
raise ValueError('TradeSha 驗證失敗')
|
|
351
|
+
|
|
352
|
+
# 解密 TradeInfo
|
|
353
|
+
decrypted = self.decrypt_trade_info(trade_info)
|
|
354
|
+
|
|
355
|
+
# 解析回傳資料
|
|
356
|
+
result_data = decrypted.get('Result', '')
|
|
357
|
+
if isinstance(result_data, str):
|
|
358
|
+
try:
|
|
359
|
+
result_dict = json.loads(result_data)
|
|
360
|
+
except:
|
|
361
|
+
result_dict = {}
|
|
362
|
+
else:
|
|
363
|
+
result_dict = result_data
|
|
364
|
+
|
|
365
|
+
return MPGCallbackData(
|
|
366
|
+
status=decrypted.get('Status', ''),
|
|
367
|
+
message=decrypted.get('Message', ''),
|
|
368
|
+
merchant_order_no=result_dict.get('MerchantOrderNo', ''),
|
|
369
|
+
amt=int(result_dict.get('Amt', 0)),
|
|
370
|
+
trade_no=result_dict.get('TradeNo', ''),
|
|
371
|
+
merchant_id=result_dict.get('MerchantID', ''),
|
|
372
|
+
payment_type=result_dict.get('PaymentType', ''),
|
|
373
|
+
pay_time=result_dict.get('PayTime', ''),
|
|
374
|
+
ip=result_dict.get('IP', ''),
|
|
375
|
+
esc_row_bank_acount=result_dict.get('EscrowBankAcount'),
|
|
376
|
+
code_no=result_dict.get('CodeNo'),
|
|
377
|
+
barcode_1=result_dict.get('Barcode_1'),
|
|
378
|
+
barcode_2=result_dict.get('Barcode_2'),
|
|
379
|
+
barcode_3=result_dict.get('Barcode_3'),
|
|
380
|
+
expire_date=result_dict.get('ExpireDate'),
|
|
381
|
+
raw=decrypted,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# Usage Example
|
|
386
|
+
if __name__ == '__main__':
|
|
387
|
+
print('=' * 60)
|
|
388
|
+
print('NewebPay 藍新金流 MPG - Python 範例')
|
|
389
|
+
print('=' * 60)
|
|
390
|
+
print()
|
|
391
|
+
|
|
392
|
+
# 檢查是否有 pycryptodome
|
|
393
|
+
if not HAS_CRYPTO:
|
|
394
|
+
print('✗ 錯誤: 需要安裝 pycryptodome 套件')
|
|
395
|
+
print(' 請執行: pip install pycryptodome')
|
|
396
|
+
exit(1)
|
|
397
|
+
|
|
398
|
+
# 注意: 需要替換為您的測試帳號
|
|
399
|
+
print('[注意] 請先至藍新金流申請測試帳號')
|
|
400
|
+
print('並將以下參數替換為您的測試環境資訊')
|
|
401
|
+
print()
|
|
402
|
+
|
|
403
|
+
# 初始化服務 (使用測試環境)
|
|
404
|
+
service = NewebPayMPGService(
|
|
405
|
+
merchant_id='YOUR_MERCHANT_ID', # 請替換為您的商店代號
|
|
406
|
+
hash_key='YOUR_HASH_KEY', # 請替換為您的 HashKey (32字元)
|
|
407
|
+
hash_iv='YOUR_HASH_IV', # 請替換為您的 HashIV (16字元)
|
|
408
|
+
is_production=False,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
# 範例: 建立 MPG 整合支付訂單
|
|
412
|
+
print('[範例] 建立 MPG 整合支付訂單')
|
|
413
|
+
print('-' * 60)
|
|
414
|
+
|
|
415
|
+
order_data = MPGOrderData(
|
|
416
|
+
merchant_order_no=f'MPG{int(datetime.now().timestamp())}',
|
|
417
|
+
amt=2500,
|
|
418
|
+
item_desc='測試商品購買',
|
|
419
|
+
email='test@example.com',
|
|
420
|
+
return_url='https://your-site.com/api/payment/callback',
|
|
421
|
+
notify_url='https://your-site.com/api/payment/notify',
|
|
422
|
+
client_back_url='https://your-site.com/order/complete',
|
|
423
|
+
enable_credit=True, # 啟用信用卡
|
|
424
|
+
enable_vacc=True, # 啟用 ATM
|
|
425
|
+
enable_cvs=True, # 啟用超商代碼
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
result = service.create_order(order_data)
|
|
430
|
+
|
|
431
|
+
if result.success:
|
|
432
|
+
print(f'✓ 訂單建立成功')
|
|
433
|
+
print(f' 訂單編號: {result.merchant_order_no}')
|
|
434
|
+
print(f' 表單網址: {result.form_action}')
|
|
435
|
+
print(f' TradeInfo: {result.trade_info[:50]}...')
|
|
436
|
+
print(f' TradeSha: {result.trade_sha[:32]}...')
|
|
437
|
+
print()
|
|
438
|
+
print('請將以下參數 POST 到表單網址:')
|
|
439
|
+
print(f' MerchantID: {result.merchant_id}')
|
|
440
|
+
print(f' TradeInfo: {result.trade_info}')
|
|
441
|
+
print(f' TradeSha: {result.trade_sha}')
|
|
442
|
+
print(f' Version: {result.version}')
|
|
443
|
+
else:
|
|
444
|
+
print(f'✗ 訂單建立失敗: {result.error_message}')
|
|
445
|
+
except Exception as e:
|
|
446
|
+
print(f'✗ 發生例外: {str(e)}')
|
|
447
|
+
|
|
448
|
+
print()
|
|
449
|
+
print('=' * 60)
|
|
450
|
+
print('範例執行完成')
|
|
451
|
+
print('=' * 60)
|