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 +4 -4
- data/.rspec +1 -0
- data/CHANGELOG.md +40 -0
- data/README.md +113 -3
- data/lib/cetustek/cancel_allowance.rb +51 -0
- data/lib/cetustek/cancel_invoice.rb +1 -1
- data/lib/cetustek/create_allowance.rb +75 -0
- data/lib/cetustek/create_invoice.rb +1 -1
- data/lib/cetustek/models/allowance_data.rb +28 -0
- data/lib/cetustek/models/invoice_data.rb +44 -8
- data/lib/cetustek/phone_barcode.rb +38 -0
- data/lib/cetustek/queries.rb +59 -0
- data/lib/cetustek/services/invoice_service.rb +3 -2
- data/lib/cetustek/services/invoice_xml_builder.rb +32 -60
- data/lib/cetustek/version.rb +1 -1
- data/lib/cetustek.rb +5 -0
- metadata +23 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5134abbcc8b7229469df2b15f6efa704828f745e499f2609e04251bca8ed90c0
|
|
4
|
+
data.tar.gz: 50312823ef3166581b9518d97955ce8f839a80ad25e3a1f155fd48ea102a919d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+
[](https://github.com/7a6163/cetustek/actions/workflows/rspec.yml)
|
|
4
|
+
[](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
|
-
###
|
|
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
|
-
|
|
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 >=
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
@
|
|
24
|
-
@
|
|
25
|
-
@
|
|
26
|
-
|
|
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
|
-
|
|
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:
|
|
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 <<
|
|
44
|
-
invoice <<
|
|
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 <<
|
|
49
|
-
invoice <<
|
|
50
|
-
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)
|
|
51
58
|
end
|
|
52
59
|
|
|
53
60
|
def add_invoice_type_info(invoice)
|
|
54
|
-
invoice <<
|
|
55
|
-
invoice <<
|
|
56
|
-
invoice <<
|
|
57
|
-
invoice <<
|
|
58
|
-
invoice <<
|
|
59
|
-
invoice <<
|
|
60
|
-
invoice <<
|
|
61
|
-
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)
|
|
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 <<
|
|
78
|
-
product <<
|
|
79
|
-
product <<
|
|
80
|
-
product <<
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
data/lib/cetustek/version.rb
CHANGED
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.
|
|
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:
|
|
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:
|
|
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: []
|