cetustek 0.2.0 → 0.4.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: 722407201f6462aed26f91e61295dc87fcfdc970db2b55adf8aa73f4a4840f1a
4
- data.tar.gz: ab37a4849d55a493854202d343c2cf082fb118813eea9d491e09970209317066
3
+ metadata.gz: 5134abbcc8b7229469df2b15f6efa704828f745e499f2609e04251bca8ed90c0
4
+ data.tar.gz: 50312823ef3166581b9518d97955ce8f839a80ad25e3a1f155fd48ea102a919d
5
5
  SHA512:
6
- metadata.gz: db34fb0378f5fd87b4c42cec44306b69afaa09ceb9ade280993f1618ed487e0c92ea6e61f3645747277e4a1e548b736f7806ced8e659c4a84a0d1a98da3efbe8
7
- data.tar.gz: 5164f077cb9fff85c4879ea31e0f51710d03b6b621545c2b757deac19f3833e7f50802470577812ca95cfccb8742b271dc71c418625a8e9b5947f08e8c22bf2a
6
+ metadata.gz: b35ecc731affbcf77b48d858de62eae3e2e9dfe8f5bc328af30af2d6d9b27e51abe03de5633eedac8fc60106b9b50647b1c5b1a640c70e965b8a2267ff856685
7
+ data.tar.gz: 5192ff08c159704e14548beb39bfb132acb4bd929308ada3f882edbd18132fe1123997030792897102c77402e5a1aa7c11ea48a8015f967609b3121ee006a263
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper
data/CHANGELOG.md CHANGED
@@ -5,6 +5,46 @@ 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.4.0] - 2026-06-29
9
+
10
+ ### Added
11
+ - `Cetustek::QueryInvoice.query(invoice_number, invoice_year)` — 查詢發票資訊 (§2.4)
12
+ - `Cetustek::QueryInvoiceNumberByOrderId.query(order_id)` — 以訂單編號查發票號碼 (§2.6)
13
+ - `Cetustek::CreateAllowance.new(allowance_data, check_allowance:).execute` — 開立折讓單 (§2.9)
14
+ - `Cetustek::CancelAllowance.new(allowance_number, reason).execute` — 作廢折讓單 (§2.10)
15
+ - `Cetustek::QueryAllowance.query(allowance_number)` — 查詢折讓資料 (§2.11)
16
+ - `Cetustek::PhoneBarcode.valid?(phone_code)` — 手機條碼驗證 (§3.1, HTTP/JSON, not SOAP)
17
+ - `Cetustek::Models::AllowanceData` value object and an optional `unit` on `InvoiceItem`
18
+
19
+ ### Changed
20
+ - `hastax` is now taken from the order via `InvoiceData.new(hastax:)` (default `1`,
21
+ tax-inclusive) instead of being hardcoded — e.g. tax-exclusive/tax-free purchases
22
+ can send `0`
23
+ - All generated XML is forced to UTF-8 encoding, preventing `Encoding::CompatibilityError`
24
+ from Savon when invoices/allowances contain Chinese text
25
+
26
+ ## [0.3.0] - 2026-06-13
27
+
28
+ ### Added
29
+ - Configurable tax handling per spec V4.16: `TaxType`, `TaxRate`, and `InvoiceType`
30
+ on `InvoiceData`, with a `Cetustek::TaxType` constants module
31
+ - Zero-rate invoice support (`TaxType` 2/5)
32
+ - Mixed-tax invoice support (`TaxType` 9): per-item `tax_type` emits the required
33
+ `DType` (`TZ`/`TN`/blank) on every detail line
34
+ - `carrier_id2` attribute on `InvoiceData` (previously read by the XML builder but
35
+ never settable, which raised `NoMethodError` when issuing an invoice)
36
+ - Test suite for the XML builder and data models
37
+
38
+ ### Changed
39
+ - XML builder now HTML-escapes every interpolated field (previously only buyer name
40
+ and product name), preventing malformed XML / injection from special characters
41
+
42
+ ### Removed
43
+ - **Breaking:** application-specific auto line items and their `InvoiceData` fields
44
+ (`total_discount`, `coupon_discount`, `delivery_fee`, `handling_fee`). These were
45
+ not part of the Cetustek API. Model discounts/fees as ordinary `InvoiceItem`s
46
+ (use a negative `unit_price` for a discount)
47
+
8
48
  ## [0.2.0] - 2025-01-22
9
49
 
10
50
  ### Added
data/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # Cetustek
2
2
 
3
+ [![RSpec Tests](https://github.com/7a6163/cetustek/actions/workflows/rspec.yml/badge.svg)](https://github.com/7a6163/cetustek/actions/workflows/rspec.yml)
4
+ [![codecov](https://codecov.io/gh/7a6163/cetustek/graph/badge.svg?token=N951Y9SE15)](https://codecov.io/gh/7a6163/cetustek)
5
+
3
6
  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.
4
7
 
5
8
  ## Features
@@ -44,7 +47,7 @@ end
44
47
 
45
48
  ## Usage
46
49
 
47
- ### Cancel an Invoice
50
+ ### Issue an Invoice
48
51
 
49
52
  ```ruby
50
53
  invoice = YourInvoiceModel.find(invoice_id)
@@ -54,7 +57,9 @@ invoice_data = Cetustek::Models::InvoiceData.new(
54
57
  buyer_identifier: invoice.receipt,
55
58
  buyer_name: invoice.name,
56
59
  buyer_email: invoice.email,
57
- items: invoice.items.map { |item|
60
+ donate_mark: 0,
61
+ payment_type: 2,
62
+ items: invoice.items.map { |item|
58
63
  Cetustek::Models::InvoiceItem.new(
59
64
  code: item.sku,
60
65
  name: item.name,
@@ -65,6 +70,110 @@ invoice_data = Cetustek::Models::InvoiceData.new(
65
70
  )
66
71
 
67
72
  result = Cetustek::CreateInvoice.new(invoice_data).execute
73
+ # => { number: "GT68514542", random_number: "9654" }
74
+ ```
75
+
76
+ ### Tax types (稅別)
77
+
78
+ `InvoiceData` defaults to taxable (`TaxType` 1) with a tax rate of `0.05` and a
79
+ general invoice type of `07`. Use `Cetustek::TaxType` to switch modes:
80
+
81
+ | Constant | Code | Meaning |
82
+ |----------|------|---------|
83
+ | `TAXABLE` | 1 | 應稅 |
84
+ | `ZERO_RATE` | 2 | 零稅率(非經海關出口) |
85
+ | `TAX_FREE` | 3 | 免稅 |
86
+ | `SPECIAL` | 4 | 應稅(特種稅率) — set `tax_rate`, use `invoice_type: '08'` |
87
+ | `ZERO_RATE_CUSTOMS` | 5 | 零稅率(經海關出口) |
88
+ | `MIXED` | 9 | 混合(應稅/零稅率/免稅,限收銀機類型發票) |
89
+
90
+ #### Zero-rate invoice (零稅率)
91
+
92
+ ```ruby
93
+ Cetustek::Models::InvoiceData.new(
94
+ # ...buyer fields, items...
95
+ tax_type: Cetustek::TaxType::ZERO_RATE,
96
+ tax_rate: 0
97
+ )
98
+ ```
99
+
100
+ #### Mixed-tax invoice (混稅, cash-register invoices only)
101
+
102
+ For `TaxType` 9 each line item must declare its own tax category via `tax_type`.
103
+ Accepts the symbols `:taxable` (default), `:zero_rate`, `:tax_free`, or the raw
104
+ `DType` codes (`''`, `'TZ'`, `'TN'`):
105
+
106
+ ```ruby
107
+ Cetustek::Models::InvoiceData.new(
108
+ # ...buyer fields...
109
+ tax_type: Cetustek::TaxType::MIXED,
110
+ items: [
111
+ Cetustek::Models::InvoiceItem.new(code: 'A', name: '應稅品', quantity: 1, unit_price: 100),
112
+ Cetustek::Models::InvoiceItem.new(code: 'B', name: '零稅率品', quantity: 1, unit_price: 100, tax_type: :zero_rate),
113
+ Cetustek::Models::InvoiceItem.new(code: 'C', name: '免稅品', quantity: 1, unit_price: 100, tax_type: :tax_free)
114
+ ]
115
+ )
116
+ ```
117
+
118
+ ### Discounts and fees
119
+
120
+ The gem is a faithful wrapper of the API's invoice detail format, so it has no
121
+ built-in discount/coupon/delivery/handling concepts. Model them as ordinary line
122
+ items — use a negative `unit_price` for a discount:
123
+
124
+ ```ruby
125
+ Cetustek::Models::InvoiceItem.new(code: 'DISCOUNT', name: '折抵', quantity: 1, unit_price: -30)
126
+ ```
127
+
128
+ ### Cancel an Invoice
129
+
130
+ ```ruby
131
+ # `invoice` responds to #number and #created_at; on success (return code "C0")
132
+ # it is updated with canceled: true.
133
+ Cetustek::CancelInvoice.new(invoice).execute
134
+ ```
135
+
136
+ ### Query invoices
137
+
138
+ ```ruby
139
+ Cetustek::QueryInvoiceByOrderId.query(order_id) # by order id
140
+ Cetustek::QueryInvoice.query(invoice_number, invoice_year) # by invoice number + year
141
+ Cetustek::QueryInvoiceNumberByOrderId.query(order_id) # just the invoice number
142
+ ```
143
+
144
+ ### Tax-inclusive vs tax-exclusive prices (`hastax`)
145
+
146
+ `hastax` comes from the order, not a fixed value: `1` (default) means the item
147
+ `unit_price`s already include tax; `0` means they are tax-exclusive (e.g. a tax-free
148
+ purchase). Set it on `InvoiceData`:
149
+
150
+ ```ruby
151
+ Cetustek::Models::InvoiceData.new(hastax: 0, items: [...])
152
+ ```
153
+
154
+ ### Allowances (折讓單)
155
+
156
+ ```ruby
157
+ allowance = Cetustek::Models::AllowanceData.new(
158
+ allowance_number: 'AA20240216000001',
159
+ allowance_date: Time.zone.today,
160
+ invoice_number: 'AA10000000',
161
+ invoice_year: '2024',
162
+ tax_type: 1,
163
+ reason: '退回',
164
+ items: [
165
+ Cetustek::Models::InvoiceItem.new(code: '0001', name: '禮券', quantity: 1, unit: '本', unit_price: 800)
166
+ ]
167
+ )
168
+ Cetustek::CreateAllowance.new(allowance).execute # => "A0" on success
169
+ Cetustek::CancelAllowance.new('AA20240216000001', '明細錯誤').execute # => "C0" on success
170
+ Cetustek::QueryAllowance.query('AA20240216000001')
171
+ ```
172
+
173
+ ### Mobile barcode validation (手機條碼)
174
+
175
+ ```ruby
176
+ Cetustek::PhoneBarcode.valid?('/ABC123') # => true / false
68
177
  ```
69
178
 
70
179
  ## Development
@@ -72,10 +181,11 @@ result = Cetustek::CreateInvoice.new(invoice_data).execute
72
181
  1. Clone this repository
73
182
  2. Run `bin/setup` to install dependencies
74
183
  3. Run `bin/console` for an interactive prompt to experiment
184
+ 4. Run `bundle exec rspec` to run the test suite
75
185
 
76
186
  ## Requirements
77
187
 
78
- - Ruby >= 2.7.0
188
+ - Ruby >= 3.0.0
79
189
  - `ox` gem for XML processing
80
190
  - `savon` gem for SOAP services
81
191
 
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ox'
4
+ require 'cgi'
5
+
6
+ module Cetustek
7
+ # 2.10 CancelAllowance 作廢折讓單. Returns the result code (C0 = success).
8
+ class CancelAllowance
9
+ def initialize(allowance_number, reason)
10
+ @allowance_number = allowance_number
11
+ @reason = reason
12
+ end
13
+
14
+ def execute
15
+ perform
16
+ @response.body[:cancel_allowance_response][:return]
17
+ end
18
+
19
+ private
20
+
21
+ def perform
22
+ client = Savon.client(wsdl: Cetustek.config.url, open_timeout: 300, read_timeout: 300)
23
+ @response = client.call(:cancel_allowance, message: {
24
+ allowancexml: generate_xml,
25
+ source: Cetustek.config.site_id + Cetustek.config.password,
26
+ rentid: Cetustek.config.username
27
+ })
28
+ end
29
+
30
+ def generate_xml
31
+ doc = Ox::Document.new
32
+ instruct = Ox::Instruct.new(:xml)
33
+ instruct[:version] = '1.0'
34
+ instruct[:encoding] = 'UTF-8'
35
+ doc << instruct
36
+
37
+ allowance = Ox::Element.new('Allowance')
38
+ allowance[:XSDVersion] = '2.8'
39
+ doc << allowance
40
+
41
+ allowance << raw_tag('AllowanceNumber', @allowance_number)
42
+ allowance << raw_tag('Reason', @reason)
43
+
44
+ Ox.dump(doc).force_encoding('UTF-8')
45
+ end
46
+
47
+ def raw_tag(name, value)
48
+ Ox::Raw.new("<#{name}>#{CGI.escapeHTML(value.to_s)}</#{name}>")
49
+ end
50
+ end
51
+ end
@@ -42,7 +42,7 @@ module Cetustek
42
42
  invoice << Ox::Raw.new("<InvoiceNumber>#{@invoice.number}</InvoiceNumber>")
43
43
  invoice << Ox::Raw.new("<InvoiceYear>#{@invoice.created_at.year}</InvoiceYear>")
44
44
  invoice << Ox::Raw.new('<Remark>退貨</Remark>')
45
- @xml = Ox.dump(doc)
45
+ @xml = Ox.dump(doc).force_encoding('UTF-8')
46
46
  end
47
47
 
48
48
  def analize_response
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ox'
4
+ require 'cgi'
5
+
6
+ module Cetustek
7
+ # 2.9 CreateAllowance 開立折讓單. Returns the result code (A0 = success).
8
+ # check_allowance: 0 = confirmed allowance (default), 1 = unconfirmed.
9
+ class CreateAllowance
10
+ def initialize(allowance_data, check_allowance: 0)
11
+ @data = allowance_data
12
+ @check_allowance = check_allowance
13
+ end
14
+
15
+ def execute
16
+ perform
17
+ @response.body[:create_allowance_response][:return]
18
+ end
19
+
20
+ private
21
+
22
+ def perform
23
+ client = Savon.client(wsdl: Cetustek.config.url, open_timeout: 300, read_timeout: 300)
24
+ @response = client.call(:create_allowance, message: {
25
+ allowancexml: generate_xml,
26
+ checkallowance: @check_allowance,
27
+ source: Cetustek.config.site_id + Cetustek.config.password,
28
+ rentid: Cetustek.config.username
29
+ })
30
+ end
31
+
32
+ def generate_xml
33
+ doc = Ox::Document.new
34
+ instruct = Ox::Instruct.new(:xml)
35
+ instruct[:version] = '1.0'
36
+ instruct[:encoding] = 'UTF-8'
37
+ doc << instruct
38
+
39
+ allowance = Ox::Element.new('Allowance')
40
+ allowance[:XSDVersion] = '2.8'
41
+ doc << allowance
42
+
43
+ allowance << raw_tag('AllowanceNumber', @data.allowance_number)
44
+ allowance << raw_tag('AllowanceDate', @data.allowance_date.strftime('%Y/%m/%d'))
45
+ allowance << raw_tag('InvoiceNumber', @data.invoice_number)
46
+ allowance << raw_tag('InvoiceYear', @data.invoice_year)
47
+ allowance << raw_tag('BuyerAddress', @data.buyer_address)
48
+ allowance << raw_tag('BuyerEmailAddress', @data.buyer_email)
49
+ allowance << raw_tag('TaxType', @data.tax_type)
50
+ allowance << raw_tag('Reason', @data.reason)
51
+ allowance << raw_tag('RoundNum', @data.round_num) unless @data.round_num.nil?
52
+ allowance << build_details
53
+
54
+ Ox.dump(doc).force_encoding('UTF-8')
55
+ end
56
+
57
+ def build_details
58
+ details = Ox::Element.new('Details')
59
+ @data.items.each do |item|
60
+ product = Ox::Element.new('ProductItem')
61
+ product << raw_tag('ProductionCode', item.code)
62
+ product << raw_tag('Description', item.name)
63
+ product << raw_tag('Quantity', item.quantity)
64
+ product << raw_tag('Unit', item.unit)
65
+ product << raw_tag('UnitPrice', item.unit_price)
66
+ details << product
67
+ end
68
+ details
69
+ end
70
+
71
+ def raw_tag(name, value)
72
+ Ox::Raw.new("<#{name}>#{CGI.escapeHTML(value.to_s)}</#{name}>")
73
+ end
74
+ end
75
+ end
@@ -13,7 +13,7 @@ module Cetustek
13
13
 
14
14
  def execute
15
15
  xml = Services::InvoiceXmlBuilder.new(@invoice_data).build
16
- response = Services::InvoiceService.new(xml, @invoice_data.order_id).create
16
+ response = Services::InvoiceService.new(xml, @invoice_data.order_id, @invoice_data.hastax).create
17
17
  result = Services::ResponseHandler.new(response, @invoice_data, xml).process
18
18
 
19
19
  if defined?(Rails) && result[:number] && result[:random_number]
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'invoice_data'
4
+
5
+ module Cetustek
6
+ module Models
7
+ # Request data for CreateAllowance (開立折讓單), spec AVM-26-03 Table 15/16.
8
+ # Line items reuse InvoiceItem (code/name/quantity/unit/unit_price).
9
+ class AllowanceData
10
+ attr_reader :allowance_number, :allowance_date, :invoice_number,
11
+ :invoice_year, :buyer_address, :buyer_email, :tax_type,
12
+ :reason, :round_num, :items
13
+
14
+ def initialize(attributes = {})
15
+ @allowance_number = attributes[:allowance_number]
16
+ @allowance_date = attributes[:allowance_date]
17
+ @invoice_number = attributes[:invoice_number]
18
+ @invoice_year = attributes[:invoice_year]
19
+ @buyer_address = attributes[:buyer_address]
20
+ @buyer_email = attributes[:buyer_email]
21
+ @tax_type = attributes[:tax_type] || TaxType::TAXABLE
22
+ @reason = attributes[:reason]
23
+ @round_num = attributes[:round_num] # optional 金額計算位數
24
+ @items = attributes[:items] || []
25
+ end
26
+ end
27
+ end
28
+ end
@@ -1,12 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cetustek
4
+ # TaxType (稅別) codes accepted by CreateInvoiceV3 (spec V4.16, Table 1).
5
+ module TaxType
6
+ TAXABLE = 1 # 應稅
7
+ ZERO_RATE = 2 # 零稅率(非經海關出口)
8
+ TAX_FREE = 3 # 免稅
9
+ SPECIAL = 4 # 應稅(特種稅率) — requires TaxRate
10
+ ZERO_RATE_CUSTOMS = 5 # 零稅率(經海關出口)
11
+ MIXED = 9 # 混合(應稅、零稅率與免稅,限收銀機類型發票)
12
+ end
13
+
4
14
  module Models
5
15
  class InvoiceData
16
+ DEFAULT_TAX_RATE = 0.05
17
+ DEFAULT_INVOICE_TYPE = '07' # 07: 一般稅額, 08: 特種稅額
18
+
6
19
  attr_reader :order_id, :order_date, :buyer_identifier, :buyer_name,
7
- :buyer_email, :donate_mark, :carrier_type, :carrier_id,
8
- :npo_ban, :items, :payment_type, :total_discount,
9
- :coupon_discount, :delivery_fee, :handling_fee
20
+ :buyer_email, :donate_mark, :carrier_type, :carrier_id,
21
+ :carrier_id2, :npo_ban, :items, :payment_type,
22
+ :tax_type, :tax_rate, :invoice_type, :hastax
10
23
 
11
24
  def initialize(attributes = {})
12
25
  @order_id = attributes[:order_id]
@@ -17,24 +30,47 @@ module Cetustek
17
30
  @donate_mark = attributes[:donate_mark]
18
31
  @carrier_type = attributes[:carrier_type]
19
32
  @carrier_id = attributes[:carrier_id]
33
+ @carrier_id2 = attributes[:carrier_id2]
20
34
  @npo_ban = attributes[:npo_ban]
21
35
  @items = attributes[:items] || []
22
36
  @payment_type = attributes[:payment_type]
23
- @total_discount = attributes[:total_discount] || 0
24
- @coupon_discount = attributes[:coupon_discount] || 0
25
- @delivery_fee = attributes[:delivery_fee] || 0
26
- @handling_fee = attributes[:handling_fee] || 0
37
+ @tax_type = attributes[:tax_type] || TaxType::TAXABLE
38
+ @tax_rate = attributes.fetch(:tax_rate, DEFAULT_TAX_RATE)
39
+ @invoice_type = attributes[:invoice_type] || DEFAULT_INVOICE_TYPE
40
+ # hastax: 0 = item prices are tax-exclusive, 1 = tax-inclusive.
41
+ # Comes from the order (e.g. tax-free purchases), not a fixed value.
42
+ @hastax = attributes.fetch(:hastax, 1)
43
+ end
44
+
45
+ # 混合稅率發票 (限收銀機):每筆明細需標註 DType。
46
+ def mixed_tax?
47
+ @tax_type.to_i == TaxType::MIXED
27
48
  end
28
49
  end
29
50
 
30
51
  class InvoiceItem
31
- attr_reader :code, :name, :quantity, :unit_price
52
+ # Per-item 稅別註記 (DType) used for mixed-tax invoices (TaxType == 9).
53
+ DTYPE_MAP = {
54
+ taxable: '', # 應稅商品 -> 空白
55
+ zero_rate: 'TZ', # 零稅率商品
56
+ tax_free: 'TN' # 免稅商品
57
+ }.freeze
58
+
59
+ attr_reader :code, :name, :quantity, :unit_price, :tax_type, :unit
32
60
 
33
61
  def initialize(attributes = {})
34
62
  @code = attributes[:code]
35
63
  @name = attributes[:name]
36
64
  @quantity = attributes[:quantity]
37
65
  @unit_price = attributes[:unit_price]
66
+ @unit = attributes[:unit]
67
+ @tax_type = attributes[:tax_type] || :taxable
68
+ end
69
+
70
+ # Returns the DType code: '', 'TZ' or 'TN'.
71
+ # Accepts the friendly symbols above or a raw code string.
72
+ def d_type
73
+ DTYPE_MAP.fetch(@tax_type) { @tax_type.to_s }
38
74
  end
39
75
  end
40
76
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+
7
+ module Cetustek
8
+ # 3.1 手機條碼 API — validates a mobile barcode (手機條碼) against the
9
+ # 財政部 platform. Plain HTTP GET returning JSON, NOT the SOAP service.
10
+ class PhoneBarcode
11
+ ENDPOINT = 'https://api.cetustek.com.tw/PhoneBar.php'
12
+ AUTH_KEY = 'Cetus9Phone1API7' # spec: fixed for now, may become per-account
13
+
14
+ def self.valid?(phone_code)
15
+ new(phone_code).valid?
16
+ end
17
+
18
+ def initialize(phone_code)
19
+ @phone_code = phone_code
20
+ end
21
+
22
+ # true when the barcode exists (isExist == 'Y').
23
+ def valid?
24
+ response['isExist'] == 'Y'
25
+ end
26
+
27
+ # Full parsed JSON response: { "isExist", "code", "msg", "TxID", "version" }.
28
+ def response
29
+ uri = URI(ENDPOINT)
30
+ uri.query = URI.encode_www_form(
31
+ rentid: Cetustek.config.username,
32
+ authkey: AUTH_KEY,
33
+ phonecode: @phone_code
34
+ )
35
+ JSON.parse(Net::HTTP.get_response(uri).body)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cetustek
4
+ # Read-only SOAP queries. Each returns the raw Savon response, mirroring
5
+ # QueryInvoiceByOrderId. Spec AVM-26-03 §2.4 / §2.6 / §2.11.
6
+ module Queries
7
+ def soap_client
8
+ Savon.client(wsdl: Cetustek.config.url, open_timeout: 300, read_timeout: 300)
9
+ end
10
+
11
+ def source
12
+ Cetustek.config.site_id + Cetustek.config.password
13
+ end
14
+
15
+ def rentid
16
+ Cetustek.config.username
17
+ end
18
+ end
19
+
20
+ # 2.4 QueryInvoice 查詢發票資訊 (by invoice number + year)
21
+ class QueryInvoice
22
+ extend Queries
23
+
24
+ def self.query(invoice_number, invoice_year)
25
+ soap_client.call(:query_invoice, message: {
26
+ invoicenumber: invoice_number,
27
+ invoiceyear: invoice_year,
28
+ source: source,
29
+ rentid: rentid
30
+ })
31
+ end
32
+ end
33
+
34
+ # 2.6 QueryInvoiceNumberbyOrderid 以訂單編號查詢發票號碼
35
+ class QueryInvoiceNumberByOrderId
36
+ extend Queries
37
+
38
+ def self.query(order_id)
39
+ soap_client.call(:query_invoice_number_by_orderid, message: {
40
+ orderid: order_id,
41
+ source: source,
42
+ rentid: rentid
43
+ })
44
+ end
45
+ end
46
+
47
+ # 2.11 QueryAllowance 查詢折讓資料
48
+ class QueryAllowance
49
+ extend Queries
50
+
51
+ def self.query(allowance_number)
52
+ soap_client.call(:query_allowance, message: {
53
+ allowancenumber: allowance_number,
54
+ source: source,
55
+ rentid: rentid
56
+ })
57
+ end
58
+ end
59
+ end
@@ -6,9 +6,10 @@ require 'logger'
6
6
  module Cetustek
7
7
  module Services
8
8
  class InvoiceService
9
- def initialize(xml, order_id = nil)
9
+ def initialize(xml, order_id = nil, hastax = 1)
10
10
  @xml = xml
11
11
  @order_id = order_id
12
+ @hastax = hastax
12
13
  end
13
14
 
14
15
  def create
@@ -33,7 +34,7 @@ module Cetustek
33
34
  invoicexml: @xml,
34
35
  source: Cetustek.config.site_id + Cetustek.config.password,
35
36
  rentid: Cetustek.config.username,
36
- hastax: 1
37
+ hastax: @hastax
37
38
  })
38
39
  end
39
40
 
@@ -15,7 +15,7 @@ module Cetustek
15
15
  doc << create_xml_instruct
16
16
  doc << create_invoice_element
17
17
 
18
- Ox.dump(doc)
18
+ Ox.dump(doc).force_encoding('UTF-8')
19
19
  end
20
20
 
21
21
  private
@@ -27,6 +27,13 @@ module Cetustek
27
27
  instruct
28
28
  end
29
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
+
30
37
  def create_invoice_element
31
38
  invoice = Ox::Element.new('Invoice')
32
39
  invoice[:XSDVersion] = '2.8'
@@ -40,25 +47,26 @@ module Cetustek
40
47
  end
41
48
 
42
49
  def add_basic_info(invoice)
43
- invoice << Ox::Raw.new("<OrderId>#{@data.order_id}</OrderId>")
44
- invoice << Ox::Raw.new("<OrderDate>#{@data.order_date.strftime('%Y/%m/%d')}</OrderDate>")
50
+ invoice << raw_tag('OrderId', @data.order_id)
51
+ invoice << raw_tag('OrderDate', @data.order_date.strftime('%Y/%m/%d'))
45
52
  end
46
53
 
47
54
  def add_buyer_info(invoice)
48
- invoice << Ox::Raw.new("<BuyerIdentifier>#{@data.buyer_identifier}</BuyerIdentifier>")
49
- invoice << Ox::Raw.new("<BuyerName>#{CGI.escapeHTML(@data.buyer_name)}</BuyerName>")
50
- invoice << Ox::Raw.new("<BuyerEmailAddress>#{@data.buyer_email}</BuyerEmailAddress>")
55
+ invoice << raw_tag('BuyerIdentifier', @data.buyer_identifier)
56
+ invoice << raw_tag('BuyerName', @data.buyer_name)
57
+ invoice << raw_tag('BuyerEmailAddress', @data.buyer_email)
51
58
  end
52
59
 
53
60
  def add_invoice_type_info(invoice)
54
- invoice << Ox::Raw.new("<DonateMark>#{@data.donate_mark}</DonateMark>")
55
- invoice << Ox::Raw.new('<InvoiceType>07</InvoiceType>')
56
- invoice << Ox::Raw.new("<CarrierType>#{@data.carrier_type}</CarrierType>")
57
- invoice << Ox::Raw.new("<CarrierId1>#{@data.carrier_id}</CarrierId1>")
58
- invoice << Ox::Raw.new("<CarrierId2>#{@data.carrier_id2}</CarrierId2>")
59
- invoice << Ox::Raw.new("<NPOBAN>#{@data.npo_ban}</NPOBAN>")
60
- invoice << Ox::Raw.new('<TaxType>1</TaxType>')
61
- invoice << Ox::Raw.new("<PayWay>#{@data.payment_type}</PayWay>")
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)
62
70
  end
63
71
 
64
72
  def add_details(invoice)
@@ -68,60 +76,24 @@ module Cetustek
68
76
  @data.items.each do |item|
69
77
  details << create_product_item(item)
70
78
  end
71
-
72
- add_additional_items(details)
73
79
  end
74
80
 
75
81
  def create_product_item(item)
76
82
  product = Ox::Element.new('ProductItem')
77
- product << Ox::Raw.new("<ProductionCode>#{item.code}</ProductionCode>")
78
- product << Ox::Raw.new("<Description>#{CGI.escapeHTML(item.name)}</Description>")
79
- product << Ox::Raw.new("<Quantity>#{item.quantity}</Quantity>")
80
- product << Ox::Raw.new("<UnitPrice>#{item.unit_price}</UnitPrice>")
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)
81
88
  product
82
89
  end
83
90
 
84
- def add_additional_items(details)
85
- add_discount_item(details) if @data.total_discount.positive?
86
- add_coupon_item(details) if @data.coupon_discount.positive?
87
- add_delivery_fee_item(details) if @data.delivery_fee.positive?
88
- add_handling_fee_item(details) if @data.handling_fee.positive?
89
- end
90
-
91
- def add_discount_item(details)
92
- discount = Ox::Element.new('ProductItem')
93
- discount << Ox::Raw.new('<ProductionCode>DISCOUNT</ProductionCode>')
94
- discount << Ox::Raw.new('<Description>折抵金額</Description>')
95
- discount << Ox::Raw.new('<Quantity>1</Quantity>')
96
- discount << Ox::Raw.new("<UnitPrice>#{@data.total_discount * -1}</UnitPrice>")
97
- details << discount
98
- end
99
-
100
- def add_coupon_item(details)
101
- coupon = Ox::Element.new('ProductItem')
102
- coupon << Ox::Raw.new('<ProductionCode>COUPON</ProductionCode>')
103
- coupon << Ox::Raw.new('<Description>分享折讓</Description>')
104
- coupon << Ox::Raw.new('<Quantity>1</Quantity>')
105
- coupon << Ox::Raw.new("<UnitPrice>#{@data.coupon_discount * -1}</UnitPrice>")
106
- details << coupon
107
- end
108
-
109
- def add_delivery_fee_item(details)
110
- delivery = Ox::Element.new('ProductItem')
111
- delivery << Ox::Raw.new('<ProductionCode>DELIVERY_FEE</ProductionCode>')
112
- delivery << Ox::Raw.new('<Description>運費</Description>')
113
- delivery << Ox::Raw.new('<Quantity>1</Quantity>')
114
- delivery << Ox::Raw.new("<UnitPrice>#{@data.delivery_fee}</UnitPrice>")
115
- details << delivery
116
- end
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?
117
95
 
118
- def add_handling_fee_item(details)
119
- handling = Ox::Element.new('ProductItem')
120
- handling << Ox::Raw.new('<ProductionCode>HANDLING_FEE</ProductionCode>')
121
- handling << Ox::Raw.new('<Description>手續費</Description>')
122
- handling << Ox::Raw.new('<Quantity>1</Quantity>')
123
- handling << Ox::Raw.new("<UnitPrice>#{@data.handling_fee}</UnitPrice>")
124
- details << handling
96
+ product << raw_tag('DType', value)
125
97
  end
126
98
  end
127
99
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cetustek
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/cetustek.rb CHANGED
@@ -2,9 +2,14 @@
2
2
 
3
3
  require_relative "cetustek/version"
4
4
  require_relative "cetustek/configuration"
5
+ require_relative "cetustek/models/allowance_data"
5
6
  require_relative "cetustek/create_invoice"
6
7
  require_relative "cetustek/cancel_invoice"
7
8
  require_relative "cetustek/query_invoice_by_order_id"
9
+ require_relative "cetustek/queries"
10
+ require_relative "cetustek/create_allowance"
11
+ require_relative "cetustek/cancel_allowance"
12
+ require_relative "cetustek/phone_barcode"
8
13
 
9
14
  module Cetustek
10
15
  class Error < StandardError; end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cetustek
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zac
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-01-22 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ox
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '13.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rspec
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.12'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.12'
54
68
  description: Cetustek is a Ruby gem designed for handling electronic invoice operations,
55
69
  including invoice cancellation. It communicates with the e-invoice system through
56
70
  SOAP Web Services.
@@ -60,14 +74,20 @@ executables: []
60
74
  extensions: []
61
75
  extra_rdoc_files: []
62
76
  files:
77
+ - ".rspec"
63
78
  - CHANGELOG.md
64
79
  - README.md
65
80
  - Rakefile
66
81
  - lib/cetustek.rb
82
+ - lib/cetustek/cancel_allowance.rb
67
83
  - lib/cetustek/cancel_invoice.rb
68
84
  - lib/cetustek/configuration.rb
85
+ - lib/cetustek/create_allowance.rb
69
86
  - lib/cetustek/create_invoice.rb
87
+ - lib/cetustek/models/allowance_data.rb
70
88
  - lib/cetustek/models/invoice_data.rb
89
+ - lib/cetustek/phone_barcode.rb
90
+ - lib/cetustek/queries.rb
71
91
  - lib/cetustek/query_invoice_by_order_id.rb
72
92
  - lib/cetustek/services/invoice_service.rb
73
93
  - lib/cetustek/services/invoice_xml_builder.rb
@@ -95,7 +115,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
95
115
  - !ruby/object:Gem::Version
96
116
  version: '0'
97
117
  requirements: []
98
- rubygems_version: 3.6.2
118
+ rubygems_version: 4.0.10
99
119
  specification_version: 4
100
120
  summary: A Ruby gem for handling electronic invoice operations
101
121
  test_files: []