cetustek 0.7.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.
@@ -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.
@@ -44,14 +56,32 @@ module Cetustek
44
56
  end
45
57
 
46
58
  module Models
59
+ # Request data for CreateInvoiceV3 (開立發票), spec AVM-26-03 Table 1/2.
60
+ # Rules the spec fixes in print are enforced here; anything needing an
61
+ # external lookup (捐贈碼、手機條碼是否存在) is left to the caller.
47
62
  class InvoiceData
48
63
  DEFAULT_TAX_RATE = 0.05
49
- DEFAULT_INVOICE_TYPE = '07' # 07: 一般稅額, 08: 特種稅額
64
+ DEFAULT_INVOICE_TYPE = '07' # 一般稅額電子發票
65
+ SPECIAL_INVOICE_TYPE = '08' # 特種稅額電子發票
66
+ # Json 回傳才拿得到平台配發的發票日期時間;Intertemporal 回開會讓本機日期失準。
67
+ DEFAULT_RTN_MSG = 'Json'
68
+ MAX_ITEMS = 9999
69
+ MAX_REMARK_LENGTH = 200
70
+ ROUND_NUMS = (0..7).freeze
71
+ TAX_TYPES = [TaxType::TAXABLE, TaxType::ZERO_RATE, TaxType::TAX_FREE,
72
+ TaxType::SPECIAL, TaxType::ZERO_RATE_CUSTOMS, TaxType::MIXED].freeze
73
+ ZERO_RATE_TAX_TYPES = [TaxType::ZERO_RATE, TaxType::ZERO_RATE_CUSTOMS].freeze
74
+ DONATE_MARKS = [DonateMark::CARRIER, DonateMark::DONATE, DonateMark::PAPER].freeze
50
75
 
51
76
  attr_reader :order_id, :order_date, :buyer_identifier, :buyer_name,
52
- :buyer_email, :donate_mark, :carrier_type, :carrier_id,
53
- :carrier_id2, :npo_ban, :items, :payment_type,
54
- :tax_type, :tax_rate, :invoice_type, :hastax
77
+ :buyer_email, :buyer_address, :buyer_person_in_charge,
78
+ :buyer_telephone, :buyer_facsimile, :buyer_customer_number,
79
+ :donate_mark, :carrier_type, :carrier_id1, :carrier_id2,
80
+ :npo_ban, :items, :payment_type, :tax_type, :tax_rate,
81
+ :zero_reason, :invoice_type, :hastax, :remark, :round_num,
82
+ :mail_send, :rtn_msg
83
+
84
+ alias carrier_id carrier_id1
55
85
 
56
86
  def initialize(attributes = {})
57
87
  @order_id = attributes[:order_id]
@@ -59,27 +89,153 @@ module Cetustek
59
89
  @buyer_identifier = attributes[:buyer_identifier]
60
90
  @buyer_name = attributes[:buyer_name]
61
91
  @buyer_email = attributes[:buyer_email]
92
+ @buyer_address = attributes[:buyer_address]
93
+ @buyer_person_in_charge = attributes[:buyer_person_in_charge]
94
+ @buyer_telephone = attributes[:buyer_telephone]
95
+ @buyer_facsimile = attributes[:buyer_facsimile]
96
+ @buyer_customer_number = attributes[:buyer_customer_number]
62
97
  @donate_mark = attributes[:donate_mark]
63
98
  @carrier_type = attributes[:carrier_type]
64
- @carrier_id = attributes[:carrier_id]
65
- @carrier_id2 = attributes[:carrier_id2]
99
+ @carrier_id1 = attributes[:carrier_id1] || attributes[:carrier_id]
100
+ @carrier_id2 = attributes[:carrier_id2] || mirrored_carrier_id2
66
101
  @npo_ban = attributes[:npo_ban]
67
102
  @items = attributes[:items] || []
68
103
  @payment_type = attributes[:payment_type]
69
104
  @tax_type = attributes[:tax_type] || TaxType::TAXABLE
70
- @tax_rate = attributes.fetch(:tax_rate, DEFAULT_TAX_RATE)
105
+ # 特種稅率是營業性質決定的,不能沿用 5% 預設值。
106
+ @tax_rate = attributes.fetch(:tax_rate) { special_tax? ? nil : DEFAULT_TAX_RATE }
107
+ @zero_reason = attributes[:zero_reason]
71
108
  @invoice_type = attributes[:invoice_type] || DEFAULT_INVOICE_TYPE
72
109
  # hastax: 0 = item prices are tax-exclusive, 1 = tax-inclusive.
73
110
  # Comes from the order (e.g. tax-free purchases), not a fixed value.
74
111
  @hastax = attributes.fetch(:hastax, 1)
112
+ @remark = attributes[:remark]
113
+ @round_num = attributes[:round_num]
114
+ @mail_send = attributes[:mail_send]
115
+ @rtn_msg = attributes.fetch(:rtn_msg, DEFAULT_RTN_MSG)
116
+ validate!
75
117
  end
76
118
 
77
- # 混合稅率發票 (限收銀機):每筆明細需標註 DType。
119
+ # 混合稅率發票:每筆明細需標註 DType。
78
120
  def mixed_tax?
79
121
  @tax_type.to_i == TaxType::MIXED
80
122
  end
123
+
124
+ # 特種稅額發票:TaxRate 必填,InvoiceType 必須為 08。
125
+ def special_tax?
126
+ @tax_type.to_i == TaxType::SPECIAL
127
+ end
128
+
129
+ private
130
+
131
+ # 只有無顯碼隱碼區分的載具能自動補 CarrierId2;會員載具兩碼不同,猜了就是送錯。
132
+ def mirrored_carrier_id2
133
+ @carrier_id1 if CarrierType::WITHOUT_HIDDEN_CODE.include?(@carrier_type.to_s)
134
+ end
135
+
136
+ def validate!
137
+ raise ArgumentError, 'order_id is required' if blank?(@order_id)
138
+
139
+ validate_order_date!
140
+ validate_items!
141
+ validate_donate_mark!
142
+ validate_pay_way!
143
+ validate_tax!
144
+ validate_remark!
145
+ validate_round_num!
146
+ end
147
+
148
+ def validate_order_date!
149
+ raise ArgumentError, 'order_date is required' if @order_date.nil?
150
+ return if @order_date.respond_to?(:strftime)
151
+
152
+ raise ArgumentError, "order_date must be a Date or Time, got #{@order_date.class}"
153
+ end
154
+
155
+ def validate_items!
156
+ raise ArgumentError, 'items must not be empty (沒有產品明細)' if @items.empty?
157
+ raise ArgumentError, "items must not exceed #{MAX_ITEMS} lines" if @items.size > MAX_ITEMS
158
+ end
159
+
160
+ def validate_donate_mark!
161
+ raise ArgumentError, 'donate_mark is required (0 載具, 1 捐贈, 2 紙本)' if blank?(@donate_mark)
162
+
163
+ unless DONATE_MARKS.include?(@donate_mark.to_i)
164
+ raise ArgumentError, "donate_mark must be 0 (載具), 1 (捐贈) or 2 (紙本), got #{@donate_mark.inspect}"
165
+ end
166
+
167
+ case @donate_mark.to_i
168
+ when DonateMark::CARRIER then validate_carrier!
169
+ when DonateMark::DONATE then validate_npo_ban!
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 (載具)'
175
+ end
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.
180
+ def validate_carrier!
181
+ missing = { buyer_email: @buyer_email, carrier_id1: @carrier_id1,
182
+ carrier_id2: @carrier_id2 }.select { |_name, value| blank?(value) }.keys
183
+ return if missing.empty?
184
+
185
+ raise ArgumentError, "#{missing.join(', ')} required when donate_mark is 0 (載具)" \
186
+ "#{'; 此載具有顯碼與隱碼之分,請分別填入' if missing == [:carrier_id2]}"
187
+ end
188
+
189
+ def validate_npo_ban!
190
+ return if @npo_ban.to_s.match?(/\A\d{3,7}\z/)
191
+
192
+ raise ArgumentError, "npo_ban must be a 3-7 digit 捐贈碼 when donate_mark is 1 (捐贈), got #{@npo_ban.inspect}"
193
+ end
194
+
195
+ def validate_pay_way!
196
+ raise ArgumentError, 'payment_type is required (see Cetustek::PayWay)' if blank?(@payment_type)
197
+ end
198
+
199
+ def validate_tax!
200
+ unless TAX_TYPES.include?(@tax_type.to_i)
201
+ raise ArgumentError, "tax_type must be one of #{TAX_TYPES.join(', ')}, got #{@tax_type.inspect}"
202
+ end
203
+
204
+ validate_zero_reason!
205
+ return unless special_tax?
206
+
207
+ raise ArgumentError, 'tax_rate is required when tax_type is 4 (特種稅率)' if blank?(@tax_rate)
208
+ return if @invoice_type.to_s == SPECIAL_INVOICE_TYPE
209
+
210
+ raise ArgumentError, "invoice_type must be '08' (特種稅額) when tax_type is 4, got #{@invoice_type.inspect}"
211
+ end
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
+
232
+ def blank?(value)
233
+ value.nil? || value.to_s.strip.empty?
234
+ end
81
235
  end
82
236
 
237
+ # A single 明細 row, spec AVM-26-03 Table 2 (發票) / Table 16 (折讓).
238
+ # 品名代號、品名、數量、單價 are 必填; 單位 is not.
83
239
  class InvoiceItem
84
240
  # Per-item 稅別註記 (DType) used for mixed-tax invoices (TaxType == 9).
85
241
  DTYPE_MAP = {
@@ -87,6 +243,7 @@ module Cetustek
87
243
  zero_rate: 'TZ', # 零稅率商品
88
244
  tax_free: 'TN' # 免稅商品
89
245
  }.freeze
246
+ REQUIRED = %i[code name quantity unit_price].freeze
90
247
 
91
248
  attr_reader :code, :name, :quantity, :unit_price, :tax_type, :unit
92
249
 
@@ -97,6 +254,7 @@ module Cetustek
97
254
  @unit_price = attributes[:unit_price]
98
255
  @unit = attributes[:unit]
99
256
  @tax_type = attributes[:tax_type] || :taxable
257
+ validate!
100
258
  end
101
259
 
102
260
  # Returns the DType code: '', 'TZ' or 'TN'.
@@ -104,6 +262,19 @@ module Cetustek
104
262
  def d_type
105
263
  DTYPE_MAP.fetch(@tax_type) { @tax_type.to_s }
106
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
107
278
  end
108
279
  end
109
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,62 +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
21
  def add_basic_info(invoice)
50
- invoice << raw_tag('OrderId', @data.order_id)
51
- 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'))
52
24
  end
53
25
 
54
26
  def add_buyer_info(invoice)
55
- invoice << raw_tag('BuyerIdentifier', @data.buyer_identifier)
56
- invoice << raw_tag('BuyerName', @data.buyer_name)
57
- invoice << raw_tag('BuyerEmailAddress', @data.buyer_email)
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)
58
35
  end
59
36
 
60
37
  def add_invoice_type_info(invoice)
61
- invoice << raw_tag('DonateMark', @data.donate_mark)
62
- invoice << raw_tag('InvoiceType', @data.invoice_type)
63
- invoice << raw_tag('CarrierType', @data.carrier_type)
64
- invoice << raw_tag('CarrierId1', @data.carrier_id)
65
- invoice << raw_tag('CarrierId2', @data.carrier_id2)
66
- invoice << raw_tag('NPOBAN', @data.npo_ban)
67
- invoice << raw_tag('TaxType', @data.tax_type)
68
- invoice << raw_tag('TaxRate', @data.tax_rate)
69
- invoice << raw_tag('PayWay', @data.payment_type)
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)
70
52
  end
71
53
 
72
54
  def add_details(invoice)
@@ -80,21 +62,16 @@ module Cetustek
80
62
 
81
63
  def create_product_item(item)
82
64
  product = Ox::Element.new('ProductItem')
83
- product << raw_tag('ProductionCode', item.code)
84
- product << raw_tag('Description', item.name)
85
- product << raw_tag('Quantity', item.quantity)
86
- product << raw_tag('UnitPrice', item.unit_price)
87
- 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?
88
73
  product
89
74
  end
90
-
91
- # DType (稅別註記) is required on every detail line only for mixed-tax
92
- # invoices (TaxType == 9).
93
- def add_dtype(product, value)
94
- return unless @data.mixed_tax?
95
-
96
- product << raw_tag('DType', value)
97
- end
98
75
  end
99
76
  end
100
77
  end
@@ -1,11 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'logger'
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
- class InvalidResponseError < StandardError; 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
+
15
+ SUCCESS_LENGTH = 15 # 發票號碼 10 碼 + ';' + 隨機碼 4 碼
16
+
17
+ # Spec AVM-26-03 Table 7.
18
+ RESULT_MESSAGES = ResultCode::COMMON.merge(ResultCode::DETAILS).merge(
19
+ 'S1' => '資料庫發生錯誤',
20
+ 'S2' => '訂單日期超過開立日期',
21
+ 'S3' => '未在申報期內',
22
+ 'S4' => '未取得發票號碼',
23
+ 'S5' => '發票號碼已使用完畢',
24
+ 'S6' => '超過租賃張數限制',
25
+ 'S7' => '訂單號碼已存在,若需重開請先作廢原發票號碼',
26
+ 'S8' => '開立的總金額為負值'
27
+ ).freeze
9
28
 
10
29
  def initialize(response, invoice_data, xml = nil)
11
30
  @response = response
@@ -14,24 +33,67 @@ module Cetustek
14
33
  end
15
34
 
16
35
  def process
17
- response_body = @response.body[:create_invoice_v3_response][:return]
18
- number, random_number = response_body.split(';')
36
+ body = @response.body[:create_invoice_v3_response][:return].to_s.strip
37
+ json = parse_json(body)
19
38
 
20
- unless random_number
21
- log_error
22
- raise InvalidResponseError, "Invalid response: #{response_body}"
23
- end
39
+ return success(json_result(json)) if json && json['msg'] == 'Success'
40
+ return success(string_result(body)) if json.nil? && success_string?(body)
24
41
 
25
- { number: number, random_number: random_number }
42
+ fail_with(json ? json['msg'].to_s : body)
26
43
  end
27
44
 
28
45
  private
29
46
 
30
- def log_error
31
- return unless defined?(Rails) && @xml
47
+ # M1 (XML 格式錯誤) 與 Invalid 不會回傳 JSON,所以形狀要靠內容判斷。
48
+ def parse_json(body)
49
+ return nil unless body.start_with?('{')
50
+
51
+ JSON.parse(body)
52
+ rescue JSON::ParserError
53
+ nil
54
+ end
55
+
56
+ def success_string?(body)
57
+ body.length == SUCCESS_LENGTH && body.include?(';')
58
+ end
59
+
60
+ def json_result(json)
61
+ {
62
+ number: json['invnumber'],
63
+ random_number: json['random'],
64
+ date: json['invdate'],
65
+ time: json['invtime'],
66
+ sale_amount: json['saleamt'],
67
+ zero_amount: json['zeroamt'],
68
+ free_amount: json['freeamt'],
69
+ tax_amount: json['taxamt'],
70
+ total_amount: json['totalamt'],
71
+ carrier_url: json['ctkurl']
72
+ }
73
+ end
74
+
75
+ def string_result(body)
76
+ number, random_number = body.split(';')
77
+ { number: number, random_number: random_number }
78
+ end
79
+
80
+ def success(result)
81
+ logger&.info("CreateInvoiceV3 #{order_id} #{result[:number]}")
82
+ result
83
+ end
84
+
85
+ def fail_with(code)
86
+ logger&.error("CreateInvoiceV3 #{order_id} #{code}")
87
+ logger&.debug(@xml) if @xml
88
+ ResultCode.raise!(code, RESULT_MESSAGES)
89
+ end
90
+
91
+ def logger
92
+ Cetustek.config.logger
93
+ end
32
94
 
33
- logger = Logger.new(Rails.root.join('log/invoice_xml.log'))
34
- logger.debug("#{@invoice_data.order_id} - #{@xml.force_encoding('UTF-8')}")
95
+ def order_id
96
+ @invoice_data.order_id
35
97
  end
36
98
  end
37
99
  end