taiwan-invoice-skill 2.6.1 → 2.6.3
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.
- package/assets/taiwan-invoice/README.md +331 -55
- package/assets/taiwan-invoice/examples/ecpay-invoice-example.py +598 -0
- package/assets/taiwan-invoice/scripts/__pycache__/core.cpython-312.pyc +0 -0
- package/assets/taiwan-invoice/scripts/__pycache__/generate-invoice-service.cpython-312.pyc +0 -0
- package/assets/taiwan-invoice/scripts/__pycache__/persist.cpython-312.pyc +0 -0
- package/assets/taiwan-invoice/scripts/__pycache__/recommend.cpython-312.pyc +0 -0
- package/assets/taiwan-invoice/scripts/__pycache__/search.cpython-312.pyc +0 -0
- package/assets/taiwan-invoice/scripts/__pycache__/test-invoice-amounts.cpython-312.pyc +0 -0
- package/assets/taiwan-invoice/scripts/error_handler.py +434 -0
- package/package.json +1 -1
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ECPay 綠界電子發票 Python 完整範例
|
|
4
|
+
|
|
5
|
+
依照 taiwan-invoice-skill 最高規範撰寫
|
|
6
|
+
支援: B2C 二聯式、B2B 三聯式、發票作廢、折讓、列印
|
|
7
|
+
|
|
8
|
+
API 文件: https://developers.ecpay.com.tw
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import base64
|
|
15
|
+
import time
|
|
16
|
+
import requests
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from typing import Dict, Literal, Optional, List
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from Crypto.Cipher import AES
|
|
21
|
+
from Crypto.Util.Padding import pad, unpad
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class InvoiceIssueData:
|
|
26
|
+
"""ECPay 發票開立資料"""
|
|
27
|
+
merchant_id: str
|
|
28
|
+
relate_number: str # 訂單編號 (唯一)
|
|
29
|
+
customer_identifier: str # 買受人統編 (B2C: 0000000000, B2B: 8碼統編)
|
|
30
|
+
customer_name: str # 買受人名稱
|
|
31
|
+
customer_addr: str # 買受人地址
|
|
32
|
+
customer_phone: str # 買受人電話
|
|
33
|
+
customer_email: str # 買受人 Email
|
|
34
|
+
sales_amount: int # 銷售額 (B2C: 含稅, B2B: 未稅)
|
|
35
|
+
tax_type: Literal['1', '2', '3', '9'] = '1' # 1=應稅, 2=零稅率, 3=免稅, 9=混合
|
|
36
|
+
tax_rate: int = 5 # 稅率 (預設5%)
|
|
37
|
+
tax_amount: int = 0 # 稅額 (B2B 必填)
|
|
38
|
+
total_amount: int = 0 # 總計 (含稅)
|
|
39
|
+
carrier_type: Optional[Literal['', '1', '2', '3']] = '' # 載具類型 (B2B不可使用)
|
|
40
|
+
carrier_num: Optional[str] = '' # 載具號碼
|
|
41
|
+
donation: Literal['0', '1'] = '0' # 是否捐贈
|
|
42
|
+
love_code: Optional[str] = '' # 捐贈碼
|
|
43
|
+
print: Literal['0', '1'] = '0' # 是否列印
|
|
44
|
+
items: List[Dict[str, any]] = field(default_factory=list)
|
|
45
|
+
inv_type: Literal['07', '08'] = '07' # 07=一般稅額, 08=特種稅額
|
|
46
|
+
vat: Literal['1', '2', '3', '4'] = '1' # 1=課稅, 2=零稅率, 3=免稅, 4=應稅(特種)
|
|
47
|
+
remark: Optional[str] = ''
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class InvoiceVoidData:
|
|
52
|
+
"""ECPay 發票作廢資料"""
|
|
53
|
+
merchant_id: str
|
|
54
|
+
invoice_no: str # 發票號碼
|
|
55
|
+
invoice_date: str # 發票開立日期 (YYYY-MM-DD)
|
|
56
|
+
reason: str # 作廢原因
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class InvoiceAllowanceData:
|
|
61
|
+
"""ECPay 發票折讓資料"""
|
|
62
|
+
merchant_id: str
|
|
63
|
+
invoice_no: str # 發票號碼
|
|
64
|
+
invoice_date: str # 發票開立日期 (YYYY-MM-DD)
|
|
65
|
+
allowance_notify: Literal['E', 'S', 'N', 'A'] = 'E' # E=Email, S=簡訊, N=不通知, A=Email+簡訊
|
|
66
|
+
customer_name: str
|
|
67
|
+
notify_mail: Optional[str] = ''
|
|
68
|
+
notify_phone: Optional[str] = ''
|
|
69
|
+
allowance_amount: int = 0 # 折讓金額
|
|
70
|
+
items: List[Dict[str, any]] = field(default_factory=list)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class InvoiceIssueResponse:
|
|
75
|
+
"""ECPay 發票開立回應"""
|
|
76
|
+
success: bool
|
|
77
|
+
invoice_number: str = ''
|
|
78
|
+
invoice_date: str = ''
|
|
79
|
+
random_number: str = ''
|
|
80
|
+
rtn_code: int = 0
|
|
81
|
+
rtn_msg: str = ''
|
|
82
|
+
error_message: str = ''
|
|
83
|
+
raw: Dict[str, any] = field(default_factory=dict)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ECPayInvoiceService:
|
|
87
|
+
"""
|
|
88
|
+
ECPay 綠界電子發票服務
|
|
89
|
+
|
|
90
|
+
認證方式: AES-128-CBC 加密
|
|
91
|
+
加密金鑰: HashKey (16 bytes), HashIV (16 bytes)
|
|
92
|
+
|
|
93
|
+
支援功能:
|
|
94
|
+
- 開立發票 (B2C 二聯式 / B2B 三聯式)
|
|
95
|
+
- 作廢發票
|
|
96
|
+
- 折讓發票
|
|
97
|
+
- 列印發票
|
|
98
|
+
- 查詢發票
|
|
99
|
+
|
|
100
|
+
測試環境:
|
|
101
|
+
- 商店代號: 2000132
|
|
102
|
+
- HashKey: ejCk326UnaZWKisg
|
|
103
|
+
- HashIV: q9jcZX8Ib9LM8wYk
|
|
104
|
+
- 測試 URL: https://einvoice-stage.ecpay.com.tw
|
|
105
|
+
|
|
106
|
+
B2C vs B2B 差異:
|
|
107
|
+
- B2C: CustomerIdentifier = 0000000000, 金額為含稅價, 可用載具/捐贈
|
|
108
|
+
- B2B: CustomerIdentifier = 8碼統編, 金額為未稅價, 需計算稅額, 不可用載具/捐贈
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
# 測試環境
|
|
112
|
+
TEST_MERCHANT_ID = '2000132'
|
|
113
|
+
TEST_HASH_KEY = 'ejCk326UnaZWKisg'
|
|
114
|
+
TEST_HASH_IV = 'q9jcZX8Ib9LM8wYk'
|
|
115
|
+
TEST_API_URL = 'https://einvoice-stage.ecpay.com.tw/B2CInvoice/Issue'
|
|
116
|
+
TEST_VOID_URL = 'https://einvoice-stage.ecpay.com.tw/B2CInvoice/Invalid'
|
|
117
|
+
TEST_ALLOWANCE_URL = 'https://einvoice-stage.ecpay.com.tw/B2CInvoice/Allowance'
|
|
118
|
+
TEST_ALLOWANCE_VOID_URL = 'https://einvoice-stage.ecpay.com.tw/B2CInvoice/AllowanceInvalid'
|
|
119
|
+
TEST_QUERY_URL = 'https://einvoice-stage.ecpay.com.tw/B2CInvoice/GetIssue'
|
|
120
|
+
|
|
121
|
+
# 正式環境
|
|
122
|
+
PROD_API_URL = 'https://einvoice.ecpay.com.tw/B2CInvoice/Issue'
|
|
123
|
+
PROD_VOID_URL = 'https://einvoice.ecpay.com.tw/B2CInvoice/Invalid'
|
|
124
|
+
PROD_ALLOWANCE_URL = 'https://einvoice.ecpay.com.tw/B2CInvoice/Allowance'
|
|
125
|
+
PROD_ALLOWANCE_VOID_URL = 'https://einvoice.ecpay.com.tw/B2CInvoice/AllowanceInvalid'
|
|
126
|
+
PROD_QUERY_URL = 'https://einvoice.ecpay.com.tw/B2CInvoice/GetIssue'
|
|
127
|
+
|
|
128
|
+
def __init__(self, merchant_id: str, hash_key: str, hash_iv: str, is_test: bool = True):
|
|
129
|
+
"""
|
|
130
|
+
初始化 ECPay 電子發票服務
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
merchant_id: 商店代號
|
|
134
|
+
hash_key: HashKey (16 bytes)
|
|
135
|
+
hash_iv: HashIV (16 bytes)
|
|
136
|
+
is_test: 是否為測試環境
|
|
137
|
+
"""
|
|
138
|
+
self.merchant_id = merchant_id
|
|
139
|
+
self.hash_key = hash_key.encode('utf-8')
|
|
140
|
+
self.hash_iv = hash_iv.encode('utf-8')
|
|
141
|
+
self.is_test = is_test
|
|
142
|
+
|
|
143
|
+
# 設定 API URL
|
|
144
|
+
if is_test:
|
|
145
|
+
self.api_url = self.TEST_API_URL
|
|
146
|
+
self.void_url = self.TEST_VOID_URL
|
|
147
|
+
self.allowance_url = self.TEST_ALLOWANCE_URL
|
|
148
|
+
self.allowance_void_url = self.TEST_ALLOWANCE_VOID_URL
|
|
149
|
+
self.query_url = self.TEST_QUERY_URL
|
|
150
|
+
else:
|
|
151
|
+
self.api_url = self.PROD_API_URL
|
|
152
|
+
self.void_url = self.PROD_VOID_URL
|
|
153
|
+
self.allowance_url = self.PROD_ALLOWANCE_URL
|
|
154
|
+
self.allowance_void_url = self.PROD_ALLOWANCE_VOID_URL
|
|
155
|
+
self.query_url = self.PROD_QUERY_URL
|
|
156
|
+
|
|
157
|
+
def _encrypt_aes(self, data: str) -> str:
|
|
158
|
+
"""
|
|
159
|
+
AES-128-CBC 加密
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
data: 要加密的字串
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Base64 編碼的加密字串
|
|
166
|
+
"""
|
|
167
|
+
# URL Encode
|
|
168
|
+
url_encoded = urllib.parse.quote(data)
|
|
169
|
+
|
|
170
|
+
# AES-128-CBC 加密
|
|
171
|
+
cipher = AES.new(self.hash_key, AES.MODE_CBC, self.hash_iv)
|
|
172
|
+
encrypted = cipher.encrypt(pad(url_encoded.encode('utf-8'), AES.block_size))
|
|
173
|
+
|
|
174
|
+
# Base64 編碼
|
|
175
|
+
return base64.b64encode(encrypted).decode('utf-8')
|
|
176
|
+
|
|
177
|
+
def _decrypt_aes(self, encrypted_data: str) -> Dict[str, any]:
|
|
178
|
+
"""
|
|
179
|
+
AES-128-CBC 解密
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
encrypted_data: Base64 編碼的加密字串
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
解密後的 JSON 物件
|
|
186
|
+
"""
|
|
187
|
+
# Base64 解碼
|
|
188
|
+
encrypted = base64.b64decode(encrypted_data)
|
|
189
|
+
|
|
190
|
+
# AES-128-CBC 解密
|
|
191
|
+
cipher = AES.new(self.hash_key, AES.MODE_CBC, self.hash_iv)
|
|
192
|
+
decrypted = unpad(cipher.decrypt(encrypted), AES.block_size).decode('utf-8')
|
|
193
|
+
|
|
194
|
+
# URL Decode
|
|
195
|
+
url_decoded = urllib.parse.unquote(decrypted)
|
|
196
|
+
|
|
197
|
+
# JSON Parse
|
|
198
|
+
return json.loads(url_decoded)
|
|
199
|
+
|
|
200
|
+
def calculate_b2b_amounts(self, total_amount: int) -> Dict[str, int]:
|
|
201
|
+
"""
|
|
202
|
+
計算 B2B 發票金額 (含稅總額 → 未稅金額 + 稅額)
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
total_amount: 含稅總額
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
{ 'sales_amount': 未稅金額, 'tax_amount': 稅額, 'total_amount': 總額 }
|
|
209
|
+
|
|
210
|
+
Example:
|
|
211
|
+
>>> svc.calculate_b2b_amounts(1050)
|
|
212
|
+
{'sales_amount': 1000, 'tax_amount': 50, 'total_amount': 1050}
|
|
213
|
+
"""
|
|
214
|
+
tax_amount = round(total_amount - (total_amount / 1.05))
|
|
215
|
+
sales_amount = total_amount - tax_amount
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
'sales_amount': sales_amount,
|
|
219
|
+
'tax_amount': tax_amount,
|
|
220
|
+
'total_amount': total_amount
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
def issue_invoice(self, data: InvoiceIssueData) -> InvoiceIssueResponse:
|
|
224
|
+
"""
|
|
225
|
+
開立電子發票
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
data: 發票開立資料
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
發票開立回應
|
|
232
|
+
|
|
233
|
+
Raises:
|
|
234
|
+
ValueError: 資料驗證失敗
|
|
235
|
+
ConnectionError: API 連線失敗
|
|
236
|
+
|
|
237
|
+
Example:
|
|
238
|
+
>>> # B2C 二聯式發票
|
|
239
|
+
>>> data = InvoiceIssueData(
|
|
240
|
+
... merchant_id='2000132',
|
|
241
|
+
... relate_number='ORD20240129001',
|
|
242
|
+
... customer_identifier='0000000000',
|
|
243
|
+
... customer_name='王小明',
|
|
244
|
+
... customer_addr='台北市信義區',
|
|
245
|
+
... customer_phone='0912345678',
|
|
246
|
+
... customer_email='test@example.com',
|
|
247
|
+
... sales_amount=1050,
|
|
248
|
+
... total_amount=1050,
|
|
249
|
+
... items=[
|
|
250
|
+
... {'ItemName': '商品A', 'ItemCount': 1, 'ItemWord': '個', 'ItemPrice': 1000, 'ItemAmount': 1050}
|
|
251
|
+
... ]
|
|
252
|
+
... )
|
|
253
|
+
>>> response = svc.issue_invoice(data)
|
|
254
|
+
"""
|
|
255
|
+
# 驗證資料
|
|
256
|
+
if data.customer_identifier != '0000000000':
|
|
257
|
+
# B2B 三聯式
|
|
258
|
+
if len(data.customer_identifier) != 8:
|
|
259
|
+
raise ValueError('B2B 發票統編必須為 8 碼')
|
|
260
|
+
if data.carrier_type or data.love_code:
|
|
261
|
+
raise ValueError('B2B 發票不可使用載具或捐贈')
|
|
262
|
+
if data.tax_amount == 0:
|
|
263
|
+
raise ValueError('B2B 發票必須計算稅額')
|
|
264
|
+
|
|
265
|
+
# 準備 API 資料
|
|
266
|
+
api_data = {
|
|
267
|
+
'MerchantID': data.merchant_id,
|
|
268
|
+
'RelateNumber': data.relate_number,
|
|
269
|
+
'CustomerID': '',
|
|
270
|
+
'CustomerIdentifier': data.customer_identifier,
|
|
271
|
+
'CustomerName': data.customer_name,
|
|
272
|
+
'CustomerAddr': data.customer_addr,
|
|
273
|
+
'CustomerPhone': data.customer_phone,
|
|
274
|
+
'CustomerEmail': data.customer_email,
|
|
275
|
+
'ClearanceMark': '',
|
|
276
|
+
'Print': data.print,
|
|
277
|
+
'Donation': data.donation,
|
|
278
|
+
'LoveCode': data.love_code,
|
|
279
|
+
'CarrierType': data.carrier_type,
|
|
280
|
+
'CarrierNum': data.carrier_num,
|
|
281
|
+
'TaxType': data.tax_type,
|
|
282
|
+
'SalesAmount': data.sales_amount,
|
|
283
|
+
'InvType': data.inv_type,
|
|
284
|
+
'vat': data.vat,
|
|
285
|
+
'Items': data.items,
|
|
286
|
+
'InvoiceRemark': data.remark,
|
|
287
|
+
'TimeStamp': int(time.time())
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
# 加密資料
|
|
291
|
+
json_string = json.dumps(api_data, ensure_ascii=False)
|
|
292
|
+
encrypted_data = self._encrypt_aes(json_string)
|
|
293
|
+
|
|
294
|
+
# 發送請求
|
|
295
|
+
try:
|
|
296
|
+
response = requests.post(
|
|
297
|
+
self.api_url,
|
|
298
|
+
data={'MerchantID': data.merchant_id, 'RqHeader': {'Timestamp': int(time.time())}, 'Data': encrypted_data},
|
|
299
|
+
headers={'Content-Type': 'application/x-www-form-urlencoded'},
|
|
300
|
+
timeout=30
|
|
301
|
+
)
|
|
302
|
+
response.raise_for_status()
|
|
303
|
+
|
|
304
|
+
# 解析回應
|
|
305
|
+
result = response.json()
|
|
306
|
+
|
|
307
|
+
if 'Data' in result:
|
|
308
|
+
decrypted = self._decrypt_aes(result['Data'])
|
|
309
|
+
|
|
310
|
+
if decrypted.get('RtnCode') == 1:
|
|
311
|
+
return InvoiceIssueResponse(
|
|
312
|
+
success=True,
|
|
313
|
+
invoice_number=decrypted.get('InvoiceNo', ''),
|
|
314
|
+
invoice_date=decrypted.get('InvoiceDate', ''),
|
|
315
|
+
random_number=decrypted.get('RandomNumber', ''),
|
|
316
|
+
rtn_code=decrypted.get('RtnCode', 0),
|
|
317
|
+
rtn_msg=decrypted.get('RtnMsg', ''),
|
|
318
|
+
raw=decrypted
|
|
319
|
+
)
|
|
320
|
+
else:
|
|
321
|
+
return InvoiceIssueResponse(
|
|
322
|
+
success=False,
|
|
323
|
+
rtn_code=decrypted.get('RtnCode', 0),
|
|
324
|
+
rtn_msg=decrypted.get('RtnMsg', ''),
|
|
325
|
+
error_message=f"發票開立失敗: {decrypted.get('RtnMsg', '未知錯誤')}",
|
|
326
|
+
raw=decrypted
|
|
327
|
+
)
|
|
328
|
+
else:
|
|
329
|
+
return InvoiceIssueResponse(
|
|
330
|
+
success=False,
|
|
331
|
+
error_message='API 回應格式錯誤',
|
|
332
|
+
raw=result
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
except requests.exceptions.RequestException as e:
|
|
336
|
+
raise ConnectionError(f'API 連線失敗: {str(e)}')
|
|
337
|
+
|
|
338
|
+
def void_invoice(self, data: InvoiceVoidData, reason: str = '訂單取消') -> Dict[str, any]:
|
|
339
|
+
"""
|
|
340
|
+
作廢電子發票
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
data: 發票作廢資料
|
|
344
|
+
reason: 作廢原因
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
作廢結果
|
|
348
|
+
|
|
349
|
+
Example:
|
|
350
|
+
>>> void_data = InvoiceVoidData(
|
|
351
|
+
... merchant_id='2000132',
|
|
352
|
+
... invoice_no='AA12345678',
|
|
353
|
+
... invoice_date='2024-01-29',
|
|
354
|
+
... reason='訂單取消'
|
|
355
|
+
... )
|
|
356
|
+
>>> result = svc.void_invoice(void_data)
|
|
357
|
+
"""
|
|
358
|
+
api_data = {
|
|
359
|
+
'MerchantID': data.merchant_id,
|
|
360
|
+
'InvoiceNo': data.invoice_no,
|
|
361
|
+
'InvoiceDate': data.invoice_date,
|
|
362
|
+
'Reason': reason,
|
|
363
|
+
'TimeStamp': int(time.time())
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
json_string = json.dumps(api_data, ensure_ascii=False)
|
|
367
|
+
encrypted_data = self._encrypt_aes(json_string)
|
|
368
|
+
|
|
369
|
+
try:
|
|
370
|
+
response = requests.post(
|
|
371
|
+
self.void_url,
|
|
372
|
+
data={'MerchantID': data.merchant_id, 'RqHeader': {'Timestamp': int(time.time())}, 'Data': encrypted_data},
|
|
373
|
+
headers={'Content-Type': 'application/x-www-form-urlencoded'},
|
|
374
|
+
timeout=30
|
|
375
|
+
)
|
|
376
|
+
response.raise_for_status()
|
|
377
|
+
|
|
378
|
+
result = response.json()
|
|
379
|
+
|
|
380
|
+
if 'Data' in result:
|
|
381
|
+
return self._decrypt_aes(result['Data'])
|
|
382
|
+
else:
|
|
383
|
+
return result
|
|
384
|
+
|
|
385
|
+
except requests.exceptions.RequestException as e:
|
|
386
|
+
raise ConnectionError(f'API 連線失敗: {str(e)}')
|
|
387
|
+
|
|
388
|
+
def issue_allowance(self, data: InvoiceAllowanceData) -> Dict[str, any]:
|
|
389
|
+
"""
|
|
390
|
+
開立折讓證明單
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
data: 折讓資料
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
折讓結果
|
|
397
|
+
|
|
398
|
+
Example:
|
|
399
|
+
>>> allowance_data = InvoiceAllowanceData(
|
|
400
|
+
... merchant_id='2000132',
|
|
401
|
+
... invoice_no='AA12345678',
|
|
402
|
+
... invoice_date='2024-01-29',
|
|
403
|
+
... customer_name='王小明',
|
|
404
|
+
... notify_mail='test@example.com',
|
|
405
|
+
... allowance_amount=100,
|
|
406
|
+
... items=[
|
|
407
|
+
... {'ItemName': '商品A', 'ItemCount': 1, 'ItemWord': '個', 'ItemPrice': 100, 'ItemAmount': 100}
|
|
408
|
+
... ]
|
|
409
|
+
... )
|
|
410
|
+
>>> result = svc.issue_allowance(allowance_data)
|
|
411
|
+
"""
|
|
412
|
+
api_data = {
|
|
413
|
+
'MerchantID': data.merchant_id,
|
|
414
|
+
'InvoiceNo': data.invoice_no,
|
|
415
|
+
'InvoiceDate': data.invoice_date,
|
|
416
|
+
'AllowanceNotify': data.allowance_notify,
|
|
417
|
+
'CustomerName': data.customer_name,
|
|
418
|
+
'NotifyMail': data.notify_mail,
|
|
419
|
+
'NotifyPhone': data.notify_phone,
|
|
420
|
+
'AllowanceAmount': data.allowance_amount,
|
|
421
|
+
'Items': data.items,
|
|
422
|
+
'TimeStamp': int(time.time())
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
json_string = json.dumps(api_data, ensure_ascii=False)
|
|
426
|
+
encrypted_data = self._encrypt_aes(json_string)
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
response = requests.post(
|
|
430
|
+
self.allowance_url,
|
|
431
|
+
data={'MerchantID': data.merchant_id, 'RqHeader': {'Timestamp': int(time.time())}, 'Data': encrypted_data},
|
|
432
|
+
headers={'Content-Type': 'application/x-www-form-urlencoded'},
|
|
433
|
+
timeout=30
|
|
434
|
+
)
|
|
435
|
+
response.raise_for_status()
|
|
436
|
+
|
|
437
|
+
result = response.json()
|
|
438
|
+
|
|
439
|
+
if 'Data' in result:
|
|
440
|
+
return self._decrypt_aes(result['Data'])
|
|
441
|
+
else:
|
|
442
|
+
return result
|
|
443
|
+
|
|
444
|
+
except requests.exceptions.RequestException as e:
|
|
445
|
+
raise ConnectionError(f'API 連線失敗: {str(e)}')
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# ============================================================================
|
|
449
|
+
# 使用範例
|
|
450
|
+
# ============================================================================
|
|
451
|
+
|
|
452
|
+
def example_b2c_invoice():
|
|
453
|
+
"""範例: B2C 二聯式發票開立"""
|
|
454
|
+
print("=== B2C 二聯式發票開立範例 ===\n")
|
|
455
|
+
|
|
456
|
+
# 初始化服務 (測試環境)
|
|
457
|
+
service = ECPayInvoiceService(
|
|
458
|
+
merchant_id=ECPayInvoiceService.TEST_MERCHANT_ID,
|
|
459
|
+
hash_key=ECPayInvoiceService.TEST_HASH_KEY,
|
|
460
|
+
hash_iv=ECPayInvoiceService.TEST_HASH_IV,
|
|
461
|
+
is_test=True
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
# 準備發票資料
|
|
465
|
+
invoice_data = InvoiceIssueData(
|
|
466
|
+
merchant_id=ECPayInvoiceService.TEST_MERCHANT_ID,
|
|
467
|
+
relate_number=f'ORD{int(time.time())}', # 唯一訂單編號
|
|
468
|
+
customer_identifier='0000000000', # B2C 固定值
|
|
469
|
+
customer_name='王小明',
|
|
470
|
+
customer_addr='台北市信義區信義路五段7號',
|
|
471
|
+
customer_phone='0912345678',
|
|
472
|
+
customer_email='test@example.com',
|
|
473
|
+
sales_amount=1050, # B2C 含稅金額
|
|
474
|
+
total_amount=1050,
|
|
475
|
+
print='0', # 不列印
|
|
476
|
+
donation='0', # 不捐贈
|
|
477
|
+
carrier_type='', # 不使用載具
|
|
478
|
+
items=[
|
|
479
|
+
{
|
|
480
|
+
'ItemName': '測試商品A',
|
|
481
|
+
'ItemCount': 1,
|
|
482
|
+
'ItemWord': '個',
|
|
483
|
+
'ItemPrice': 1000,
|
|
484
|
+
'ItemTaxType': '1',
|
|
485
|
+
'ItemAmount': 1050
|
|
486
|
+
}
|
|
487
|
+
]
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
# 開立發票
|
|
491
|
+
try:
|
|
492
|
+
response = service.issue_invoice(invoice_data)
|
|
493
|
+
|
|
494
|
+
if response.success:
|
|
495
|
+
print(f"✓ 發票開立成功!")
|
|
496
|
+
print(f" 發票號碼: {response.invoice_number}")
|
|
497
|
+
print(f" 發票日期: {response.invoice_date}")
|
|
498
|
+
print(f" 隨機碼: {response.random_number}")
|
|
499
|
+
else:
|
|
500
|
+
print(f"✗ 發票開立失敗: {response.error_message}")
|
|
501
|
+
print(f" 錯誤碼: {response.rtn_code}")
|
|
502
|
+
print(f" 錯誤訊息: {response.rtn_msg}")
|
|
503
|
+
|
|
504
|
+
except Exception as e:
|
|
505
|
+
print(f"✗ 發生錯誤: {str(e)}")
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def example_b2b_invoice():
|
|
509
|
+
"""範例: B2B 三聯式發票開立"""
|
|
510
|
+
print("\n=== B2B 三聯式發票開立範例 ===\n")
|
|
511
|
+
|
|
512
|
+
service = ECPayInvoiceService(
|
|
513
|
+
merchant_id=ECPayInvoiceService.TEST_MERCHANT_ID,
|
|
514
|
+
hash_key=ECPayInvoiceService.TEST_HASH_KEY,
|
|
515
|
+
hash_iv=ECPayInvoiceService.TEST_HASH_IV,
|
|
516
|
+
is_test=True
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
# 計算 B2B 金額 (含稅 1050 → 未稅 1000 + 稅額 50)
|
|
520
|
+
amounts = service.calculate_b2b_amounts(1050)
|
|
521
|
+
|
|
522
|
+
invoice_data = InvoiceIssueData(
|
|
523
|
+
merchant_id=ECPayInvoiceService.TEST_MERCHANT_ID,
|
|
524
|
+
relate_number=f'ORD{int(time.time())}',
|
|
525
|
+
customer_identifier='80129529', # 8 碼統編
|
|
526
|
+
customer_name='測試公司股份有限公司',
|
|
527
|
+
customer_addr='台北市中正區重慶南路一段122號',
|
|
528
|
+
customer_phone='02-23113456',
|
|
529
|
+
customer_email='company@example.com',
|
|
530
|
+
sales_amount=amounts['sales_amount'], # 未稅金額
|
|
531
|
+
tax_amount=amounts['tax_amount'], # 稅額
|
|
532
|
+
total_amount=amounts['total_amount'], # 總額
|
|
533
|
+
print='0',
|
|
534
|
+
donation='0',
|
|
535
|
+
carrier_type='', # B2B 不可使用載具
|
|
536
|
+
items=[
|
|
537
|
+
{
|
|
538
|
+
'ItemName': '測試商品B',
|
|
539
|
+
'ItemCount': 1,
|
|
540
|
+
'ItemWord': '個',
|
|
541
|
+
'ItemPrice': 1000, # 未稅單價
|
|
542
|
+
'ItemTaxType': '1',
|
|
543
|
+
'ItemAmount': 1000 # 未稅小計
|
|
544
|
+
}
|
|
545
|
+
]
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
try:
|
|
549
|
+
response = service.issue_invoice(invoice_data)
|
|
550
|
+
|
|
551
|
+
if response.success:
|
|
552
|
+
print(f"✓ B2B 發票開立成功!")
|
|
553
|
+
print(f" 發票號碼: {response.invoice_number}")
|
|
554
|
+
print(f" 未稅金額: {amounts['sales_amount']}")
|
|
555
|
+
print(f" 稅額: {amounts['tax_amount']}")
|
|
556
|
+
print(f" 總額: {amounts['total_amount']}")
|
|
557
|
+
else:
|
|
558
|
+
print(f"✗ B2B 發票開立失敗: {response.error_message}")
|
|
559
|
+
|
|
560
|
+
except Exception as e:
|
|
561
|
+
print(f"✗ 發生錯誤: {str(e)}")
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def example_void_invoice():
|
|
565
|
+
"""範例: 發票作廢"""
|
|
566
|
+
print("\n=== 發票作廢範例 ===\n")
|
|
567
|
+
|
|
568
|
+
service = ECPayInvoiceService(
|
|
569
|
+
merchant_id=ECPayInvoiceService.TEST_MERCHANT_ID,
|
|
570
|
+
hash_key=ECPayInvoiceService.TEST_HASH_KEY,
|
|
571
|
+
hash_iv=ECPayInvoiceService.TEST_HASH_IV,
|
|
572
|
+
is_test=True
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
void_data = InvoiceVoidData(
|
|
576
|
+
merchant_id=ECPayInvoiceService.TEST_MERCHANT_ID,
|
|
577
|
+
invoice_no='AA12345678', # 實際發票號碼
|
|
578
|
+
invoice_date='2024/01/29',
|
|
579
|
+
reason='訂單取消'
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
try:
|
|
583
|
+
result = service.void_invoice(void_data)
|
|
584
|
+
print(f"作廢結果: {result}")
|
|
585
|
+
|
|
586
|
+
except Exception as e:
|
|
587
|
+
print(f"✗ 發生錯誤: {str(e)}")
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
if __name__ == '__main__':
|
|
591
|
+
# 執行範例
|
|
592
|
+
example_b2c_invoice()
|
|
593
|
+
example_b2b_invoice()
|
|
594
|
+
# example_void_invoice() # 需要有效的發票號碼
|
|
595
|
+
|
|
596
|
+
print("\n" + "="*50)
|
|
597
|
+
print("範例執行完畢!")
|
|
598
|
+
print("="*50)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|