taiwan-invoice-skill 2.0.0 → 2.2.0
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/README.md +131 -0
- package/assets/taiwan-invoice/SKILL.md +452 -0
- package/assets/taiwan-invoice/data/error-codes.csv +41 -0
- package/assets/taiwan-invoice/data/field-mappings.csv +27 -0
- package/assets/taiwan-invoice/data/operations.csv +11 -0
- package/assets/taiwan-invoice/data/providers.csv +4 -0
- package/assets/taiwan-invoice/data/tax-rules.csv +9 -0
- package/assets/taiwan-invoice/data/troubleshooting.csv +17 -0
- package/assets/taiwan-invoice/scripts/__pycache__/core.cpython-312.pyc +0 -0
- package/assets/taiwan-invoice/scripts/core.py +304 -0
- package/assets/taiwan-invoice/scripts/generate-invoice-service.py +642 -128
- package/assets/taiwan-invoice/scripts/recommend.py +340 -0
- package/assets/taiwan-invoice/scripts/search.py +201 -0
- package/assets/templates/base/quick-reference.md +85 -0
- package/assets/templates/platforms/{antigravity.json → agent.json} +6 -3
- package/assets/templates/platforms/claude.json +5 -2
- package/assets/templates/platforms/codebuddy.json +5 -2
- package/assets/templates/platforms/codex.json +5 -2
- package/assets/templates/platforms/continue.json +5 -2
- package/assets/templates/platforms/copilot.json +5 -2
- package/assets/templates/platforms/cursor.json +5 -2
- package/assets/templates/platforms/gemini.json +5 -2
- package/assets/templates/platforms/kiro.json +5 -2
- package/assets/templates/platforms/opencode.json +5 -2
- package/assets/templates/platforms/qoder.json +5 -2
- package/assets/templates/platforms/roocode.json +5 -2
- package/assets/templates/platforms/trae.json +5 -2
- package/assets/templates/platforms/windsurf.json +5 -2
- package/dist/index.js +265 -60
- package/package.json +3 -2
|
@@ -1,135 +1,207 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
-
|
|
3
|
+
Taiwan Invoice Service Generator
|
|
4
|
+
根據 CSV 數據生成完整的發票服務實作
|
|
5
|
+
|
|
6
|
+
用法:
|
|
7
|
+
python generate-invoice-service.py ECPay # 生成 ECPay 服務 (TypeScript)
|
|
8
|
+
python generate-invoice-service.py SmilePay --lang python # 生成 SmilePay 服務 (Python)
|
|
9
|
+
python generate-invoice-service.py Amego --output ./lib/ # 指定輸出目錄
|
|
10
|
+
python generate-invoice-service.py NewProvider # 生成新服務商模板
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
13
|
import sys
|
|
14
14
|
import os
|
|
15
|
+
import csv
|
|
16
|
+
import argparse
|
|
15
17
|
from pathlib import Path
|
|
18
|
+
from typing import Dict, List, Optional, Any
|
|
19
|
+
|
|
20
|
+
# 取得腳本目錄
|
|
21
|
+
SCRIPT_DIR = Path(__file__).parent
|
|
22
|
+
DATA_DIR = SCRIPT_DIR.parent / 'data'
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_csv(filename: str) -> List[Dict[str, str]]:
|
|
26
|
+
"""載入 CSV 檔案"""
|
|
27
|
+
filepath = DATA_DIR / filename
|
|
28
|
+
if not filepath.exists():
|
|
29
|
+
return []
|
|
30
|
+
|
|
31
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
32
|
+
return list(csv.DictReader(f))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_provider_info(provider: str) -> Optional[Dict[str, str]]:
|
|
36
|
+
"""取得服務商資訊"""
|
|
37
|
+
providers = load_csv('providers.csv')
|
|
38
|
+
for p in providers:
|
|
39
|
+
if p['provider'].lower() == provider.lower():
|
|
40
|
+
return p
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_operations(provider: str) -> List[Dict[str, str]]:
|
|
45
|
+
"""取得服務商的操作端點"""
|
|
46
|
+
operations = load_csv('operations.csv')
|
|
47
|
+
result = []
|
|
48
|
+
for op in operations:
|
|
49
|
+
endpoint_key = f"{provider.lower()}_endpoint" if provider.lower() != 'ecpay' else f"{provider.lower()}_b2c_endpoint"
|
|
50
|
+
if endpoint_key in op or f"{provider.lower()}_b2c_endpoint" in op:
|
|
51
|
+
result.append(op)
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_field_mappings(provider: str) -> List[Dict[str, str]]:
|
|
56
|
+
"""取得欄位映射"""
|
|
57
|
+
mappings = load_csv('field-mappings.csv')
|
|
58
|
+
provider_col = f"{provider.lower()}_name"
|
|
59
|
+
return [m for m in mappings if m.get(provider_col)]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_error_codes(provider: str) -> List[Dict[str, str]]:
|
|
63
|
+
"""取得錯誤碼"""
|
|
64
|
+
errors = load_csv('error-codes.csv')
|
|
65
|
+
return [e for e in errors if e['provider'].lower() == provider.lower()]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# TypeScript 模板
|
|
69
|
+
TS_TEMPLATE = '''import crypto from 'crypto'
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* {display_name} 電子發票服務
|
|
73
|
+
*
|
|
74
|
+
* 認證方式: {auth_method}
|
|
75
|
+
* 加密方式: {encryption}
|
|
76
|
+
*
|
|
77
|
+
* 自動生成 by taiwan-invoice-skill
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
interface InvoiceIssueData {{
|
|
81
|
+
OrderId: string
|
|
82
|
+
BuyerIdentifier?: string
|
|
83
|
+
BuyerName?: string
|
|
84
|
+
BuyerEmail?: string
|
|
85
|
+
IsB2B?: boolean
|
|
86
|
+
SalesAmount?: number
|
|
87
|
+
TaxAmount?: number
|
|
88
|
+
TotalAmount?: number
|
|
89
|
+
ProductItem?: Array<{{
|
|
90
|
+
Description: string
|
|
91
|
+
Quantity: number
|
|
92
|
+
UnitPrice: number
|
|
93
|
+
Amount: number
|
|
94
|
+
}}>
|
|
95
|
+
}}
|
|
16
96
|
|
|
97
|
+
interface InvoiceIssueResponse {{
|
|
98
|
+
success: boolean
|
|
99
|
+
code: number | string
|
|
100
|
+
msg: string
|
|
101
|
+
invoiceNumber?: string
|
|
102
|
+
randomNumber?: string
|
|
103
|
+
raw?: any
|
|
104
|
+
}}
|
|
105
|
+
|
|
106
|
+
interface InvoiceVoidResponse {{
|
|
107
|
+
success: boolean
|
|
108
|
+
msg: string
|
|
109
|
+
}}
|
|
17
110
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
111
|
+
interface InvoicePrintResponse {{
|
|
112
|
+
success: boolean
|
|
113
|
+
type: 'html' | 'redirect' | 'form'
|
|
114
|
+
printUrl?: string
|
|
115
|
+
htmlContent?: string
|
|
116
|
+
formData?: Record<string, string>
|
|
117
|
+
}}
|
|
21
118
|
|
|
22
|
-
export class {
|
|
119
|
+
export class {class_name}InvoiceService {{
|
|
120
|
+
private TEST_URL = '{test_url}'
|
|
121
|
+
private PROD_URL = '{prod_url}'
|
|
23
122
|
private API_BASE_URL: string
|
|
24
|
-
|
|
25
|
-
|
|
123
|
+
|
|
124
|
+
// 測試帳號
|
|
125
|
+
private TEST_MERCHANT_ID = '{test_merchant_id}'
|
|
126
|
+
{test_credentials}
|
|
26
127
|
|
|
27
128
|
constructor(isProd: boolean = false) {{
|
|
28
|
-
this.API_BASE_URL = isProd ? this.
|
|
129
|
+
this.API_BASE_URL = isProd ? this.PROD_URL : this.TEST_URL
|
|
29
130
|
}}
|
|
30
131
|
|
|
31
132
|
/**
|
|
32
|
-
*
|
|
133
|
+
* 開立發票
|
|
134
|
+
* 端點: {issue_endpoint}
|
|
33
135
|
*/
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
where: {{ userId }},
|
|
37
|
-
}})
|
|
136
|
+
async issueInvoice(merchantId: string, hashKey: string, hashIV: string, data: InvoiceIssueData): Promise<InvoiceIssueResponse> {{
|
|
137
|
+
const isB2B = data.IsB2B === true
|
|
38
138
|
|
|
39
|
-
|
|
40
|
-
|
|
139
|
+
// 金額計算
|
|
140
|
+
const amounts = this.calculateAmounts(data.TotalAmount || data.SalesAmount || 0, isB2B)
|
|
141
|
+
|
|
142
|
+
// 準備 API 資料
|
|
143
|
+
const apiData = {{
|
|
144
|
+
{issue_fields}
|
|
41
145
|
}}
|
|
42
146
|
|
|
43
|
-
|
|
44
|
-
|
|
147
|
+
// TODO: 實作加密/簽章
|
|
148
|
+
// {auth_method}
|
|
45
149
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const response = await fetch(`${{this.API_BASE_URL}}/issue`, {{
|
|
65
|
-
method: 'POST',
|
|
66
|
-
headers: {{
|
|
67
|
-
'Content-Type': 'application/json',
|
|
68
|
-
}},
|
|
69
|
-
body: JSON.stringify(apiData),
|
|
70
|
-
}})
|
|
71
|
-
|
|
72
|
-
const result = await response.json()
|
|
73
|
-
|
|
74
|
-
return {{
|
|
75
|
-
success: result.code === 0,
|
|
76
|
-
code: result.code,
|
|
77
|
-
msg: result.message,
|
|
78
|
-
invoiceNumber: result.invoice_number,
|
|
79
|
-
randomNumber: result.random_number,
|
|
80
|
-
raw: result,
|
|
81
|
-
}}
|
|
82
|
-
}} catch (error) {{
|
|
83
|
-
console.error('[{ProviderName}] 開立發票失敗:', error)
|
|
84
|
-
throw error
|
|
150
|
+
const response = await fetch(`${{this.API_BASE_URL}}{issue_endpoint}`, {{
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers: {{
|
|
153
|
+
'Content-Type': '{content_type}',
|
|
154
|
+
}},
|
|
155
|
+
body: {request_body},
|
|
156
|
+
}})
|
|
157
|
+
|
|
158
|
+
const result = await response.json()
|
|
159
|
+
|
|
160
|
+
return {{
|
|
161
|
+
success: {success_condition},
|
|
162
|
+
code: result.{code_field},
|
|
163
|
+
msg: result.{msg_field},
|
|
164
|
+
invoiceNumber: result.{invoice_field},
|
|
165
|
+
randomNumber: result.{random_field},
|
|
166
|
+
raw: result,
|
|
85
167
|
}}
|
|
86
168
|
}}
|
|
87
169
|
|
|
88
170
|
/**
|
|
89
171
|
* 作廢發票
|
|
172
|
+
* 端點: {void_endpoint}
|
|
90
173
|
*/
|
|
91
|
-
async voidInvoice(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
console.error('[{ProviderName}] 作廢發票失敗:', error)
|
|
103
|
-
throw error
|
|
174
|
+
async voidInvoice(merchantId: string, hashKey: string, hashIV: string, invoiceNumber: string, reason: string): Promise<InvoiceVoidResponse> {{
|
|
175
|
+
const apiData = {{
|
|
176
|
+
InvoiceNumber: invoiceNumber,
|
|
177
|
+
Reason: reason,
|
|
178
|
+
}}
|
|
179
|
+
|
|
180
|
+
// TODO: 實作 API 請求
|
|
181
|
+
|
|
182
|
+
return {{
|
|
183
|
+
success: true,
|
|
184
|
+
msg: '發票作廢成功',
|
|
104
185
|
}}
|
|
105
186
|
}}
|
|
106
187
|
|
|
107
188
|
/**
|
|
108
189
|
* 列印發票
|
|
190
|
+
* 端點: {print_endpoint}
|
|
109
191
|
*/
|
|
110
|
-
async printInvoice(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
// - type: 'html' - 回傳 HTML 內容
|
|
117
|
-
// - type: 'redirect' - 回傳 URL 跳轉
|
|
118
|
-
// - type: 'form' - 回傳表單資料
|
|
119
|
-
|
|
120
|
-
return {{
|
|
121
|
-
success: true,
|
|
122
|
-
type: 'redirect',
|
|
123
|
-
printUrl: `${{this.API_BASE_URL}}/print?invoice=${{invoiceNumber}}`,
|
|
124
|
-
}}
|
|
125
|
-
}} catch (error) {{
|
|
126
|
-
console.error('[{ProviderName}] 列印發票失敗:', error)
|
|
127
|
-
throw error
|
|
192
|
+
async printInvoice(merchantId: string, hashKey: string, invoiceNumber: string, invoiceDate: string, randomNumber: string): Promise<InvoicePrintResponse> {{
|
|
193
|
+
// {print_method}
|
|
194
|
+
return {{
|
|
195
|
+
success: true,
|
|
196
|
+
type: '{print_type}',
|
|
197
|
+
printUrl: `${{this.API_BASE_URL}}{print_endpoint}?InvoiceNo=${{invoiceNumber}}`,
|
|
128
198
|
}}
|
|
129
199
|
}}
|
|
130
200
|
|
|
131
201
|
/**
|
|
132
|
-
*
|
|
202
|
+
* 金額計算
|
|
203
|
+
* B2C: TaxAmount = 0, SalesAmount = 含稅總額
|
|
204
|
+
* B2B: TaxAmount = 稅額, SalesAmount = 未稅
|
|
133
205
|
*/
|
|
134
206
|
private calculateAmounts(totalAmount: number, isB2B: boolean) {{
|
|
135
207
|
if (isB2B) {{
|
|
@@ -141,52 +213,494 @@ export class {ClassName}InvoiceService implements InvoiceService {{
|
|
|
141
213
|
}}
|
|
142
214
|
}}
|
|
143
215
|
|
|
144
|
-
|
|
145
|
-
* 輔助方法:加密/簽章
|
|
146
|
-
*/
|
|
147
|
-
private generateSignature(data: any, secret: string): string {{
|
|
148
|
-
// TODO: 實作加密/簽章邏輯
|
|
149
|
-
// 範例: MD5
|
|
150
|
-
const signString = JSON.stringify(data) + secret
|
|
151
|
-
return crypto.createHash('md5').update(signString).digest('hex')
|
|
152
|
-
}}
|
|
216
|
+
{encryption_method}
|
|
153
217
|
}}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 錯誤碼參考
|
|
221
|
+
{error_codes}
|
|
222
|
+
*/
|
|
223
|
+
'''
|
|
224
|
+
|
|
225
|
+
# Python 模板
|
|
226
|
+
PY_TEMPLATE = '''#!/usr/bin/env python3
|
|
154
227
|
"""
|
|
228
|
+
{display_name} 電子發票服務
|
|
155
229
|
|
|
230
|
+
認證方式: {auth_method}
|
|
231
|
+
加密方式: {encryption}
|
|
156
232
|
|
|
157
|
-
|
|
233
|
+
自動生成 by taiwan-invoice-skill
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
import hashlib
|
|
237
|
+
import json
|
|
238
|
+
import urllib.parse
|
|
239
|
+
from typing import Dict, Any, Optional, List
|
|
240
|
+
from dataclasses import dataclass
|
|
241
|
+
|
|
242
|
+
@dataclass
|
|
243
|
+
class InvoiceIssueData:
|
|
244
|
+
order_id: str
|
|
245
|
+
buyer_identifier: str = ""
|
|
246
|
+
buyer_name: str = ""
|
|
247
|
+
buyer_email: str = ""
|
|
248
|
+
is_b2b: bool = False
|
|
249
|
+
sales_amount: int = 0
|
|
250
|
+
tax_amount: int = 0
|
|
251
|
+
total_amount: int = 0
|
|
252
|
+
product_items: List[Dict[str, Any]] = None
|
|
253
|
+
|
|
254
|
+
@dataclass
|
|
255
|
+
class InvoiceIssueResponse:
|
|
256
|
+
success: bool
|
|
257
|
+
code: str
|
|
258
|
+
msg: str
|
|
259
|
+
invoice_number: str = ""
|
|
260
|
+
random_number: str = ""
|
|
261
|
+
raw: Dict = None
|
|
262
|
+
|
|
263
|
+
class {class_name}InvoiceService:
|
|
264
|
+
"""
|
|
265
|
+
{display_name} 電子發票服務
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
TEST_URL = '{test_url}'
|
|
269
|
+
PROD_URL = '{prod_url}'
|
|
270
|
+
|
|
271
|
+
# 測試帳號
|
|
272
|
+
TEST_MERCHANT_ID = '{test_merchant_id}'
|
|
273
|
+
{test_credentials_py}
|
|
274
|
+
|
|
275
|
+
def __init__(self, is_prod: bool = False):
|
|
276
|
+
self.api_base_url = self.PROD_URL if is_prod else self.TEST_URL
|
|
277
|
+
|
|
278
|
+
def issue_invoice(self, merchant_id: str, hash_key: str, hash_iv: str,
|
|
279
|
+
data: InvoiceIssueData) -> InvoiceIssueResponse:
|
|
280
|
+
"""
|
|
281
|
+
開立發票
|
|
282
|
+
端點: {issue_endpoint}
|
|
283
|
+
"""
|
|
284
|
+
is_b2b = data.is_b2b
|
|
285
|
+
|
|
286
|
+
# 金額計算
|
|
287
|
+
amounts = self._calculate_amounts(data.total_amount or data.sales_amount or 0, is_b2b)
|
|
288
|
+
|
|
289
|
+
# 準備 API 資料
|
|
290
|
+
api_data = {{
|
|
291
|
+
{issue_fields_py}
|
|
292
|
+
}}
|
|
293
|
+
|
|
294
|
+
# TODO: 實作加密/簽章和 API 請求
|
|
295
|
+
# {auth_method}
|
|
296
|
+
|
|
297
|
+
return InvoiceIssueResponse(
|
|
298
|
+
success=True,
|
|
299
|
+
code="0",
|
|
300
|
+
msg="",
|
|
301
|
+
invoice_number="",
|
|
302
|
+
random_number="",
|
|
303
|
+
raw={{}}
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def void_invoice(self, merchant_id: str, hash_key: str, hash_iv: str,
|
|
307
|
+
invoice_number: str, reason: str) -> Dict[str, Any]:
|
|
308
|
+
"""
|
|
309
|
+
作廢發票
|
|
310
|
+
端點: {void_endpoint}
|
|
311
|
+
"""
|
|
312
|
+
api_data = {{
|
|
313
|
+
"InvoiceNumber": invoice_number,
|
|
314
|
+
"Reason": reason,
|
|
315
|
+
}}
|
|
316
|
+
|
|
317
|
+
# TODO: 實作 API 請求
|
|
318
|
+
|
|
319
|
+
return {{"success": True, "msg": "發票作廢成功"}}
|
|
320
|
+
|
|
321
|
+
def print_invoice(self, merchant_id: str, invoice_number: str,
|
|
322
|
+
invoice_date: str, random_number: str) -> Dict[str, Any]:
|
|
323
|
+
"""
|
|
324
|
+
列印發票
|
|
325
|
+
端點: {print_endpoint}
|
|
326
|
+
"""
|
|
327
|
+
return {{
|
|
328
|
+
"success": True,
|
|
329
|
+
"type": "{print_type}",
|
|
330
|
+
"print_url": f"{{self.api_base_url}}{print_endpoint}?InvoiceNo={{invoice_number}}"
|
|
331
|
+
}}
|
|
332
|
+
|
|
333
|
+
def _calculate_amounts(self, total_amount: int, is_b2b: bool) -> Dict[str, int]:
|
|
334
|
+
"""
|
|
335
|
+
金額計算
|
|
336
|
+
B2C: TaxAmount = 0, SalesAmount = 含稅總額
|
|
337
|
+
B2B: TaxAmount = 稅額, SalesAmount = 未稅
|
|
338
|
+
"""
|
|
339
|
+
if is_b2b:
|
|
340
|
+
tax_amount = round(total_amount - (total_amount / 1.05))
|
|
341
|
+
sales_amount = total_amount - tax_amount
|
|
342
|
+
return {{"sales_amount": sales_amount, "tax_amount": tax_amount, "total_amount": total_amount}}
|
|
343
|
+
else:
|
|
344
|
+
return {{"sales_amount": total_amount, "tax_amount": 0, "total_amount": total_amount}}
|
|
345
|
+
|
|
346
|
+
{encryption_method_py}
|
|
347
|
+
|
|
348
|
+
# 錯誤碼參考
|
|
349
|
+
ERROR_CODES = {{
|
|
350
|
+
{error_codes_py}
|
|
351
|
+
}}
|
|
352
|
+
'''
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def get_ecpay_specifics() -> Dict[str, str]:
|
|
356
|
+
"""ECPay 特定配置"""
|
|
357
|
+
return {
|
|
358
|
+
'test_credentials': ' private TEST_HASH_KEY = \'ejCk326UnaZWKisg\'\n private TEST_HASH_IV = \'q9jcZX8Ib9LM8wYk\'',
|
|
359
|
+
'test_credentials_py': ' TEST_HASH_KEY = "ejCk326UnaZWKisg"\n TEST_HASH_IV = "q9jcZX8Ib9LM8wYk"',
|
|
360
|
+
'issue_endpoint': '/B2CInvoice/Issue',
|
|
361
|
+
'void_endpoint': '/B2CInvoice/Invalid',
|
|
362
|
+
'print_endpoint': '/Invoice/Print',
|
|
363
|
+
'content_type': 'application/json',
|
|
364
|
+
'request_body': 'JSON.stringify({ MerchantID: merchantId, RqHeader: { Timestamp: Math.floor(Date.now() / 1000) }, Data: encryptedData })',
|
|
365
|
+
'success_condition': 'result.TransCode === 1 && result.Data?.RtnCode === 1',
|
|
366
|
+
'code_field': 'Data?.RtnCode || result.TransCode',
|
|
367
|
+
'msg_field': 'Data?.RtnMsg || result.TransMsg',
|
|
368
|
+
'invoice_field': 'Data?.InvoiceNo',
|
|
369
|
+
'random_field': 'Data?.RandomNumber',
|
|
370
|
+
'print_type': 'form',
|
|
371
|
+
'print_method': 'ECPay 使用 POST 表單跳轉',
|
|
372
|
+
'encryption_method': ''' /**
|
|
373
|
+
* AES-128-CBC 加密
|
|
374
|
+
*/
|
|
375
|
+
private encryptData(data: any, hashKey: string, hashIV: string): string {
|
|
376
|
+
const jsonString = JSON.stringify(data)
|
|
377
|
+
const urlEncoded = encodeURIComponent(jsonString)
|
|
378
|
+
|
|
379
|
+
const cipher = crypto.createCipheriv('aes-128-cbc', hashKey, hashIV)
|
|
380
|
+
let encrypted = cipher.update(urlEncoded, 'utf8', 'base64')
|
|
381
|
+
encrypted += cipher.final('base64')
|
|
382
|
+
|
|
383
|
+
return encrypted
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* AES-128-CBC 解密
|
|
388
|
+
*/
|
|
389
|
+
private decryptData(encryptedData: string, hashKey: string, hashIV: string): any {
|
|
390
|
+
const decipher = crypto.createDecipheriv('aes-128-cbc', hashKey, hashIV)
|
|
391
|
+
let decrypted = decipher.update(encryptedData, 'base64', 'utf8')
|
|
392
|
+
decrypted += decipher.final('utf8')
|
|
393
|
+
|
|
394
|
+
const urlDecoded = decodeURIComponent(decrypted)
|
|
395
|
+
return JSON.parse(urlDecoded)
|
|
396
|
+
}''',
|
|
397
|
+
'encryption_method_py': ''' def _encrypt_data(self, data: Dict, hash_key: str, hash_iv: str) -> str:
|
|
398
|
+
"""AES-128-CBC 加密"""
|
|
399
|
+
from Crypto.Cipher import AES
|
|
400
|
+
from Crypto.Util.Padding import pad
|
|
401
|
+
import base64
|
|
402
|
+
|
|
403
|
+
json_string = json.dumps(data, ensure_ascii=False)
|
|
404
|
+
url_encoded = urllib.parse.quote(json_string)
|
|
405
|
+
|
|
406
|
+
cipher = AES.new(hash_key.encode(), AES.MODE_CBC, hash_iv.encode())
|
|
407
|
+
padded = pad(url_encoded.encode(), AES.block_size)
|
|
408
|
+
encrypted = cipher.encrypt(padded)
|
|
409
|
+
|
|
410
|
+
return base64.b64encode(encrypted).decode()''',
|
|
411
|
+
'issue_fields': ''' MerchantID: merchantId,
|
|
412
|
+
RelateNumber: data.OrderId,
|
|
413
|
+
CustomerIdentifier: data.BuyerIdentifier || '',
|
|
414
|
+
CustomerName: data.BuyerName || '',
|
|
415
|
+
CustomerEmail: data.BuyerEmail || '',
|
|
416
|
+
Print: isB2B ? '1' : '0',
|
|
417
|
+
Donation: '0',
|
|
418
|
+
TaxType: '1',
|
|
419
|
+
InvType: '07',
|
|
420
|
+
SalesAmount: amounts.salesAmount,
|
|
421
|
+
TaxAmount: amounts.taxAmount,
|
|
422
|
+
TotalAmount: amounts.totalAmount,
|
|
423
|
+
Items: data.ProductItem?.map((item, idx) => ({
|
|
424
|
+
ItemSeq: idx + 1,
|
|
425
|
+
ItemName: item.Description,
|
|
426
|
+
ItemCount: item.Quantity,
|
|
427
|
+
ItemWord: '個',
|
|
428
|
+
ItemPrice: item.UnitPrice,
|
|
429
|
+
ItemAmount: item.Amount,
|
|
430
|
+
})) || [],''',
|
|
431
|
+
'issue_fields_py': ''' "MerchantID": merchant_id,
|
|
432
|
+
"RelateNumber": data.order_id,
|
|
433
|
+
"CustomerIdentifier": data.buyer_identifier or "",
|
|
434
|
+
"CustomerName": data.buyer_name or "",
|
|
435
|
+
"CustomerEmail": data.buyer_email or "",
|
|
436
|
+
"Print": "1" if is_b2b else "0",
|
|
437
|
+
"Donation": "0",
|
|
438
|
+
"TaxType": "1",
|
|
439
|
+
"InvType": "07",
|
|
440
|
+
"SalesAmount": amounts["sales_amount"],
|
|
441
|
+
"TaxAmount": amounts["tax_amount"],
|
|
442
|
+
"TotalAmount": amounts["total_amount"],
|
|
443
|
+
"Items": [
|
|
444
|
+
{
|
|
445
|
+
"ItemSeq": idx + 1,
|
|
446
|
+
"ItemName": item["Description"],
|
|
447
|
+
"ItemCount": item["Quantity"],
|
|
448
|
+
"ItemWord": "個",
|
|
449
|
+
"ItemPrice": item["UnitPrice"],
|
|
450
|
+
"ItemAmount": item["Amount"],
|
|
451
|
+
}
|
|
452
|
+
for idx, item in enumerate(data.product_items or [])
|
|
453
|
+
],''',
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def get_smilepay_specifics() -> Dict[str, str]:
|
|
458
|
+
"""SmilePay 特定配置"""
|
|
459
|
+
return {
|
|
460
|
+
'test_credentials': ' private TEST_VERIFY_KEY = \'9D73935693EE0237FABA6AB744E48661\'',
|
|
461
|
+
'test_credentials_py': ' TEST_VERIFY_KEY = "9D73935693EE0237FABA6AB744E48661"',
|
|
462
|
+
'issue_endpoint': '/SPEinvoice_Storage.asp',
|
|
463
|
+
'void_endpoint': '/SPEinvoice_Storage_Modify.asp',
|
|
464
|
+
'print_endpoint': '/SmilePayCarrier/InvoiceDetails.php',
|
|
465
|
+
'content_type': 'application/x-www-form-urlencoded',
|
|
466
|
+
'request_body': 'new URLSearchParams(apiData).toString()',
|
|
467
|
+
'success_condition': 'result.Status === "0"',
|
|
468
|
+
'code_field': 'Status',
|
|
469
|
+
'msg_field': 'Desc',
|
|
470
|
+
'invoice_field': 'InvoiceNumber',
|
|
471
|
+
'random_field': 'RandomNumber',
|
|
472
|
+
'print_type': 'redirect',
|
|
473
|
+
'print_method': 'SmilePay 使用 GET URL 跳轉',
|
|
474
|
+
'encryption_method': ''' // SmilePay 使用 Verify_key 驗證,無需額外加密''',
|
|
475
|
+
'encryption_method_py': ''' # SmilePay 使用 Verify_key 驗證,無需額外加密
|
|
476
|
+
pass''',
|
|
477
|
+
'issue_fields': ''' Grvc: merchantId,
|
|
478
|
+
Verify_key: hashKey,
|
|
479
|
+
InvoiceDate: new Date().toISOString().split('T')[0].replace(/-/g, '/'),
|
|
480
|
+
InvoiceTime: new Date().toTimeString().split(' ')[0],
|
|
481
|
+
Intype: '07',
|
|
482
|
+
TaxType: '1',
|
|
483
|
+
DonateMark: '0',
|
|
484
|
+
Buyer_id: isB2B ? data.BuyerIdentifier : '',
|
|
485
|
+
Name: data.BuyerName || '',
|
|
486
|
+
Email: data.BuyerEmail || '',
|
|
487
|
+
Description: data.ProductItem?.map(i => i.Description).join('|') || '',
|
|
488
|
+
Quantity: data.ProductItem?.map(i => i.Quantity).join('|') || '',
|
|
489
|
+
UnitPrice: data.ProductItem?.map(i => i.UnitPrice).join('|') || '',
|
|
490
|
+
Amount: data.ProductItem?.map(i => i.Amount).join('|') || '',
|
|
491
|
+
AllAmount: String(amounts.totalAmount),
|
|
492
|
+
data_id: data.OrderId,''',
|
|
493
|
+
'issue_fields_py': ''' "Grvc": merchant_id,
|
|
494
|
+
"Verify_key": hash_key,
|
|
495
|
+
"InvoiceDate": datetime.now().strftime("%Y/%m/%d"),
|
|
496
|
+
"InvoiceTime": datetime.now().strftime("%H:%M:%S"),
|
|
497
|
+
"Intype": "07",
|
|
498
|
+
"TaxType": "1",
|
|
499
|
+
"DonateMark": "0",
|
|
500
|
+
"Buyer_id": data.buyer_identifier if is_b2b else "",
|
|
501
|
+
"Name": data.buyer_name or "",
|
|
502
|
+
"Email": data.buyer_email or "",
|
|
503
|
+
"Description": "|".join(i["Description"] for i in (data.product_items or [])),
|
|
504
|
+
"Quantity": "|".join(str(i["Quantity"]) for i in (data.product_items or [])),
|
|
505
|
+
"UnitPrice": "|".join(str(i["UnitPrice"]) for i in (data.product_items or [])),
|
|
506
|
+
"Amount": "|".join(str(i["Amount"]) for i in (data.product_items or [])),
|
|
507
|
+
"AllAmount": str(amounts["total_amount"]),
|
|
508
|
+
"data_id": data.order_id,''',
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def get_amego_specifics() -> Dict[str, str]:
|
|
513
|
+
"""Amego 特定配置"""
|
|
514
|
+
return {
|
|
515
|
+
'test_credentials': ' private TEST_APP_KEY = \'sHeq7t8G1wiQvhAuIM27\'',
|
|
516
|
+
'test_credentials_py': ' TEST_APP_KEY = "sHeq7t8G1wiQvhAuIM27"',
|
|
517
|
+
'issue_endpoint': '/json/f0401',
|
|
518
|
+
'void_endpoint': '/json/f0501',
|
|
519
|
+
'print_endpoint': '/json/invoice_file',
|
|
520
|
+
'content_type': 'application/x-www-form-urlencoded',
|
|
521
|
+
'request_body': 'new URLSearchParams({ invoice: merchantId, data: encodeURIComponent(JSON.stringify(apiData)), time: String(timestamp), sign: signature }).toString()',
|
|
522
|
+
'success_condition': 'result.code === 0',
|
|
523
|
+
'code_field': 'code',
|
|
524
|
+
'msg_field': 'msg',
|
|
525
|
+
'invoice_field': 'invoice_number',
|
|
526
|
+
'random_field': 'random_number',
|
|
527
|
+
'print_type': 'redirect',
|
|
528
|
+
'print_method': 'Amego 回傳 PDF URL (10分鐘有效)',
|
|
529
|
+
'encryption_method': ''' /**
|
|
530
|
+
* MD5 簽章
|
|
531
|
+
*/
|
|
532
|
+
private generateSignature(data: any, time: number, appKey: string): string {
|
|
533
|
+
const signString = JSON.stringify(data) + time + appKey
|
|
534
|
+
return crypto.createHash('md5').update(signString).digest('hex')
|
|
535
|
+
}''',
|
|
536
|
+
'encryption_method_py': ''' def _generate_signature(self, data: Dict, time: int, app_key: str) -> str:
|
|
537
|
+
"""MD5 簽章"""
|
|
538
|
+
sign_string = json.dumps(data, ensure_ascii=False) + str(time) + app_key
|
|
539
|
+
return hashlib.md5(sign_string.encode()).hexdigest()''',
|
|
540
|
+
'issue_fields': ''' OrderId: data.OrderId,
|
|
541
|
+
BuyerIdentifier: data.BuyerIdentifier || '0000000000',
|
|
542
|
+
BuyerName: data.BuyerName || '客人',
|
|
543
|
+
BuyerEmailAddress: data.BuyerEmail || '',
|
|
544
|
+
SalesAmount: amounts.salesAmount,
|
|
545
|
+
FreeTaxSalesAmount: 0,
|
|
546
|
+
ZeroTaxSalesAmount: 0,
|
|
547
|
+
TaxType: 1,
|
|
548
|
+
TaxRate: '0.05',
|
|
549
|
+
TaxAmount: amounts.taxAmount,
|
|
550
|
+
TotalAmount: amounts.totalAmount,
|
|
551
|
+
DetailVat: isB2B ? 0 : 1,
|
|
552
|
+
ProductItem: data.ProductItem?.map(item => ({
|
|
553
|
+
Description: item.Description,
|
|
554
|
+
Quantity: item.Quantity,
|
|
555
|
+
UnitPrice: item.UnitPrice,
|
|
556
|
+
Amount: item.Amount,
|
|
557
|
+
TaxType: 1,
|
|
558
|
+
})) || [],''',
|
|
559
|
+
'issue_fields_py': ''' "OrderId": data.order_id,
|
|
560
|
+
"BuyerIdentifier": data.buyer_identifier or "0000000000",
|
|
561
|
+
"BuyerName": data.buyer_name or "客人",
|
|
562
|
+
"BuyerEmailAddress": data.buyer_email or "",
|
|
563
|
+
"SalesAmount": amounts["sales_amount"],
|
|
564
|
+
"FreeTaxSalesAmount": 0,
|
|
565
|
+
"ZeroTaxSalesAmount": 0,
|
|
566
|
+
"TaxType": 1,
|
|
567
|
+
"TaxRate": "0.05",
|
|
568
|
+
"TaxAmount": amounts["tax_amount"],
|
|
569
|
+
"TotalAmount": amounts["total_amount"],
|
|
570
|
+
"DetailVat": 0 if is_b2b else 1,
|
|
571
|
+
"ProductItem": [
|
|
572
|
+
{
|
|
573
|
+
"Description": item["Description"],
|
|
574
|
+
"Quantity": item["Quantity"],
|
|
575
|
+
"UnitPrice": item["UnitPrice"],
|
|
576
|
+
"Amount": item["Amount"],
|
|
577
|
+
"TaxType": 1,
|
|
578
|
+
}
|
|
579
|
+
for item in (data.product_items or [])
|
|
580
|
+
],''',
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def generate_service(provider: str, lang: str = 'typescript', output_dir: str = '.'):
|
|
158
585
|
"""生成服務檔案"""
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
586
|
+
provider_info = get_provider_info(provider)
|
|
587
|
+
error_codes = get_error_codes(provider)
|
|
588
|
+
|
|
589
|
+
# 選擇特定配置
|
|
590
|
+
if provider.lower() == 'ecpay':
|
|
591
|
+
specifics = get_ecpay_specifics()
|
|
592
|
+
elif provider.lower() == 'smilepay':
|
|
593
|
+
specifics = get_smilepay_specifics()
|
|
594
|
+
elif provider.lower() == 'amego':
|
|
595
|
+
specifics = get_amego_specifics()
|
|
596
|
+
else:
|
|
597
|
+
# 新服務商使用通用模板
|
|
598
|
+
specifics = {
|
|
599
|
+
'test_credentials': ' // TODO: 填入測試金鑰',
|
|
600
|
+
'test_credentials_py': ' # TODO: 填入測試金鑰',
|
|
601
|
+
'issue_endpoint': '/issue',
|
|
602
|
+
'void_endpoint': '/void',
|
|
603
|
+
'print_endpoint': '/print',
|
|
604
|
+
'content_type': 'application/json',
|
|
605
|
+
'request_body': 'JSON.stringify(apiData)',
|
|
606
|
+
'success_condition': 'result.code === 0',
|
|
607
|
+
'code_field': 'code',
|
|
608
|
+
'msg_field': 'msg',
|
|
609
|
+
'invoice_field': 'invoice_number',
|
|
610
|
+
'random_field': 'random_number',
|
|
611
|
+
'print_type': 'redirect',
|
|
612
|
+
'print_method': 'TODO: 實作列印邏輯',
|
|
613
|
+
'encryption_method': ' // TODO: 實作加密/簽章',
|
|
614
|
+
'encryption_method_py': ' # TODO: 實作加密/簽章\n pass',
|
|
615
|
+
'issue_fields': ' // TODO: 填入 API 參數',
|
|
616
|
+
'issue_fields_py': ' # TODO: 填入 API 參數',
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
# 格式化錯誤碼
|
|
620
|
+
if lang == 'typescript':
|
|
621
|
+
error_codes_str = '\n'.join([f" * {e['code']}: {e['message_zh']}" for e in error_codes[:15]])
|
|
622
|
+
else:
|
|
623
|
+
error_codes_str = '\n'.join([f' "{e["code"]}": "{e["message_zh"]}",' for e in error_codes[:15]])
|
|
624
|
+
|
|
625
|
+
# 準備模板變數
|
|
626
|
+
template_vars = {
|
|
627
|
+
'class_name': provider.capitalize(),
|
|
628
|
+
'display_name': provider_info['display_name'] if provider_info else provider,
|
|
629
|
+
'auth_method': provider_info['auth_method'] if provider_info else 'TODO',
|
|
630
|
+
'encryption': provider_info['encryption'] if provider_info else 'TODO',
|
|
631
|
+
'test_url': provider_info['test_url'] if provider_info else 'https://test.example.com',
|
|
632
|
+
'prod_url': provider_info['prod_url'] if provider_info else 'https://api.example.com',
|
|
633
|
+
'test_merchant_id': provider_info['test_merchant_id'] if provider_info else 'TEST123',
|
|
634
|
+
'error_codes': error_codes_str,
|
|
635
|
+
'error_codes_py': error_codes_str,
|
|
636
|
+
**specifics,
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
# 選擇模板
|
|
640
|
+
if lang == 'python':
|
|
641
|
+
template = PY_TEMPLATE
|
|
642
|
+
ext = 'py'
|
|
643
|
+
else:
|
|
644
|
+
template = TS_TEMPLATE
|
|
645
|
+
ext = 'ts'
|
|
162
646
|
|
|
163
647
|
# 生成內容
|
|
164
|
-
content =
|
|
165
|
-
ClassName=class_name,
|
|
166
|
-
ProviderName=provider_name,
|
|
167
|
-
provider=provider_lower,
|
|
168
|
-
)
|
|
648
|
+
content = template.format(**template_vars)
|
|
169
649
|
|
|
170
650
|
# 輸出檔案
|
|
171
|
-
|
|
651
|
+
output_path = Path(output_dir)
|
|
652
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
653
|
+
|
|
654
|
+
filename = f"{provider.lower()}-invoice-service.{ext}"
|
|
655
|
+
filepath = output_path / filename
|
|
172
656
|
|
|
173
|
-
with open(
|
|
657
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
174
658
|
f.write(content)
|
|
175
659
|
|
|
176
|
-
print(f"[OK] 已生成服務檔案: {
|
|
660
|
+
print(f"[OK] 已生成服務檔案: {filepath}")
|
|
661
|
+
print(f"\n服務商資訊:")
|
|
662
|
+
if provider_info:
|
|
663
|
+
print(f" 名稱: {provider_info['display_name']}")
|
|
664
|
+
print(f" 認證: {provider_info['auth_method']}")
|
|
665
|
+
print(f" 加密: {provider_info['encryption']}")
|
|
666
|
+
print(f"\n錯誤碼數量: {len(error_codes)}")
|
|
667
|
+
|
|
177
668
|
print(f"\n接下來的步驟:")
|
|
178
|
-
print(f"1.
|
|
179
|
-
print(f"2.
|
|
180
|
-
print(f"3.
|
|
181
|
-
|
|
182
|
-
|
|
669
|
+
print(f"1. 檢查生成的程式碼")
|
|
670
|
+
print(f"2. 完成 TODO 標記的部分")
|
|
671
|
+
print(f"3. 整合到專案中")
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def main():
|
|
675
|
+
parser = argparse.ArgumentParser(
|
|
676
|
+
description='Taiwan Invoice Service Generator',
|
|
677
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
678
|
+
epilog="""
|
|
679
|
+
Examples:
|
|
680
|
+
python generate-invoice-service.py ECPay
|
|
681
|
+
python generate-invoice-service.py SmilePay --lang python
|
|
682
|
+
python generate-invoice-service.py Amego --output ./lib/services/
|
|
683
|
+
python generate-invoice-service.py NewProvider
|
|
684
|
+
"""
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
parser.add_argument('provider', help='服務商名稱 (ECPay, SmilePay, Amego, 或新服務商名稱)')
|
|
688
|
+
parser.add_argument('-l', '--lang', choices=['typescript', 'python'], default='typescript',
|
|
689
|
+
help='輸出語言 (default: typescript)')
|
|
690
|
+
parser.add_argument('-o', '--output', default='.', help='輸出目錄 (default: 當前目錄)')
|
|
691
|
+
parser.add_argument('--list', action='store_true', help='列出支援的服務商')
|
|
692
|
+
|
|
693
|
+
args = parser.parse_args()
|
|
694
|
+
|
|
695
|
+
if args.list:
|
|
696
|
+
providers = load_csv('providers.csv')
|
|
697
|
+
print("\n支援的服務商:")
|
|
698
|
+
for p in providers:
|
|
699
|
+
print(f" {p['provider']} - {p['display_name']}")
|
|
700
|
+
return
|
|
183
701
|
|
|
702
|
+
generate_service(args.provider, args.lang, args.output)
|
|
184
703
|
|
|
185
|
-
if __name__ == "__main__":
|
|
186
|
-
if len(sys.argv) < 2:
|
|
187
|
-
print("使用方法: python generate-invoice-service.py <ProviderName>")
|
|
188
|
-
print("範例: python generate-invoice-service.py NewProvider")
|
|
189
|
-
sys.exit(1)
|
|
190
704
|
|
|
191
|
-
|
|
192
|
-
|
|
705
|
+
if __name__ == '__main__':
|
|
706
|
+
main()
|