cetustek 0.9.0 → 0.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 887153f21f24293aa3ef9f6e4ee7ad48ec803c65f7104605a4d615894a5be19a
4
- data.tar.gz: 14667650b0e2bf27c060df01cef3bb220d73bdb4ab6deb8bf5c1bf29bd9ba050
3
+ metadata.gz: c6a6aca36dc760878fe11e9107416c7296ef5fa7835d1e223f3cab8248970fb7
4
+ data.tar.gz: 96fb067d26a7cb40e0b429a8a9f502fa486bc5bc2efcded04bb136bcba4b99c1
5
5
  SHA512:
6
- metadata.gz: 508a2c1acb9f548116e963c7ce6b536a8e5aec3bc3d18189a56b52403a0d32f485191d5af2d6c5a3eef4a30a1f041a0eb94fe5ba8f73e38c7b30f06022f85a55
7
- data.tar.gz: e2235999236cd67ee01462050391d1de6bfb4968eea812b7c9b4c7fc96b0559334e0800d5701d485d410d30f10d8486cd68bc217e04e1a9fb80169cc3e227977
6
+ metadata.gz: 4cdf8f6a063bb979aecd5d974dd1482329582359831bf0cbb20f46c50987dbbb677e675dd3507c3bbd69883277103760900dc12b55c3650f3215b1e3ab3dc408
7
+ data.tar.gz: 6240dd9f19af70f835d4323a5fad194a77ef8be409f503013887095c1bd25ece1331507030443ae8a7ea571aa97c6a47eb6982e0142a2605f467afbd47977564
data/CHANGELOG.md CHANGED
@@ -5,6 +5,51 @@ 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.11.0] - 2026-08-24
9
+
10
+ ### Added
11
+
12
+ - `config.transport` — `:httpi` (預設,維持現有行為) 或 `:faraday`。設成其他值
13
+ 在 `Cetustek.configure` 當下就 raise `ArgumentError`。
14
+
15
+ ### Fixed
16
+
17
+ - 沙盒端點 `invoice.cetustek.com.tw` 的 `certificate verify failed`:savon 預設
18
+ 的 HTTPI 首選 adapter 是 httpclient,而 httpclient 只認 gem 內附的
19
+ `cacert.pem`,裡面沒有該端點憑證鏈的根 Certum Trusted Root CA。只要 bundle
20
+ 裡有 httpclient,沙盒開票就會全數失敗。`config.transport = :faraday` 改走
21
+ Faraday 的 `net_http`,讀系統信任庫。production (`www.ei.com.tw`) 不受影響。
22
+ Faraday 路徑不能傳 `open_timeout`/`read_timeout`(savon 2.17 的
23
+ `FaradayMigrationHint::OPTIONS` 會 raise),改由 `client.faraday.options`
24
+ 設定,兩條路徑的 300 秒逾時一致。
25
+
26
+ ## [0.10.0] - 2026-08-07
27
+
28
+ 延續 0.9.0 的 code review:統一驗證力度與查詢介面,讓四個查詢類別與
29
+ `CancelInvoice`/`CancelAllowance` 的行為一致。
30
+
31
+ ### Added
32
+ - `QueryInvoice.find`、`QueryInvoiceByOrderId.find`、`QueryInvoiceNumberByOrderId.find`:
33
+ 比照既有的 `QueryAllowance.find`,把 Savon 回應解析成 snake_case 的 Hash(`QueryInvoice`
34
+ 與 `QueryInvoiceByOrderId` 共用 Table 13 格式,含 `:seller`/`:buyer`/`:details`),
35
+ `nil` 表示空回覆或規格說的 `"nodata"`,非 XML 的代碼字串一律拋 `Cetustek::ResultError`
36
+ - `CancelInvoice` 補上 `invoice_number`/`invoice_year` 必填檢查(Table 9 皆為 Y),
37
+ 原本只驗證 `remark`,另兩個欄位空白時要等伺服器回 `M:?` 才會發現
38
+
39
+ ### Changed
40
+ - 四個查詢類別的 XML 解析共用邏輯(`text_of`/`snake_case`)搬到 `Cetustek::Xml.parse_fields`,
41
+ 不再各自留一份幾乎相同的 private method
42
+ - `QueryInvoice`/`QueryInvoiceNumberByOrderId`/`QueryAllowance` 改用 `Soap#soap_call`,
43
+ 不再手動組 `source`/`rentid`,與 `QueryInvoiceByOrderId`/`CancelInvoice` 等類別一致
44
+ - `QueryInvoice.parse`/`QueryAllowance.parse` 共用的「空值/nodata 判斷、非 XML 代碼丟
45
+ `ResultError`、解析成 Ox root」邏輯收成 `Cetustek::Xml.parse_response`
46
+ - `QueryInvoice.party` 改名 `parse_party`,跟同一個類別的 `parse` 放在一起看更清楚
47
+
48
+ ### Fixed
49
+ - **Breaking:** `QueryInvoiceNumberByOrderId.find` 原本會把非發票號碼、非 `"nodata"`
50
+ 的回覆(如 `Invalid`)直接當發票號碼回傳;現在會比對 Table 9 的字軌+8碼數字格式,
51
+ 格式不符者一律丟 `Cetustek::ResultError`,跟其他 `Query*.find` 一致
52
+
8
53
  ## [0.9.0] - 2026-08-05
9
54
 
10
55
  比對規格 AVM-26-03 做的 code review 修正:0.8.0 有幾條規則寫得比規格寬或比規格嚴,
data/README.md CHANGED
@@ -50,9 +50,33 @@ Cetustek.configure do |config|
50
50
  # Only the order id, invoice number and result code are logged (never the
51
51
  # response body); the request XML is logged at debug level on failure.
52
52
  config.logger = Rails.logger
53
+
54
+ # Optional. :httpi (default, savon's own HTTP stack) or :faraday.
55
+ # See "SSL on the sandbox endpoint" below — if sandbox calls fail with
56
+ # `certificate verify failed`, set this to :faraday.
57
+ config.transport = :faraday
53
58
  end
54
59
  ```
55
60
 
61
+ ### SSL on the sandbox endpoint (`certificate verify failed`)
62
+
63
+ The sandbox endpoint `invoice.cetustek.com.tw` chains to **Certum Trusted Root
64
+ CA**. Savon's default HTTP stack is HTTPI, whose first-choice adapter is
65
+ `httpclient` — and `httpclient` ignores the system trust store, trusting only
66
+ the `cacert.pem` bundled inside the gem, which does not include that root. So
67
+ if anything in your bundle pulls in `httpclient` (plenty of gems do), every
68
+ sandbox call fails with:
69
+
70
+ ```
71
+ SSL_connect ... certificate verify failed (unable to get local issuer certificate)
72
+ ```
73
+
74
+ Production (`www.ei.com.tw`, Sectigo/USERTrust) is unaffected.
75
+
76
+ Fix it with `config.transport = :faraday`, which goes through Faraday's
77
+ `net_http` adapter and reads the system trust store. The 300s open/read
78
+ timeouts are applied either way.
79
+
56
80
  ## Usage
57
81
 
58
82
  ### Issue an Invoice
@@ -212,6 +236,10 @@ Cetustek::CancelInvoice.new('AB12345678', 2024, remark: '明細錯誤',
212
236
  return_tax_document_number: '65327645').execute
213
237
  ```
214
238
 
239
+ `invoice_number` and `invoice_year` are Table 9's other two `必填` fields, and
240
+ raise `ArgumentError` if left blank — the same guaranteed-rejection guarantee
241
+ `InvoiceData`/`AllowanceData` give for their own required fields.
242
+
215
243
  Uploading is not the end of it: the cancellation still has to be confirmed
216
244
  manually on the 鯨躍 platform before the invoice counts as void. Any code other
217
245
  than `"C0"` raises `Cetustek::ResultError` (`C5 - 該發票已經作廢過`, …), and
@@ -219,10 +247,35 @@ marking your own record as canceled is the caller's job.
219
247
 
220
248
  ### Query invoices
221
249
 
250
+ Each query class has both a raw `.query` (the Savon response, for callers who
251
+ want the untouched SOAP object) and a `.find` that parses the same response
252
+ into a plain Ruby value, mirroring `QueryAllowance` below:
253
+
254
+ ```ruby
255
+ Cetustek::QueryInvoiceByOrderId.query(order_id) # raw Savon response
256
+ Cetustek::QueryInvoiceByOrderId.find(order_id) # parsed Hash, or nil
257
+ Cetustek::QueryInvoice.query(invoice_number, invoice_year) # raw Savon response
258
+ Cetustek::QueryInvoice.find(invoice_number, invoice_year) # parsed Hash, or nil
259
+ Cetustek::QueryInvoiceNumberByOrderId.query(order_id) # raw Savon response
260
+ Cetustek::QueryInvoiceNumberByOrderId.find(order_id) # bare invoice number String, or nil
261
+ ```
262
+
263
+ `.find` returns `nil` when the platform answers with nothing at all or with
264
+ the documented `"nodata"`; a non-XML answer (a result code) is raised as
265
+ `Cetustek::ResultError`, same as `QueryAllowance.find`.
266
+
267
+ `QueryInvoice.find`/`QueryInvoiceByOrderId.find` (both share Table 13's XML
268
+ shape) return the fields as snake_case symbols, with `:seller`/`:buyer`
269
+ sub-hashes and the line items under `:details`:
270
+
222
271
  ```ruby
223
- Cetustek::QueryInvoiceByOrderId.query(order_id) # by order id
224
- Cetustek::QueryInvoice.query(invoice_number, invoice_year) # by invoice number + year
225
- Cetustek::QueryInvoiceNumberByOrderId.query(order_id) # just the invoice number
272
+ { order_id: '44556655', invoice_number: 'AA00000027', invoice_date: '2011/05/19',
273
+ invoice_time: '19:10:49', invoice_status: '開立', donate_mark: '0',
274
+ sales_amount: '120', tax_amount: '6', total_amount: '126', ctk_url: nil,
275
+ seller: { identifier: '53118823', name: '鯨躍科技有限公司', ... },
276
+ buyer: { identifier: '55669988', name: '漢客料理店', ... },
277
+ details: [{ product_code: 'AA783457', description: '筆記本(綠色)', quantity: '1',
278
+ unit: nil, unit_price: '60', amount: '60', sequence_number: '1' }] }
226
279
  ```
227
280
 
228
281
  ### Tax-inclusive vs tax-exclusive prices (`hastax`)
@@ -22,6 +22,8 @@ module Cetustek
22
22
 
23
23
  # remark 是 Table 9 的必填作廢原因,沒有預設值可用:理由是業務決定的。
24
24
  def initialize(invoice_number, invoice_year, remark:, return_tax_document_number: nil)
25
+ raise ArgumentError, 'invoice_number is required' if invoice_number.to_s.strip.empty?
26
+ raise ArgumentError, 'invoice_year is required' if invoice_year.to_s.strip.empty?
25
27
  raise ArgumentError, 'remark (作廢原因) is required' if remark.to_s.strip.empty?
26
28
  raise ArgumentError, "remark must not exceed #{MAX_REMARK_LENGTH} characters" if remark.length > MAX_REMARK_LENGTH
27
29
 
@@ -2,11 +2,26 @@
2
2
 
3
3
  module Cetustek
4
4
  class Configuration
5
+ # HTTPI (savon 的預設) 第一順位是 httpclient,而 httpclient 只認 gem 內附的
6
+ # cacert.pem,裡面沒有 Certum Trusted Root CA — 也就是 sandbox 端點
7
+ # invoice.cetustek.com.tw 憑證鏈的根。只要環境裡有 httpclient,沙盒開票就會
8
+ # 全數 certificate verify failed。改用 :faraday 走 net_http,讀系統信任庫。
9
+ TRANSPORTS = %i[httpi faraday].freeze
10
+
5
11
  # logger is opt-in: nil means this gem writes nothing anywhere.
6
12
  attr_accessor :environment, :site_id, :username, :password, :logger
13
+ attr_reader :transport
7
14
 
8
15
  def initialize
9
16
  @environment = :sandbox
17
+ @transport = :httpi
18
+ end
19
+
20
+ def transport=(value)
21
+ raise ArgumentError, "transport must be one of #{TRANSPORTS.join(', ')}, got #{value.inspect}" \
22
+ unless TRANSPORTS.include?(value)
23
+
24
+ @transport = value
10
25
  end
11
26
 
12
27
  def url
@@ -1,41 +1,81 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ox'
4
-
5
3
  module Cetustek
6
4
  # @deprecated use Cetustek::Soap. Kept so `extend Queries` keeps working.
7
5
  Queries = Soap
8
6
 
9
7
  # 2.4 QueryInvoice 查詢發票資訊 (by invoice number + year)
10
8
  class QueryInvoice
11
- extend Queries
9
+ extend Soap
10
+
11
+ # Spec AVM-26-03 Table 13.
12
+ FIELDS = %w[OrderID InvoiceNumber InvoiceDate InvoiceTime MainRemark CheckNumber
13
+ RandomNumber InvoiceStatus DonateMark SalesAmount FreeTaxSalesAmount
14
+ ZeroTaxSalesAmount TaxAmount TotalAmount CtkUrl].freeze
15
+ PARTY_FIELDS = %w[Identifier Name Address PersonInCharge TelephoneNumber
16
+ FacsimileNumber EmailAddress].freeze
17
+ DETAIL_FIELDS = %w[ProductCode Description Quantity Unit UnitPrice Amount SequenceNumber].freeze
12
18
 
13
19
  def self.query(invoice_number, invoice_year)
14
- soap_client.call(:query_invoice, message: {
15
- invoicenumber: invoice_number,
16
- invoiceyear: invoice_year,
17
- source: source,
18
- rentid: rentid
19
- })
20
+ soap_call(:query_invoice, invoicenumber: invoice_number, invoiceyear: invoice_year)
21
+ end
22
+
23
+ # Same query, with the returned XML parsed into a Hash of snake_case keys
24
+ # plus :seller/:buyer sub-hashes and a :details array. nil when the
25
+ # platform answers with nothing at all, or the documented "nodata".
26
+ def self.find(invoice_number, invoice_year)
27
+ parse(soap_return(query(invoice_number, invoice_year), :query_invoice))
28
+ end
29
+
30
+ # §2.5 QueryInvoicebyOrderid shares this exact XML shape (Table 13), so
31
+ # QueryInvoiceByOrderId#find reuses this instead of duplicating it.
32
+ def self.parse(xml)
33
+ root = Xml.parse_response(xml, nil_values: %w[nodata])
34
+ return nil unless root
35
+
36
+ data = Xml.parse_fields(root, FIELDS)
37
+ data[:seller] = parse_party(root, 'Seller')
38
+ data[:buyer] = parse_party(root, 'Buyer')
39
+ data[:details] = root.locate('Details/ProductItem').map { |item| Xml.parse_fields(item, DETAIL_FIELDS) }
40
+ data
41
+ end
42
+
43
+ def self.parse_party(root, name)
44
+ element = root.locate(name).first
45
+ element && Xml.parse_fields(element, PARTY_FIELDS)
20
46
  end
47
+
48
+ private_class_method :parse_party
21
49
  end
22
50
 
23
51
  # 2.6 QueryInvoiceNumberbyOrderid 以訂單編號查詢發票號碼
24
52
  class QueryInvoiceNumberByOrderId
25
- extend Queries
53
+ extend Soap
54
+
55
+ # 發票號碼格式,字軌 2 碼大寫字母+8 碼數字 (Table 9). Anything else that
56
+ # isn't the "nodata" sentinel is a bare result code, not a number.
57
+ FORMAT = /\A[A-Z]{2}\d{8}\z/
26
58
 
27
59
  def self.query(order_id)
28
- soap_client.call(:query_invoice_number_by_orderid, message: {
29
- orderid: order_id,
30
- source: source,
31
- rentid: rentid
32
- })
60
+ soap_call(:query_invoice_number_by_orderid, orderid: order_id)
61
+ end
62
+
63
+ # Same query, returning just the bare invoice number string, or nil for
64
+ # an empty response or the documented "nodata". A response that isn't
65
+ # either shape is a result code, raised as ResultError like the other
66
+ # Query* classes — it used to be returned as if it were the number.
67
+ def self.find(order_id)
68
+ body = soap_return(query(order_id), :query_invoice_number_by_orderid).to_s.strip
69
+ return nil if body.empty? || body == 'nodata'
70
+ return body if body.match?(FORMAT)
71
+
72
+ ResultCode.raise!(body, ResultCode::COMMON)
33
73
  end
34
74
  end
35
75
 
36
76
  # 2.11 QueryAllowance 查詢折讓資料
37
77
  class QueryAllowance
38
- extend Queries
78
+ extend Soap
39
79
 
40
80
  # Spec AVM-26-03 Table 20. Note the query response uses ProductCode and
41
81
  # InvoiceDate, unlike CreateAllowance's ProductionCode and InvoiceYear.
@@ -46,11 +86,7 @@ module Cetustek
46
86
  UnitPrice Amount Tax TaxType].freeze
47
87
 
48
88
  def self.query(allowance_number)
49
- soap_client.call(:query_allowance, message: {
50
- allowancenumber: allowance_number,
51
- source: source,
52
- rentid: rentid
53
- })
89
+ soap_call(:query_allowance, allowancenumber: allowance_number)
54
90
  end
55
91
 
56
92
  # Same query, with the returned XML parsed into a Hash of snake_case keys
@@ -61,29 +97,12 @@ module Cetustek
61
97
  end
62
98
 
63
99
  def self.parse(xml)
64
- body = xml.to_s.strip
65
- return nil if body.empty?
66
- # §2.11 只描述成功時的 XML;非 XML 的回覆是代碼字串,原樣拋給呼叫端。
67
- ResultCode.raise!(body, ResultCode::COMMON) unless body.start_with?('<')
68
-
69
- root = Ox.parse(body)
70
- root = root.root if root.is_a?(Ox::Document)
71
-
72
- data = FIELDS.to_h { |field| [snake_case(field), text_of(root, field)] }
73
- data[:details] = root.locate('Details/ProductItem').map do |item|
74
- DETAIL_FIELDS.to_h { |field| [snake_case(field), text_of(item, field)] }
75
- end
76
- data
77
- end
78
-
79
- def self.text_of(element, name)
80
- element.locate(name).first&.text
81
- end
100
+ root = Xml.parse_response(xml)
101
+ return nil unless root
82
102
 
83
- def self.snake_case(name)
84
- name.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.to_sym
103
+ data = Xml.parse_fields(root, FIELDS)
104
+ data[:details] = root.locate('Details/ProductItem').map { |item| Xml.parse_fields(item, DETAIL_FIELDS) }
105
+ data
85
106
  end
86
-
87
- private_class_method :text_of, :snake_case
88
107
  end
89
108
  end
@@ -8,5 +8,11 @@ module Cetustek
8
8
  def self.query(order_id)
9
9
  soap_call(:query_invoice_by_orderid, orderid: order_id)
10
10
  end
11
+
12
+ # Same query, parsed into a Hash. §2.5 shares Table 13's XML shape with
13
+ # §2.4, so this reuses QueryInvoice.parse instead of duplicating it.
14
+ def self.find(order_id)
15
+ QueryInvoice.parse(soap_return(query(order_id), :query_invoice_by_orderid))
16
+ end
11
17
  end
12
18
  end
data/lib/cetustek/soap.rb CHANGED
@@ -6,8 +6,26 @@ module Cetustek
6
6
  # Shared SOAP plumbing: every operation hits the same WSDL and authenticates
7
7
  # with 網站代碼+APIPassword (source) and the 租賃者統編 (rentid).
8
8
  module Soap
9
+ TIMEOUT = 300
10
+
9
11
  def soap_client
10
- Savon.client(wsdl: Cetustek.config.url, open_timeout: 300, read_timeout: 300)
12
+ return httpi_client unless Cetustek.config.transport == :faraday
13
+
14
+ faraday_client
15
+ end
16
+
17
+ def httpi_client
18
+ Savon.client(wsdl: Cetustek.config.url, open_timeout: TIMEOUT, read_timeout: TIMEOUT)
19
+ end
20
+
21
+ # savon 2.17 的 FaradayMigrationHint::OPTIONS 會讓 open_timeout/read_timeout
22
+ # 在 transport: :faraday 下直接 raise,逾時改由 faraday 連線自己設。
23
+ def faraday_client
24
+ require 'faraday'
25
+ Savon.client(wsdl: Cetustek.config.url, transport: :faraday).tap do |client|
26
+ client.faraday.options.open_timeout = TIMEOUT
27
+ client.faraday.options.read_timeout = TIMEOUT
28
+ end
11
29
  end
12
30
 
13
31
  def source
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cetustek
4
- VERSION = "0.9.0"
4
+ VERSION = "0.11.0"
5
5
  end
data/lib/cetustek/xml.rb CHANGED
@@ -39,5 +39,34 @@ module Cetustek
39
39
 
40
40
  element << tag(name, value)
41
41
  end
42
+
43
+ # Shared guard every Query* class needs before it can parse a response
44
+ # body: nil for an empty answer or one of the documented sentinel strings
45
+ # (e.g. "nodata"); ResultError for a bare result code; otherwise the
46
+ # parsed root element, ready for parse_fields.
47
+ def parse_response(body, nil_values: [])
48
+ text = body.to_s.strip
49
+ return nil if text.empty? || nil_values.include?(text)
50
+
51
+ ResultCode.raise!(text, ResultCode::COMMON) unless text.start_with?('<')
52
+
53
+ root = Ox.parse(text)
54
+ root.is_a?(Ox::Document) ? root.root : root
55
+ end
56
+
57
+ # Shared response-parsing side: every Query* class turns a response
58
+ # element into a Hash of snake_case keys, so this lives here once instead
59
+ # of once per query class.
60
+ def parse_fields(element, fields)
61
+ fields.to_h { |field| [snake_case(field), text_of(element, field)] }
62
+ end
63
+
64
+ def text_of(element, name)
65
+ element&.locate(name)&.first&.text
66
+ end
67
+
68
+ def snake_case(name)
69
+ name.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.to_sym
70
+ end
42
71
  end
43
72
  end
data/lib/cetustek.rb CHANGED
@@ -8,8 +8,8 @@ require_relative "cetustek/soap"
8
8
  require_relative "cetustek/models/allowance_data"
9
9
  require_relative "cetustek/create_invoice"
10
10
  require_relative "cetustek/cancel_invoice"
11
- require_relative "cetustek/query_invoice_by_order_id"
12
11
  require_relative "cetustek/queries"
12
+ require_relative "cetustek/query_invoice_by_order_id"
13
13
  require_relative "cetustek/create_allowance"
14
14
  require_relative "cetustek/cancel_allowance"
15
15
  require_relative "cetustek/phone_barcode"
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.9.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zac