cetustek 0.5.0 → 0.7.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: 2914b87b1c342a5309f39629998f26cf081179b02c064177f44628c0d1853329
4
- data.tar.gz: e65afdb10fcfa7c358f7eaa7f953169f0265b6f69e56b624b75e5fe4ea1fc61e
3
+ metadata.gz: edd228bea5d7cd37feceb6be6f790272b6bd66c8638b70518ea13d8a23454dcb
4
+ data.tar.gz: a365620162f118e193b9ddaa1caf50e335e43bac777831cfcff5c2b7322aff3e
5
5
  SHA512:
6
- metadata.gz: 5fe69cd1d22f538861b5dde8124133db53f618b059fc6cb5f7ed6cbe85c0f894c6c4aefb7eb1fda410d883a98f4f910f049a5e9aa0687b5108f7a407f16b209c
7
- data.tar.gz: 0b64ceeeda241d5f7780e8b7ced545f2309657f3dd3dfcce7b7d631658ed6b1144b2481cd510920ed08ce863970e448c65d9ec485950a41c6ea57eddbeff5f14
6
+ metadata.gz: 7849c00fa5e89b28fb010dc4e71061b464a06aa9ea95d8213d135c5dcf031a80fe693fa79912c96bcef6d6f7c2e9a2dc0db9e34be516cc50ab6b62f433221b1f
7
+ data.tar.gz: 94041a6cc510de760b50799e7b8aa8d23159da26e93a1b0312c3f2be28decd51b925504ce416a9ac48ea8c04dc412e463437dcc69a7dcff020c50b9fbc54a482
data/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ 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.7.0] - 2026-08-04
9
+
10
+ ### Added
11
+ - `Cetustek::QueryAllowance.find(allowance_number)` — 查詢折讓資料 (§2.11) with the
12
+ returned XML parsed into a Hash (snake_case keys + `:details` array); `nil` when the
13
+ allowance number is unknown. `.query` still returns the raw Savon response
14
+ - `Cetustek::ResultError` (with `#code`) plus the full 折讓 result-code tables from
15
+ spec Table 17/19, including the suffixed forms (`M:欄位`, `D2_3`)
16
+
17
+ ### Changed
18
+ - **Breaking:** `CreateAllowance#execute` / `CancelAllowance#execute` now raise
19
+ `Cetustek::ResultError` on any code other than `A0` / `C0` instead of returning it
20
+ (e.g. `A2` 折讓金額大於原發票, `A7` 折讓日期早於發票日, `C2` 已申報無法作廢)
21
+ - **Breaking:** `AllowanceData` validates `tax_type`; only `1` 應稅 / `2` 零稅率 /
22
+ `3` 免稅 are valid on a 折讓單 (spec Table 15), the invoice-only `4`/`5`/`9` now
23
+ raise `ArgumentError`
24
+ - **Breaking:** `CreateAllowance.new(data, check_allowance: 1)` now raises
25
+ `ArgumentError`. The spec struck out the unconfirmed-allowance flag — 114/01/01 起
26
+ 上傳的折讓單皆為已確認,只能送 `0`
27
+
28
+ ## [0.6.0] - 2026-06-30
29
+
30
+ ### Added
31
+ - `Cetustek::DonateMark` constants for 捐贈註記 (spec AVM-26-03 Table 1):
32
+ `CARRIER` (0), `DONATE` (1), `PAPER` (2). Convenience only — `donate_mark`
33
+ still accepts any raw value.
34
+
8
35
  ## [0.5.0] - 2026-06-30
9
36
 
10
37
  ### Added
data/README.md CHANGED
@@ -134,6 +134,11 @@ Cetustek::Models::InvoiceData.new(
134
134
  | | | | `QUAN_PAY` | `X` |
135
135
  | | | | `COIN_CARD` | `Z` |
136
136
 
137
+ ### Donation mark (`donate_mark` / DonateMark)
138
+
139
+ `donate_mark` accepts any raw code, or use `Cetustek::DonateMark`:
140
+ `CARRIER` (0, 載具), `DONATE` (1, 捐贈), `PAPER` (2, 紙本).
141
+
137
142
  ### Discounts and fees
138
143
 
139
144
  The gem is a faithful wrapper of the API's invoice detail format, so it has no
@@ -186,7 +191,30 @@ allowance = Cetustek::Models::AllowanceData.new(
186
191
  )
187
192
  Cetustek::CreateAllowance.new(allowance).execute # => "A0" on success
188
193
  Cetustek::CancelAllowance.new('AA20240216000001', '明細錯誤').execute # => "C0" on success
189
- Cetustek::QueryAllowance.query('AA20240216000001')
194
+ Cetustek::QueryAllowance.find('AA20240216000001') # parsed Hash, nil if unknown
195
+ Cetustek::QueryAllowance.query('AA20240216000001') # raw Savon response
196
+ ```
197
+
198
+ `tax_type` on an allowance only accepts `1` 應稅, `2` 零稅率 or `3` 免稅 — the
199
+ invoice-only values (`4`, `5`, `9`) raise `ArgumentError`. `unit_price` is
200
+ **tax-inclusive** (there is no `hastax` on allowances).
201
+
202
+ Any other result code raises `Cetustek::ResultError`, whose `#code` is the raw
203
+ code and whose message includes the documented reason (e.g. `A2 - 所有折讓金額加總
204
+ 不能大於原發票金額`, `C2 - 折讓單已申報,無法作廢`).
205
+
206
+ `QueryAllowance.find` returns the Table 20 fields as snake_case symbols with the
207
+ line items under `:details`, values kept as the raw strings from the XML:
208
+
209
+ ```ruby
210
+ { allowance_number: 'AA20240216000001', allowance_date: '2024/02/16',
211
+ invoice_number: 'AA10000000', invoice_date: '2024/02/14',
212
+ buyer_identifier: '12345678', buyer_name: '測試公司', buyer_address: nil,
213
+ reason: '退回', allowance_status: '開立', back_status: '已確認',
214
+ sale_amount: '95', tax_amount: '5',
215
+ details: [{ sequence_number: '1', product_code: '0001', description: '禮券',
216
+ quantity: '1', unit: '本', unit_price: '95', amount: '95',
217
+ tax: '5', tax_type: '1' }] }
190
218
  ```
191
219
 
192
220
  ### Mobile barcode validation (手機條碼)
@@ -4,8 +4,22 @@ require 'ox'
4
4
  require 'cgi'
5
5
 
6
6
  module Cetustek
7
- # 2.10 CancelAllowance 作廢折讓單. Returns the result code (C0 = success).
7
+ # 2.10 CancelAllowance 作廢折讓單. Returns "C0" on success, raises ResultError otherwise.
8
8
  class CancelAllowance
9
+ SUCCESS_CODE = 'C0'
10
+
11
+ # Spec AVM-26-03 Table 19.
12
+ RESULT_MESSAGES = {
13
+ 'M:' => '欄位未填或格式錯誤',
14
+ 'M1' => 'XML 格式錯誤',
15
+ 'C1' => '上傳失敗',
16
+ 'C2' => '折讓單已申報,無法作廢',
17
+ 'C3' => '折讓單號不存在或須為已確認後的折讓單',
18
+ 'C4' => '該作廢折讓單已經上傳',
19
+ 'C5' => '折讓單已過作廢期限,無法作廢',
20
+ 'Invalid' => '無效 IP,請通知系統商'
21
+ }.freeze
22
+
9
23
  def initialize(allowance_number, reason)
10
24
  @allowance_number = allowance_number
11
25
  @reason = reason
@@ -13,7 +27,8 @@ module Cetustek
13
27
 
14
28
  def execute
15
29
  perform
16
- @response.body[:cancel_allowance_response][:return]
30
+ ResultCode.check!(@response.body[:cancel_allowance_response][:return],
31
+ RESULT_MESSAGES, success: SUCCESS_CODE)
17
32
  end
18
33
 
19
34
  private
@@ -4,17 +4,45 @@ require 'ox'
4
4
  require 'cgi'
5
5
 
6
6
  module Cetustek
7
- # 2.9 CreateAllowance 開立折讓單. Returns the result code (A0 = success).
8
- # check_allowance: 0 = confirmed allowance (default), 1 = unconfirmed.
7
+ # 2.9 CreateAllowance 開立折讓單. Returns "A0" on success, raises ResultError otherwise.
9
8
  class CreateAllowance
9
+ SUCCESS_CODE = 'A0'
10
+
11
+ # 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 筆',
23
+ 'A1' => '上傳失敗',
24
+ 'A2' => '所有折讓金額加總不能大於原發票金額',
25
+ 'A3' => '發票號碼不存在',
26
+ 'A4' => '發票號碼已經被作廢',
27
+ 'A5' => '折讓單已經上傳',
28
+ 'A6' => '折讓總金額須大於零',
29
+ 'A7' => '折讓日期應大於原發票開立日期',
30
+ 'Invalid' => '無效 IP,請通知系統商'
31
+ }.freeze
32
+
33
+ # check_allowance 只剩 0(已確認的折讓單):規格 §2.9 已刪除 1,
34
+ # 114/01/01 起上傳的折讓單皆為已確認。
10
35
  def initialize(allowance_data, check_allowance: 0)
36
+ raise ArgumentError, 'check_allowance must be 0 (114/01/01 起折讓單皆為已確認)' unless check_allowance.to_i.zero?
37
+
11
38
  @data = allowance_data
12
- @check_allowance = check_allowance
39
+ @check_allowance = 0
13
40
  end
14
41
 
15
42
  def execute
16
43
  perform
17
- @response.body[:create_allowance_response][:return]
44
+ ResultCode.check!(@response.body[:create_allowance_response][:return],
45
+ RESULT_MESSAGES, success: SUCCESS_CODE)
18
46
  end
19
47
 
20
48
  private
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cetustek
4
+ class Error < StandardError; end
5
+
6
+ # Raised when an API call returns a result code other than the success code.
7
+ class ResultError < Error
8
+ attr_reader :code
9
+
10
+ def initialize(code, description = nil)
11
+ @code = code
12
+ super([code, description].compact.join(' - '))
13
+ end
14
+ end
15
+
16
+ module ResultCode
17
+ # Codes may carry a suffix naming the offending field or detail row
18
+ # ("M:AllowanceDate", "D2_3"), so an exact miss falls back to the table key
19
+ # without the suffix.
20
+ def self.describe(code, messages)
21
+ messages[code] || messages[code.to_s.sub(/(?<=[:_]).*\z/, '')]
22
+ end
23
+
24
+ # Returns the code on success, otherwise raises ResultError.
25
+ def self.check!(code, messages, success:)
26
+ return code if code == success
27
+
28
+ raise ResultError.new(code, describe(code, messages))
29
+ end
30
+ end
31
+ end
@@ -7,6 +7,10 @@ module Cetustek
7
7
  # Request data for CreateAllowance (開立折讓單), spec AVM-26-03 Table 15/16.
8
8
  # Line items reuse InvoiceItem (code/name/quantity/unit/unit_price).
9
9
  class AllowanceData
10
+ # 折讓單稅別只有應稅/零稅率/免稅,發票的 4 (特種稅率)、5 (經海關出口)
11
+ # 與 9 (混合) 在折讓單無效。
12
+ TAX_TYPES = [TaxType::TAXABLE, TaxType::ZERO_RATE, TaxType::TAX_FREE].freeze
13
+
10
14
  attr_reader :allowance_number, :allowance_date, :invoice_number,
11
15
  :invoice_year, :buyer_address, :buyer_email, :tax_type,
12
16
  :reason, :round_num, :items
@@ -19,6 +23,10 @@ module Cetustek
19
23
  @buyer_address = attributes[:buyer_address]
20
24
  @buyer_email = attributes[:buyer_email]
21
25
  @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
+
22
30
  @reason = attributes[:reason]
23
31
  @round_num = attributes[:round_num] # optional 金額計算位數
24
32
  @items = attributes[:items] || []
@@ -35,6 +35,14 @@ module Cetustek
35
35
  COIN_CARD = 'Z' # 銀角零卡
36
36
  end
37
37
 
38
+ # DonateMark (捐贈註記) codes for CreateInvoiceV3, spec AVM-26-03 Table 1.
39
+ # Convenience constants only — InvoiceData#donate_mark still accepts any raw value.
40
+ module DonateMark
41
+ CARRIER = 0 # 載具
42
+ DONATE = 1 # 捐贈
43
+ PAPER = 2 # 紙本
44
+ end
45
+
38
46
  module Models
39
47
  class InvoiceData
40
48
  DEFAULT_TAX_RATE = 0.05
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'ox'
4
+
3
5
  module Cetustek
4
6
  # Read-only SOAP queries. Each returns the raw Savon response, mirroring
5
7
  # QueryInvoiceByOrderId. Spec AVM-26-03 §2.4 / §2.6 / §2.11.
@@ -48,6 +50,14 @@ module Cetustek
48
50
  class QueryAllowance
49
51
  extend Queries
50
52
 
53
+ # Spec AVM-26-03 Table 20. Note the query response uses ProductCode and
54
+ # InvoiceDate, unlike CreateAllowance's ProductionCode and InvoiceYear.
55
+ FIELDS = %w[AllowanceNumber AllowanceDate InvoiceNumber InvoiceDate
56
+ BuyerIdentifier BuyerName BuyerAddress Reason AllowanceStatus
57
+ BackStatus SaleAmount TaxAmount].freeze
58
+ DETAIL_FIELDS = %w[SequenceNumber ProductCode Description Quantity Unit
59
+ UnitPrice Amount Tax TaxType].freeze
60
+
51
61
  def self.query(allowance_number)
52
62
  soap_client.call(:query_allowance, message: {
53
63
  allowancenumber: allowance_number,
@@ -55,5 +65,36 @@ module Cetustek
55
65
  rentid: rentid
56
66
  })
57
67
  end
68
+
69
+ # Same query, with the returned XML parsed into a Hash of snake_case keys
70
+ # plus a :details array. Values are the raw strings from the XML; returns
71
+ # nil when the allowance number is unknown.
72
+ def self.find(allowance_number)
73
+ parse(query(allowance_number).body[:query_allowance_response][:return])
74
+ end
75
+
76
+ def self.parse(xml)
77
+ body = xml.to_s.strip
78
+ return nil if body.empty? || body == 'nodata'
79
+
80
+ root = Ox.parse(body)
81
+ root = root.root if root.is_a?(Ox::Document)
82
+
83
+ data = FIELDS.to_h { |field| [snake_case(field), text_of(root, field)] }
84
+ data[:details] = root.locate('Details/ProductItem').map do |item|
85
+ DETAIL_FIELDS.to_h { |field| [snake_case(field), text_of(item, field)] }
86
+ end
87
+ data
88
+ end
89
+
90
+ def self.text_of(element, name)
91
+ element.locate(name).first&.text
92
+ end
93
+
94
+ def self.snake_case(name)
95
+ name.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.to_sym
96
+ end
97
+
98
+ private_class_method :text_of, :snake_case
58
99
  end
59
100
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cetustek
4
- VERSION = "0.5.0"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/cetustek.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "cetustek/version"
4
+ require_relative "cetustek/errors"
4
5
  require_relative "cetustek/configuration"
5
6
  require_relative "cetustek/models/allowance_data"
6
7
  require_relative "cetustek/create_invoice"
@@ -12,8 +13,6 @@ require_relative "cetustek/cancel_allowance"
12
13
  require_relative "cetustek/phone_barcode"
13
14
 
14
15
  module Cetustek
15
- class Error < StandardError; end
16
-
17
16
  class << self
18
17
  def configure
19
18
  yield(config)
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.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zac
@@ -84,6 +84,7 @@ files:
84
84
  - lib/cetustek/configuration.rb
85
85
  - lib/cetustek/create_allowance.rb
86
86
  - lib/cetustek/create_invoice.rb
87
+ - lib/cetustek/errors.rb
87
88
  - lib/cetustek/models/allowance_data.rb
88
89
  - lib/cetustek/models/invoice_data.rb
89
90
  - lib/cetustek/phone_barcode.rb
@@ -115,7 +116,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
115
116
  - !ruby/object:Gem::Version
116
117
  version: '0'
117
118
  requirements: []
118
- rubygems_version: 4.0.10
119
+ rubygems_version: 4.0.16
119
120
  specification_version: 4
120
121
  summary: A Ruby gem for handling electronic invoice operations
121
122
  test_files: []