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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: edd228bea5d7cd37feceb6be6f790272b6bd66c8638b70518ea13d8a23454dcb
4
- data.tar.gz: a365620162f118e193b9ddaa1caf50e335e43bac777831cfcff5c2b7322aff3e
3
+ metadata.gz: 887153f21f24293aa3ef9f6e4ee7ad48ec803c65f7104605a4d615894a5be19a
4
+ data.tar.gz: 14667650b0e2bf27c060df01cef3bb220d73bdb4ab6deb8bf5c1bf29bd9ba050
5
5
  SHA512:
6
- metadata.gz: 7849c00fa5e89b28fb010dc4e71061b464a06aa9ea95d8213d135c5dcf031a80fe693fa79912c96bcef6d6f7c2e9a2dc0db9e34be516cc50ab6b62f433221b1f
7
- data.tar.gz: 94041a6cc510de760b50799e7b8aa8d23159da26e93a1b0312c3f2be28decd51b925504ce416a9ac48ea8c04dc412e463437dcc69a7dcff020c50b9fbc54a482
6
+ metadata.gz: 508a2c1acb9f548116e963c7ce6b536a8e5aec3bc3d18189a56b52403a0d32f485191d5af2d6c5a3eef4a30a1f041a0eb94fe5ba8f73e38c7b30f06022f85a55
7
+ data.tar.gz: e2235999236cd67ee01462050391d1de6bfb4968eea812b7c9b4c7fc96b0559334e0800d5701d485d410d30f10d8486cd68bc217e04e1a9fb80169cc3e227977
data/CHANGELOG.md CHANGED
@@ -5,6 +5,77 @@ 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
+
47
+ ## [0.8.0] - 2026-08-04
48
+
49
+ ### Added
50
+ - `Cetustek.config.logger`(預設 `nil`,即這個 gem 不寫任何東西)。只記訂單編號、
51
+ 發票號碼與結果代碼,失敗時才以 debug 記下請求 XML
52
+ - 開立發票補上規格 Table 1/2 缺漏的欄位:明細的 `Unit`,主檔的 `Remark`、`ZeroReason`、
53
+ `RoundNum`、`MailSend`、`RtnMsg`,以及 `BuyerAddress`/`BuyerPersonInCharge`/
54
+ `BuyerTelephoneNumber`/`BuyerFacsimileNumber`/`BuyerCustomerNumber`
55
+ - `InvoiceData` 依規格驗證必填與條件欄位(`order_id`/`order_date`/`items`/
56
+ `donate_mark`/`payment_type` 必填;`donate_mark=0` 需 email 與載具三欄;
57
+ `donate_mark=1` 需 3-7 碼 `npo_ban`;`tax_type=4` 需明確 `tax_rate` 且
58
+ `invoice_type` 為 `08`),違反時建構就丟 `ArgumentError`
59
+ - `carrier_id1` 成為顯碼的正式名稱(`carrier_id` 保留為別名),`carrier_id2` 未填時
60
+ 自動鏡射顯碼 —— 手機條碼與自然人憑證沒有顯隱碼之分
61
+ - `CancelInvoice` 支援 `remark:`(作廢原因,預設 `'退貨'`)與
62
+ `return_tax_document_number:`(專案作廢核准文號,超過申報期間作廢時需要)
63
+
64
+ ### Changed
65
+ - **Breaking:** 開立發票預設帶 `<RtnMsg>Json</RtnMsg>`,`CreateInvoice#execute` 除了
66
+ 既有的 `:number`/`:random_number` 另外回傳 `:date`/`:time` 與各項金額。發票日期改以
67
+ API 回傳為準,不再用本機 `Time.zone.today` 推測(`Intertemporal` 回開會讓本機日期錯誤)。
68
+ 傳 `rtn_msg: nil` 可退回舊的 15 碼字串模式
69
+ - **Breaking:** 開立與作廢發票失敗一律丟 `Cetustek::ResultError`,帶原始代碼與 Table 7 /
70
+ Table 10 的中文說明。`ResponseHandler::InvalidResponseError` 降為 `ResultError` 的子類
71
+ (deprecated,既有 rescue 仍可運作);`CancelInvoice` 不再靜默回傳 nil
72
+ - **Breaking:** `CancelInvoice.new(invoice_number, invoice_year, remark:)` 改收明確參數,
73
+ 不再接受 invoice 物件,也不再回寫 `canceled: true`
74
+ - **Breaking:** 移除 `CreateInvoice` 的 `invoice_info` 回寫與兩處 `Rails.root/log/*` 寫檔
75
+ (原本會把含買受人 email 的整包回應寫進固定路徑);`InvoiceService.new(xml, hastax)`
76
+ 不再接受 `order_id`
77
+ - 成功判斷改用規格寫的 15 碼規則(`發票號碼;隨機碼`),而非「字串含分號」
78
+
8
79
  ## [0.7.0] - 2026-08-04
9
80
 
10
81
  ### Added
data/README.md CHANGED
@@ -4,16 +4,18 @@
4
4
  [![RSpec Tests](https://github.com/7a6163/cetustek/actions/workflows/rspec.yml/badge.svg)](https://github.com/7a6163/cetustek/actions/workflows/rspec.yml)
5
5
  [![codecov](https://codecov.io/gh/7a6163/cetustek/graph/badge.svg?token=N951Y9SE15)](https://codecov.io/gh/7a6163/cetustek)
6
6
 
7
- Cetustek is a Ruby gem designed for handling electronic invoice operations, including invoice cancellation. It communicates with the e-invoice system through SOAP Web Services.
7
+ Cetustek is a Ruby wrapper for the 鯨躍 Cetustek e-invoice API (虛擬多通路,
8
+ spec AVM-26-03), covering 電子發票 and 折讓單 over SOAP Web Services.
8
9
 
9
10
  ## Features
10
11
 
11
- - Electronic invoice cancellation
12
- - XML format generation
13
- - SOAP Web Services integration
14
- - Environment-specific configuration (sandbox/production)
15
- - Service-oriented architecture
16
- - Robust error handling
12
+ - 開立發票 (CreateInvoiceV3) with 載具/捐贈/紙本, 零稅率, 混合稅率 and 特種稅額 support
13
+ - 作廢發票 (CancelInvoice), including the 專案作廢核准文號 for late cancellations
14
+ - 折讓單: 開立 (CreateAllowance), 作廢 (CancelAllowance), 查詢 (QueryAllowance)
15
+ - Queries by invoice number or order id, plus 手機條碼 validation
16
+ - Validation of the rules the spec fixes in print, before anything is sent
17
+ - Result codes raised as `Cetustek::ResultError` with the documented reason
18
+ - Environment-specific configuration (sandbox/production) and opt-in logging
17
19
 
18
20
  ## Installation
19
21
 
@@ -43,6 +45,11 @@ Cetustek.configure do |config|
43
45
  config.site_id = ENV['CETUSTEK_SITE_ID']
44
46
  config.username = ENV['CETUSTEK_USERNAME']
45
47
  config.password = ENV['CETUSTEK_PASSWORD']
48
+
49
+ # Optional. Defaults to nil, i.e. this gem writes nothing anywhere.
50
+ # Only the order id, invoice number and result code are logged (never the
51
+ # response body); the request XML is logged at debug level on failure.
52
+ config.logger = Rails.logger
46
53
  end
47
54
  ```
48
55
 
@@ -58,22 +65,67 @@ invoice_data = Cetustek::Models::InvoiceData.new(
58
65
  buyer_identifier: invoice.receipt,
59
66
  buyer_name: invoice.name,
60
67
  buyer_email: invoice.email,
61
- donate_mark: 0,
62
- payment_type: 2,
68
+ donate_mark: Cetustek::DonateMark::CARRIER,
69
+ carrier_type: Cetustek::CarrierType::MOBILE_BARCODE, # 3J0002
70
+ carrier_id: invoice.barcode, # 手機條碼無顯隱碼之分,CarrierId2 自動填相同值
71
+ payment_type: Cetustek::PayWay::ATM,
63
72
  items: invoice.items.map { |item|
64
73
  Cetustek::Models::InvoiceItem.new(
65
74
  code: item.sku,
66
75
  name: item.name,
67
76
  quantity: item.quantity,
77
+ unit: item.unit,
68
78
  unit_price: item.price
69
79
  )
70
80
  }
71
81
  )
72
82
 
73
83
  result = Cetustek::CreateInvoice.new(invoice_data).execute
74
- # => { number: "GT68514542", random_number: "9654" }
84
+ # => { number: "WB02100001", random_number: "3690",
85
+ # date: "2026/02/10", time: "11:19:57",
86
+ # sale_amount: 666, zero_amount: 0, free_amount: 0,
87
+ # tax_amount: 0, total_amount: 666, carrier_url: "..." }
75
88
  ```
76
89
 
90
+ Always use the returned `:date`/`:time` as the invoice date. The platform's
91
+ `Intertemporal` default issues invoices dated in the previous filing period on
92
+ the 1st–2nd of a month, so the local date can be wrong.
93
+ Persisting the result is the caller's job — this gem writes to no database.
94
+
95
+ `InvoiceData.new` raises `ArgumentError` for the rules the spec fixes in print,
96
+ so a guaranteed rejection never leaves your process:
97
+
98
+ - `order_id`, `order_date` (a `Date`/`Time`), `items`, `donate_mark` and `payment_type` are required
99
+ - every item needs `code`, `name`, `quantity` and `unit_price` (`unit` is optional)
100
+ - `donate_mark: 0` (載具) requires `buyer_email`, `carrier_id1` and `carrier_id2`
101
+ - `donate_mark: 1` (捐贈) requires a 3–7 digit `npo_ban`
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.
121
+
122
+ Anything needing an external lookup (是否為有效手機條碼、捐贈碼是否存在) is left
123
+ to the caller — see `Cetustek::PhoneBarcode` below.
124
+
125
+ Any result code other than a successful issue raises `Cetustek::ResultError`,
126
+ whose `#code` is the raw Table 7 code and whose message carries the documented
127
+ reason (`S7 - 訂單號碼已存在,若需重開請先作廢原發票號碼`, `D3_2 - 單價未填或格式錯誤`, …).
128
+
77
129
  ### Tax types (稅別)
78
130
 
79
131
  `InvoiceData` defaults to taxable (`TaxType` 1) with a tax rate of `0.05` and a
@@ -86,7 +138,7 @@ general invoice type of `07`. Use `Cetustek::TaxType` to switch modes:
86
138
  | `TAX_FREE` | 3 | 免稅 |
87
139
  | `SPECIAL` | 4 | 應稅(特種稅率) — set `tax_rate`, use `invoice_type: '08'` |
88
140
  | `ZERO_RATE_CUSTOMS` | 5 | 零稅率(經海關出口) |
89
- | `MIXED` | 9 | 混合(應稅/零稅率/免稅,限收銀機類型發票) |
141
+ | `MIXED` | 9 | 混合(應稅/零稅率/免稅) |
90
142
 
91
143
  #### Zero-rate invoice (零稅率)
92
144
 
@@ -149,14 +201,22 @@ items — use a negative `unit_price` for a discount:
149
201
  Cetustek::Models::InvoiceItem.new(code: 'DISCOUNT', name: '折抵', quantity: 1, unit_price: -30)
150
202
  ```
151
203
 
152
- ### Cancel an Invoice
204
+ ### Cancel an Invoice (作廢發票確認)
153
205
 
154
206
  ```ruby
155
- # `invoice` responds to #number and #created_at; on success (return code "C0")
156
- # it is updated with canceled: true.
157
- Cetustek::CancelInvoice.new(invoice).execute
207
+ # remark Table 9 的必填作廢原因,最多 20 字,沒有預設值
208
+ Cetustek::CancelInvoice.new('AB12345678', 2024, remark: '退貨').execute # => "C0"
209
+
210
+ # 超過申報期間才需要專案作廢核准文號 (否則會收到 C3)
211
+ Cetustek::CancelInvoice.new('AB12345678', 2024, remark: '明細錯誤',
212
+ return_tax_document_number: '65327645').execute
158
213
  ```
159
214
 
215
+ Uploading is not the end of it: the cancellation still has to be confirmed
216
+ manually on the 鯨躍 platform before the invoice counts as void. Any code other
217
+ than `"C0"` raises `Cetustek::ResultError` (`C5 - 該發票已經作廢過`, …), and
218
+ marking your own record as canceled is the caller's job.
219
+
160
220
  ### Query invoices
161
221
 
162
222
  ```ruby
@@ -175,6 +235,23 @@ purchase). Set it on `InvoiceData`:
175
235
  Cetustek::Models::InvoiceData.new(hastax: 0, items: [...])
176
236
  ```
177
237
 
238
+ ### Other Table 1 fields
239
+
240
+ | Attribute | Tag | Notes |
241
+ |-----------|-----|-------|
242
+ | `buyer_address` / `buyer_person_in_charge` / `buyer_telephone` / `buyer_facsimile` / `buyer_customer_number` | `BuyerAddress` / `BuyerPersonInCharge` / `BuyerTelephoneNumber` / `BuyerFacsimileNumber` / `BuyerCustomerNumber` | 選填,常用於 B2B |
243
+ | `remark` | `Remark` | 備註,200 字 |
244
+ | `zero_reason` | `ZeroReason` | 零稅率原因;未填時平台預設 `72`(TaxType 2)或 `71`(TaxType 5) |
245
+ | `round_num` | `RoundNum` | 金額計算位數,未填預設 4 |
246
+ | `mail_send` | `MailSend` | `0`(預設)由加值中心寄送通知,`1` 自行處理;限 `donate_mark: 0` |
247
+ | `rtn_msg` | `RtnMsg` | 預設 `'Json'`;傳 `nil` 退回只回傳 15 碼字串的舊模式 |
248
+
249
+ Fields with a platform-side default (`ZeroReason`, `RoundNum`, `MailSend`,
250
+ `RtnMsg`) are omitted from the XML entirely when `nil`, so the platform applies
251
+ its own default. `Intertemporal`(發票回開)is deliberately not exposed: it changes
252
+ which filing period's 字軌 the invoice is issued under, and the platform default
253
+ is the right behaviour.
254
+
178
255
  ### Allowances (折讓單)
179
256
 
180
257
  ```ruby
@@ -191,13 +268,19 @@ allowance = Cetustek::Models::AllowanceData.new(
191
268
  )
192
269
  Cetustek::CreateAllowance.new(allowance).execute # => "A0" on success
193
270
  Cetustek::CancelAllowance.new('AA20240216000001', '明細錯誤').execute # => "C0" on success
194
- Cetustek::QueryAllowance.find('AA20240216000001') # parsed Hash, nil if unknown
271
+ Cetustek::QueryAllowance.find('AA20240216000001') # parsed Hash
195
272
  Cetustek::QueryAllowance.query('AA20240216000001') # raw Savon response
196
273
  ```
197
274
 
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).
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`.
201
284
 
202
285
  Any other result code raises `Cetustek::ResultError`, whose `#code` is the raw
203
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,54 +1,51 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Cetustek
4
+ # 2.2 CancelInvoice 作廢發票確認. Returns "C0" on success, raises ResultError
5
+ # otherwise. Uploading is not the end of it — the cancellation still has to be
6
+ # confirmed manually on the 鯨躍 platform before the invoice counts as void.
2
7
  class CancelInvoice
3
- def initialize(invoice)
4
- @invoice = invoice
8
+ include Soap
9
+
10
+ SUCCESS_CODE = 'C0'
11
+ MAX_REMARK_LENGTH = 20
12
+
13
+ # Spec AVM-26-03 Table 10.
14
+ RESULT_MESSAGES = ResultCode::COMMON.merge(
15
+ 'C1' => '資料庫發生錯誤',
16
+ 'C2' => '資料有誤',
17
+ 'C3' => '該發票已過申報期間,需填入核准作廢文號 (return_tax_document_number)',
18
+ 'C4' => '無此發票號碼可以作廢',
19
+ 'C5' => '該發票已經作廢過',
20
+ 'C6' => '作廢資訊已經傳送過'
21
+ ).freeze
22
+
23
+ # remark 是 Table 9 的必填作廢原因,沒有預設值可用:理由是業務決定的。
24
+ def initialize(invoice_number, invoice_year, remark:, return_tax_document_number: nil)
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
27
+
28
+ @invoice_number = invoice_number
29
+ @invoice_year = invoice_year
30
+ @remark = remark
31
+ @return_tax_document_number = return_tax_document_number
5
32
  end
6
33
 
7
34
  def execute
8
- generate_xml
9
- perform
10
- analize_response
35
+ response = soap_call(:cancel_invoice, invoicexml: generate_xml)
36
+ ResultCode.check!(soap_return(response, :cancel_invoice), RESULT_MESSAGES, success: SUCCESS_CODE)
11
37
  end
12
38
 
13
39
  private
14
40
 
15
- def perform
16
- url = Cetustek.config.url
17
- client = Savon.client(
18
- wsdl: url,
19
- open_timeout: 300,
20
- read_timeout: 300
21
- )
22
-
23
- @response = client.call(:cancel_invoice, message:
24
- { invoicexml: @xml,
25
- source: Cetustek.config.site_id + Cetustek.config.password,
26
- rentid: Cetustek.config.username })
27
- end
28
-
29
41
  def generate_xml
30
- doc = Ox::Document.new
31
-
32
- instruct = Ox::Instruct.new(:xml)
33
- instruct[:version] = '1.0'
34
- instruct[:encoding] = 'UTF-8'
35
- instruct[:standalone] = 'yes'
36
- doc << instruct
37
-
38
- invoice = Ox::Element.new('Invoice')
39
- invoice[:XSDVersion] = '2.8'
40
- doc << invoice
41
-
42
- invoice << Ox::Raw.new("<InvoiceNumber>#{@invoice.number}</InvoiceNumber>")
43
- invoice << Ox::Raw.new("<InvoiceYear>#{@invoice.created_at.year}</InvoiceYear>")
44
- invoice << Ox::Raw.new('<Remark>退貨</Remark>')
45
- @xml = Ox.dump(doc).force_encoding('UTF-8')
46
- end
47
-
48
- def analize_response
49
- return unless @response.body[:cancel_invoice_response][:return] == 'C0'
50
-
51
- @invoice.update(canceled: true)
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
52
49
  end
53
50
  end
54
51
  end
@@ -2,7 +2,8 @@
2
2
 
3
3
  module Cetustek
4
4
  class Configuration
5
- attr_accessor :environment, :site_id, :username, :password
5
+ # logger is opt-in: nil means this gem writes nothing anywhere.
6
+ attr_accessor :environment, :site_id, :username, :password, :logger
6
7
 
7
8
  def initialize
8
9
  @environment = :sandbox
@@ -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,37 +2,23 @@
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
8
+ # 2.1 CreateInvoiceV3 開立發票. Returns the issued invoice on success and
9
+ # raises ResultError otherwise. Persisting the result is the caller's job.
9
10
  class CreateInvoice
11
+ include Soap
12
+
10
13
  def initialize(invoice_data)
11
14
  @invoice_data = invoice_data
12
15
  end
13
16
 
14
17
  def execute
15
18
  xml = Services::InvoiceXmlBuilder.new(@invoice_data).build
16
- response = Services::InvoiceService.new(xml, @invoice_data.order_id, @invoice_data.hastax).create
17
- result = Services::ResponseHandler.new(response, @invoice_data, xml).process
18
-
19
- if defined?(Rails) && result[:number] && result[:random_number]
20
- update_invoice_info(result)
21
- end
22
-
23
- result
24
- end
25
-
26
- private
27
-
28
- def update_invoice_info(result)
29
- return unless @invoice_data.respond_to?(:invoice_info)
30
-
31
- @invoice_data.invoice_info.update(
32
- number: result[:number],
33
- random_number: result[:random_number],
34
- created_at: Time.zone.today
35
- )
19
+ # hastax: 0 = 明細單價未稅, 1 = 含稅; comes from the order, not a constant.
20
+ response = soap_call(:create_invoice_v3, invoicexml: xml, hastax: @invoice_data.hastax)
21
+ Services::ResponseHandler.new(response, @invoice_data, xml).process
36
22
  end
37
23
  end
38
24
  end