paid_tw-ubn 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1235c21850cf6463a28a5ffaebf881de7c52e76019319883414ca2d0b694a0b8
4
+ data.tar.gz: d1c2e35153e2cbfb248e616290f28b1142d4d8719fb8c66e6345e9dc44b34409
5
+ SHA512:
6
+ metadata.gz: 3bb51f144ba623cb7d548a88a81d7b323b1deeae215f40ec16d565b1dfabe09c3b02262d8cd261005a9984012f7381625fd378db9b1053a2ba363efd1c6518ce
7
+ data.tar.gz: f0db2095e1a65c3b9fb62d4d233dd514ae31b4aee1a25433a3ae0b99ec874a260ae56af410a7c05c927803b176bbec993bf892c4f0f8d1d2308acf58094bb56d
data/LICENSE ADDED
@@ -0,0 +1,45 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 paid.tw
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ NOTE: This MIT license covers the SOURCE CODE in this repository only.
26
+
27
+ The published DATA artifacts are derived from open data released by
28
+ 財政部財政資訊中心 under 政府資料開放授權條款-第1版 (Open Government Data
29
+ License v1, https://data.gov.tw/license). Failing to attach the 顯名聲明
30
+ below means the licence was not granted.
31
+
32
+ 提供機關/單位:財政部財政資訊中心
33
+ 開放資料:
34
+ - 全國營業(稅籍)登記資料集(https://data.gov.tw/dataset/9400,BGMOPEN1.zip)
35
+ - 全國營業(稅籍)登記(停業)資料集(https://data.gov.tw/dataset/75140,BGMOPEN1X.csv)
36
+ 版本:each build's extract date, recorded in manifest.json as dataDate and
37
+ attribution.datasets[].version (for example 28-OCT-26).
38
+
39
+ 此開放資料依政府資料開放授權條款 (Open Government Data License) 進行公眾釋出,使用者於遵守本條款各項規定之前提下,得利用之。
40
+
41
+ 政府資料開放授權條款:https://data.gov.tw/license
42
+
43
+ See docs/data-dictionary.md. Per-record GCIS responses, when used, are a
44
+ separate government source under the same licence and need their own
45
+ attribution; this repository does not republish a GCIS dump.
data/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # paid_tw-ubn
2
+
3
+ The Ruby counterpart of the [`@paid-tw/ubn`](https://www.npmjs.com/package/@paid-tw/ubn)
4
+ npm client, for the server side of a checkout: validate a Taiwan Unified
5
+ Business Number (統一編號), compare an invoice title (抬頭) with the registered
6
+ name, and look the registered name up through a deployed
7
+ [tw-ubn](https://github.com/paid-tw/tw-ubn) Worker.
8
+
9
+ Pure Ruby, no runtime dependencies, Ruby 3.2 or later.
10
+
11
+ ```ruby
12
+ # Gemfile
13
+ gem 'paid_tw-ubn'
14
+ ```
15
+
16
+ ```ruby
17
+ require 'paid_tw/ubn'
18
+
19
+ PaidTw::Ubn.valid?('22099131') # => true (post-2023 rule: sum divisible by 5)
20
+
21
+ title = PaidTw::Ubn.lookup_title('22099131', base_url: 'https://ubn.example.com', timeout: 2)
22
+
23
+ if title.nil?
24
+ # No answer: bad checksum, timeout, network error, non-2xx, or a name that
25
+ # cannot be used as a title. Treat the UBN as not looked up.
26
+ elsif !title.found?
27
+ # No public source has this UBN. Not proof that it is invalid.
28
+ else
29
+ title.name # => "台灣積體電路製造股份有限公司"
30
+ title.status # => "active", "suspended", "closed" or nil (unknown)
31
+ PaidTw::Ubn.same_title?(typed_title, title.name)
32
+ end
33
+ ```
34
+
35
+ | Ruby | npm client |
36
+ |---|---|
37
+ | `PaidTw::Ubn.valid?(ban)` | `validateUBN(ban)` |
38
+ | `PaidTw::Ubn.lookup_title(ban, base_url:, timeout:)` | `lookupTitle(ban, { baseUrl, timeoutMs })` |
39
+ | `PaidTw::Ubn.same_title?(a, b)` | `isSameTitle(a, b)` |
40
+ | `PaidTw::Ubn.private_use?(title)` | `hasPrivateUse(title)` |
41
+
42
+ `lookup_title` never raises. `timeout` is in seconds and bounds the connection
43
+ and each read, not the whole call; the request is not retried. A found `Title`
44
+ also carries `ban`, `address`, `use_invoice`, `kind`, `source`,
45
+ `company_status`, `closed_date` and `setup_date`, each nil when the Worker did
46
+ not send it. What a lookup result means, and why "not found" is not "invalid",
47
+ is in the [repository README](https://github.com/paid-tw/tw-ubn#readme).
48
+
49
+ `same_title?` ignores full- vs half-width forms (NFKC), 臺 vs 台, whitespace and
50
+ Latin case. It never strips or abbreviates a legal form such as 股份有限公司.
51
+
52
+ The shard reader (`lookupVAT`, `shardOf`) and the direct FIA / GCIS clients of
53
+ the npm package are not ported: a server should ask the Worker, which caches.
54
+
55
+ ## Develop
56
+
57
+ ```bash
58
+ cd ruby && bundle install && bundle exec rake test
59
+ ```
60
+
61
+ The tests read the vectors in [`../fixtures`](../fixtures), which the npm
62
+ client's tests read too, and the Rust core's sample of real UBNs.
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaidTw
4
+ module Ubn
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ require_relative 'ubn/version'
8
+
9
+ module PaidTw
10
+ # Taiwan Unified Business Number (UBN/BAN) helpers: checksum
11
+ # validation, invoice-title comparison, and a title lookup against a deployed
12
+ # tw-ubn Worker. The Ruby counterpart of the `@paid-tw/ubn` npm client; the
13
+ # two are held together by the vectors in `fixtures/`.
14
+ module Ubn
15
+ WEIGHTS = [1, 2, 1, 2, 1, 2, 4, 1].freeze
16
+
17
+ # What ECMAScript's `\s` matches, so that a title compares the same here as
18
+ # in the npm client. Ruby's own `\s` is ASCII-only and `[[:space:]]` differs
19
+ # on U+0085 and U+FEFF.
20
+ WHITESPACE = "\t\n\v\f\r    - 

   "
21
+ WHITESPACE_RUN = /[#{WHITESPACE}]+/
22
+ PRIVATE_USE = /\p{Co}/
23
+
24
+ # A capital sigma that ends a word: a cased letter before it and none after
25
+ # it, case-ignorable characters (combining marks, full stops, apostrophes,
26
+ # ...) skipped on both sides. ECMAScript's `toLowerCase` gives it the final
27
+ # form "ς"; Ruby's `downcase` knows no context and gives "σ". A character
28
+ # that is both cased and case-ignorable counts as ignorable, as JavaScript
29
+ # engines have it.
30
+ CASED = '[\p{Cased}&&\P{Case_Ignorable}]'
31
+ FINAL_SIGMA = /(#{CASED}\p{Case_Ignorable}*)Σ(?!\p{Case_Ignorable}*#{CASED})/
32
+
33
+ STATUSES = %w[active suspended closed].freeze
34
+ KINDS = %w[business nonprofit school agency].freeze
35
+ SOURCES = %w[dataset fia-api gcis].freeze
36
+
37
+ DEFAULT_TIMEOUT = 2
38
+ SUCCESS = (200..299)
39
+
40
+ # The answer of `lookup_title`. `found?` is false when the Worker answered
41
+ # and no source it consulted has a record; every other field is then nil.
42
+ # A nil `status` means unknown, not active.
43
+ Title = Data.define(
44
+ :found, :ban, :name, :status, :address, :use_invoice, :kind, :source,
45
+ :company_status, :closed_date, :setup_date
46
+ ) do
47
+ def initialize(found:, ban: nil, name: nil, status: nil, address: nil, use_invoice: nil,
48
+ kind: nil, source: nil, company_status: nil, closed_date: nil, setup_date: nil)
49
+ super
50
+ end
51
+
52
+ def found? = found
53
+ end
54
+
55
+ NOT_FOUND = Title.new(found: false)
56
+
57
+ module_function
58
+
59
+ # True when `ban` is a well-formed 8-digit UBN under the post-2023 rule
60
+ # (weighted digit sum divisible by 5). It says nothing about whether the
61
+ # number is registered. Never raises: a value that is not a string, or a
62
+ # string whose bytes are invalid in its own encoding, is false.
63
+ def valid?(ban)
64
+ # A regexp raises on a string it cannot read: one with invalid bytes, or
65
+ # in an encoding that is not ASCII-compatible (UTF-16, UTF-32).
66
+ return false unless ban.is_a?(String) && ban.encoding.ascii_compatible? && ban.valid_encoding?
67
+ return false unless ban.match?(/\A[0-9]{8}\z/)
68
+
69
+ sum = ban.each_char.with_index.sum do |char, index|
70
+ product = char.to_i * WEIGHTS[index]
71
+ (product / 10) + (product % 10)
72
+ end
73
+
74
+ (sum % 5).zero? || (ban[6] == '7' && ((sum + 1) % 5).zero?)
75
+ end
76
+
77
+ # True when `title` contains a Unicode Private Use Area character. The tax
78
+ # registry writes some names with locally-defined glyphs mapped there; they
79
+ # render as boxes and an e-invoice platform rejects them.
80
+ def private_use?(title)
81
+ utf8(title).match?(PRIVATE_USE)
82
+ end
83
+
84
+ # True when two invoice titles name the same entity once incidental typing
85
+ # differences are ignored: full- vs half-width forms (NFKC), the two
86
+ # spellings of "tai" (U+81FA and U+53F0), all whitespace, and Latin case.
87
+ # Legal forms are never stripped or abbreviated ("Co., Ltd." written in
88
+ # full is not its bracketed short form), and an empty title matches nothing.
89
+ # Neither does a title with bytes that have no reading as UTF-8: a string
90
+ # in another encoding is compared by its characters, one that is broken or
91
+ # binary with non-ASCII bytes is not compared at all.
92
+ def same_title?(a, b)
93
+ normalized = normalize_title(a)
94
+ !normalized.empty? && normalized == normalize_title(b)
95
+ end
96
+
97
+ # Looks up the registered name of `ban` through a deployed tw-ubn Worker
98
+ # (`GET <base_url>/v1/vat/<ban>`). Never raises. Three outcomes:
99
+ #
100
+ # - a `Title` with `found?` true: a registration with a usable name. Its
101
+ # `status` may be "suspended" or "closed"; what to do then is the
102
+ # caller's call.
103
+ # - `NOT_FOUND`: the Worker answered and no source has a record. This is
104
+ # not proof of an invalid UBN: many withholding-unit UBNs (clinics,
105
+ # professional practices, ...) have no public record.
106
+ # - nil: no answer. A malformed UBN (no request is made), a timeout, a
107
+ # network error, any non-2xx response, a body that is not the expected
108
+ # JSON, a `ban` in the body other than the one asked for, or a name that
109
+ # is empty or carries Private Use Area glyphs.
110
+ #
111
+ # `timeout` is in seconds and bounds the connection and each read, not the
112
+ # whole call. `http` replaces the transport: a callable taking the URL and
113
+ # the timeout, returning `[status, body]`.
114
+ def lookup_title(ban, base_url:, timeout: DEFAULT_TIMEOUT, http: nil)
115
+ return nil unless valid?(ban)
116
+
117
+ url = "#{base_url.to_s.sub(%r{/+\z}, '')}/v1/vat/#{ban}"
118
+ status, body = (http || method(:get)).call(url, timeout)
119
+ return nil unless SUCCESS.cover?(status)
120
+
121
+ parse(JSON.parse(body), ban)
122
+ rescue StandardError
123
+ nil
124
+ end
125
+
126
+ def get(url, timeout)
127
+ uri = URI.parse(url)
128
+ raise ArgumentError, "not an http(s) URL: #{url}" unless uri.is_a?(URI::HTTP)
129
+
130
+ options = {
131
+ use_ssl: uri.scheme == 'https',
132
+ open_timeout: timeout,
133
+ read_timeout: timeout,
134
+ write_timeout: timeout,
135
+ # Net::HTTP retries an idempotent request once, which would double the
136
+ # time a dead Worker can hold the caller.
137
+ max_retries: 0
138
+ }
139
+ Net::HTTP.start(uri.host, uri.port, **options) do |connection|
140
+ connection.request_get(uri.request_uri) do |response|
141
+ status = response.code.to_i
142
+ # The status is all a non-2xx response has to say. Returning from
143
+ # inside the block leaves before Net::HTTP reads the body, and closes
144
+ # the connection: an error body that stalls or trickles holds no one.
145
+ return [status, nil] unless SUCCESS.cover?(status)
146
+
147
+ return [status, response.read_body]
148
+ end
149
+ end
150
+ end
151
+
152
+ def parse(body, ban)
153
+ return nil unless body.is_a?(Hash)
154
+
155
+ found = body['found']
156
+ return nil unless [true, false].include?(found)
157
+ return nil if body.key?('ban') && body['ban'] != ban
158
+ return NOT_FOUND unless found
159
+
160
+ name = body['name']
161
+ return nil unless name.is_a?(String) && !name.gsub(WHITESPACE_RUN, '').empty?
162
+ return nil if body['nameHasPrivateUse'] == true || private_use?(name)
163
+
164
+ Title.new(
165
+ found: true,
166
+ ban: ban,
167
+ name: name,
168
+ status: one_of(body['status'], STATUSES),
169
+ address: string(body['address']),
170
+ use_invoice: [true, false].include?(body['useInvoice']) ? body['useInvoice'] : nil,
171
+ kind: one_of(body['kind'], KINDS),
172
+ source: one_of(body['source'], SOURCES),
173
+ company_status: string(body['companyStatus']),
174
+ closed_date: string(body['closedDate']),
175
+ setup_date: string(body['setupDate'])
176
+ )
177
+ end
178
+
179
+ # A title that has no reading as UTF-8 normalizes to the empty title, which
180
+ # matches nothing. Replacing its unreadable bytes instead would turn each
181
+ # into the same U+FFFD and make different titles compare equal.
182
+ def normalize_title(title)
183
+ text = title.to_s.encode(Encoding::UTF_8)
184
+ return '' unless text.valid_encoding?
185
+
186
+ text
187
+ .unicode_normalize(:nfkc)
188
+ .tr('臺', '台')
189
+ .gsub(WHITESPACE_RUN, '')
190
+ .then { |stripped| lowercase(stripped) }
191
+ rescue EncodingError
192
+ ''
193
+ end
194
+
195
+ # `String#downcase` as ECMAScript's `toLowerCase` has it: see FINAL_SIGMA.
196
+ def lowercase(text)
197
+ return text.downcase unless text.include?('Σ')
198
+
199
+ text.gsub(FINAL_SIGMA, '\1ς').downcase
200
+ end
201
+
202
+ def utf8(value)
203
+ value.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
204
+ end
205
+
206
+ def one_of(value, allowed)
207
+ value if allowed.include?(value)
208
+ end
209
+
210
+ def string(value)
211
+ value if value.is_a?(String)
212
+ end
213
+
214
+ private_class_method :get, :parse, :normalize_title, :lowercase, :utf8, :one_of, :string
215
+ end
216
+ end
metadata ADDED
@@ -0,0 +1,49 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: paid_tw-ubn
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - paid-tw
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'The Ruby counterpart of the @paid-tw/ubn npm client: validate a Taiwan
13
+ Unified Business Number, compare an invoice title with the registered name, and
14
+ look the registered name up through a deployed tw-ubn Worker.'
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - LICENSE
20
+ - README.md
21
+ - lib/paid_tw/ubn.rb
22
+ - lib/paid_tw/ubn/version.rb
23
+ homepage: https://github.com/paid-tw/tw-ubn
24
+ licenses:
25
+ - MIT
26
+ metadata:
27
+ source_code_uri: https://github.com/paid-tw/tw-ubn/tree/main/ruby
28
+ changelog_uri: https://github.com/paid-tw/tw-ubn/blob/main/CHANGELOG.md
29
+ bug_tracker_uri: https://github.com/paid-tw/tw-ubn/issues
30
+ rubygems_mfa_required: 'true'
31
+ rdoc_options: []
32
+ require_paths:
33
+ - lib
34
+ required_ruby_version: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '3.2'
39
+ required_rubygems_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ requirements: []
45
+ rubygems_version: 3.6.9
46
+ specification_version: 4
47
+ summary: Taiwan business number (統一編號) checksum, invoice-title comparison and title
48
+ lookup
49
+ test_files: []