cetustek 0.2.0 → 0.3.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: cc27cec134bbdcde72328147677ace14f302f2423128209d8ec0ddd190ceeca7
4
+ data.tar.gz: cfb7115017ef61ba4c928459f1d8d8815bf5a1fd616cc050a8be012a942aac3e
5
5
  SHA512:
6
- metadata.gz: db34fb0378f5fd87b4c42cec44306b69afaa09ceb9ade280993f1618ed487e0c92ea6e61f3645747277e4a1e548b736f7806ced8e659c4a84a0d1a98da3efbe8
7
- data.tar.gz: 5164f077cb9fff85c4879ea31e0f51710d03b6b621545c2b757deac19f3833e7f50802470577812ca95cfccb8742b271dc71c418625a8e9b5947f08e8c22bf2a
6
+ metadata.gz: 1a350443ea05a452ec354e1f9d7a5cb624cd04087baecef9f756c715350497bdc6d9b0fbedc41ee2087d711aba66b72ecfbb7c207d239d8b8278266335a3eae4
7
+ data.tar.gz: 5ac422d52d5d11b0bf71d97186ab9bd257f667ec135c942f87df934f66cb4f7653a40cda4794d62171aa72d905f98d60fe46e416409399e07abbfe679a063130
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper
data/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ 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.3.0] - 2026-06-13
9
+
10
+ ### Added
11
+ - Configurable tax handling per spec V4.16: `TaxType`, `TaxRate`, and `InvoiceType`
12
+ on `InvoiceData`, with a `Cetustek::TaxType` constants module
13
+ - Zero-rate invoice support (`TaxType` 2/5)
14
+ - Mixed-tax invoice support (`TaxType` 9): per-item `tax_type` emits the required
15
+ `DType` (`TZ`/`TN`/blank) on every detail line
16
+ - `carrier_id2` attribute on `InvoiceData` (previously read by the XML builder but
17
+ never settable, which raised `NoMethodError` when issuing an invoice)
18
+ - Test suite for the XML builder and data models
19
+
20
+ ### Changed
21
+ - XML builder now HTML-escapes every interpolated field (previously only buyer name
22
+ and product name), preventing malformed XML / injection from special characters
23
+
24
+ ### Removed
25
+ - **Breaking:** application-specific auto line items and their `InvoiceData` fields
26
+ (`total_discount`, `coupon_discount`, `delivery_fee`, `handling_fee`). These were
27
+ not part of the Cetustek API. Model discounts/fees as ordinary `InvoiceItem`s
28
+ (use a negative `unit_price` for a discount)
29
+
8
30
  ## [0.2.0] - 2025-01-22
9
31
 
10
32
  ### Added
data/README.md CHANGED
@@ -44,7 +44,7 @@ end
44
44
 
45
45
  ## Usage
46
46
 
47
- ### Cancel an Invoice
47
+ ### Issue an Invoice
48
48
 
49
49
  ```ruby
50
50
  invoice = YourInvoiceModel.find(invoice_id)
@@ -54,7 +54,9 @@ invoice_data = Cetustek::Models::InvoiceData.new(
54
54
  buyer_identifier: invoice.receipt,
55
55
  buyer_name: invoice.name,
56
56
  buyer_email: invoice.email,
57
- items: invoice.items.map { |item|
57
+ donate_mark: 0,
58
+ payment_type: 2,
59
+ items: invoice.items.map { |item|
58
60
  Cetustek::Models::InvoiceItem.new(
59
61
  code: item.sku,
60
62
  name: item.name,
@@ -65,6 +67,73 @@ invoice_data = Cetustek::Models::InvoiceData.new(
65
67
  )
66
68
 
67
69
  result = Cetustek::CreateInvoice.new(invoice_data).execute
70
+ # => { number: "GT68514542", random_number: "9654" }
71
+ ```
72
+
73
+ ### Tax types (稅別)
74
+
75
+ `InvoiceData` defaults to taxable (`TaxType` 1) with a tax rate of `0.05` and a
76
+ general invoice type of `07`. Use `Cetustek::TaxType` to switch modes:
77
+
78
+ | Constant | Code | Meaning |
79
+ |----------|------|---------|
80
+ | `TAXABLE` | 1 | 應稅 |
81
+ | `ZERO_RATE` | 2 | 零稅率(非經海關出口) |
82
+ | `TAX_FREE` | 3 | 免稅 |
83
+ | `SPECIAL` | 4 | 應稅(特種稅率) — set `tax_rate`, use `invoice_type: '08'` |
84
+ | `ZERO_RATE_CUSTOMS` | 5 | 零稅率(經海關出口) |
85
+ | `MIXED` | 9 | 混合(應稅/零稅率/免稅,限收銀機類型發票) |
86
+
87
+ #### Zero-rate invoice (零稅率)
88
+
89
+ ```ruby
90
+ Cetustek::Models::InvoiceData.new(
91
+ # ...buyer fields, items...
92
+ tax_type: Cetustek::TaxType::ZERO_RATE,
93
+ tax_rate: 0
94
+ )
95
+ ```
96
+
97
+ #### Mixed-tax invoice (混稅, cash-register invoices only)
98
+
99
+ For `TaxType` 9 each line item must declare its own tax category via `tax_type`.
100
+ Accepts the symbols `:taxable` (default), `:zero_rate`, `:tax_free`, or the raw
101
+ `DType` codes (`''`, `'TZ'`, `'TN'`):
102
+
103
+ ```ruby
104
+ Cetustek::Models::InvoiceData.new(
105
+ # ...buyer fields...
106
+ tax_type: Cetustek::TaxType::MIXED,
107
+ items: [
108
+ Cetustek::Models::InvoiceItem.new(code: 'A', name: '應稅品', quantity: 1, unit_price: 100),
109
+ Cetustek::Models::InvoiceItem.new(code: 'B', name: '零稅率品', quantity: 1, unit_price: 100, tax_type: :zero_rate),
110
+ Cetustek::Models::InvoiceItem.new(code: 'C', name: '免稅品', quantity: 1, unit_price: 100, tax_type: :tax_free)
111
+ ]
112
+ )
113
+ ```
114
+
115
+ ### Discounts and fees
116
+
117
+ The gem is a faithful wrapper of the API's invoice detail format, so it has no
118
+ built-in discount/coupon/delivery/handling concepts. Model them as ordinary line
119
+ items — use a negative `unit_price` for a discount:
120
+
121
+ ```ruby
122
+ Cetustek::Models::InvoiceItem.new(code: 'DISCOUNT', name: '折抵', quantity: 1, unit_price: -30)
123
+ ```
124
+
125
+ ### Cancel an Invoice
126
+
127
+ ```ruby
128
+ # `invoice` responds to #number and #created_at; on success (return code "C0")
129
+ # it is updated with canceled: true.
130
+ Cetustek::CancelInvoice.new(invoice).execute
131
+ ```
132
+
133
+ ### Query an Invoice by Order ID
134
+
135
+ ```ruby
136
+ response = Cetustek::QueryInvoiceByOrderId.query(order_id)
68
137
  ```
69
138
 
70
139
  ## Development
@@ -72,10 +141,11 @@ result = Cetustek::CreateInvoice.new(invoice_data).execute
72
141
  1. Clone this repository
73
142
  2. Run `bin/setup` to install dependencies
74
143
  3. Run `bin/console` for an interactive prompt to experiment
144
+ 4. Run `bundle exec rspec` to run the test suite
75
145
 
76
146
  ## Requirements
77
147
 
78
- - Ruby >= 2.7.0
148
+ - Ruby >= 3.0.0
79
149
  - `ox` gem for XML processing
80
150
  - `savon` gem for SOAP services
81
151
 
@@ -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
10
23
 
11
24
  def initialize(attributes = {})
12
25
  @order_id = attributes[:order_id]
@@ -17,24 +30,43 @@ 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
+ end
41
+
42
+ # 混合稅率發票 (限收銀機):每筆明細需標註 DType。
43
+ def mixed_tax?
44
+ @tax_type.to_i == TaxType::MIXED
27
45
  end
28
46
  end
29
47
 
30
48
  class InvoiceItem
31
- attr_reader :code, :name, :quantity, :unit_price
49
+ # Per-item 稅別註記 (DType) used for mixed-tax invoices (TaxType == 9).
50
+ DTYPE_MAP = {
51
+ taxable: '', # 應稅商品 -> 空白
52
+ zero_rate: 'TZ', # 零稅率商品
53
+ tax_free: 'TN' # 免稅商品
54
+ }.freeze
55
+
56
+ attr_reader :code, :name, :quantity, :unit_price, :tax_type
32
57
 
33
58
  def initialize(attributes = {})
34
59
  @code = attributes[:code]
35
60
  @name = attributes[:name]
36
61
  @quantity = attributes[:quantity]
37
62
  @unit_price = attributes[:unit_price]
63
+ @tax_type = attributes[:tax_type] || :taxable
64
+ end
65
+
66
+ # Returns the DType code: '', 'TZ' or 'TN'.
67
+ # Accepts the friendly symbols above or a raw code string.
68
+ def d_type
69
+ DTYPE_MAP.fetch(@tax_type) { @tax_type.to_s }
38
70
  end
39
71
  end
40
72
  end
@@ -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.3.0"
5
5
  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.3.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,6 +74,7 @@ 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
@@ -95,7 +110,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
95
110
  - !ruby/object:Gem::Version
96
111
  version: '0'
97
112
  requirements: []
98
- rubygems_version: 3.6.2
113
+ rubygems_version: 4.0.10
99
114
  specification_version: 4
100
115
  summary: A Ruby gem for handling electronic invoice operations
101
116
  test_files: []