taiwan-invoice-skill 2.1.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.
@@ -1,135 +1,207 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- 快速生成新的發票服務商實作模板
4
-
5
- 使用方法:
6
- python generate-invoice-service.py NewProvider
7
-
8
- 這會生成:
9
- - lib/services/newprovider-invoice-service.ts
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
- TEMPLATE = """import crypto from 'crypto'
19
- import { InvoiceService, InvoiceIssueData, InvoiceIssueResponse, InvoiceVoidResponse, InvoicePrintResponse } from './invoice-provider'
20
- import { prisma } from '@/lib/prisma'
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 {ClassName}InvoiceService implements InvoiceService {{
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
- private TEST_API_URL = 'https://test.{provider}.com/api'
25
- private PROD_API_URL = 'https://api.{provider}.com'
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.PROD_API_URL : this.TEST_API_URL
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
- private async getUserInvoiceSettings(userId: string) {{
35
- const settings = await prisma.invoiceSettings.findUnique({{
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
- if (!settings || !settings.{provider}ApiKey) {{
40
- throw new Error('{ProviderName} 發票設定不完整')
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
- return settings
44
- }}
147
+ // TODO: 實作加密/簽章
148
+ // {auth_method}
45
149
 
46
- /**
47
- * 開立發票
48
- */
49
- async issueInvoice(userId: string, data: InvoiceIssueData): Promise<InvoiceIssueResponse> {{
50
- try {{
51
- const settings = await this.getUserInvoiceSettings(userId)
52
- const isB2B = data.IsB2B === true
53
-
54
- // TODO: 實作 API 請求邏輯
55
- // 1. 準備資料
56
- // 2. 加密/簽章
57
- // 3. 發送請求
58
- // 4. 解析回應
59
-
60
- const apiData = {{
61
- // TODO: 填入 API 參數
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(userId: string, invoiceNumber: string, reason: string): Promise<InvoiceVoidResponse> {{
92
- try {{
93
- const settings = await this.getUserInvoiceSettings(userId)
94
-
95
- // TODO: 實作作廢邏輯
96
-
97
- return {{
98
- success: true,
99
- msg: '發票作廢成功',
100
- }}
101
- }} catch (error) {{
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(userId: string, invoiceNumber: string): Promise<InvoicePrintResponse> {{
111
- try {{
112
- const settings = await this.getUserInvoiceSettings(userId)
113
-
114
- // TODO: 實作列印邏輯
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
- def generate_service(provider_name: str):
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
- class_name = provider_name.capitalize()
161
- provider_lower = provider_name.lower()
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 = TEMPLATE.format(
165
- ClassName=class_name,
166
- ProviderName=provider_name,
167
- provider=provider_lower,
168
- )
648
+ content = template.format(**template_vars)
169
649
 
170
650
  # 輸出檔案
171
- output_file = f"{provider_lower}-invoice-service.ts"
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(output_file, 'w', encoding='utf-8') as f:
657
+ with open(filepath, 'w', encoding='utf-8') as f:
174
658
  f.write(content)
175
659
 
176
- print(f"[OK] 已生成服務檔案: {output_file}")
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. 編輯 {output_file},實作 API 邏輯")
179
- print(f"2. InvoiceServiceFactory 註冊服務")
180
- print(f"3. 在 Prisma schema 新增 InvoiceProvider enum")
181
- print(f"4. 執行 prisma migrate 或 db push")
182
- print(f"5. 更新前端設定頁面")
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
- provider = sys.argv[1]
192
- generate_service(provider)
705
+ if __name__ == '__main__':
706
+ main()