cetustek 0.7.0 → 0.8.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +71 -14
- data/lib/cetustek/cancel_invoice.rb +48 -25
- data/lib/cetustek/configuration.rb +2 -1
- data/lib/cetustek/create_invoice.rb +4 -20
- data/lib/cetustek/models/invoice_data.rb +111 -7
- data/lib/cetustek/services/invoice_service.rb +8 -29
- data/lib/cetustek/services/invoice_xml_builder.rb +18 -1
- data/lib/cetustek/services/response_handler.rb +88 -14
- data/lib/cetustek/version.rb +1 -1
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 98c3af7dadac0df75cb3fc24f02e124765716e1856034ed120cead8e2f2ce1a5
|
|
4
|
+
data.tar.gz: 7d0a29575e2b24e786371b989da8107437eeb7d319bbf9514a83db6e7912fc79
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6243d83ecc59c97b961ec8185d46967b8e04148b752e2a810c519e238e53a9d8450944c10214cd320f465a1137e1d97c63bd1d8bf547bd59b16d6deace421916
|
|
7
|
+
data.tar.gz: 9224b032dcb1eae4facaa3278b0c732daf79fbbcfe00ea17532a27b5bf989ba862711427021f2cd5a459abdce171ff0199931b2f99531cb3baa9c406457e3be4
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.8.0] - 2026-08-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `Cetustek.config.logger`(預設 `nil`,即這個 gem 不寫任何東西)。只記訂單編號、
|
|
12
|
+
發票號碼與結果代碼,失敗時才以 debug 記下請求 XML
|
|
13
|
+
- 開立發票補上規格 Table 1/2 缺漏的欄位:明細的 `Unit`,主檔的 `Remark`、`ZeroReason`、
|
|
14
|
+
`RoundNum`、`MailSend`、`RtnMsg`,以及 `BuyerAddress`/`BuyerPersonInCharge`/
|
|
15
|
+
`BuyerTelephoneNumber`/`BuyerFacsimileNumber`/`BuyerCustomerNumber`
|
|
16
|
+
- `InvoiceData` 依規格驗證必填與條件欄位(`order_id`/`order_date`/`items`/
|
|
17
|
+
`donate_mark`/`payment_type` 必填;`donate_mark=0` 需 email 與載具三欄;
|
|
18
|
+
`donate_mark=1` 需 3-7 碼 `npo_ban`;`tax_type=4` 需明確 `tax_rate` 且
|
|
19
|
+
`invoice_type` 為 `08`),違反時建構就丟 `ArgumentError`
|
|
20
|
+
- `carrier_id1` 成為顯碼的正式名稱(`carrier_id` 保留為別名),`carrier_id2` 未填時
|
|
21
|
+
自動鏡射顯碼 —— 手機條碼與自然人憑證沒有顯隱碼之分
|
|
22
|
+
- `CancelInvoice` 支援 `remark:`(作廢原因,預設 `'退貨'`)與
|
|
23
|
+
`return_tax_document_number:`(專案作廢核准文號,超過申報期間作廢時需要)
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- **Breaking:** 開立發票預設帶 `<RtnMsg>Json</RtnMsg>`,`CreateInvoice#execute` 除了
|
|
27
|
+
既有的 `:number`/`:random_number` 另外回傳 `:date`/`:time` 與各項金額。發票日期改以
|
|
28
|
+
API 回傳為準,不再用本機 `Time.zone.today` 推測(`Intertemporal` 回開會讓本機日期錯誤)。
|
|
29
|
+
傳 `rtn_msg: nil` 可退回舊的 15 碼字串模式
|
|
30
|
+
- **Breaking:** 開立與作廢發票失敗一律丟 `Cetustek::ResultError`,帶原始代碼與 Table 7 /
|
|
31
|
+
Table 10 的中文說明。`ResponseHandler::InvalidResponseError` 降為 `ResultError` 的子類
|
|
32
|
+
(deprecated,既有 rescue 仍可運作);`CancelInvoice` 不再靜默回傳 nil
|
|
33
|
+
- **Breaking:** `CancelInvoice.new(invoice_number, invoice_year, remark:)` 改收明確參數,
|
|
34
|
+
不再接受 invoice 物件,也不再回寫 `canceled: true`
|
|
35
|
+
- **Breaking:** 移除 `CreateInvoice` 的 `invoice_info` 回寫與兩處 `Rails.root/log/*` 寫檔
|
|
36
|
+
(原本會把含買受人 email 的整包回應寫進固定路徑);`InvoiceService.new(xml, hastax)`
|
|
37
|
+
不再接受 `order_id`
|
|
38
|
+
- 成功判斷改用規格寫的 15 碼規則(`發票號碼;隨機碼`),而非「字串含分號」
|
|
39
|
+
|
|
8
40
|
## [0.7.0] - 2026-08-04
|
|
9
41
|
|
|
10
42
|
### Added
|
data/README.md
CHANGED
|
@@ -4,16 +4,18 @@
|
|
|
4
4
|
[](https://github.com/7a6163/cetustek/actions/workflows/rspec.yml)
|
|
5
5
|
[](https://codecov.io/gh/7a6163/cetustek)
|
|
6
6
|
|
|
7
|
-
Cetustek is a Ruby
|
|
7
|
+
Cetustek is a Ruby wrapper for the 鯨躍 Cetustek e-invoice API (虛擬多通路,
|
|
8
|
+
spec AVM-26-03), covering 電子發票 and 折讓單 over SOAP Web Services.
|
|
8
9
|
|
|
9
10
|
## Features
|
|
10
11
|
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
12
|
+
- 開立發票 (CreateInvoiceV3) with 載具/捐贈/紙本, 零稅率, 混合稅率 and 特種稅額 support
|
|
13
|
+
- 作廢發票 (CancelInvoice), including the 專案作廢核准文號 for late cancellations
|
|
14
|
+
- 折讓單: 開立 (CreateAllowance), 作廢 (CancelAllowance), 查詢 (QueryAllowance)
|
|
15
|
+
- Queries by invoice number or order id, plus 手機條碼 validation
|
|
16
|
+
- Validation of the rules the spec fixes in print, before anything is sent
|
|
17
|
+
- Result codes raised as `Cetustek::ResultError` with the documented reason
|
|
18
|
+
- Environment-specific configuration (sandbox/production) and opt-in logging
|
|
17
19
|
|
|
18
20
|
## Installation
|
|
19
21
|
|
|
@@ -43,6 +45,11 @@ Cetustek.configure do |config|
|
|
|
43
45
|
config.site_id = ENV['CETUSTEK_SITE_ID']
|
|
44
46
|
config.username = ENV['CETUSTEK_USERNAME']
|
|
45
47
|
config.password = ENV['CETUSTEK_PASSWORD']
|
|
48
|
+
|
|
49
|
+
# Optional. Defaults to nil, i.e. this gem writes nothing anywhere.
|
|
50
|
+
# Only the order id, invoice number and result code are logged (never the
|
|
51
|
+
# response body); the request XML is logged at debug level on failure.
|
|
52
|
+
config.logger = Rails.logger
|
|
46
53
|
end
|
|
47
54
|
```
|
|
48
55
|
|
|
@@ -58,22 +65,48 @@ invoice_data = Cetustek::Models::InvoiceData.new(
|
|
|
58
65
|
buyer_identifier: invoice.receipt,
|
|
59
66
|
buyer_name: invoice.name,
|
|
60
67
|
buyer_email: invoice.email,
|
|
61
|
-
donate_mark:
|
|
62
|
-
|
|
68
|
+
donate_mark: Cetustek::DonateMark::CARRIER,
|
|
69
|
+
carrier_type: '3J0002', # 手機條碼
|
|
70
|
+
carrier_id: invoice.barcode, # CarrierId2 is mirrored automatically
|
|
71
|
+
payment_type: Cetustek::PayWay::ATM,
|
|
63
72
|
items: invoice.items.map { |item|
|
|
64
73
|
Cetustek::Models::InvoiceItem.new(
|
|
65
74
|
code: item.sku,
|
|
66
75
|
name: item.name,
|
|
67
76
|
quantity: item.quantity,
|
|
77
|
+
unit: item.unit,
|
|
68
78
|
unit_price: item.price
|
|
69
79
|
)
|
|
70
80
|
}
|
|
71
81
|
)
|
|
72
82
|
|
|
73
83
|
result = Cetustek::CreateInvoice.new(invoice_data).execute
|
|
74
|
-
# => { number: "
|
|
84
|
+
# => { number: "WB02100001", random_number: "3690",
|
|
85
|
+
# date: "2026/02/10", time: "11:19:57",
|
|
86
|
+
# sale_amount: 666, zero_amount: 0, free_amount: 0,
|
|
87
|
+
# tax_amount: 0, total_amount: 666, carrier_url: "..." }
|
|
75
88
|
```
|
|
76
89
|
|
|
90
|
+
Always use the returned `:date`/`:time` as the invoice date. The platform's
|
|
91
|
+
`Intertemporal` default issues invoices dated in the previous filing period on
|
|
92
|
+
the 1st–2nd of a month, so the local date can be wrong.
|
|
93
|
+
Persisting the result is the caller's job — this gem writes to no database.
|
|
94
|
+
|
|
95
|
+
`InvoiceData.new` raises `ArgumentError` for the rules the spec fixes in print,
|
|
96
|
+
so a guaranteed rejection never leaves your process:
|
|
97
|
+
|
|
98
|
+
- `order_id`, `order_date` (a `Date`/`Time`), `items`, `donate_mark` and `payment_type` are required
|
|
99
|
+
- `donate_mark: 0` (載具) requires `buyer_email`, `carrier_type` and `carrier_id`
|
|
100
|
+
- `donate_mark: 1` (捐贈) requires a 3–7 digit `npo_ban`
|
|
101
|
+
- `tax_type: 4` (特種稅率) requires an explicit `tax_rate` and `invoice_type: '08'`
|
|
102
|
+
|
|
103
|
+
Anything needing an external lookup (是否為有效手機條碼、捐贈碼是否存在) is left
|
|
104
|
+
to the caller — see `Cetustek::PhoneBarcode` below.
|
|
105
|
+
|
|
106
|
+
Any result code other than a successful issue raises `Cetustek::ResultError`,
|
|
107
|
+
whose `#code` is the raw Table 7 code and whose message carries the documented
|
|
108
|
+
reason (`S7 - 訂單號碼已存在,若需重開請先作廢原發票號碼`, `D3_2 - 單價未填或格式錯誤`, …).
|
|
109
|
+
|
|
77
110
|
### Tax types (稅別)
|
|
78
111
|
|
|
79
112
|
`InvoiceData` defaults to taxable (`TaxType` 1) with a tax rate of `0.05` and a
|
|
@@ -149,14 +182,21 @@ items — use a negative `unit_price` for a discount:
|
|
|
149
182
|
Cetustek::Models::InvoiceItem.new(code: 'DISCOUNT', name: '折抵', quantity: 1, unit_price: -30)
|
|
150
183
|
```
|
|
151
184
|
|
|
152
|
-
### Cancel an Invoice
|
|
185
|
+
### Cancel an Invoice (作廢發票確認)
|
|
153
186
|
|
|
154
187
|
```ruby
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
188
|
+
Cetustek::CancelInvoice.new('AB12345678', 2024).execute # => "C0"
|
|
189
|
+
Cetustek::CancelInvoice.new('AB12345678', 2024, remark: '明細錯誤').execute # 作廢原因,預設 '退貨'
|
|
190
|
+
|
|
191
|
+
# 超過申報期間才需要專案作廢核准文號 (否則會收到 C3)
|
|
192
|
+
Cetustek::CancelInvoice.new('AB12345678', 2024, return_tax_document_number: '65327645').execute
|
|
158
193
|
```
|
|
159
194
|
|
|
195
|
+
Uploading is not the end of it: the cancellation still has to be confirmed
|
|
196
|
+
manually on the 鯨躍 platform before the invoice counts as void. Any code other
|
|
197
|
+
than `"C0"` raises `Cetustek::ResultError` (`C5 - 該發票已經作廢過`, …), and
|
|
198
|
+
marking your own record as canceled is the caller's job.
|
|
199
|
+
|
|
160
200
|
### Query invoices
|
|
161
201
|
|
|
162
202
|
```ruby
|
|
@@ -175,6 +215,23 @@ purchase). Set it on `InvoiceData`:
|
|
|
175
215
|
Cetustek::Models::InvoiceData.new(hastax: 0, items: [...])
|
|
176
216
|
```
|
|
177
217
|
|
|
218
|
+
### Other Table 1 fields
|
|
219
|
+
|
|
220
|
+
| Attribute | Tag | Notes |
|
|
221
|
+
|-----------|-----|-------|
|
|
222
|
+
| `buyer_address` / `buyer_person_in_charge` / `buyer_telephone` / `buyer_facsimile` / `buyer_customer_number` | `BuyerAddress` / `BuyerPersonInCharge` / `BuyerTelephoneNumber` / `BuyerFacsimileNumber` / `BuyerCustomerNumber` | 選填,常用於 B2B |
|
|
223
|
+
| `remark` | `Remark` | 備註,200 字 |
|
|
224
|
+
| `zero_reason` | `ZeroReason` | 零稅率原因;未填時平台預設 `72`(TaxType 2)或 `71`(TaxType 5) |
|
|
225
|
+
| `round_num` | `RoundNum` | 金額計算位數,未填預設 4 |
|
|
226
|
+
| `mail_send` | `MailSend` | `0`(預設)由加值中心寄送通知,`1` 自行處理 |
|
|
227
|
+
| `rtn_msg` | `RtnMsg` | 預設 `'Json'`;傳 `nil` 退回只回傳 15 碼字串的舊模式 |
|
|
228
|
+
|
|
229
|
+
Fields with a platform-side default (`ZeroReason`, `RoundNum`, `MailSend`,
|
|
230
|
+
`RtnMsg`) are omitted from the XML entirely when `nil`, so the platform applies
|
|
231
|
+
its own default. `Intertemporal`(發票回開)is deliberately not exposed: it changes
|
|
232
|
+
which filing period's 字軌 the invoice is issued under, and the platform default
|
|
233
|
+
is the right behaviour.
|
|
234
|
+
|
|
178
235
|
### Allowances (折讓單)
|
|
179
236
|
|
|
180
237
|
```ruby
|
|
@@ -1,54 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ox'
|
|
4
|
+
require 'cgi'
|
|
5
|
+
|
|
1
6
|
module Cetustek
|
|
7
|
+
# 2.2 CancelInvoice 作廢發票確認. Returns "C0" on success, raises ResultError
|
|
8
|
+
# otherwise. Uploading is not the end of it — the cancellation still has to be
|
|
9
|
+
# confirmed manually on the 鯨躍 platform before the invoice counts as void.
|
|
2
10
|
class CancelInvoice
|
|
3
|
-
|
|
4
|
-
|
|
11
|
+
SUCCESS_CODE = 'C0'
|
|
12
|
+
|
|
13
|
+
# Spec AVM-26-03 Table 10.
|
|
14
|
+
RESULT_MESSAGES = {
|
|
15
|
+
'M:' => '欄位未填或格式錯誤',
|
|
16
|
+
'M0' => 'XML 格式錯誤',
|
|
17
|
+
'M1' => 'XML 格式錯誤',
|
|
18
|
+
'C1' => '資料庫發生錯誤',
|
|
19
|
+
'C2' => '資料有誤',
|
|
20
|
+
'C3' => '該發票已過申報期間,需填入核准作廢文號 (return_tax_document_number)',
|
|
21
|
+
'C4' => '無此發票號碼可以作廢',
|
|
22
|
+
'C5' => '該發票已經作廢過',
|
|
23
|
+
'C6' => '作廢資訊已經傳送過',
|
|
24
|
+
'Invalid' => '無效 IP,請通知系統商'
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def initialize(invoice_number, invoice_year, remark: '退貨', return_tax_document_number: nil)
|
|
28
|
+
raise ArgumentError, 'remark (作廢原因) is required' if remark.to_s.strip.empty?
|
|
29
|
+
|
|
30
|
+
@invoice_number = invoice_number
|
|
31
|
+
@invoice_year = invoice_year
|
|
32
|
+
@remark = remark
|
|
33
|
+
@return_tax_document_number = return_tax_document_number
|
|
5
34
|
end
|
|
6
35
|
|
|
7
36
|
def execute
|
|
8
|
-
generate_xml
|
|
9
37
|
perform
|
|
10
|
-
|
|
38
|
+
ResultCode.check!(@response.body[:cancel_invoice_response][:return],
|
|
39
|
+
RESULT_MESSAGES, success: SUCCESS_CODE)
|
|
11
40
|
end
|
|
12
41
|
|
|
13
42
|
private
|
|
14
43
|
|
|
15
44
|
def perform
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
@response = client.call(:cancel_invoice, message:
|
|
24
|
-
{ invoicexml: @xml,
|
|
25
|
-
source: Cetustek.config.site_id + Cetustek.config.password,
|
|
26
|
-
rentid: Cetustek.config.username })
|
|
45
|
+
client = Savon.client(wsdl: Cetustek.config.url, open_timeout: 300, read_timeout: 300)
|
|
46
|
+
@response = client.call(:cancel_invoice, message: {
|
|
47
|
+
invoicexml: generate_xml,
|
|
48
|
+
source: Cetustek.config.site_id + Cetustek.config.password,
|
|
49
|
+
rentid: Cetustek.config.username
|
|
50
|
+
})
|
|
27
51
|
end
|
|
28
52
|
|
|
29
53
|
def generate_xml
|
|
30
54
|
doc = Ox::Document.new
|
|
31
|
-
|
|
32
55
|
instruct = Ox::Instruct.new(:xml)
|
|
33
56
|
instruct[:version] = '1.0'
|
|
34
57
|
instruct[:encoding] = 'UTF-8'
|
|
35
|
-
instruct[:standalone] = 'yes'
|
|
36
58
|
doc << instruct
|
|
37
59
|
|
|
38
60
|
invoice = Ox::Element.new('Invoice')
|
|
39
61
|
invoice[:XSDVersion] = '2.8'
|
|
40
62
|
doc << invoice
|
|
41
63
|
|
|
42
|
-
invoice <<
|
|
43
|
-
invoice <<
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
64
|
+
invoice << raw_tag('InvoiceNumber', @invoice_number)
|
|
65
|
+
invoice << raw_tag('InvoiceYear', @invoice_year)
|
|
66
|
+
# 專案作廢核准文號:只在超過申報期間作廢時才需要 (Table 10 的 C3)。
|
|
67
|
+
invoice << raw_tag('ReturnTaxDocumentNumber', @return_tax_document_number) if @return_tax_document_number
|
|
68
|
+
invoice << raw_tag('Remark', @remark)
|
|
47
69
|
|
|
48
|
-
|
|
49
|
-
|
|
70
|
+
Ox.dump(doc).force_encoding('UTF-8')
|
|
71
|
+
end
|
|
50
72
|
|
|
51
|
-
|
|
73
|
+
def raw_tag(name, value)
|
|
74
|
+
Ox::Raw.new("<#{name}>#{CGI.escapeHTML(value.to_s)}</#{name}>")
|
|
52
75
|
end
|
|
53
76
|
end
|
|
54
77
|
end
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Cetustek
|
|
4
4
|
class Configuration
|
|
5
|
-
|
|
5
|
+
# logger is opt-in: nil means this gem writes nothing anywhere.
|
|
6
|
+
attr_accessor :environment, :site_id, :username, :password, :logger
|
|
6
7
|
|
|
7
8
|
def initialize
|
|
8
9
|
@environment = :sandbox
|
|
@@ -6,6 +6,8 @@ require_relative 'services/invoice_service'
|
|
|
6
6
|
require_relative 'services/response_handler'
|
|
7
7
|
|
|
8
8
|
module Cetustek
|
|
9
|
+
# 2.1 CreateInvoiceV3 開立發票. Returns the issued invoice on success and
|
|
10
|
+
# raises ResultError otherwise. Persisting the result is the caller's job.
|
|
9
11
|
class CreateInvoice
|
|
10
12
|
def initialize(invoice_data)
|
|
11
13
|
@invoice_data = invoice_data
|
|
@@ -13,26 +15,8 @@ module Cetustek
|
|
|
13
15
|
|
|
14
16
|
def execute
|
|
15
17
|
xml = Services::InvoiceXmlBuilder.new(@invoice_data).build
|
|
16
|
-
response = Services::InvoiceService.new(xml, @invoice_data.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if defined?(Rails) && result[:number] && result[:random_number]
|
|
20
|
-
update_invoice_info(result)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
result
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
private
|
|
27
|
-
|
|
28
|
-
def update_invoice_info(result)
|
|
29
|
-
return unless @invoice_data.respond_to?(:invoice_info)
|
|
30
|
-
|
|
31
|
-
@invoice_data.invoice_info.update(
|
|
32
|
-
number: result[:number],
|
|
33
|
-
random_number: result[:random_number],
|
|
34
|
-
created_at: Time.zone.today
|
|
35
|
-
)
|
|
18
|
+
response = Services::InvoiceService.new(xml, @invoice_data.hastax).create
|
|
19
|
+
Services::ResponseHandler.new(response, @invoice_data, xml).process
|
|
36
20
|
end
|
|
37
21
|
end
|
|
38
22
|
end
|
|
@@ -44,14 +44,29 @@ module Cetustek
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
module Models
|
|
47
|
+
# Request data for CreateInvoiceV3 (開立發票), spec AVM-26-03 Table 1/2.
|
|
48
|
+
# Rules the spec fixes in print are enforced here; anything needing an
|
|
49
|
+
# external lookup (捐贈碼、手機條碼是否存在) is left to the caller.
|
|
47
50
|
class InvoiceData
|
|
48
51
|
DEFAULT_TAX_RATE = 0.05
|
|
49
|
-
DEFAULT_INVOICE_TYPE = '07'
|
|
52
|
+
DEFAULT_INVOICE_TYPE = '07' # 一般稅額電子發票
|
|
53
|
+
SPECIAL_INVOICE_TYPE = '08' # 特種稅額電子發票
|
|
54
|
+
# Json 回傳才拿得到平台配發的發票日期時間;Intertemporal 回開會讓本機日期失準。
|
|
55
|
+
DEFAULT_RTN_MSG = 'Json'
|
|
56
|
+
MAX_ITEMS = 9999
|
|
57
|
+
TAX_TYPES = [TaxType::TAXABLE, TaxType::ZERO_RATE, TaxType::TAX_FREE,
|
|
58
|
+
TaxType::SPECIAL, TaxType::ZERO_RATE_CUSTOMS, TaxType::MIXED].freeze
|
|
59
|
+
DONATE_MARKS = [DonateMark::CARRIER, DonateMark::DONATE, DonateMark::PAPER].freeze
|
|
50
60
|
|
|
51
61
|
attr_reader :order_id, :order_date, :buyer_identifier, :buyer_name,
|
|
52
|
-
:buyer_email, :
|
|
53
|
-
:
|
|
54
|
-
:
|
|
62
|
+
:buyer_email, :buyer_address, :buyer_person_in_charge,
|
|
63
|
+
:buyer_telephone, :buyer_facsimile, :buyer_customer_number,
|
|
64
|
+
:donate_mark, :carrier_type, :carrier_id1, :carrier_id2,
|
|
65
|
+
:npo_ban, :items, :payment_type, :tax_type, :tax_rate,
|
|
66
|
+
:zero_reason, :invoice_type, :hastax, :remark, :round_num,
|
|
67
|
+
:mail_send, :rtn_msg
|
|
68
|
+
|
|
69
|
+
alias carrier_id carrier_id1
|
|
55
70
|
|
|
56
71
|
def initialize(attributes = {})
|
|
57
72
|
@order_id = attributes[:order_id]
|
|
@@ -59,25 +74,114 @@ module Cetustek
|
|
|
59
74
|
@buyer_identifier = attributes[:buyer_identifier]
|
|
60
75
|
@buyer_name = attributes[:buyer_name]
|
|
61
76
|
@buyer_email = attributes[:buyer_email]
|
|
77
|
+
@buyer_address = attributes[:buyer_address]
|
|
78
|
+
@buyer_person_in_charge = attributes[:buyer_person_in_charge]
|
|
79
|
+
@buyer_telephone = attributes[:buyer_telephone]
|
|
80
|
+
@buyer_facsimile = attributes[:buyer_facsimile]
|
|
81
|
+
@buyer_customer_number = attributes[:buyer_customer_number]
|
|
62
82
|
@donate_mark = attributes[:donate_mark]
|
|
63
83
|
@carrier_type = attributes[:carrier_type]
|
|
64
|
-
@
|
|
65
|
-
|
|
84
|
+
@carrier_id1 = attributes[:carrier_id1] || attributes[:carrier_id]
|
|
85
|
+
# 手機條碼與自然人憑證沒有顯碼隱碼之分,兩欄填相同值。
|
|
86
|
+
@carrier_id2 = attributes[:carrier_id2] || @carrier_id1
|
|
66
87
|
@npo_ban = attributes[:npo_ban]
|
|
67
88
|
@items = attributes[:items] || []
|
|
68
89
|
@payment_type = attributes[:payment_type]
|
|
69
90
|
@tax_type = attributes[:tax_type] || TaxType::TAXABLE
|
|
70
|
-
|
|
91
|
+
# 特種稅率是營業性質決定的,不能沿用 5% 預設值。
|
|
92
|
+
@tax_rate = attributes.fetch(:tax_rate) { special_tax? ? nil : DEFAULT_TAX_RATE }
|
|
93
|
+
@zero_reason = attributes[:zero_reason]
|
|
71
94
|
@invoice_type = attributes[:invoice_type] || DEFAULT_INVOICE_TYPE
|
|
72
95
|
# hastax: 0 = item prices are tax-exclusive, 1 = tax-inclusive.
|
|
73
96
|
# Comes from the order (e.g. tax-free purchases), not a fixed value.
|
|
74
97
|
@hastax = attributes.fetch(:hastax, 1)
|
|
98
|
+
@remark = attributes[:remark]
|
|
99
|
+
@round_num = attributes[:round_num]
|
|
100
|
+
@mail_send = attributes[:mail_send]
|
|
101
|
+
@rtn_msg = attributes.fetch(:rtn_msg, DEFAULT_RTN_MSG)
|
|
102
|
+
validate!
|
|
75
103
|
end
|
|
76
104
|
|
|
77
105
|
# 混合稅率發票 (限收銀機):每筆明細需標註 DType。
|
|
78
106
|
def mixed_tax?
|
|
79
107
|
@tax_type.to_i == TaxType::MIXED
|
|
80
108
|
end
|
|
109
|
+
|
|
110
|
+
# 特種稅額發票:TaxRate 必填,InvoiceType 必須為 08。
|
|
111
|
+
def special_tax?
|
|
112
|
+
@tax_type.to_i == TaxType::SPECIAL
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def validate!
|
|
118
|
+
raise ArgumentError, 'order_id is required' if blank?(@order_id)
|
|
119
|
+
|
|
120
|
+
validate_order_date!
|
|
121
|
+
validate_items!
|
|
122
|
+
validate_donate_mark!
|
|
123
|
+
validate_pay_way!
|
|
124
|
+
validate_tax!
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def validate_order_date!
|
|
128
|
+
raise ArgumentError, 'order_date is required' if @order_date.nil?
|
|
129
|
+
return if @order_date.respond_to?(:strftime)
|
|
130
|
+
|
|
131
|
+
raise ArgumentError, "order_date must be a Date or Time, got #{@order_date.class}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def validate_items!
|
|
135
|
+
raise ArgumentError, 'items must not be empty (沒有產品明細)' if @items.empty?
|
|
136
|
+
raise ArgumentError, "items must not exceed #{MAX_ITEMS} lines" if @items.size > MAX_ITEMS
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def validate_donate_mark!
|
|
140
|
+
raise ArgumentError, 'donate_mark is required (0 載具, 1 捐贈, 2 紙本)' if blank?(@donate_mark)
|
|
141
|
+
|
|
142
|
+
unless DONATE_MARKS.include?(@donate_mark.to_i)
|
|
143
|
+
raise ArgumentError, "donate_mark must be 0 (載具), 1 (捐贈) or 2 (紙本), got #{@donate_mark.inspect}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
case @donate_mark.to_i
|
|
147
|
+
when DonateMark::CARRIER then validate_carrier!
|
|
148
|
+
when DonateMark::DONATE then validate_npo_ban!
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def validate_carrier!
|
|
153
|
+
missing = { buyer_email: @buyer_email, carrier_type: @carrier_type,
|
|
154
|
+
carrier_id1: @carrier_id1 }.select { |_name, value| blank?(value) }.keys
|
|
155
|
+
return if missing.empty?
|
|
156
|
+
|
|
157
|
+
raise ArgumentError, "#{missing.join(', ')} required when donate_mark is 0 (載具)"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def validate_npo_ban!
|
|
161
|
+
return if @npo_ban.to_s.match?(/\A\d{3,7}\z/)
|
|
162
|
+
|
|
163
|
+
raise ArgumentError, "npo_ban must be a 3-7 digit 捐贈碼 when donate_mark is 1 (捐贈), got #{@npo_ban.inspect}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def validate_pay_way!
|
|
167
|
+
raise ArgumentError, 'payment_type is required (see Cetustek::PayWay)' if blank?(@payment_type)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def validate_tax!
|
|
171
|
+
unless TAX_TYPES.include?(@tax_type.to_i)
|
|
172
|
+
raise ArgumentError, "tax_type must be one of #{TAX_TYPES.join(', ')}, got #{@tax_type.inspect}"
|
|
173
|
+
end
|
|
174
|
+
return unless special_tax?
|
|
175
|
+
|
|
176
|
+
raise ArgumentError, 'tax_rate is required when tax_type is 4 (特種稅率)' if blank?(@tax_rate)
|
|
177
|
+
return if @invoice_type.to_s == SPECIAL_INVOICE_TYPE
|
|
178
|
+
|
|
179
|
+
raise ArgumentError, "invoice_type must be '08' (特種稅額) when tax_type is 4, got #{@invoice_type.inspect}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def blank?(value)
|
|
183
|
+
value.nil? || value.to_s.strip.empty?
|
|
184
|
+
end
|
|
81
185
|
end
|
|
82
186
|
|
|
83
187
|
class InvoiceItem
|
|
@@ -1,48 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'savon'
|
|
4
|
-
require 'logger'
|
|
5
4
|
|
|
6
5
|
module Cetustek
|
|
7
6
|
module Services
|
|
7
|
+
# 2.1 CreateInvoiceV3(invoicexml, hastax, rentid, source)
|
|
8
8
|
class InvoiceService
|
|
9
|
-
def initialize(xml,
|
|
9
|
+
def initialize(xml, hastax = 1)
|
|
10
10
|
@xml = xml
|
|
11
|
-
@order_id = order_id
|
|
12
11
|
@hastax = hastax
|
|
13
12
|
end
|
|
14
13
|
|
|
15
14
|
def create
|
|
16
|
-
client = build_soap_client
|
|
17
|
-
response = call_create_invoice(client)
|
|
18
|
-
log_response(response)
|
|
19
|
-
response
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
private
|
|
23
|
-
|
|
24
|
-
def build_soap_client
|
|
25
15
|
Savon.client(
|
|
26
16
|
wsdl: Cetustek.config.url,
|
|
27
17
|
open_timeout: 300,
|
|
28
18
|
read_timeout: 300
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
source: Cetustek.config.site_id + Cetustek.config.password,
|
|
36
|
-
rentid: Cetustek.config.username,
|
|
37
|
-
hastax: @hastax
|
|
38
|
-
})
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def log_response(response)
|
|
42
|
-
return unless defined?(Rails)
|
|
43
|
-
|
|
44
|
-
logger = Logger.new(Rails.root.join('log/invoice.log'))
|
|
45
|
-
logger.debug("#{@order_id} - #{response.body}") if @order_id
|
|
19
|
+
).call(:create_invoice_v3, message: {
|
|
20
|
+
invoicexml: @xml,
|
|
21
|
+
source: Cetustek.config.site_id + Cetustek.config.password,
|
|
22
|
+
rentid: Cetustek.config.username,
|
|
23
|
+
hastax: @hastax
|
|
24
|
+
})
|
|
46
25
|
end
|
|
47
26
|
end
|
|
48
27
|
end
|
|
@@ -46,6 +46,12 @@ module Cetustek
|
|
|
46
46
|
invoice
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
# Fields whose 備註 says "若未填,預設 X" are omitted entirely when nil, so
|
|
50
|
+
# the platform applies its own default instead of parsing an empty value.
|
|
51
|
+
def optional_tag(invoice, name, value)
|
|
52
|
+
invoice << raw_tag(name, value) unless value.nil?
|
|
53
|
+
end
|
|
54
|
+
|
|
49
55
|
def add_basic_info(invoice)
|
|
50
56
|
invoice << raw_tag('OrderId', @data.order_id)
|
|
51
57
|
invoice << raw_tag('OrderDate', @data.order_date.strftime('%Y/%m/%d'))
|
|
@@ -54,19 +60,29 @@ module Cetustek
|
|
|
54
60
|
def add_buyer_info(invoice)
|
|
55
61
|
invoice << raw_tag('BuyerIdentifier', @data.buyer_identifier)
|
|
56
62
|
invoice << raw_tag('BuyerName', @data.buyer_name)
|
|
63
|
+
invoice << raw_tag('BuyerAddress', @data.buyer_address)
|
|
64
|
+
invoice << raw_tag('BuyerPersonInCharge', @data.buyer_person_in_charge)
|
|
65
|
+
invoice << raw_tag('BuyerTelephoneNumber', @data.buyer_telephone)
|
|
66
|
+
invoice << raw_tag('BuyerFacsimileNumber', @data.buyer_facsimile)
|
|
57
67
|
invoice << raw_tag('BuyerEmailAddress', @data.buyer_email)
|
|
68
|
+
invoice << raw_tag('BuyerCustomerNumber', @data.buyer_customer_number)
|
|
58
69
|
end
|
|
59
70
|
|
|
60
71
|
def add_invoice_type_info(invoice)
|
|
61
72
|
invoice << raw_tag('DonateMark', @data.donate_mark)
|
|
62
73
|
invoice << raw_tag('InvoiceType', @data.invoice_type)
|
|
63
74
|
invoice << raw_tag('CarrierType', @data.carrier_type)
|
|
64
|
-
invoice << raw_tag('CarrierId1', @data.
|
|
75
|
+
invoice << raw_tag('CarrierId1', @data.carrier_id1)
|
|
65
76
|
invoice << raw_tag('CarrierId2', @data.carrier_id2)
|
|
66
77
|
invoice << raw_tag('NPOBAN', @data.npo_ban)
|
|
67
78
|
invoice << raw_tag('TaxType', @data.tax_type)
|
|
68
79
|
invoice << raw_tag('TaxRate', @data.tax_rate)
|
|
80
|
+
optional_tag(invoice, 'ZeroReason', @data.zero_reason)
|
|
69
81
|
invoice << raw_tag('PayWay', @data.payment_type)
|
|
82
|
+
invoice << raw_tag('Remark', @data.remark)
|
|
83
|
+
optional_tag(invoice, 'MailSend', @data.mail_send)
|
|
84
|
+
optional_tag(invoice, 'RoundNum', @data.round_num)
|
|
85
|
+
optional_tag(invoice, 'RtnMsg', @data.rtn_msg)
|
|
70
86
|
end
|
|
71
87
|
|
|
72
88
|
def add_details(invoice)
|
|
@@ -83,6 +99,7 @@ module Cetustek
|
|
|
83
99
|
product << raw_tag('ProductionCode', item.code)
|
|
84
100
|
product << raw_tag('Description', item.name)
|
|
85
101
|
product << raw_tag('Quantity', item.quantity)
|
|
102
|
+
product << raw_tag('Unit', item.unit)
|
|
86
103
|
product << raw_tag('UnitPrice', item.unit_price)
|
|
87
104
|
add_dtype(product, item.d_type)
|
|
88
105
|
product
|
|
@@ -1,37 +1,111 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require '
|
|
3
|
+
require 'json'
|
|
4
4
|
|
|
5
5
|
module Cetustek
|
|
6
6
|
module Services
|
|
7
|
+
# Parses the CreateInvoiceV3 return value. Three shapes are possible:
|
|
8
|
+
# the Table 8 JSON object (RtnMsg=Json), the 15-character
|
|
9
|
+
# "發票號碼;隨機碼" string, or a bare Table 7 result code.
|
|
7
10
|
class ResponseHandler
|
|
8
|
-
|
|
11
|
+
# Kept as a ResultError subclass so pre-0.7 rescues keep working.
|
|
12
|
+
# @deprecated rescue Cetustek::ResultError instead.
|
|
13
|
+
class InvalidResponseError < ResultError; end
|
|
9
14
|
|
|
10
|
-
|
|
15
|
+
SUCCESS_LENGTH = 15 # 發票號碼 10 碼 + ';' + 隨機碼 4 碼
|
|
16
|
+
|
|
17
|
+
# Spec AVM-26-03 Table 7.
|
|
18
|
+
RESULT_MESSAGES = {
|
|
19
|
+
'M:' => '欄位未填或格式錯誤',
|
|
20
|
+
'M0' => 'XML 格式錯誤',
|
|
21
|
+
'M1' => 'XML 格式錯誤',
|
|
22
|
+
'D0' => '沒有產品明細',
|
|
23
|
+
'D0_' => '產品編號格式錯誤',
|
|
24
|
+
'D1_' => '品名未填或格式錯誤',
|
|
25
|
+
'D2_' => '數量未填或格式錯誤',
|
|
26
|
+
'D3_' => '單價未填或格式錯誤',
|
|
27
|
+
'D4_' => '單位格式錯誤',
|
|
28
|
+
'D5_' => '數量*單價,其小計整數位大於 13 位',
|
|
29
|
+
'D999' => '明細筆數最多 9999 筆',
|
|
30
|
+
'S1' => '資料庫發生錯誤',
|
|
31
|
+
'S2' => '訂單日期超過開立日期',
|
|
32
|
+
'S3' => '未在申報期內',
|
|
33
|
+
'S4' => '未取得發票號碼',
|
|
34
|
+
'S5' => '發票號碼已使用完畢',
|
|
35
|
+
'S6' => '超過租賃張數限制',
|
|
36
|
+
'S7' => '訂單號碼已存在,若需重開請先作廢原發票號碼',
|
|
37
|
+
'S8' => '開立的總金額為負值',
|
|
38
|
+
'Invalid' => '無效 IP,請通知系統商'
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
def initialize(response, invoice_data = nil, xml = nil)
|
|
11
42
|
@response = response
|
|
12
43
|
@invoice_data = invoice_data
|
|
13
44
|
@xml = xml
|
|
14
45
|
end
|
|
15
46
|
|
|
16
47
|
def process
|
|
17
|
-
|
|
18
|
-
|
|
48
|
+
body = @response.body[:create_invoice_v3_response][:return].to_s.strip
|
|
49
|
+
json = parse_json(body)
|
|
19
50
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
raise InvalidResponseError, "Invalid response: #{response_body}"
|
|
23
|
-
end
|
|
51
|
+
return success(json_result(json)) if json && json['msg'] == 'Success'
|
|
52
|
+
return success(string_result(body)) if json.nil? && success_string?(body)
|
|
24
53
|
|
|
25
|
-
|
|
54
|
+
fail_with(json ? json['msg'].to_s : body)
|
|
26
55
|
end
|
|
27
56
|
|
|
28
57
|
private
|
|
29
58
|
|
|
30
|
-
|
|
31
|
-
|
|
59
|
+
# M1 (XML 格式錯誤) 與 Invalid 不會回傳 JSON,所以形狀要靠內容判斷。
|
|
60
|
+
def parse_json(body)
|
|
61
|
+
return nil unless body.start_with?('{')
|
|
62
|
+
|
|
63
|
+
JSON.parse(body)
|
|
64
|
+
rescue JSON::ParserError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def success_string?(body)
|
|
69
|
+
body.length == SUCCESS_LENGTH && body.include?(';')
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def json_result(json)
|
|
73
|
+
{
|
|
74
|
+
number: json['invnumber'],
|
|
75
|
+
random_number: json['random'],
|
|
76
|
+
date: json['invdate'],
|
|
77
|
+
time: json['invtime'],
|
|
78
|
+
sale_amount: json['saleamt'],
|
|
79
|
+
zero_amount: json['zeroamt'],
|
|
80
|
+
free_amount: json['freeamt'],
|
|
81
|
+
tax_amount: json['taxamt'],
|
|
82
|
+
total_amount: json['totalamt'],
|
|
83
|
+
carrier_url: json['ctkurl']
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def string_result(body)
|
|
88
|
+
number, random_number = body.split(';')
|
|
89
|
+
{ number: number, random_number: random_number }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def success(result)
|
|
93
|
+
logger&.info("CreateInvoiceV3 #{order_id} #{result[:number]}")
|
|
94
|
+
result
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def fail_with(code)
|
|
98
|
+
logger&.error("CreateInvoiceV3 #{order_id} #{code}")
|
|
99
|
+
logger&.debug(@xml) if @xml
|
|
100
|
+
raise InvalidResponseError.new(code, ResultCode.describe(code, RESULT_MESSAGES))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def logger
|
|
104
|
+
Cetustek.config.logger
|
|
105
|
+
end
|
|
32
106
|
|
|
33
|
-
|
|
34
|
-
|
|
107
|
+
def order_id
|
|
108
|
+
@invoice_data&.order_id
|
|
35
109
|
end
|
|
36
110
|
end
|
|
37
111
|
end
|
data/lib/cetustek/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cetustek
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Zac
|
|
@@ -65,9 +65,9 @@ dependencies:
|
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
67
|
version: '3.12'
|
|
68
|
-
description: Cetustek is a Ruby
|
|
69
|
-
|
|
70
|
-
SOAP Web Services.
|
|
68
|
+
description: 'Cetustek is a Ruby wrapper for the 鯨躍 Cetustek e-invoice API (虛擬多通路,
|
|
69
|
+
spec AVM-26-03): issuing and cancelling 電子發票 and 折讓單, the read-only queries, and
|
|
70
|
+
手機條碼 validation, over SOAP Web Services.'
|
|
71
71
|
email:
|
|
72
72
|
- 579103+7a6163@users.noreply.github.com
|
|
73
73
|
executables: []
|
|
@@ -118,5 +118,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
118
118
|
requirements: []
|
|
119
119
|
rubygems_version: 4.0.16
|
|
120
120
|
specification_version: 4
|
|
121
|
-
summary: A Ruby
|
|
121
|
+
summary: A Ruby client for the Cetustek e-invoice API (電子發票加值中心)
|
|
122
122
|
test_files: []
|