Skip to content

Commit 52bd8ba

Browse files
Add TaxId value object and :tax_id flex_attribute (#74)
- Add TaxId custom type that extends string type - Add :tax_id flex attribute - Test flex_attribute :tax_id in TestRecord ## Context Implement TaxId attribute with validation for the format XXX-XX-XXXX, following a similar approach to the MemorableDate attribute which extends ActiveRecord::Type::Date. --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Loren Yu <[email protected]>
1 parent 9e1c106 commit 52bd8ba

10 files changed

Lines changed: 244 additions & 10 deletions

File tree

app/lib/flex/attributes.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ module Attributes
44
include Flex::Attributes::AddressAttribute
55
include Flex::Attributes::MemorableDateAttribute
66
include Flex::Attributes::NameAttribute
7+
include Flex::Attributes::TaxIdAttribute
78

89
class_methods do
910
def flex_attribute(name, type, options = {})
@@ -14,6 +15,8 @@ def flex_attribute(name, type, options = {})
1415
name_attribute name, options
1516
when :address
1617
address_attribute name, options
18+
when :tax_id
19+
tax_id_attribute name, options
1720
else
1821
raise ArgumentError, "Unsupported attribute type: #{type}"
1922
end

app/lib/flex/attributes/memorable_date_attribute.rb

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,6 @@ def memorable_date_attribute(name, options)
3131

3232
validate :"validate_#{name}"
3333

34-
if options[:presence]
35-
validates name, presence: true
36-
end
37-
3834
# Create a validation method that checks if the value is a valid date
3935
define_method "validate_#{name}" do
4036
value = send(name)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
module Flex
2+
module Attributes
3+
module TaxIdAttribute
4+
extend ActiveSupport::Concern
5+
6+
# A custom ActiveRecord type that allows storing a Tax ID (such as SSN).
7+
# It uses the Flex::TaxId value object for storage and formatting.
8+
class TaxIdType < ActiveRecord::Type::String
9+
# Override cast to ensure proper Tax ID format
10+
def cast(value)
11+
return nil if value.nil?
12+
13+
# If it's already a TaxId, return it
14+
return value if value.is_a?(Flex::TaxId)
15+
16+
# Otherwise create a new TaxId object
17+
Flex::TaxId.new(value)
18+
end
19+
20+
def type
21+
:tax_id
22+
end
23+
end
24+
25+
class_methods do
26+
def tax_id_attribute(name, options = {})
27+
attribute name, TaxIdType.new
28+
validates name, format: { with: Flex::TaxId::TAX_ID_FORMAT_NO_DASHES, message: :invalid_tax_id }, allow_nil: true
29+
end
30+
end
31+
end
32+
end
33+
end

app/models/flex/tax_id.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
module Flex
2+
class TaxId < String
3+
include Comparable
4+
5+
TAX_ID_FORMAT_NO_DASHES = /\A\d{9}\z/
6+
7+
def initialize(value)
8+
# Store only the digits, stripping any non-numeric characters
9+
super(value.to_s.gsub(/\D/, ""))
10+
end
11+
12+
# Returns the Tax ID with dashes in XXX-XX-XXXX format
13+
def formatted
14+
if length == 9
15+
"#{self[0..2]}-#{self[3..4]}-#{self[5..8]}"
16+
else
17+
self
18+
end
19+
end
20+
21+
def <=>(other)
22+
other_tax_id = other.is_a?(TaxId) ? other : TaxId.new(other.to_s)
23+
super(other_tax_id)
24+
end
25+
end
26+
end

config/locales/flex/en.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
en:
2+
attributes:
3+
tax_id: "Tax ID"
4+
errors:
5+
messages:
6+
invalid_date: "is an invalid date"
7+
invalid_tax_id: "is not a valid Taxpayer Identification Number (TIN). Use the format (XXX-XX-XXXX)"
28
flex:
39
application_forms:
410
index:
@@ -56,9 +62,6 @@ en:
5662
all: "All tasks"
5763
no_tasks_alert:
5864
message: "No tasks available!"
59-
errors:
60-
messages:
61-
invalid_date: "is an invalid date"
6265
tasks:
6366
statuses:
6467
pending: "Pending"

config/locales/flex/es-US.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ es-US:
5959
errors:
6060
messages:
6161
invalid_date: "Fecha inválida"
62+
invalid_tax_id: "no es un número de identificación fiscal válido. Use el formato: (XXX-XX-XXXX)"
6263
tasks:
6364
statuses:
6465
pending: "Pendiente"

spec/dummy/app/models/test_record.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ class TestRecord < ApplicationRecord
44
flex_attribute :address, :address
55
flex_attribute :date_of_birth, :memorable_date
66
flex_attribute :name, :name
7+
flex_attribute :tax_id, :tax_id
78
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class AddTaxIdToTestRecords < ActiveRecord::Migration[8.0]
2+
def change
3+
add_column :test_records, :tax_id, :string
4+
end
5+
end

spec/dummy/db/schema.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@
1010
#
1111
# It's strongly recommended that you check this file into your version control system.
1212

13-
ActiveRecord::Schema[8.0].define(version: 2025_05_17_053839) do
13+
ActiveRecord::Schema[8.0].define(version: 2025_05_18_000000) do
1414
create_table "flex_tasks", force: :cascade do |t|
1515
t.string "type"
1616
t.text "description"
1717
t.integer "status", default: 0
18-
t.integer "assignee_id"
19-
t.integer "case_id"
18+
t.string "assignee_id"
19+
t.string "case_id"
2020
t.datetime "created_at", null: false
2121
t.datetime "updated_at", null: false
2222
t.date "due_on"
@@ -73,6 +73,7 @@
7373
t.string "address_city"
7474
t.string "address_state"
7575
t.string "address_zip_code"
76+
t.string "tax_id"
7677
end
7778

7879
create_table "users", force: :cascade do |t|

spec/dummy/spec/lib/flex/attributes_spec.rb

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,169 @@
139139
expect(object.address_zip_code).to eq("10003")
140140
end
141141
end
142+
143+
describe "tax_id attribute" do
144+
it "allows setting a tax_id as a TaxId object" do
145+
tax_id = Flex::TaxId.new("123456789")
146+
object.tax_id = tax_id
147+
148+
expect(object.tax_id).to be_a(Flex::TaxId)
149+
expect(object.tax_id.formatted).to eq("123-45-6789")
150+
end
151+
152+
it "allows setting a tax_id as a string" do
153+
object.tax_id = "123456789"
154+
155+
expect(object.tax_id).to be_a(Flex::TaxId)
156+
expect(object.tax_id.formatted).to eq("123-45-6789")
157+
end
158+
159+
[
160+
[ "123456789", "123-45-6789" ],
161+
[ "123-45-6789", "123-45-6789" ],
162+
[ "123 45 6789", "123-45-6789" ]
163+
].each do |input_string, expected|
164+
it "formats tax_id correctly [#{input_string}]" do
165+
object.tax_id = input_string
166+
expect(object.tax_id.formatted).to eq(expected)
167+
end
168+
end
169+
170+
it "preserves invalid values for validation" do
171+
object.tax_id = "12345"
172+
173+
expect(object.tax_id).to be_a(Flex::TaxId)
174+
expect(object.tax_id.formatted).to eq("12345") # Raw value since not 9 digits
175+
expect(object).not_to be_valid
176+
expect(object.errors.full_messages_for("tax_id")).to eq([ "Tax ID is not a valid Taxpayer Identification Number (TIN). Use the format (XXX-XX-XXXX)" ])
177+
end
178+
179+
describe "TaxId.<=>" do
180+
it "allows sorting tax ids" do
181+
tax_ids = [
182+
Flex::TaxId.new("987654321"),
183+
Flex::TaxId.new("123456789"),
184+
Flex::TaxId.new("456789123")
185+
]
186+
187+
sorted_tax_ids = tax_ids.sort
188+
expect(sorted_tax_ids.map(&:formatted)).to eq([
189+
"123-45-6789",
190+
"456-78-9123",
191+
"987-65-4321"
192+
])
193+
end
194+
195+
it "compares tax ids numerically" do
196+
lower = Flex::TaxId.new("123456789")
197+
higher = Flex::TaxId.new("987654321")
198+
199+
expect(lower <=> higher).to eq(-1)
200+
expect(higher <=> lower).to eq(1)
201+
expect(lower <=> lower).to eq(0)
202+
end
203+
204+
it "handles comparison with different formats" do
205+
tax_id1 = Flex::TaxId.new("123-45-6789")
206+
tax_id2 = Flex::TaxId.new("123456789")
207+
208+
expect(tax_id1 <=> tax_id2).to eq(0)
209+
end
210+
211+
it "handles comparison with string values" do
212+
tax_id = Flex::TaxId.new("123-45-6789")
213+
string_value = "123456789"
214+
215+
expect(tax_id <=> string_value).to eq(0)
216+
expect(tax_id <=> "987654321").to eq(-1)
217+
expect(tax_id <=> "000456789").to eq(1)
218+
end
219+
end
220+
end
221+
222+
describe "persisting and loading from database" do
223+
let(:record) { TestRecord.new }
224+
225+
it "persists and loads name object correctly" do
226+
name = Flex::Name.new("John", "Middle", "Doe")
227+
record.name = name
228+
record.save!
229+
230+
loaded_record = TestRecord.find(record.id)
231+
expect(loaded_record.name).to be_a(Flex::Name)
232+
expect(loaded_record.name).to eq(name)
233+
expect(loaded_record.name_first).to eq("John")
234+
expect(loaded_record.name_middle).to eq("Middle")
235+
expect(loaded_record.name_last).to eq("Doe")
236+
end
237+
238+
it "persists and loads address object correctly" do
239+
address = Flex::Address.new("123 Main St", "Apt 4B", "Boston", "MA", "02108")
240+
record.address = address
241+
record.save!
242+
243+
loaded_record = TestRecord.find(record.id)
244+
expect(loaded_record.address).to be_a(Flex::Address)
245+
expect(loaded_record.address).to eq(address)
246+
expect(loaded_record.address_street_line_1).to eq("123 Main St")
247+
expect(loaded_record.address_street_line_2).to eq("Apt 4B")
248+
expect(loaded_record.address_city).to eq("Boston")
249+
expect(loaded_record.address_state).to eq("MA")
250+
expect(loaded_record.address_zip_code).to eq("02108")
251+
end
252+
253+
it "persists and loads tax_id object correctly" do
254+
tax_id = Flex::TaxId.new("123-45-6789")
255+
record.tax_id = tax_id
256+
record.save!
257+
258+
loaded_record = TestRecord.find(record.id)
259+
expect(loaded_record.tax_id).to be_a(Flex::TaxId)
260+
expect(loaded_record.tax_id).to eq(tax_id)
261+
expect(loaded_record.tax_id.formatted).to eq("123-45-6789")
262+
end
263+
264+
it "persists and loads memorable date correctly" do
265+
date = Date.new(2020, 1, 2)
266+
record.date_of_birth = date
267+
record.save!
268+
269+
loaded_record = TestRecord.find(record.id)
270+
expect(loaded_record.date_of_birth).to eq(date)
271+
expect(loaded_record.date_of_birth.year).to eq(2020)
272+
expect(loaded_record.date_of_birth.month).to eq(1)
273+
expect(loaded_record.date_of_birth.day).to eq(2)
274+
end
275+
276+
it "preserves all attributes when saving and loading multiple value objects" do
277+
record.name = Flex::Name.new("Jane", "Marie", "Smith")
278+
record.address = Flex::Address.new("456 Oak St", "Unit 7", "Chicago", "IL", "60601")
279+
record.tax_id = Flex::TaxId.new("987-65-4321")
280+
record.date_of_birth = Date.new(1990, 3, 15)
281+
record.save!
282+
283+
loaded_record = TestRecord.find(record.id)
284+
285+
# Verify name
286+
expect(loaded_record.name).to eq(Flex::Name.new("Jane", "Marie", "Smith"))
287+
expect(loaded_record.name_first).to eq("Jane")
288+
expect(loaded_record.name_middle).to eq("Marie")
289+
expect(loaded_record.name_last).to eq("Smith")
290+
291+
# Verify address
292+
expect(loaded_record.address).to eq(Flex::Address.new("456 Oak St", "Unit 7", "Chicago", "IL", "60601"))
293+
expect(loaded_record.address_street_line_1).to eq("456 Oak St")
294+
expect(loaded_record.address_street_line_2).to eq("Unit 7")
295+
expect(loaded_record.address_city).to eq("Chicago")
296+
expect(loaded_record.address_state).to eq("IL")
297+
expect(loaded_record.address_zip_code).to eq("60601")
298+
299+
# Verify tax_id
300+
expect(loaded_record.tax_id).to eq(Flex::TaxId.new("987-65-4321"))
301+
expect(loaded_record.tax_id.formatted).to eq("987-65-4321")
302+
303+
# Verify date_of_birth
304+
expect(loaded_record.date_of_birth).to eq(Date.new(1990, 3, 15))
305+
end
306+
end
142307
end

0 commit comments

Comments
 (0)