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,457 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ PAYUNi 統一金流 Python 完整範例
4
+
5
+ 依照 taiwan-payment-skill 最高規範撰寫
6
+ 支援: 信用卡、ATM、超商代碼、AFTEE、iCash Pay
7
+
8
+ API 文件: https://www.payuni.com.tw
9
+ """
10
+
11
+ import hashlib
12
+ import urllib.parse
13
+ import time
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
+ HAS_CRYPTO = True
21
+ except ImportError:
22
+ HAS_CRYPTO = False
23
+
24
+
25
+ @dataclass
26
+ class PaymentOrderData:
27
+ """PAYUNi 付款訂單資料"""
28
+ mer_trade_no: str
29
+ trade_amt: int
30
+ prod_desc: str
31
+ return_url: str
32
+ notify_url: str
33
+ pay_type: Literal['Credit', 'VACC', 'CVS', 'AFTEE', 'iCashPay']
34
+ trade_limit_date: Optional[str] = None
35
+ unified_id: Optional[str] = None
36
+ buyer_name: Optional[str] = None
37
+ buyer_tel: Optional[str] = None
38
+ buyer_email: Optional[str] = None
39
+
40
+
41
+ @dataclass
42
+ class PaymentOrderResponse:
43
+ """PAYUNi 付款訂單回應"""
44
+ success: bool
45
+ status: str
46
+ message: str
47
+ mer_trade_no: str
48
+ trade_no: Optional[str] = None
49
+ payment_url: Optional[str] = None
50
+ atm_bank_code: Optional[str] = None
51
+ atm_account: Optional[str] = None
52
+ atm_expire_date: Optional[str] = None
53
+ cvs_code: Optional[str] = None
54
+ cvs_expire_date: Optional[str] = None
55
+ error_code: Optional[str] = None
56
+ raw: Dict = field(default_factory=dict)
57
+
58
+
59
+ @dataclass
60
+ class PaymentCallbackData:
61
+ """PAYUNi 付款回傳資料"""
62
+ status: str
63
+ message: str
64
+ mer_id: str
65
+ mer_trade_no: str
66
+ trade_no: str
67
+ trade_amt: int
68
+ trade_status: str
69
+ pay_type: str
70
+ pay_date: str
71
+ settle_date: Optional[str] = None
72
+ checksum: str
73
+ raw: Dict = field(default_factory=dict)
74
+
75
+
76
+ class PAYUNiPaymentService:
77
+ """
78
+ PAYUNi 統一金流服務
79
+
80
+ 認證方式: AES-256-GCM + SHA256
81
+ 加密方式: AES-GCM 加密 + SHA256 驗證
82
+
83
+ 支援付款方式:
84
+ - Credit: 信用卡
85
+ - VACC: ATM 轉帳
86
+ - CVS: 超商代碼
87
+ - AFTEE: AFTEE 先享後付
88
+ - iCashPay: iCash Pay
89
+
90
+ 測試環境:
91
+ - API URL: https://sandbox-api.payuni.com.tw/api/upp
92
+ - 需至 PAYUNi 申請測試帳號
93
+ """
94
+
95
+ # 測試環境
96
+ TEST_API_URL = 'https://sandbox-api.payuni.com.tw/api/upp'
97
+ TEST_QUERY_URL = 'https://sandbox-api.payuni.com.tw/api/trade_query'
98
+
99
+ # 正式環境
100
+ PROD_API_URL = 'https://api.payuni.com.tw/api/upp'
101
+ PROD_QUERY_URL = 'https://api.payuni.com.tw/api/trade_query'
102
+
103
+ def __init__(
104
+ self,
105
+ mer_id: str,
106
+ hash_key: str,
107
+ hash_iv: str,
108
+ is_production: bool = False
109
+ ):
110
+ """
111
+ 初始化 PAYUNi 金流服務
112
+
113
+ Args:
114
+ mer_id: 商店代號
115
+ hash_key: HashKey
116
+ hash_iv: HashIV (16 bytes)
117
+ is_production: 是否為正式環境 (預設 False)
118
+
119
+ Raises:
120
+ ImportError: 缺少 pycryptodome 套件
121
+ """
122
+ if not HAS_CRYPTO:
123
+ raise ImportError('需要安裝 pycryptodome: pip install pycryptodome')
124
+
125
+ self.mer_id = mer_id
126
+ self.hash_key = hash_key.encode('utf-8')
127
+ self.hash_iv = hash_iv.encode('utf-8')
128
+ self.api_url = self.PROD_API_URL if is_production else self.TEST_API_URL
129
+ self.query_url = self.PROD_QUERY_URL if is_production else self.TEST_QUERY_URL
130
+
131
+ def encrypt_data(self, data: Dict[str, any]) -> str:
132
+ """
133
+ 加密資料 (AES-256-GCM)
134
+
135
+ Args:
136
+ data: 交易資料字典
137
+
138
+ Returns:
139
+ str: AES-GCM 加密後的 hex 字串 (含 tag)
140
+
141
+ Example:
142
+ >>> data = {'MerID': 'MS123', 'TradeAmt': 100}
143
+ >>> encrypted = service.encrypt_data(data)
144
+ >>> len(encrypted) > 0
145
+ True
146
+ """
147
+ # 步驟 1: 轉換為查詢字串
148
+ query_string = urllib.parse.urlencode(data)
149
+
150
+ # 步驟 2: AES-256-GCM 加密
151
+ cipher = AES.new(self.hash_key, AES.MODE_GCM, nonce=self.hash_iv)
152
+ encrypted, tag = cipher.encrypt_and_digest(query_string.encode('utf-8'))
153
+
154
+ # 步驟 3: 組合加密資料和 tag,轉換為 hex
155
+ return (encrypted + tag).hex()
156
+
157
+ def decrypt_data(self, encrypted_data: str) -> Dict[str, any]:
158
+ """
159
+ 解密資料 (AES-256-GCM)
160
+
161
+ Args:
162
+ encrypted_data: AES-GCM 加密的 hex 字串
163
+
164
+ Returns:
165
+ Dict: 解密後的資料字典
166
+
167
+ Raises:
168
+ ValueError: 解密失敗或驗證失敗
169
+ """
170
+ try:
171
+ # 步驟 1: hex 轉 bytes
172
+ data = bytes.fromhex(encrypted_data)
173
+
174
+ # 步驟 2: 分離加密資料和 tag (最後 16 bytes 為 tag)
175
+ encrypted = data[:-16]
176
+ tag = data[-16:]
177
+
178
+ # 步驟 3: AES-256-GCM 解密並驗證
179
+ decipher = AES.new(self.hash_key, AES.MODE_GCM, nonce=self.hash_iv)
180
+ decrypted = decipher.decrypt_and_verify(encrypted, tag)
181
+
182
+ # 步驟 4: 解析查詢字串
183
+ query_string = decrypted.decode('utf-8')
184
+ params = urllib.parse.parse_qs(query_string)
185
+
186
+ # 步驟 5: 轉換為單值字典
187
+ result = {k: v[0] if len(v) == 1 else v for k, v in params.items()}
188
+ return result
189
+ except Exception as e:
190
+ raise ValueError(f'解密失敗: {str(e)}')
191
+
192
+ def generate_checksum(self, encrypt_info: str) -> str:
193
+ """
194
+ 產生 Checksum (SHA256)
195
+
196
+ Args:
197
+ encrypt_info: 加密後的資料
198
+
199
+ Returns:
200
+ str: SHA256 雜湊值 (大寫)
201
+
202
+ Example:
203
+ >>> checksum = service.generate_checksum('abcd1234')
204
+ >>> len(checksum)
205
+ 64
206
+ """
207
+ # 組合字串: EncryptInfo + HashKey + HashIV
208
+ raw = encrypt_info + self.hash_key.decode('utf-8') + self.hash_iv.decode('utf-8')
209
+
210
+ # SHA256 雜湊並轉大寫
211
+ return hashlib.sha256(raw.encode('utf-8')).hexdigest().upper()
212
+
213
+ def verify_checksum(self, encrypt_info: str, checksum: str) -> bool:
214
+ """
215
+ 驗證 Checksum
216
+
217
+ Args:
218
+ encrypt_info: 加密後的資料
219
+ checksum: 接收到的 Checksum
220
+
221
+ Returns:
222
+ bool: 驗證是否通過
223
+ """
224
+ calculated_checksum = self.generate_checksum(encrypt_info)
225
+ return calculated_checksum == checksum.upper()
226
+
227
+ def create_order(
228
+ self,
229
+ data: PaymentOrderData,
230
+ ) -> PaymentOrderResponse:
231
+ """
232
+ 建立付款訂單
233
+
234
+ Args:
235
+ data: 付款訂單資料 (PaymentOrderData)
236
+
237
+ Returns:
238
+ PaymentOrderResponse: 付款訂單回應
239
+
240
+ Raises:
241
+ ValueError: 參數驗證失敗
242
+ Exception: API 請求失敗
243
+
244
+ Example:
245
+ >>> order_data = PaymentOrderData(
246
+ ... mer_trade_no=f'UNI{int(time.time())}',
247
+ ... trade_amt=3000,
248
+ ... prod_desc='測試商品',
249
+ ... return_url='https://your-site.com/return',
250
+ ... notify_url='https://your-site.com/notify',
251
+ ... pay_type='Credit',
252
+ ... )
253
+ >>> result = service.create_order(order_data)
254
+ >>> print(result.payment_url)
255
+ """
256
+ # 參數驗證
257
+ if data.trade_amt < 1:
258
+ raise ValueError('金額必須大於 0')
259
+ if len(data.mer_trade_no) > 30:
260
+ raise ValueError('訂單編號不可超過 30 字元')
261
+
262
+ # 準備 API 參數
263
+ trade_data = {
264
+ 'MerID': self.mer_id,
265
+ 'MerTradeNo': data.mer_trade_no,
266
+ 'TradeAmt': data.trade_amt,
267
+ 'ProdDesc': data.prod_desc,
268
+ 'ReturnURL': data.return_url,
269
+ 'NotifyURL': data.notify_url,
270
+ 'PayType': data.pay_type,
271
+ 'Timestamp': int(time.time()),
272
+ }
273
+
274
+ # 可選參數
275
+ if data.trade_limit_date:
276
+ trade_data['TradeLimitDate'] = data.trade_limit_date
277
+ if data.unified_id:
278
+ trade_data['UnifiedID'] = data.unified_id
279
+ if data.buyer_name:
280
+ trade_data['BuyerName'] = data.buyer_name
281
+ if data.buyer_tel:
282
+ trade_data['BuyerTel'] = data.buyer_tel
283
+ if data.buyer_email:
284
+ trade_data['BuyerEmail'] = data.buyer_email
285
+
286
+ # 加密資料
287
+ encrypt_info = self.encrypt_data(trade_data)
288
+
289
+ # 產生 Checksum
290
+ checksum = self.generate_checksum(encrypt_info)
291
+
292
+ # 準備 API 請求
293
+ api_data = {
294
+ 'MerID': self.mer_id,
295
+ 'Version': '1.0',
296
+ 'EncryptInfo': encrypt_info,
297
+ 'HashInfo': checksum,
298
+ }
299
+
300
+ # 發送 API 請求
301
+ try:
302
+ import requests
303
+ response = requests.post(
304
+ self.api_url,
305
+ data=api_data,
306
+ timeout=30,
307
+ )
308
+ response.raise_for_status()
309
+ result = response.json()
310
+ except Exception as e:
311
+ raise Exception(f'API 請求失敗: {str(e)}')
312
+
313
+ # 解析回應
314
+ if result.get('Status') != 'SUCCESS':
315
+ return PaymentOrderResponse(
316
+ success=False,
317
+ status=result.get('Status', 'ERROR'),
318
+ message=result.get('Message', '未知錯誤'),
319
+ mer_trade_no=data.mer_trade_no,
320
+ error_code=result.get('ErrCode'),
321
+ raw=result,
322
+ )
323
+
324
+ # 解密回應資料
325
+ response_encrypt_info = result.get('EncryptInfo', '')
326
+ if response_encrypt_info:
327
+ try:
328
+ decrypted = self.decrypt_data(response_encrypt_info)
329
+ except:
330
+ decrypted = {}
331
+ else:
332
+ decrypted = {}
333
+
334
+ return PaymentOrderResponse(
335
+ success=True,
336
+ status=result.get('Status', ''),
337
+ message=result.get('Message', ''),
338
+ mer_trade_no=data.mer_trade_no,
339
+ trade_no=decrypted.get('TradeNo'),
340
+ payment_url=decrypted.get('PaymentURL'),
341
+ atm_bank_code=decrypted.get('ATMBankCode'),
342
+ atm_account=decrypted.get('ATMAcct'),
343
+ atm_expire_date=decrypted.get('ATMExpireDate'),
344
+ cvs_code=decrypted.get('CVSCode'),
345
+ cvs_expire_date=decrypted.get('CVSExpireDate'),
346
+ raw=result,
347
+ )
348
+
349
+ def parse_callback(self, callback_data: Dict[str, str]) -> PaymentCallbackData:
350
+ """
351
+ 解析付款回傳資料
352
+
353
+ Args:
354
+ callback_data: POST 回傳的參數字典
355
+
356
+ Returns:
357
+ PaymentCallbackData: 解析後的回傳資料
358
+
359
+ Raises:
360
+ ValueError: Checksum 驗證失敗或解密失敗
361
+
362
+ Example:
363
+ >>> callback = request.form.to_dict()
364
+ >>> result = service.parse_callback(callback)
365
+ >>> if result.status == 'SUCCESS':
366
+ ... print(f"付款成功: {result.trade_no}")
367
+ """
368
+ # 驗證 Checksum
369
+ encrypt_info = callback_data.get('EncryptInfo', '')
370
+ hash_info = callback_data.get('HashInfo', '')
371
+
372
+ if not self.verify_checksum(encrypt_info, hash_info):
373
+ raise ValueError('Checksum 驗證失敗')
374
+
375
+ # 解密資料
376
+ decrypted = self.decrypt_data(encrypt_info)
377
+
378
+ return PaymentCallbackData(
379
+ status=decrypted.get('Status', ''),
380
+ message=decrypted.get('Message', ''),
381
+ mer_id=decrypted.get('MerID', ''),
382
+ mer_trade_no=decrypted.get('MerTradeNo', ''),
383
+ trade_no=decrypted.get('TradeNo', ''),
384
+ trade_amt=int(decrypted.get('TradeAmt', 0)),
385
+ trade_status=decrypted.get('TradeStatus', ''),
386
+ pay_type=decrypted.get('PayType', ''),
387
+ pay_date=decrypted.get('PayDate', ''),
388
+ settle_date=decrypted.get('SettleDate'),
389
+ checksum=hash_info,
390
+ raw=decrypted,
391
+ )
392
+
393
+
394
+ # Usage Example
395
+ if __name__ == '__main__':
396
+ print('=' * 60)
397
+ print('PAYUNi 統一金流 - Python 範例')
398
+ print('=' * 60)
399
+ print()
400
+
401
+ # 檢查是否有 pycryptodome
402
+ if not HAS_CRYPTO:
403
+ print('✗ 錯誤: 需要安裝 pycryptodome 套件')
404
+ print(' 請執行: pip install pycryptodome')
405
+ exit(1)
406
+
407
+ # 注意: 需要替換為您的測試帳號
408
+ print('[注意] 請先至 PAYUNi 申請測試帳號')
409
+ print('並將以下參數替換為您的測試環境資訊')
410
+ print()
411
+
412
+ # 初始化服務 (使用測試環境)
413
+ service = PAYUNiPaymentService(
414
+ mer_id='YOUR_MERCHANT_ID', # 請替換為您的商店代號
415
+ hash_key='YOUR_HASH_KEY', # 請替換為您的 HashKey
416
+ hash_iv='YOUR_HASH_IV', # 請替換為您的 HashIV (16 bytes)
417
+ is_production=False,
418
+ )
419
+
420
+ # 範例: 建立信用卡付款訂單
421
+ print('[範例] 建立信用卡付款訂單')
422
+ print('-' * 60)
423
+
424
+ order_data = PaymentOrderData(
425
+ mer_trade_no=f'UNI{int(time.time())}',
426
+ trade_amt=3000,
427
+ prod_desc='測試商品購買',
428
+ return_url='https://your-site.com/payment/return',
429
+ notify_url='https://your-site.com/payment/notify',
430
+ pay_type='Credit',
431
+ buyer_name='測試買家',
432
+ buyer_email='test@example.com',
433
+ )
434
+
435
+ try:
436
+ result = service.create_order(order_data)
437
+
438
+ if result.success:
439
+ print(f'✓ 訂單建立成功')
440
+ print(f' 訂單編號: {result.mer_trade_no}')
441
+ print(f' 交易編號: {result.trade_no}')
442
+ print(f' 付款網址: {result.payment_url}')
443
+ print()
444
+ print('請將買家導向付款網址完成付款')
445
+ else:
446
+ print(f'✗ 訂單建立失敗')
447
+ print(f' 狀態: {result.status}')
448
+ print(f' 訊息: {result.message}')
449
+ if result.error_code:
450
+ print(f' 錯誤碼: {result.error_code}')
451
+ except Exception as e:
452
+ print(f'✗ 發生例外: {str(e)}')
453
+
454
+ print()
455
+ print('=' * 60)
456
+ print('範例執行完成')
457
+ print('=' * 60)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taiwan-payment-skill",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "AI-powered Taiwan Payment Gateway integration toolkit for Claude Code and other AI coding assistants",
5
5
  "keywords": [
6
6
  "taiwan",
@@ -41,16 +41,18 @@
41
41
  "author": "Moksa1123",
42
42
  "license": "MIT",
43
43
  "dependencies": {
44
- "commander": "^12.0.0",
45
44
  "chalk": "^4.1.2",
45
+ "cli-progress": "^3.12.0",
46
+ "commander": "^11.1.0",
46
47
  "ora": "^5.4.1",
47
- "prompts": "^2.4.2",
48
- "cli-progress": "^3.12.0"
48
+ "prompts": "^2.4.2"
49
49
  },
50
50
  "devDependencies": {
51
- "@types/node": "^20.11.0",
52
- "esbuild": "^0.20.0",
53
- "typescript": "^5.3.0"
51
+ "@types/cli-progress": "^3.11.6",
52
+ "@types/node": "^22.10.1",
53
+ "@types/prompts": "^2.4.9",
54
+ "esbuild": "^0.24.0",
55
+ "typescript": "^5.7.2"
54
56
  },
55
57
  "engines": {
56
58
  "node": ">=18.0.0"