taiwan-payment-skill 1.0.0 → 1.0.2

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)