cetustek 0.8.0 → 0.9.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: 98c3af7dadac0df75cb3fc24f02e124765716e1856034ed120cead8e2f2ce1a5
4
- data.tar.gz: 7d0a29575e2b24e786371b989da8107437eeb7d319bbf9514a83db6e7912fc79
3
+ metadata.gz: 887153f21f24293aa3ef9f6e4ee7ad48ec803c65f7104605a4d615894a5be19a
4
+ data.tar.gz: 14667650b0e2bf27c060df01cef3bb220d73bdb4ab6deb8bf5c1bf29bd9ba050
5
5
  SHA512:
6
- metadata.gz: 6243d83ecc59c97b961ec8185d46967b8e04148b752e2a810c519e238e53a9d8450944c10214cd320f465a1137e1d97c63bd1d8bf547bd59b16d6deace421916
7
- data.tar.gz: 9224b032dcb1eae4facaa3278b0c732daf79fbbcfe00ea17532a27b5bf989ba862711427021f2cd5a459abdce171ff0199931b2f99531cb3baa9c406457e3be4
6
+ metadata.gz: 508a2c1acb9f548116e963c7ce6b536a8e5aec3bc3d18189a56b52403a0d32f485191d5af2d6c5a3eef4a30a1f041a0eb94fe5ba8f73e38c7b30f06022f85a55
7
+ data.tar.gz: e2235999236cd67ee01462050391d1de6bfb4968eea812b7c9b4c7fc96b0559334e0800d5701d485d410d30f10d8486cd68bc217e04e1a9fb80169cc3e227977
data/CHANGELOG.md CHANGED
@@ -5,6 +5,45 @@ 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.9.0] - 2026-08-05
9
+
10
+ 比對規格 AVM-26-03 做的 code review 修正:0.8.0 有幾條規則寫得比規格寬或比規格嚴,
11
+ 另外把四份重複的 SOAP/XML 樣板收成共用模組。
12
+
13
+ ### Fixed
14
+ - **Breaking:** `carrier_id2` 只在無顯碼隱碼區分的載具(手機條碼 `3J0002`、自然人憑證
15
+ `CQ0001`)自動鏡射顯碼。會員載具(如鯨躍發票卡 `EJ0011`)兩碼本來就不同,0.8.0 會把顯碼
16
+ 當隱碼送出;現在未填 `carrier_id2` 會直接丟 `ArgumentError`
17
+ - `donate_mark: 0` 不再強制 `carrier_type`:規格註明「使用鯨躍發票卡,電子郵件必填,
18
+ 載具類別可為空或填 EJ0011」,原本的檢查讓這個情境無法送出。必填改為 `buyer_email`
19
+ 與 `carrier_id1`/`carrier_id2`
20
+ - `QueryAllowance.find` 移除規格沒有的 `'nodata'` 哨兵值;非 XML 的回覆(代碼字串)
21
+ 改為丟 `ResultError`,只有真的空回覆才回傳 `nil`
22
+ - 修掉註解裡捏造的規格出處(`spec V4.16`)與 TaxType 9 的「限收銀機類型發票」限制,
23
+ Table 1 只寫「9:混合(應稅、零稅率與免稅)」
24
+
25
+ ### Added
26
+ - `Cetustek::CarrierType`:`MOBILE_BARCODE` / `CITIZEN_CERT` / `CETUSTEK_CARD` 常數
27
+ - `AllowanceData` 依 Table 15 驗證必填(折讓單號、折讓日期、發票號碼、發票年份、
28
+ 折讓原因、至少一筆明細)與 `round_num` 0-7、`reason` 20 字。原本 `allowance_date`
29
+ 為 nil 會在組 XML 時炸成 `NoMethodError`
30
+ - `InvoiceItem` 依 Table 2/16 驗證 `code`/`name`/`quantity`/`unit_price` 必填(`unit` 選填)
31
+ - `CancelAllowance` 驗證 Table 18 的兩個必填欄位與 20 字上限
32
+ - `InvoiceData` 補上規格寫死的條件與範圍:`zero_reason` 限 `tax_type` 2/5、
33
+ `mail_send` 限 `donate_mark: 0`、`round_num` 0-7、`remark` 200 字
34
+
35
+ ### Changed
36
+ - **Breaking:** `CancelInvoice` 的 `remark:`(作廢原因)改為必填且限 20 字 —— Table 9 是
37
+ 必填欄位,預設 `'退貨'` 等於幫呼叫端編造理由
38
+ - **Breaking:** 移除 `Services::InvoiceService`,它只剩一次 Savon 呼叫;`CreateInvoice`
39
+ 直接送出
40
+ - `ResponseHandler` 失敗時丟 `ResultError` 本身,與折讓路徑一致。
41
+ `ResponseHandler::InvalidResponseError` 改為 `ResultError` 的別名常數(deprecated),
42
+ 既有 `rescue InvalidResponseError` 仍然攔得到
43
+ - 新增 `Cetustek::Xml` 與 `Cetustek::Soap`,收掉五份 Savon client 與四份 `raw_tag` 複製;
44
+ Table 7/10/17/19 共用的代碼列集中在 `ResultCode::COMMON` 與 `ResultCode::DETAILS`。
45
+ `Cetustek::Queries` 成為 `Cetustek::Soap` 的別名(deprecated)
46
+
8
47
  ## [0.8.0] - 2026-08-04
9
48
 
10
49
  ### Added
data/README.md CHANGED
@@ -66,8 +66,8 @@ invoice_data = Cetustek::Models::InvoiceData.new(
66
66
  buyer_name: invoice.name,
67
67
  buyer_email: invoice.email,
68
68
  donate_mark: Cetustek::DonateMark::CARRIER,
69
- carrier_type: '3J0002', # 手機條碼
70
- carrier_id: invoice.barcode, # CarrierId2 is mirrored automatically
69
+ carrier_type: Cetustek::CarrierType::MOBILE_BARCODE, # 3J0002
70
+ carrier_id: invoice.barcode, # 手機條碼無顯隱碼之分,CarrierId2 自動填相同值
71
71
  payment_type: Cetustek::PayWay::ATM,
72
72
  items: invoice.items.map { |item|
73
73
  Cetustek::Models::InvoiceItem.new(
@@ -96,9 +96,28 @@ Persisting the result is the caller's job — this gem writes to no database.
96
96
  so a guaranteed rejection never leaves your process:
97
97
 
98
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`
99
+ - every item needs `code`, `name`, `quantity` and `unit_price` (`unit` is optional)
100
+ - `donate_mark: 0` (載具) requires `buyer_email`, `carrier_id1` and `carrier_id2`
100
101
  - `donate_mark: 1` (捐贈) requires a 3–7 digit `npo_ban`
101
102
  - `tax_type: 4` (特種稅率) requires an explicit `tax_rate` and `invoice_type: '08'`
103
+ - `zero_reason` only with `tax_type` 2 or 5; `mail_send` only with `donate_mark: 0`
104
+ - `round_num` 0–7, `remark` at most 200 characters
105
+
106
+ ### Carriers (載具)
107
+
108
+ `Cetustek::CarrierType` names the codes the spec spells out:
109
+
110
+ | Constant | Code | Meaning |
111
+ |----------|------|---------|
112
+ | `MOBILE_BARCODE` | `3J0002` | 手機條碼 |
113
+ | `CITIZEN_CERT` | `CQ0001` | 自然人憑證條碼 |
114
+ | `CETUSTEK_CARD` | `EJ0011` | 鯨躍發票卡 |
115
+
116
+ 手機條碼與自然人憑證「無顯碼隱碼區分」, so passing `carrier_id` (or `carrier_id1`)
117
+ alone is enough and `CarrierId2` is filled with the same value. Member carriers
118
+ do carry two different codes, so both `carrier_id1` and `carrier_id2` must be
119
+ given — the gem will not guess a 隱碼 it cannot know. 鯨躍發票卡 may be requested
120
+ with a blank `carrier_type`, as the spec allows.
102
121
 
103
122
  Anything needing an external lookup (是否為有效手機條碼、捐贈碼是否存在) is left
104
123
  to the caller — see `Cetustek::PhoneBarcode` below.
@@ -119,7 +138,7 @@ general invoice type of `07`. Use `Cetustek::TaxType` to switch modes:
119
138
  | `TAX_FREE` | 3 | 免稅 |
120
139
  | `SPECIAL` | 4 | 應稅(特種稅率) — set `tax_rate`, use `invoice_type: '08'` |
121
140
  | `ZERO_RATE_CUSTOMS` | 5 | 零稅率(經海關出口) |
122
- | `MIXED` | 9 | 混合(應稅/零稅率/免稅,限收銀機類型發票) |
141
+ | `MIXED` | 9 | 混合(應稅/零稅率/免稅) |
123
142
 
124
143
  #### Zero-rate invoice (零稅率)
125
144
 
@@ -185,11 +204,12 @@ Cetustek::Models::InvoiceItem.new(code: 'DISCOUNT', name: '折抵', quantity: 1,
185
204
  ### Cancel an Invoice (作廢發票確認)
186
205
 
187
206
  ```ruby
188
- Cetustek::CancelInvoice.new('AB12345678', 2024).execute # => "C0"
189
- Cetustek::CancelInvoice.new('AB12345678', 2024, remark: '明細錯誤').execute # 作廢原因,預設 '退貨'
207
+ # remark 是 Table 9 的必填作廢原因,最多 20 字,沒有預設值
208
+ Cetustek::CancelInvoice.new('AB12345678', 2024, remark: '退貨').execute # => "C0"
190
209
 
191
210
  # 超過申報期間才需要專案作廢核准文號 (否則會收到 C3)
192
- Cetustek::CancelInvoice.new('AB12345678', 2024, return_tax_document_number: '65327645').execute
211
+ Cetustek::CancelInvoice.new('AB12345678', 2024, remark: '明細錯誤',
212
+ return_tax_document_number: '65327645').execute
193
213
  ```
194
214
 
195
215
  Uploading is not the end of it: the cancellation still has to be confirmed
@@ -223,7 +243,7 @@ Cetustek::Models::InvoiceData.new(hastax: 0, items: [...])
223
243
  | `remark` | `Remark` | 備註,200 字 |
224
244
  | `zero_reason` | `ZeroReason` | 零稅率原因;未填時平台預設 `72`(TaxType 2)或 `71`(TaxType 5) |
225
245
  | `round_num` | `RoundNum` | 金額計算位數,未填預設 4 |
226
- | `mail_send` | `MailSend` | `0`(預設)由加值中心寄送通知,`1` 自行處理 |
246
+ | `mail_send` | `MailSend` | `0`(預設)由加值中心寄送通知,`1` 自行處理;限 `donate_mark: 0` |
227
247
  | `rtn_msg` | `RtnMsg` | 預設 `'Json'`;傳 `nil` 退回只回傳 15 碼字串的舊模式 |
228
248
 
229
249
  Fields with a platform-side default (`ZeroReason`, `RoundNum`, `MailSend`,
@@ -248,13 +268,19 @@ allowance = Cetustek::Models::AllowanceData.new(
248
268
  )
249
269
  Cetustek::CreateAllowance.new(allowance).execute # => "A0" on success
250
270
  Cetustek::CancelAllowance.new('AA20240216000001', '明細錯誤').execute # => "C0" on success
251
- Cetustek::QueryAllowance.find('AA20240216000001') # parsed Hash, nil if unknown
271
+ Cetustek::QueryAllowance.find('AA20240216000001') # parsed Hash
252
272
  Cetustek::QueryAllowance.query('AA20240216000001') # raw Savon response
253
273
  ```
254
274
 
255
- `tax_type` on an allowance only accepts `1` 應稅, `2` 零稅率 or `3` 免稅 — the
256
- invoice-only values (`4`, `5`, `9`) raise `ArgumentError`. `unit_price` is
257
- **tax-inclusive** (there is no `hastax` on allowances).
275
+ `AllowanceData.new` raises `ArgumentError` for the Table 15 rules: `allowance_number`,
276
+ `allowance_date` (a `Date`/`Time`), `invoice_number`, `invoice_year`, `reason` (20 字)
277
+ and at least one item are required, and `round_num` must be 0–7. `tax_type` only
278
+ accepts `1` 應稅, `2` 零稅率 or `3` 免稅 — the invoice-only values (`4`, `5`, `9`)
279
+ raise. `unit_price` is **tax-inclusive** (there is no `hastax` on allowances).
280
+ `CancelAllowance` takes the two Table 18 fields, both required, 作廢原因 up to 20 字.
281
+
282
+ `QueryAllowance.find` returns `nil` only when the platform answers with nothing at
283
+ all; a non-XML answer is a result code and is raised as `ResultError`.
258
284
 
259
285
  Any other result code raises `Cetustek::ResultError`, whose `#code` is the raw
260
286
  code and whose message includes the documented reason (e.g. `A2 - 所有折讓金額加總
@@ -1,66 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ox'
4
- require 'cgi'
5
-
6
3
  module Cetustek
7
4
  # 2.10 CancelAllowance 作廢折讓單. Returns "C0" on success, raises ResultError otherwise.
8
5
  class CancelAllowance
6
+ include Soap
7
+
9
8
  SUCCESS_CODE = 'C0'
9
+ MAX_REASON_LENGTH = 20
10
10
 
11
11
  # Spec AVM-26-03 Table 19.
12
- RESULT_MESSAGES = {
13
- 'M:' => '欄位未填或格式錯誤',
14
- 'M1' => 'XML 格式錯誤',
12
+ RESULT_MESSAGES = ResultCode::COMMON.merge(
15
13
  'C1' => '上傳失敗',
16
14
  'C2' => '折讓單已申報,無法作廢',
17
15
  'C3' => '折讓單號不存在或須為已確認後的折讓單',
18
16
  'C4' => '該作廢折讓單已經上傳',
19
- 'C5' => '折讓單已過作廢期限,無法作廢',
20
- 'Invalid' => '無效 IP,請通知系統商'
21
- }.freeze
17
+ 'C5' => '折讓單已過作廢期限,無法作廢'
18
+ ).freeze
22
19
 
20
+ # Table 18 has only these two fields, both 必填.
23
21
  def initialize(allowance_number, reason)
22
+ raise ArgumentError, 'allowance_number is required' if allowance_number.to_s.strip.empty?
23
+ raise ArgumentError, 'reason (作廢原因) is required' if reason.to_s.strip.empty?
24
+ raise ArgumentError, "reason must not exceed #{MAX_REASON_LENGTH} characters" if reason.length > MAX_REASON_LENGTH
25
+
24
26
  @allowance_number = allowance_number
25
27
  @reason = reason
26
28
  end
27
29
 
28
30
  def execute
29
- perform
30
- ResultCode.check!(@response.body[:cancel_allowance_response][:return],
31
- RESULT_MESSAGES, success: SUCCESS_CODE)
31
+ response = soap_call(:cancel_allowance, allowancexml: generate_xml)
32
+ ResultCode.check!(soap_return(response, :cancel_allowance), RESULT_MESSAGES, success: SUCCESS_CODE)
32
33
  end
33
34
 
34
35
  private
35
36
 
36
- def perform
37
- client = Savon.client(wsdl: Cetustek.config.url, open_timeout: 300, read_timeout: 300)
38
- @response = client.call(:cancel_allowance, message: {
39
- allowancexml: generate_xml,
40
- source: Cetustek.config.site_id + Cetustek.config.password,
41
- rentid: Cetustek.config.username
42
- })
43
- end
44
-
45
37
  def generate_xml
46
- doc = Ox::Document.new
47
- instruct = Ox::Instruct.new(:xml)
48
- instruct[:version] = '1.0'
49
- instruct[:encoding] = 'UTF-8'
50
- doc << instruct
51
-
52
- allowance = Ox::Element.new('Allowance')
53
- allowance[:XSDVersion] = '2.8'
54
- doc << allowance
55
-
56
- allowance << raw_tag('AllowanceNumber', @allowance_number)
57
- allowance << raw_tag('Reason', @reason)
58
-
59
- Ox.dump(doc).force_encoding('UTF-8')
60
- end
61
-
62
- def raw_tag(name, value)
63
- Ox::Raw.new("<#{name}>#{CGI.escapeHTML(value.to_s)}</#{name}>")
38
+ Xml.document('Allowance') do |allowance|
39
+ Xml.append(allowance, 'AllowanceNumber', @allowance_number)
40
+ Xml.append(allowance, 'Reason', @reason)
41
+ end
64
42
  end
65
43
  end
66
44
  end
@@ -1,31 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ox'
4
- require 'cgi'
5
-
6
3
  module Cetustek
7
4
  # 2.2 CancelInvoice 作廢發票確認. Returns "C0" on success, raises ResultError
8
5
  # otherwise. Uploading is not the end of it — the cancellation still has to be
9
6
  # confirmed manually on the 鯨躍 platform before the invoice counts as void.
10
7
  class CancelInvoice
8
+ include Soap
9
+
11
10
  SUCCESS_CODE = 'C0'
11
+ MAX_REMARK_LENGTH = 20
12
12
 
13
13
  # Spec AVM-26-03 Table 10.
14
- RESULT_MESSAGES = {
15
- 'M:' => '欄位未填或格式錯誤',
16
- 'M0' => 'XML 格式錯誤',
17
- 'M1' => 'XML 格式錯誤',
14
+ RESULT_MESSAGES = ResultCode::COMMON.merge(
18
15
  'C1' => '資料庫發生錯誤',
19
16
  'C2' => '資料有誤',
20
17
  'C3' => '該發票已過申報期間,需填入核准作廢文號 (return_tax_document_number)',
21
18
  'C4' => '無此發票號碼可以作廢',
22
19
  'C5' => '該發票已經作廢過',
23
- 'C6' => '作廢資訊已經傳送過',
24
- 'Invalid' => '無效 IP,請通知系統商'
25
- }.freeze
20
+ 'C6' => '作廢資訊已經傳送過'
21
+ ).freeze
26
22
 
27
- def initialize(invoice_number, invoice_year, remark: '退貨', return_tax_document_number: nil)
23
+ # remark Table 9 的必填作廢原因,沒有預設值可用:理由是業務決定的。
24
+ def initialize(invoice_number, invoice_year, remark:, return_tax_document_number: nil)
28
25
  raise ArgumentError, 'remark (作廢原因) is required' if remark.to_s.strip.empty?
26
+ raise ArgumentError, "remark must not exceed #{MAX_REMARK_LENGTH} characters" if remark.length > MAX_REMARK_LENGTH
29
27
 
30
28
  @invoice_number = invoice_number
31
29
  @invoice_year = invoice_year
@@ -34,44 +32,20 @@ module Cetustek
34
32
  end
35
33
 
36
34
  def execute
37
- perform
38
- ResultCode.check!(@response.body[:cancel_invoice_response][:return],
39
- RESULT_MESSAGES, success: SUCCESS_CODE)
35
+ response = soap_call(:cancel_invoice, invoicexml: generate_xml)
36
+ ResultCode.check!(soap_return(response, :cancel_invoice), RESULT_MESSAGES, success: SUCCESS_CODE)
40
37
  end
41
38
 
42
39
  private
43
40
 
44
- def perform
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
- })
51
- end
52
-
53
41
  def generate_xml
54
- doc = Ox::Document.new
55
- instruct = Ox::Instruct.new(:xml)
56
- instruct[:version] = '1.0'
57
- instruct[:encoding] = 'UTF-8'
58
- doc << instruct
59
-
60
- invoice = Ox::Element.new('Invoice')
61
- invoice[:XSDVersion] = '2.8'
62
- doc << invoice
63
-
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)
69
-
70
- Ox.dump(doc).force_encoding('UTF-8')
71
- end
72
-
73
- def raw_tag(name, value)
74
- Ox::Raw.new("<#{name}>#{CGI.escapeHTML(value.to_s)}</#{name}>")
42
+ Xml.document('Invoice') do |invoice|
43
+ Xml.append(invoice, 'InvoiceNumber', @invoice_number)
44
+ Xml.append(invoice, 'InvoiceYear', @invoice_year)
45
+ # 專案作廢核准文號:只在超過申報期間作廢時才需要 (Table 10 的 C3)。
46
+ Xml.append(invoice, 'ReturnTaxDocumentNumber', @return_tax_document_number, skip_nil: true)
47
+ Xml.append(invoice, 'Remark', @remark)
48
+ end
75
49
  end
76
50
  end
77
51
  end
@@ -1,34 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ox'
4
- require 'cgi'
5
-
6
3
  module Cetustek
7
4
  # 2.9 CreateAllowance 開立折讓單. Returns "A0" on success, raises ResultError otherwise.
8
5
  class CreateAllowance
6
+ include Soap
7
+
9
8
  SUCCESS_CODE = 'A0'
10
9
 
11
10
  # Spec AVM-26-03 Table 17.
12
- RESULT_MESSAGES = {
13
- 'M:' => '欄位未填或格式錯誤',
14
- 'M1' => 'XML 格式錯誤',
15
- 'D0' => '沒有產品明細',
16
- 'D0_' => '產品編號格式錯誤',
17
- 'D1_' => '品名未填或格式錯誤',
18
- 'D2_' => '數量未填或格式錯誤',
19
- 'D3_' => '單價未填或格式錯誤',
20
- 'D4_' => '單位格式錯誤',
21
- 'D5_' => '數量*單價,其小計整數位大於 13 位',
22
- 'D999' => '明細筆數最多 9999 筆',
11
+ RESULT_MESSAGES = ResultCode::COMMON.merge(ResultCode::DETAILS).merge(
23
12
  'A1' => '上傳失敗',
24
13
  'A2' => '所有折讓金額加總不能大於原發票金額',
25
14
  'A3' => '發票號碼不存在',
26
15
  'A4' => '發票號碼已經被作廢',
27
16
  'A5' => '折讓單已經上傳',
28
17
  'A6' => '折讓總金額須大於零',
29
- 'A7' => '折讓日期應大於原發票開立日期',
30
- 'Invalid' => '無效 IP,請通知系統商'
31
- }.freeze
18
+ 'A7' => '折讓日期應大於原發票開立日期'
19
+ ).freeze
32
20
 
33
21
  # check_allowance 只剩 0(已確認的折讓單):規格 §2.9 已刪除 1,
34
22
  # 114/01/01 起上傳的折讓單皆為已確認。
@@ -40,64 +28,39 @@ module Cetustek
40
28
  end
41
29
 
42
30
  def execute
43
- perform
44
- ResultCode.check!(@response.body[:create_allowance_response][:return],
45
- RESULT_MESSAGES, success: SUCCESS_CODE)
31
+ response = soap_call(:create_allowance, allowancexml: generate_xml, checkallowance: @check_allowance)
32
+ ResultCode.check!(soap_return(response, :create_allowance), RESULT_MESSAGES, success: SUCCESS_CODE)
46
33
  end
47
34
 
48
35
  private
49
36
 
50
- def perform
51
- client = Savon.client(wsdl: Cetustek.config.url, open_timeout: 300, read_timeout: 300)
52
- @response = client.call(:create_allowance, message: {
53
- allowancexml: generate_xml,
54
- checkallowance: @check_allowance,
55
- source: Cetustek.config.site_id + Cetustek.config.password,
56
- rentid: Cetustek.config.username
57
- })
58
- end
59
-
60
37
  def generate_xml
61
- doc = Ox::Document.new
62
- instruct = Ox::Instruct.new(:xml)
63
- instruct[:version] = '1.0'
64
- instruct[:encoding] = 'UTF-8'
65
- doc << instruct
66
-
67
- allowance = Ox::Element.new('Allowance')
68
- allowance[:XSDVersion] = '2.8'
69
- doc << allowance
70
-
71
- allowance << raw_tag('AllowanceNumber', @data.allowance_number)
72
- allowance << raw_tag('AllowanceDate', @data.allowance_date.strftime('%Y/%m/%d'))
73
- allowance << raw_tag('InvoiceNumber', @data.invoice_number)
74
- allowance << raw_tag('InvoiceYear', @data.invoice_year)
75
- allowance << raw_tag('BuyerAddress', @data.buyer_address)
76
- allowance << raw_tag('BuyerEmailAddress', @data.buyer_email)
77
- allowance << raw_tag('TaxType', @data.tax_type)
78
- allowance << raw_tag('Reason', @data.reason)
79
- allowance << raw_tag('RoundNum', @data.round_num) unless @data.round_num.nil?
80
- allowance << build_details
81
-
82
- Ox.dump(doc).force_encoding('UTF-8')
38
+ Xml.document('Allowance') do |allowance|
39
+ Xml.append(allowance, 'AllowanceNumber', @data.allowance_number)
40
+ Xml.append(allowance, 'AllowanceDate', @data.allowance_date.strftime('%Y/%m/%d'))
41
+ Xml.append(allowance, 'InvoiceNumber', @data.invoice_number)
42
+ Xml.append(allowance, 'InvoiceYear', @data.invoice_year)
43
+ Xml.append(allowance, 'BuyerAddress', @data.buyer_address)
44
+ Xml.append(allowance, 'BuyerEmailAddress', @data.buyer_email)
45
+ Xml.append(allowance, 'TaxType', @data.tax_type)
46
+ Xml.append(allowance, 'Reason', @data.reason)
47
+ Xml.append(allowance, 'RoundNum', @data.round_num, skip_nil: true)
48
+ allowance << build_details
49
+ end
83
50
  end
84
51
 
85
52
  def build_details
86
53
  details = Ox::Element.new('Details')
87
54
  @data.items.each do |item|
88
55
  product = Ox::Element.new('ProductItem')
89
- product << raw_tag('ProductionCode', item.code)
90
- product << raw_tag('Description', item.name)
91
- product << raw_tag('Quantity', item.quantity)
92
- product << raw_tag('Unit', item.unit)
93
- product << raw_tag('UnitPrice', item.unit_price)
56
+ Xml.append(product, 'ProductionCode', item.code)
57
+ Xml.append(product, 'Description', item.name)
58
+ Xml.append(product, 'Quantity', item.quantity)
59
+ Xml.append(product, 'Unit', item.unit)
60
+ Xml.append(product, 'UnitPrice', item.unit_price)
94
61
  details << product
95
62
  end
96
63
  details
97
64
  end
98
-
99
- def raw_tag(name, value)
100
- Ox::Raw.new("<#{name}>#{CGI.escapeHTML(value.to_s)}</#{name}>")
101
- end
102
65
  end
103
66
  end
@@ -2,20 +2,22 @@
2
2
 
3
3
  require_relative 'models/invoice_data'
4
4
  require_relative 'services/invoice_xml_builder'
5
- require_relative 'services/invoice_service'
6
5
  require_relative 'services/response_handler'
7
6
 
8
7
  module Cetustek
9
8
  # 2.1 CreateInvoiceV3 開立發票. Returns the issued invoice on success and
10
9
  # raises ResultError otherwise. Persisting the result is the caller's job.
11
10
  class CreateInvoice
11
+ include Soap
12
+
12
13
  def initialize(invoice_data)
13
14
  @invoice_data = invoice_data
14
15
  end
15
16
 
16
17
  def execute
17
18
  xml = Services::InvoiceXmlBuilder.new(@invoice_data).build
18
- response = Services::InvoiceService.new(xml, @invoice_data.hastax).create
19
+ # hastax: 0 = 明細單價未稅, 1 = 含稅; comes from the order, not a constant.
20
+ response = soap_call(:create_invoice_v3, invoicexml: xml, hastax: @invoice_data.hastax)
19
21
  Services::ResponseHandler.new(response, @invoice_data, xml).process
20
22
  end
21
23
  end
@@ -14,6 +14,26 @@ module Cetustek
14
14
  end
15
15
 
16
16
  module ResultCode
17
+ # Rows that are identical across the spec's result tables (7/10/17/19).
18
+ COMMON = {
19
+ 'M:' => '欄位未填或格式錯誤',
20
+ 'M0' => 'XML 格式錯誤',
21
+ 'M1' => 'XML 格式錯誤',
22
+ 'Invalid' => '無效 IP,請通知系統商'
23
+ }.freeze
24
+
25
+ # Detail-line rows, identical between Table 7 (發票) and Table 17 (折讓).
26
+ DETAILS = {
27
+ 'D0' => '沒有產品明細',
28
+ 'D0_' => '產品編號格式錯誤',
29
+ 'D1_' => '品名未填或格式錯誤',
30
+ 'D2_' => '數量未填或格式錯誤',
31
+ 'D3_' => '單價未填或格式錯誤',
32
+ 'D4_' => '單位格式錯誤',
33
+ 'D5_' => '數量*單價,其小計整數位大於 13 位',
34
+ 'D999' => '明細筆數最多 9999 筆'
35
+ }.freeze
36
+
17
37
  # Codes may carry a suffix naming the offending field or detail row
18
38
  # ("M:AllowanceDate", "D2_3"), so an exact miss falls back to the table key
19
39
  # without the suffix.
@@ -25,6 +45,10 @@ module Cetustek
25
45
  def self.check!(code, messages, success:)
26
46
  return code if code == success
27
47
 
48
+ raise!(code, messages)
49
+ end
50
+
51
+ def self.raise!(code, messages)
28
52
  raise ResultError.new(code, describe(code, messages))
29
53
  end
30
54
  end
@@ -10,6 +10,8 @@ module Cetustek
10
10
  # 折讓單稅別只有應稅/零稅率/免稅,發票的 4 (特種稅率)、5 (經海關出口)
11
11
  # 與 9 (混合) 在折讓單無效。
12
12
  TAX_TYPES = [TaxType::TAXABLE, TaxType::ZERO_RATE, TaxType::TAX_FREE].freeze
13
+ MAX_REASON_LENGTH = 20
14
+ ROUND_NUMS = (0..7).freeze
13
15
 
14
16
  attr_reader :allowance_number, :allowance_date, :invoice_number,
15
17
  :invoice_year, :buyer_address, :buyer_email, :tax_type,
@@ -23,13 +25,54 @@ module Cetustek
23
25
  @buyer_address = attributes[:buyer_address]
24
26
  @buyer_email = attributes[:buyer_email]
25
27
  @tax_type = attributes[:tax_type] || TaxType::TAXABLE
26
- unless TAX_TYPES.include?(@tax_type.to_i)
27
- raise ArgumentError, "tax_type must be 1 (應稅), 2 (零稅率) or 3 (免稅), got #{@tax_type.inspect}"
28
- end
29
-
30
28
  @reason = attributes[:reason]
31
29
  @round_num = attributes[:round_num] # optional 金額計算位數
32
30
  @items = attributes[:items] || []
31
+ validate!
32
+ end
33
+
34
+ private
35
+
36
+ def validate!
37
+ missing = { allowance_number: @allowance_number, invoice_number: @invoice_number,
38
+ invoice_year: @invoice_year, reason: @reason }.select { |_k, v| blank?(v) }.keys
39
+ raise ArgumentError, "#{missing.join(', ')} required" if missing.any?
40
+
41
+ validate_allowance_date!
42
+ validate_tax_type!
43
+ validate_reason!
44
+ validate_round_num!
45
+ raise ArgumentError, 'items must not be empty (沒有產品明細)' if @items.empty?
46
+ end
47
+
48
+ def validate_allowance_date!
49
+ raise ArgumentError, 'allowance_date is required' if @allowance_date.nil?
50
+ return if @allowance_date.respond_to?(:strftime)
51
+
52
+ raise ArgumentError, "allowance_date must be a Date or Time, got #{@allowance_date.class}"
53
+ end
54
+
55
+ def validate_tax_type!
56
+ return if TAX_TYPES.include?(@tax_type.to_i)
57
+
58
+ raise ArgumentError, "tax_type must be 1 (應稅), 2 (零稅率) or 3 (免稅), got #{@tax_type.inspect}"
59
+ end
60
+
61
+ def validate_reason!
62
+ return if @reason.to_s.length <= MAX_REASON_LENGTH
63
+
64
+ raise ArgumentError, "reason must not exceed #{MAX_REASON_LENGTH} characters"
65
+ end
66
+
67
+ def validate_round_num!
68
+ return if @round_num.nil? || ROUND_NUMS.include?(@round_num.to_i)
69
+
70
+ raise ArgumentError, "round_num must be between #{ROUND_NUMS.first} and #{ROUND_NUMS.last}, " \
71
+ "got #{@round_num.inspect}"
72
+ end
73
+
74
+ def blank?(value)
75
+ value.nil? || value.to_s.strip.empty?
33
76
  end
34
77
  end
35
78
  end
@@ -1,14 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cetustek
4
- # TaxType (稅別) codes accepted by CreateInvoiceV3 (spec V4.16, Table 1).
4
+ # TaxType (稅別) codes accepted by CreateInvoiceV3, spec AVM-26-03 Table 1.
5
5
  module TaxType
6
6
  TAXABLE = 1 # 應稅
7
7
  ZERO_RATE = 2 # 零稅率(非經海關出口)
8
8
  TAX_FREE = 3 # 免稅
9
9
  SPECIAL = 4 # 應稅(特種稅率) — requires TaxRate
10
10
  ZERO_RATE_CUSTOMS = 5 # 零稅率(經海關出口)
11
- MIXED = 9 # 混合(應稅、零稅率與免稅,限收銀機類型發票)
11
+ MIXED = 9 # 混合(應稅、零稅率與免稅)
12
+ end
13
+
14
+ # CarrierType (載具類別) codes named in spec AVM-26-03 Table 1. The field is
15
+ # 6 chars and 依電子整合平台核發填入, so any other code is accepted as-is.
16
+ module CarrierType
17
+ MOBILE_BARCODE = '3J0002' # 手機條碼,以「/」起始
18
+ CITIZEN_CERT = 'CQ0001' # 自然人憑證條碼,2 碼大寫字母加 14 碼數字
19
+ CETUSTEK_CARD = 'EJ0011' # 鯨躍發票卡
20
+
21
+ # These carriers 「無顯碼隱碼區分」, so CarrierId1 and CarrierId2 hold the
22
+ # same value. Member carriers (e.g. 鯨躍發票卡) do carry distinct codes.
23
+ WITHOUT_HIDDEN_CODE = [MOBILE_BARCODE, CITIZEN_CERT].freeze
12
24
  end
13
25
 
14
26
  # PayWay (付款方式) codes for CreateInvoiceV3, spec AVM-26-03 Table 4.
@@ -54,8 +66,11 @@ module Cetustek
54
66
  # Json 回傳才拿得到平台配發的發票日期時間;Intertemporal 回開會讓本機日期失準。
55
67
  DEFAULT_RTN_MSG = 'Json'
56
68
  MAX_ITEMS = 9999
69
+ MAX_REMARK_LENGTH = 200
70
+ ROUND_NUMS = (0..7).freeze
57
71
  TAX_TYPES = [TaxType::TAXABLE, TaxType::ZERO_RATE, TaxType::TAX_FREE,
58
72
  TaxType::SPECIAL, TaxType::ZERO_RATE_CUSTOMS, TaxType::MIXED].freeze
73
+ ZERO_RATE_TAX_TYPES = [TaxType::ZERO_RATE, TaxType::ZERO_RATE_CUSTOMS].freeze
59
74
  DONATE_MARKS = [DonateMark::CARRIER, DonateMark::DONATE, DonateMark::PAPER].freeze
60
75
 
61
76
  attr_reader :order_id, :order_date, :buyer_identifier, :buyer_name,
@@ -82,8 +97,7 @@ module Cetustek
82
97
  @donate_mark = attributes[:donate_mark]
83
98
  @carrier_type = attributes[:carrier_type]
84
99
  @carrier_id1 = attributes[:carrier_id1] || attributes[:carrier_id]
85
- # 手機條碼與自然人憑證沒有顯碼隱碼之分,兩欄填相同值。
86
- @carrier_id2 = attributes[:carrier_id2] || @carrier_id1
100
+ @carrier_id2 = attributes[:carrier_id2] || mirrored_carrier_id2
87
101
  @npo_ban = attributes[:npo_ban]
88
102
  @items = attributes[:items] || []
89
103
  @payment_type = attributes[:payment_type]
@@ -102,7 +116,7 @@ module Cetustek
102
116
  validate!
103
117
  end
104
118
 
105
- # 混合稅率發票 (限收銀機):每筆明細需標註 DType。
119
+ # 混合稅率發票:每筆明細需標註 DType。
106
120
  def mixed_tax?
107
121
  @tax_type.to_i == TaxType::MIXED
108
122
  end
@@ -114,6 +128,11 @@ module Cetustek
114
128
 
115
129
  private
116
130
 
131
+ # 只有無顯碼隱碼區分的載具能自動補 CarrierId2;會員載具兩碼不同,猜了就是送錯。
132
+ def mirrored_carrier_id2
133
+ @carrier_id1 if CarrierType::WITHOUT_HIDDEN_CODE.include?(@carrier_type.to_s)
134
+ end
135
+
117
136
  def validate!
118
137
  raise ArgumentError, 'order_id is required' if blank?(@order_id)
119
138
 
@@ -122,6 +141,8 @@ module Cetustek
122
141
  validate_donate_mark!
123
142
  validate_pay_way!
124
143
  validate_tax!
144
+ validate_remark!
145
+ validate_round_num!
125
146
  end
126
147
 
127
148
  def validate_order_date!
@@ -147,14 +168,22 @@ module Cetustek
147
168
  when DonateMark::CARRIER then validate_carrier!
148
169
  when DonateMark::DONATE then validate_npo_ban!
149
170
  end
171
+
172
+ return if @mail_send.nil? || @donate_mark.to_i == DonateMark::CARRIER
173
+
174
+ raise ArgumentError, 'mail_send may only be used when donate_mark is 0 (載具)'
150
175
  end
151
176
 
177
+ # CarrierType is left out of the required set on purpose: 鯨躍發票卡
178
+ # 「載具類別可為空或填 EJ0011」, and a blank value is how the platform is
179
+ # told to use it.
152
180
  def validate_carrier!
153
- missing = { buyer_email: @buyer_email, carrier_type: @carrier_type,
154
- carrier_id1: @carrier_id1 }.select { |_name, value| blank?(value) }.keys
181
+ missing = { buyer_email: @buyer_email, carrier_id1: @carrier_id1,
182
+ carrier_id2: @carrier_id2 }.select { |_name, value| blank?(value) }.keys
155
183
  return if missing.empty?
156
184
 
157
- raise ArgumentError, "#{missing.join(', ')} required when donate_mark is 0 (載具)"
185
+ raise ArgumentError, "#{missing.join(', ')} required when donate_mark is 0 (載具)" \
186
+ "#{'; 此載具有顯碼與隱碼之分,請分別填入' if missing == [:carrier_id2]}"
158
187
  end
159
188
 
160
189
  def validate_npo_ban!
@@ -171,6 +200,8 @@ module Cetustek
171
200
  unless TAX_TYPES.include?(@tax_type.to_i)
172
201
  raise ArgumentError, "tax_type must be one of #{TAX_TYPES.join(', ')}, got #{@tax_type.inspect}"
173
202
  end
203
+
204
+ validate_zero_reason!
174
205
  return unless special_tax?
175
206
 
176
207
  raise ArgumentError, 'tax_rate is required when tax_type is 4 (特種稅率)' if blank?(@tax_rate)
@@ -179,11 +210,32 @@ module Cetustek
179
210
  raise ArgumentError, "invoice_type must be '08' (特種稅額) when tax_type is 4, got #{@invoice_type.inspect}"
180
211
  end
181
212
 
213
+ def validate_zero_reason!
214
+ return if @zero_reason.nil? || ZERO_RATE_TAX_TYPES.include?(@tax_type.to_i)
215
+
216
+ raise ArgumentError, 'zero_reason may only be used when tax_type is 2 or 5 (零稅率)'
217
+ end
218
+
219
+ def validate_remark!
220
+ return if @remark.nil? || @remark.to_s.length <= MAX_REMARK_LENGTH
221
+
222
+ raise ArgumentError, "remark must not exceed #{MAX_REMARK_LENGTH} characters"
223
+ end
224
+
225
+ def validate_round_num!
226
+ return if @round_num.nil? || ROUND_NUMS.include?(@round_num.to_i)
227
+
228
+ raise ArgumentError, "round_num must be between #{ROUND_NUMS.first} and #{ROUND_NUMS.last}, " \
229
+ "got #{@round_num.inspect}"
230
+ end
231
+
182
232
  def blank?(value)
183
233
  value.nil? || value.to_s.strip.empty?
184
234
  end
185
235
  end
186
236
 
237
+ # A single 明細 row, spec AVM-26-03 Table 2 (發票) / Table 16 (折讓).
238
+ # 品名代號、品名、數量、單價 are 必填; 單位 is not.
187
239
  class InvoiceItem
188
240
  # Per-item 稅別註記 (DType) used for mixed-tax invoices (TaxType == 9).
189
241
  DTYPE_MAP = {
@@ -191,6 +243,7 @@ module Cetustek
191
243
  zero_rate: 'TZ', # 零稅率商品
192
244
  tax_free: 'TN' # 免稅商品
193
245
  }.freeze
246
+ REQUIRED = %i[code name quantity unit_price].freeze
194
247
 
195
248
  attr_reader :code, :name, :quantity, :unit_price, :tax_type, :unit
196
249
 
@@ -201,6 +254,7 @@ module Cetustek
201
254
  @unit_price = attributes[:unit_price]
202
255
  @unit = attributes[:unit]
203
256
  @tax_type = attributes[:tax_type] || :taxable
257
+ validate!
204
258
  end
205
259
 
206
260
  # Returns the DType code: '', 'TZ' or 'TN'.
@@ -208,6 +262,19 @@ module Cetustek
208
262
  def d_type
209
263
  DTYPE_MAP.fetch(@tax_type) { @tax_type.to_s }
210
264
  end
265
+
266
+ private
267
+
268
+ def validate!
269
+ missing = REQUIRED.select { |name| blank?(public_send(name)) }
270
+ return if missing.empty?
271
+
272
+ raise ArgumentError, "item #{missing.join(', ')} required (品名代號、品名、數量、單價皆必填)"
273
+ end
274
+
275
+ def blank?(value)
276
+ value.nil? || value.to_s.strip.empty?
277
+ end
211
278
  end
212
279
  end
213
280
  end
@@ -3,21 +3,8 @@
3
3
  require 'ox'
4
4
 
5
5
  module Cetustek
6
- # Read-only SOAP queries. Each returns the raw Savon response, mirroring
7
- # QueryInvoiceByOrderId. Spec AVM-26-03 §2.4 / §2.6 / §2.11.
8
- module Queries
9
- def soap_client
10
- Savon.client(wsdl: Cetustek.config.url, open_timeout: 300, read_timeout: 300)
11
- end
12
-
13
- def source
14
- Cetustek.config.site_id + Cetustek.config.password
15
- end
16
-
17
- def rentid
18
- Cetustek.config.username
19
- end
20
- end
6
+ # @deprecated use Cetustek::Soap. Kept so `extend Queries` keeps working.
7
+ Queries = Soap
21
8
 
22
9
  # 2.4 QueryInvoice 查詢發票資訊 (by invoice number + year)
23
10
  class QueryInvoice
@@ -68,14 +55,16 @@ module Cetustek
68
55
 
69
56
  # Same query, with the returned XML parsed into a Hash of snake_case keys
70
57
  # plus a :details array. Values are the raw strings from the XML; returns
71
- # nil when the allowance number is unknown.
58
+ # nil when the platform answers with nothing at all.
72
59
  def self.find(allowance_number)
73
- parse(query(allowance_number).body[:query_allowance_response][:return])
60
+ parse(soap_return(query(allowance_number), :query_allowance))
74
61
  end
75
62
 
76
63
  def self.parse(xml)
77
64
  body = xml.to_s.strip
78
- return nil if body.empty? || body == 'nodata'
65
+ return nil if body.empty?
66
+ # §2.11 只描述成功時的 XML;非 XML 的回覆是代碼字串,原樣拋給呼叫端。
67
+ ResultCode.raise!(body, ResultCode::COMMON) unless body.start_with?('<')
79
68
 
80
69
  root = Ox.parse(body)
81
70
  root = root.root if root.is_a?(Ox::Document)
@@ -1,19 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Cetustek
4
+ # 2.5 QueryInvoicebyOrderid 以訂單編號查詢發票資訊
2
5
  class QueryInvoiceByOrderId
3
- def self.query(order_id)
4
- url = Cetustek.config.url
5
- client = Savon.client(
6
- wsdl: url,
7
- open_timeout: 300,
8
- read_timeout: 300
9
- )
6
+ extend Soap
10
7
 
11
- @response = client.call(:query_invoice_by_orderid, message:
12
- {
13
- orderid: order_id,
14
- source: Cetustek.config.site_id + Cetustek.config.password,
15
- rentid: Cetustek.config.username
16
- })
8
+ def self.query(order_id)
9
+ soap_call(:query_invoice_by_orderid, orderid: order_id)
17
10
  end
18
11
  end
19
12
  end
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ox'
4
- require 'cgi'
5
-
6
3
  module Cetustek
7
4
  module Services
8
5
  class InvoiceXmlBuilder
@@ -11,78 +8,47 @@ module Cetustek
11
8
  end
12
9
 
13
10
  def build
14
- doc = Ox::Document.new
15
- doc << create_xml_instruct
16
- doc << create_invoice_element
17
-
18
- Ox.dump(doc).force_encoding('UTF-8')
11
+ Xml.document('Invoice') do |invoice|
12
+ add_basic_info(invoice)
13
+ add_buyer_info(invoice)
14
+ add_invoice_type_info(invoice)
15
+ add_details(invoice)
16
+ end
19
17
  end
20
18
 
21
19
  private
22
20
 
23
- def create_xml_instruct
24
- instruct = Ox::Instruct.new(:xml)
25
- instruct[:version] = '1.0'
26
- instruct[:encoding] = 'UTF-8'
27
- instruct
28
- end
29
-
30
- # Builds a raw XML element with the value HTML-escaped, so that special
31
- # characters (&, <, >, ", ') in any dynamic field cannot break the XML
32
- # or be used for injection.
33
- def raw_tag(name, value)
34
- Ox::Raw.new("<#{name}>#{CGI.escapeHTML(value.to_s)}</#{name}>")
35
- end
36
-
37
- def create_invoice_element
38
- invoice = Ox::Element.new('Invoice')
39
- invoice[:XSDVersion] = '2.8'
40
-
41
- add_basic_info(invoice)
42
- add_buyer_info(invoice)
43
- add_invoice_type_info(invoice)
44
- add_details(invoice)
45
-
46
- invoice
47
- end
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
-
55
21
  def add_basic_info(invoice)
56
- invoice << raw_tag('OrderId', @data.order_id)
57
- invoice << raw_tag('OrderDate', @data.order_date.strftime('%Y/%m/%d'))
22
+ Xml.append(invoice, 'OrderId', @data.order_id)
23
+ Xml.append(invoice, 'OrderDate', @data.order_date.strftime('%Y/%m/%d'))
58
24
  end
59
25
 
60
26
  def add_buyer_info(invoice)
61
- invoice << raw_tag('BuyerIdentifier', @data.buyer_identifier)
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)
67
- invoice << raw_tag('BuyerEmailAddress', @data.buyer_email)
68
- invoice << raw_tag('BuyerCustomerNumber', @data.buyer_customer_number)
27
+ Xml.append(invoice, 'BuyerIdentifier', @data.buyer_identifier)
28
+ Xml.append(invoice, 'BuyerName', @data.buyer_name)
29
+ Xml.append(invoice, 'BuyerAddress', @data.buyer_address)
30
+ Xml.append(invoice, 'BuyerPersonInCharge', @data.buyer_person_in_charge)
31
+ Xml.append(invoice, 'BuyerTelephoneNumber', @data.buyer_telephone)
32
+ Xml.append(invoice, 'BuyerFacsimileNumber', @data.buyer_facsimile)
33
+ Xml.append(invoice, 'BuyerEmailAddress', @data.buyer_email)
34
+ Xml.append(invoice, 'BuyerCustomerNumber', @data.buyer_customer_number)
69
35
  end
70
36
 
71
37
  def add_invoice_type_info(invoice)
72
- invoice << raw_tag('DonateMark', @data.donate_mark)
73
- invoice << raw_tag('InvoiceType', @data.invoice_type)
74
- invoice << raw_tag('CarrierType', @data.carrier_type)
75
- invoice << raw_tag('CarrierId1', @data.carrier_id1)
76
- invoice << raw_tag('CarrierId2', @data.carrier_id2)
77
- invoice << raw_tag('NPOBAN', @data.npo_ban)
78
- invoice << raw_tag('TaxType', @data.tax_type)
79
- invoice << raw_tag('TaxRate', @data.tax_rate)
80
- optional_tag(invoice, 'ZeroReason', @data.zero_reason)
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)
38
+ Xml.append(invoice, 'DonateMark', @data.donate_mark)
39
+ Xml.append(invoice, 'InvoiceType', @data.invoice_type)
40
+ Xml.append(invoice, 'CarrierType', @data.carrier_type)
41
+ Xml.append(invoice, 'CarrierId1', @data.carrier_id1)
42
+ Xml.append(invoice, 'CarrierId2', @data.carrier_id2)
43
+ Xml.append(invoice, 'NPOBAN', @data.npo_ban)
44
+ Xml.append(invoice, 'TaxType', @data.tax_type)
45
+ Xml.append(invoice, 'TaxRate', @data.tax_rate)
46
+ Xml.append(invoice, 'ZeroReason', @data.zero_reason, skip_nil: true)
47
+ Xml.append(invoice, 'PayWay', @data.payment_type)
48
+ Xml.append(invoice, 'Remark', @data.remark)
49
+ Xml.append(invoice, 'MailSend', @data.mail_send, skip_nil: true)
50
+ Xml.append(invoice, 'RoundNum', @data.round_num, skip_nil: true)
51
+ Xml.append(invoice, 'RtnMsg', @data.rtn_msg, skip_nil: true)
86
52
  end
87
53
 
88
54
  def add_details(invoice)
@@ -96,22 +62,16 @@ module Cetustek
96
62
 
97
63
  def create_product_item(item)
98
64
  product = Ox::Element.new('ProductItem')
99
- product << raw_tag('ProductionCode', item.code)
100
- product << raw_tag('Description', item.name)
101
- product << raw_tag('Quantity', item.quantity)
102
- product << raw_tag('Unit', item.unit)
103
- product << raw_tag('UnitPrice', item.unit_price)
104
- add_dtype(product, item.d_type)
65
+ Xml.append(product, 'ProductionCode', item.code)
66
+ Xml.append(product, 'Description', item.name)
67
+ Xml.append(product, 'Quantity', item.quantity)
68
+ Xml.append(product, 'Unit', item.unit)
69
+ Xml.append(product, 'UnitPrice', item.unit_price)
70
+ # DType (稅別註記) is required on every detail line only for mixed-tax
71
+ # invoices (TaxType == 9).
72
+ Xml.append(product, 'DType', item.d_type) if @data.mixed_tax?
105
73
  product
106
74
  end
107
-
108
- # DType (稅別註記) is required on every detail line only for mixed-tax
109
- # invoices (TaxType == 9).
110
- def add_dtype(product, value)
111
- return unless @data.mixed_tax?
112
-
113
- product << raw_tag('DType', value)
114
- end
115
75
  end
116
76
  end
117
77
  end
@@ -8,25 +8,14 @@ module Cetustek
8
8
  # the Table 8 JSON object (RtnMsg=Json), the 15-character
9
9
  # "發票號碼;隨機碼" string, or a bare Table 7 result code.
10
10
  class ResponseHandler
11
- # Kept as a ResultError subclass so pre-0.7 rescues keep working.
12
- # @deprecated rescue Cetustek::ResultError instead.
13
- class InvalidResponseError < ResultError; end
11
+ # @deprecated rescue Cetustek::ResultError instead. Kept as an alias of
12
+ # the class actually raised so pre-0.7 rescues keep matching.
13
+ InvalidResponseError = ResultError
14
14
 
15
15
  SUCCESS_LENGTH = 15 # 發票號碼 10 碼 + ';' + 隨機碼 4 碼
16
16
 
17
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 筆',
18
+ RESULT_MESSAGES = ResultCode::COMMON.merge(ResultCode::DETAILS).merge(
30
19
  'S1' => '資料庫發生錯誤',
31
20
  'S2' => '訂單日期超過開立日期',
32
21
  'S3' => '未在申報期內',
@@ -34,11 +23,10 @@ module Cetustek
34
23
  'S5' => '發票號碼已使用完畢',
35
24
  'S6' => '超過租賃張數限制',
36
25
  'S7' => '訂單號碼已存在,若需重開請先作廢原發票號碼',
37
- 'S8' => '開立的總金額為負值',
38
- 'Invalid' => '無效 IP,請通知系統商'
39
- }.freeze
26
+ 'S8' => '開立的總金額為負值'
27
+ ).freeze
40
28
 
41
- def initialize(response, invoice_data = nil, xml = nil)
29
+ def initialize(response, invoice_data, xml = nil)
42
30
  @response = response
43
31
  @invoice_data = invoice_data
44
32
  @xml = xml
@@ -97,7 +85,7 @@ module Cetustek
97
85
  def fail_with(code)
98
86
  logger&.error("CreateInvoiceV3 #{order_id} #{code}")
99
87
  logger&.debug(@xml) if @xml
100
- raise InvalidResponseError.new(code, ResultCode.describe(code, RESULT_MESSAGES))
88
+ ResultCode.raise!(code, RESULT_MESSAGES)
101
89
  end
102
90
 
103
91
  def logger
@@ -105,7 +93,7 @@ module Cetustek
105
93
  end
106
94
 
107
95
  def order_id
108
- @invoice_data&.order_id
96
+ @invoice_data.order_id
109
97
  end
110
98
  end
111
99
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'savon'
4
+
5
+ module Cetustek
6
+ # Shared SOAP plumbing: every operation hits the same WSDL and authenticates
7
+ # with 網站代碼+APIPassword (source) and the 租賃者統編 (rentid).
8
+ module Soap
9
+ def soap_client
10
+ Savon.client(wsdl: Cetustek.config.url, open_timeout: 300, read_timeout: 300)
11
+ end
12
+
13
+ def source
14
+ Cetustek.config.site_id + Cetustek.config.password
15
+ end
16
+
17
+ def rentid
18
+ Cetustek.config.username
19
+ end
20
+
21
+ def soap_call(operation, message)
22
+ soap_client.call(operation, message: message.merge(source: source, rentid: rentid))
23
+ end
24
+
25
+ def soap_return(response, operation)
26
+ response.body[:"#{operation}_response"][:return]
27
+ end
28
+ end
29
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cetustek
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ox'
4
+ require 'cgi'
5
+
6
+ module Cetustek
7
+ # Every request document in the spec is the same shape: a UTF-8 declaration
8
+ # and a single <Invoice>/<Allowance> root carrying XSDVersion.
9
+ module Xml
10
+ XSD_VERSION = '2.8'
11
+
12
+ module_function
13
+
14
+ def document(root_name)
15
+ doc = Ox::Document.new
16
+ instruct = Ox::Instruct.new(:xml)
17
+ instruct[:version] = '1.0'
18
+ instruct[:encoding] = 'UTF-8'
19
+ doc << instruct
20
+
21
+ root = Ox::Element.new(root_name)
22
+ root[:XSDVersion] = XSD_VERSION
23
+ doc << root
24
+ yield root
25
+
26
+ Ox.dump(doc).force_encoding('UTF-8')
27
+ end
28
+
29
+ # Values are HTML-escaped so that special characters (&, <, >, ", ') in any
30
+ # dynamic field cannot break the XML or be used for injection.
31
+ def tag(name, value)
32
+ Ox::Raw.new("<#{name}>#{CGI.escapeHTML(value.to_s)}</#{name}>")
33
+ end
34
+
35
+ # Fields whose 備註 says "若未填,預設 X" are left out entirely when nil, so
36
+ # the platform applies its own default instead of parsing an empty value.
37
+ def append(element, name, value, skip_nil: false)
38
+ return if skip_nil && value.nil?
39
+
40
+ element << tag(name, value)
41
+ end
42
+ end
43
+ end
data/lib/cetustek.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  require_relative "cetustek/version"
4
4
  require_relative "cetustek/errors"
5
5
  require_relative "cetustek/configuration"
6
+ require_relative "cetustek/xml"
7
+ require_relative "cetustek/soap"
6
8
  require_relative "cetustek/models/allowance_data"
7
9
  require_relative "cetustek/create_invoice"
8
10
  require_relative "cetustek/cancel_invoice"
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.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zac
@@ -90,10 +90,11 @@ files:
90
90
  - lib/cetustek/phone_barcode.rb
91
91
  - lib/cetustek/queries.rb
92
92
  - lib/cetustek/query_invoice_by_order_id.rb
93
- - lib/cetustek/services/invoice_service.rb
94
93
  - lib/cetustek/services/invoice_xml_builder.rb
95
94
  - lib/cetustek/services/response_handler.rb
95
+ - lib/cetustek/soap.rb
96
96
  - lib/cetustek/version.rb
97
+ - lib/cetustek/xml.rb
97
98
  - sig/cetustek.rbs
98
99
  homepage: https://github.com/7a6163/cetustek
99
100
  licenses:
@@ -1,28 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'savon'
4
-
5
- module Cetustek
6
- module Services
7
- # 2.1 CreateInvoiceV3(invoicexml, hastax, rentid, source)
8
- class InvoiceService
9
- def initialize(xml, hastax = 1)
10
- @xml = xml
11
- @hastax = hastax
12
- end
13
-
14
- def create
15
- Savon.client(
16
- wsdl: Cetustek.config.url,
17
- open_timeout: 300,
18
- read_timeout: 300
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
- })
25
- end
26
- end
27
- end
28
- end