Cetustek
Cetustek is a Ruby wrapper for the 鯨躍 Cetustek e-invoice API (虛擬多通路, spec AVM-26-03), covering 電子發票 and 折讓單 over SOAP Web Services.
Features
- 開立發票 (CreateInvoiceV3) with 載具/捐贈/紙本, 零稅率, 混合稅率 and 特種稅額 support
- 作廢發票 (CancelInvoice), including the 專案作廢核准文號 for late cancellations
- 折讓單: 開立 (CreateAllowance), 作廢 (CancelAllowance), 查詢 (QueryAllowance)
- Queries by invoice number or order id, plus 手機條碼 validation
- Validation of the rules the spec fixes in print, before anything is sent
- Result codes raised as
Cetustek::ResultErrorwith the documented reason - Environment-specific configuration (sandbox/production) and opt-in logging
Installation
Add this line to your application's Gemfile:
gem 'cetustek'
Then execute:
bundle install
Configuration
Configure Cetustek in your application:
# config/initializers/cetustek.rb
Cetustek.configure do |config|
# Set environment (:production or :sandbox)
config.environment = Rails.env.production? ? :production : :sandbox
# Set authentication credentials
config.site_id = ENV['CETUSTEK_SITE_ID']
config.username = ENV['CETUSTEK_USERNAME']
config.password = ENV['CETUSTEK_PASSWORD']
# Optional. Defaults to nil, i.e. this gem writes nothing anywhere.
# Only the order id, invoice number and result code are logged (never the
# response body); the request XML is logged at debug level on failure.
config.logger = Rails.logger
end
Usage
Issue an Invoice
invoice = YourInvoiceModel.find(invoice_id)
invoice_data = Cetustek::Models::InvoiceData.new(
order_id: invoice.order_id,
order_date: Time.zone.today,
buyer_identifier: invoice.receipt,
buyer_name: invoice.name,
buyer_email: invoice.email,
donate_mark: Cetustek::DonateMark::CARRIER,
carrier_type: Cetustek::CarrierType::MOBILE_BARCODE, # 3J0002
carrier_id: invoice., # 手機條碼無顯隱碼之分,CarrierId2 自動填相同值
payment_type: Cetustek::PayWay::ATM,
items: invoice.items.map { |item|
Cetustek::Models::InvoiceItem.new(
code: item.sku,
name: item.name,
quantity: item.quantity,
unit: item.unit,
unit_price: item.price
)
}
)
result = Cetustek::CreateInvoice.new(invoice_data).execute
# => { number: "WB02100001", random_number: "3690",
# date: "2026/02/10", time: "11:19:57",
# sale_amount: 666, zero_amount: 0, free_amount: 0,
# tax_amount: 0, total_amount: 666, carrier_url: "..." }
Always use the returned :date/:time as the invoice date. The platform's
Intertemporal default issues invoices dated in the previous filing period on
the 1st–2nd of a month, so the local date can be wrong.
Persisting the result is the caller's job — this gem writes to no database.
InvoiceData.new raises ArgumentError for the rules the spec fixes in print,
so a guaranteed rejection never leaves your process:
order_id,order_date(aDate/Time),items,donate_markandpayment_typeare required- every item needs
code,name,quantityandunit_price(unitis optional) donate_mark: 0(載具) requiresbuyer_email,carrier_id1andcarrier_id2donate_mark: 1(捐贈) requires a 3–7 digitnpo_bantax_type: 4(特種稅率) requires an explicittax_rateandinvoice_type: '08'zero_reasononly withtax_type2 or 5;mail_sendonly withdonate_mark: 0round_num0–7,remarkat most 200 characters
Carriers (載具)
Cetustek::CarrierType names the codes the spec spells out:
| Constant | Code | Meaning |
|---|---|---|
MOBILE_BARCODE |
3J0002 |
手機條碼 |
CITIZEN_CERT |
CQ0001 |
自然人憑證條碼 |
CETUSTEK_CARD |
EJ0011 |
鯨躍發票卡 |
手機條碼與自然人憑證「無顯碼隱碼區分」, so passing carrier_id (or carrier_id1)
alone is enough and CarrierId2 is filled with the same value. Member carriers
do carry two different codes, so both carrier_id1 and carrier_id2 must be
given — the gem will not guess a 隱碼 it cannot know. 鯨躍發票卡 may be requested
with a blank carrier_type, as the spec allows.
Anything needing an external lookup (是否為有效手機條碼、捐贈碼是否存在) is left
to the caller — see Cetustek::PhoneBarcode below.
Any result code other than a successful issue raises Cetustek::ResultError,
whose #code is the raw Table 7 code and whose message carries the documented
reason (S7 - 訂單號碼已存在,若需重開請先作廢原發票號碼, D3_2 - 單價未填或格式錯誤, …).
Tax types (稅別)
InvoiceData defaults to taxable (TaxType 1) with a tax rate of 0.05 and a
general invoice type of 07. Use Cetustek::TaxType to switch modes:
| Constant | Code | Meaning |
|---|---|---|
TAXABLE |
1 | 應稅 |
ZERO_RATE |
2 | 零稅率(非經海關出口) |
TAX_FREE |
3 | 免稅 |
SPECIAL |
4 | 應稅(特種稅率) — set tax_rate, use invoice_type: '08' |
ZERO_RATE_CUSTOMS |
5 | 零稅率(經海關出口) |
MIXED |
9 | 混合(應稅/零稅率/免稅) |
Zero-rate invoice (零稅率)
Cetustek::Models::InvoiceData.new(
# ...buyer fields, items...
tax_type: Cetustek::TaxType::ZERO_RATE,
tax_rate: 0
)
Mixed-tax invoice (混稅, cash-register invoices only)
For TaxType 9 each line item must declare its own tax category via tax_type.
Accepts the symbols :taxable (default), :zero_rate, :tax_free, or the raw
DType codes ('', 'TZ', 'TN'):
Cetustek::Models::InvoiceData.new(
# ...buyer fields...
tax_type: Cetustek::TaxType::MIXED,
items: [
Cetustek::Models::InvoiceItem.new(code: 'A', name: '應稅品', quantity: 1, unit_price: 100),
Cetustek::Models::InvoiceItem.new(code: 'B', name: '零稅率品', quantity: 1, unit_price: 100, tax_type: :zero_rate),
Cetustek::Models::InvoiceItem.new(code: 'C', name: '免稅品', quantity: 1, unit_price: 100, tax_type: :tax_free)
]
)
Payment method (payment_type / PayWay)
payment_type accepts any raw code, or use Cetustek::PayWay for readability
(payment_type: Cetustek::PayWay::LINE_PAY):
| Constant | Code | Constant | Code | |
|---|---|---|---|---|
CASH |
1 | GOOGLE_PAY |
G |
|
ATM |
2 | JKO_PAY |
J |
|
CREDIT_CARD |
3 | LINE_PAY |
L |
|
CVS |
4 | PI_WALLET |
P |
|
OTHER |
5 | SAMSUNG_PAY |
S |
|
E_PAYMENT |
6 | TAIWAN_PAY |
T |
|
APPLE_PAY |
A |
EASY_WALLET |
U |
|
AFTEE |
E |
PX_PAY |
W |
|
QUAN_PAY |
X |
|||
COIN_CARD |
Z |
Donation mark (donate_mark / DonateMark)
donate_mark accepts any raw code, or use Cetustek::DonateMark:
CARRIER (0, 載具), DONATE (1, 捐贈), PAPER (2, 紙本).
Discounts and fees
The gem is a faithful wrapper of the API's invoice detail format, so it has no
built-in discount/coupon/delivery/handling concepts. Model them as ordinary line
items — use a negative unit_price for a discount:
Cetustek::Models::InvoiceItem.new(code: 'DISCOUNT', name: '折抵', quantity: 1, unit_price: -30)
Cancel an Invoice (作廢發票確認)
# remark 是 Table 9 的必填作廢原因,最多 20 字,沒有預設值
Cetustek::CancelInvoice.new('AB12345678', 2024, remark: '退貨').execute # => "C0"
# 超過申報期間才需要專案作廢核准文號 (否則會收到 C3)
Cetustek::CancelInvoice.new('AB12345678', 2024, remark: '明細錯誤',
return_tax_document_number: '65327645').execute
Uploading is not the end of it: the cancellation still has to be confirmed
manually on the 鯨躍 platform before the invoice counts as void. Any code other
than "C0" raises Cetustek::ResultError (C5 - 該發票已經作廢過, …), and
marking your own record as canceled is the caller's job.
Query invoices
Cetustek::QueryInvoiceByOrderId.query(order_id) # by order id
Cetustek::QueryInvoice.query(invoice_number, invoice_year) # by invoice number + year
Cetustek::QueryInvoiceNumberByOrderId.query(order_id) # just the invoice number
Tax-inclusive vs tax-exclusive prices (hastax)
hastax comes from the order, not a fixed value: 1 (default) means the item
unit_prices already include tax; 0 means they are tax-exclusive (e.g. a tax-free
purchase). Set it on InvoiceData:
Cetustek::Models::InvoiceData.new(hastax: 0, items: [...])
Other Table 1 fields
| Attribute | Tag | Notes |
|---|---|---|
buyer_address / buyer_person_in_charge / buyer_telephone / buyer_facsimile / buyer_customer_number |
BuyerAddress / BuyerPersonInCharge / BuyerTelephoneNumber / BuyerFacsimileNumber / BuyerCustomerNumber |
選填,常用於 B2B |
remark |
Remark |
備註,200 字 |
zero_reason |
ZeroReason |
零稅率原因;未填時平台預設 72(TaxType 2)或 71(TaxType 5) |
round_num |
RoundNum |
金額計算位數,未填預設 4 |
mail_send |
MailSend |
0(預設)由加值中心寄送通知,1 自行處理;限 donate_mark: 0 |
rtn_msg |
RtnMsg |
預設 'Json';傳 nil 退回只回傳 15 碼字串的舊模式 |
Fields with a platform-side default (ZeroReason, RoundNum, MailSend,
RtnMsg) are omitted from the XML entirely when nil, so the platform applies
its own default. Intertemporal(發票回開)is deliberately not exposed: it changes
which filing period's 字軌 the invoice is issued under, and the platform default
is the right behaviour.
Allowances (折讓單)
allowance = Cetustek::Models::AllowanceData.new(
allowance_number: 'AA20240216000001',
allowance_date: Time.zone.today,
invoice_number: 'AA10000000',
invoice_year: '2024',
tax_type: 1,
reason: '退回',
items: [
Cetustek::Models::InvoiceItem.new(code: '0001', name: '禮券', quantity: 1, unit: '本', unit_price: 800)
]
)
Cetustek::CreateAllowance.new(allowance).execute # => "A0" on success
Cetustek::CancelAllowance.new('AA20240216000001', '明細錯誤').execute # => "C0" on success
Cetustek::QueryAllowance.find('AA20240216000001') # parsed Hash
Cetustek::QueryAllowance.query('AA20240216000001') # raw Savon response
AllowanceData.new raises ArgumentError for the Table 15 rules: allowance_number,
allowance_date (a Date/Time), invoice_number, invoice_year, reason (20 字)
and at least one item are required, and round_num must be 0–7. tax_type only
accepts 1 應稅, 2 零稅率 or 3 免稅 — the invoice-only values (4, 5, 9)
raise. unit_price is tax-inclusive (there is no hastax on allowances).
CancelAllowance takes the two Table 18 fields, both required, 作廢原因 up to 20 字.
QueryAllowance.find returns nil only when the platform answers with nothing at
all; a non-XML answer is a result code and is raised as ResultError.
Any other result code raises Cetustek::ResultError, whose #code is the raw
code and whose message includes the documented reason (e.g. A2 - 所有折讓金額加總 不能大於原發票金額, C2 - 折讓單已申報,無法作廢).
QueryAllowance.find returns the Table 20 fields as snake_case symbols with the
line items under :details, values kept as the raw strings from the XML:
{ allowance_number: 'AA20240216000001', allowance_date: '2024/02/16',
invoice_number: 'AA10000000', invoice_date: '2024/02/14',
buyer_identifier: '12345678', buyer_name: '測試公司', buyer_address: nil,
reason: '退回', allowance_status: '開立', back_status: '已確認',
sale_amount: '95', tax_amount: '5',
details: [{ sequence_number: '1', product_code: '0001', description: '禮券',
quantity: '1', unit: '本', unit_price: '95', amount: '95',
tax: '5', tax_type: '1' }] }
Mobile barcode validation (手機條碼)
Cetustek::PhoneBarcode.valid?('/ABC123') # => true / false
Development
- Clone this repository
- Run
bin/setupto install dependencies - Run
bin/consolefor an interactive prompt to experiment - Run
bundle exec rspecto run the test suite
Requirements
- Ruby >= 3.0.0
oxgem for XML processingsavongem for SOAP services
Contributing
- Fork this project
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -am 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Versioning
This project follows Semantic Versioning. See the CHANGELOG.md file for version details.
License
This gem is available as open source under the terms of the MIT License.