square_sandbox_simulator 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.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +176 -0
  4. data/bin/simulate +388 -0
  5. data/lib/square_sandbox_simulator/configuration.rb +193 -0
  6. data/lib/square_sandbox_simulator/data/cafe_bakery/categories.json +54 -0
  7. data/lib/square_sandbox_simulator/data/cafe_bakery/combos.json +33 -0
  8. data/lib/square_sandbox_simulator/data/cafe_bakery/coupon_codes.json +133 -0
  9. data/lib/square_sandbox_simulator/data/cafe_bakery/discounts.json +113 -0
  10. data/lib/square_sandbox_simulator/data/cafe_bakery/items.json +55 -0
  11. data/lib/square_sandbox_simulator/data/cafe_bakery/modifiers.json +73 -0
  12. data/lib/square_sandbox_simulator/data/cafe_bakery/tax_rates.json +26 -0
  13. data/lib/square_sandbox_simulator/data/cafe_bakery/tenders.json +41 -0
  14. data/lib/square_sandbox_simulator/data/restaurant/categories.json +54 -0
  15. data/lib/square_sandbox_simulator/data/restaurant/combos.json +265 -0
  16. data/lib/square_sandbox_simulator/data/restaurant/coupon_codes.json +266 -0
  17. data/lib/square_sandbox_simulator/data/restaurant/discounts.json +198 -0
  18. data/lib/square_sandbox_simulator/data/restaurant/gift_cards.json +82 -0
  19. data/lib/square_sandbox_simulator/data/restaurant/items.json +388 -0
  20. data/lib/square_sandbox_simulator/data/restaurant/modifiers.json +62 -0
  21. data/lib/square_sandbox_simulator/data/restaurant/tax_rates.json +38 -0
  22. data/lib/square_sandbox_simulator/data/restaurant/tenders.json +41 -0
  23. data/lib/square_sandbox_simulator/data/salon_spa/categories.json +24 -0
  24. data/lib/square_sandbox_simulator/data/salon_spa/combos.json +88 -0
  25. data/lib/square_sandbox_simulator/data/salon_spa/coupon_codes.json +96 -0
  26. data/lib/square_sandbox_simulator/data/salon_spa/discounts.json +93 -0
  27. data/lib/square_sandbox_simulator/data/salon_spa/gift_cards.json +47 -0
  28. data/lib/square_sandbox_simulator/data/salon_spa/items.json +100 -0
  29. data/lib/square_sandbox_simulator/data/salon_spa/modifiers.json +49 -0
  30. data/lib/square_sandbox_simulator/data/salon_spa/tax_rates.json +17 -0
  31. data/lib/square_sandbox_simulator/data/salon_spa/tenders.json +41 -0
  32. data/lib/square_sandbox_simulator/database.rb +224 -0
  33. data/lib/square_sandbox_simulator/db/factories/api_requests.rb +95 -0
  34. data/lib/square_sandbox_simulator/db/factories/business_types.rb +178 -0
  35. data/lib/square_sandbox_simulator/db/factories/categories.rb +379 -0
  36. data/lib/square_sandbox_simulator/db/factories/daily_summaries.rb +56 -0
  37. data/lib/square_sandbox_simulator/db/factories/items.rb +1526 -0
  38. data/lib/square_sandbox_simulator/db/factories/simulated_orders.rb +112 -0
  39. data/lib/square_sandbox_simulator/db/factories/simulated_payments.rb +61 -0
  40. data/lib/square_sandbox_simulator/db/migrate/20260312000000_enable_pgcrypto.rb +7 -0
  41. data/lib/square_sandbox_simulator/db/migrate/20260312000001_create_business_types.rb +18 -0
  42. data/lib/square_sandbox_simulator/db/migrate/20260312000002_create_categories.rb +18 -0
  43. data/lib/square_sandbox_simulator/db/migrate/20260312000003_create_items.rb +23 -0
  44. data/lib/square_sandbox_simulator/db/migrate/20260312000004_create_simulated_orders.rb +36 -0
  45. data/lib/square_sandbox_simulator/db/migrate/20260312000005_create_simulated_payments.rb +26 -0
  46. data/lib/square_sandbox_simulator/db/migrate/20260312000006_create_api_requests.rb +27 -0
  47. data/lib/square_sandbox_simulator/db/migrate/20260312000007_create_daily_summaries.rb +24 -0
  48. data/lib/square_sandbox_simulator/generators/data_loader.rb +202 -0
  49. data/lib/square_sandbox_simulator/generators/entity_generator.rb +248 -0
  50. data/lib/square_sandbox_simulator/generators/order_generator.rb +632 -0
  51. data/lib/square_sandbox_simulator/models/api_request.rb +43 -0
  52. data/lib/square_sandbox_simulator/models/business_type.rb +25 -0
  53. data/lib/square_sandbox_simulator/models/category.rb +18 -0
  54. data/lib/square_sandbox_simulator/models/daily_summary.rb +68 -0
  55. data/lib/square_sandbox_simulator/models/item.rb +33 -0
  56. data/lib/square_sandbox_simulator/models/record.rb +16 -0
  57. data/lib/square_sandbox_simulator/models/simulated_order.rb +42 -0
  58. data/lib/square_sandbox_simulator/models/simulated_payment.rb +28 -0
  59. data/lib/square_sandbox_simulator/seeder.rb +242 -0
  60. data/lib/square_sandbox_simulator/services/base_service.rb +253 -0
  61. data/lib/square_sandbox_simulator/services/square/catalog_service.rb +203 -0
  62. data/lib/square_sandbox_simulator/services/square/customer_service.rb +130 -0
  63. data/lib/square_sandbox_simulator/services/square/order_service.rb +121 -0
  64. data/lib/square_sandbox_simulator/services/square/payment_service.rb +136 -0
  65. data/lib/square_sandbox_simulator/services/square/services_manager.rb +68 -0
  66. data/lib/square_sandbox_simulator/services/square/team_service.rb +108 -0
  67. data/lib/square_sandbox_simulator/version.rb +5 -0
  68. data/lib/square_sandbox_simulator.rb +47 -0
  69. metadata +348 -0
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SquareSandboxSimulator
4
+ module Models
5
+ class Category < Record
6
+ belongs_to :business_type
7
+ has_many :items, dependent: :destroy
8
+
9
+ # Validations — no uniqueness constraint; real Square merchants
10
+ # frequently have duplicate category names.
11
+ validates :name, presence: true
12
+
13
+ # Explicit sort scope (avoids default_scope anti-pattern)
14
+ scope :sorted, -> { order(:sort_order) }
15
+ scope :with_items, -> { joins(:items).distinct }
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SquareSandboxSimulator
4
+ module Models
5
+ class DailySummary < Record
6
+ # Validations
7
+ validates :merchant_id, presence: true
8
+ validates :business_date, presence: true,
9
+ uniqueness: { scope: :merchant_id }
10
+
11
+ # Scopes
12
+ scope :for_merchant, ->(merchant_id) { where(merchant_id: merchant_id) }
13
+ scope :on_date, ->(date) { where(business_date: date) }
14
+ scope :today, -> { where(business_date: Date.today) }
15
+ scope :between_dates, ->(from, to) { where(business_date: from..to) }
16
+ scope :recent, ->(days = 7) { where("business_date >= ?", days.days.ago.to_date) }
17
+
18
+ # Generate (or update) a daily summary by aggregating simulated orders.
19
+ # Race-condition safe: retries on unique constraint violation.
20
+ #
21
+ # @param merchant_id [String] Square merchant ID
22
+ # @param date [Date] Business date to summarize
23
+ # @return [DailySummary] the created or updated summary
24
+ def self.generate_for!(merchant_id, date)
25
+ orders = SimulatedOrder.for_location(merchant_id).on_date(date).successful
26
+ payments = SimulatedPayment.joins(:simulated_order)
27
+ .where(simulated_orders: {
28
+ square_location_id: merchant_id,
29
+ business_date: date,
30
+ status: "paid",
31
+ })
32
+
33
+ # Build breakdown by meal period and dining option
34
+ breakdown = {
35
+ by_meal_period: orders.group(:meal_period).count,
36
+ by_dining_option: orders.group(:dining_option).count,
37
+ by_tender: payments.group(:tender_name).count,
38
+ revenue_by_meal_period: orders.group(:meal_period).sum(:total),
39
+ revenue_by_dining_option: orders.group(:dining_option).sum(:total),
40
+ }
41
+
42
+ attrs = {
43
+ order_count: orders.count,
44
+ payment_count: payments.count,
45
+ refund_count: SimulatedOrder.for_location(merchant_id).on_date(date).refunded.count,
46
+ total_revenue: orders.sum(:total),
47
+ total_tax: orders.sum(:tax_amount),
48
+ total_tips: orders.sum(:tip_amount),
49
+ total_discounts: orders.sum(:discount_amount),
50
+ breakdown: breakdown,
51
+ }
52
+
53
+ summary = find_or_initialize_by(merchant_id: merchant_id, business_date: date)
54
+ summary.assign_attributes(attrs)
55
+ summary.save!
56
+ summary
57
+ rescue ::ActiveRecord::RecordNotUnique
58
+ # Concurrent insert won — retry will find the existing record and update it
59
+ retry
60
+ end
61
+
62
+ # Convenience: total revenue in dollars
63
+ def total_revenue_dollars
64
+ (total_revenue || 0) / 100.0
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SquareSandboxSimulator
4
+ module Models
5
+ class Item < Record
6
+ belongs_to :category
7
+
8
+ # Validations — no uniqueness constraint; real Square merchants
9
+ # can have items with the same name in the same category.
10
+ validates :name, presence: true
11
+ validates :price, presence: true,
12
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
13
+
14
+ # Scopes
15
+ scope :active, -> { where(active: true) }
16
+ scope :inactive, -> { where(active: false) }
17
+
18
+ scope :for_business_type, lambda { |key|
19
+ joins(category: :business_type)
20
+ .where(business_types: { key: key })
21
+ }
22
+
23
+ scope :in_category, lambda { |category_name|
24
+ joins(:category).where(categories: { name: category_name })
25
+ }
26
+
27
+ # Price in dollars (convenience)
28
+ def price_dollars
29
+ (price || 0) / 100.0
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module SquareSandboxSimulator
6
+ module Models
7
+ # Shared base class for all simulator ActiveRecord models.
8
+ #
9
+ # Equivalent to ApplicationRecord in Rails, but for standalone usage.
10
+ # All models inherit from this so we can add shared behaviour
11
+ # (e.g. logging, default scopes) in one place.
12
+ class Record < ::ActiveRecord::Base
13
+ self.abstract_class = true
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SquareSandboxSimulator
4
+ module Models
5
+ class SimulatedOrder < Record
6
+ belongs_to :business_type, optional: true
7
+ has_many :simulated_payments, dependent: :destroy
8
+
9
+ # NOTE: no has_many :items — simulated orders track monetary totals only.
10
+ # Line-item detail lives in the Square API response (stored in metadata jsonb).
11
+
12
+ # Validations
13
+ validates :square_location_id, presence: true
14
+ validates :status, presence: true
15
+ validates :business_date, presence: true
16
+
17
+ # Status scopes
18
+ scope :successful, -> { where(status: "paid") }
19
+ scope :open_orders, -> { where(status: "open") }
20
+ scope :refunded, -> { where(status: "refunded") }
21
+
22
+ # Time scopes
23
+ scope :today, -> { where(business_date: Date.today) }
24
+ scope :on_date, ->(date) { where(business_date: date) }
25
+ scope :between_dates, ->(from, to) { where(business_date: from..to) }
26
+
27
+ # Filter scopes
28
+ scope :for_location, ->(location_id) { where(square_location_id: location_id) }
29
+ scope :for_meal_period, ->(period) { where(meal_period: period) }
30
+ scope :for_dining_option, ->(option) { where(dining_option: option) }
31
+
32
+ # Amounts in dollars (convenience)
33
+ def total_dollars
34
+ (total || 0) / 100.0
35
+ end
36
+
37
+ def subtotal_dollars
38
+ (subtotal || 0) / 100.0
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SquareSandboxSimulator
4
+ module Models
5
+ class SimulatedPayment < Record
6
+ belongs_to :simulated_order
7
+
8
+ # Validations
9
+ validates :tender_name, presence: true
10
+ validates :status, presence: true
11
+ validates :amount, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
12
+
13
+ # Status scopes
14
+ scope :successful, -> { where(status: "SUCCESS") }
15
+ scope :pending, -> { where(status: "pending") }
16
+ scope :refunded, -> { where(status: "refunded") }
17
+
18
+ # Tender scopes
19
+ scope :cash, -> { where(tender_name: "Cash") }
20
+ scope :by_tender, ->(name) { where(tender_name: name) }
21
+
22
+ # Amount in dollars (convenience)
23
+ def amount_dollars
24
+ (amount || 0) / 100.0
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "factory_bot"
4
+
5
+ module SquareSandboxSimulator
6
+ # Seeds the database with realistic Square sandbox data using FactoryBot factories.
7
+ #
8
+ # Idempotent -- safe to run multiple times without creating duplicates.
9
+ # Uses `find_or_create_by!` on unique keys (BusinessType.key,
10
+ # Category.name+business_type, Item.name+category).
11
+ #
12
+ # @example Seed all business types
13
+ # SquareSandboxSimulator::Seeder.seed!
14
+ #
15
+ # @example Seed a single business type
16
+ # SquareSandboxSimulator::Seeder.seed!(business_type: :retail_clothing)
17
+ #
18
+ class Seeder
19
+ # Maps each business type trait to its category traits,
20
+ # and each category trait to its item traits.
21
+ #
22
+ # Structure: { bt_trait => { cat_trait => [item_traits] } }
23
+ SEED_MAP = {
24
+ restaurant: {
25
+ appetizers: %i[buffalo_wings mozzarella_sticks loaded_nachos spinach_artichoke_dip calamari],
26
+ entrees: %i[grilled_salmon ny_strip_steak chicken_parmesan pasta_primavera herb_roasted_chicken],
27
+ sides: %i[french_fries coleslaw mashed_potatoes side_salad onion_rings],
28
+ desserts: %i[chocolate_lava_cake ny_cheesecake restaurant_tiramisu apple_pie ice_cream_sundae],
29
+ beverages: %i[soft_drink iced_tea lemonade sparkling_water restaurant_coffee],
30
+ },
31
+ cafe_bakery: {
32
+ coffee_espresso: %i[house_drip_coffee espresso cappuccino latte cold_brew],
33
+ pastries: %i[croissant blueberry_muffin cinnamon_roll chocolate_chip_cookie],
34
+ breakfast: %i[avocado_toast breakfast_burrito acai_bowl yogurt_parfait],
35
+ sandwiches: %i[turkey_club caprese_panini chicken_caesar_wrap blt],
36
+ smoothies: %i[berry_blast_smoothie green_detox_juice mango_tango_smoothie fresh_oj],
37
+ # Intentional duplicates (mirrors real POS messiness)
38
+ coffee_espresso_dup: %i[house_drip_coffee_dup latte_dup iced_latte matcha_latte],
39
+ pastries_dup: %i[croissant_dup blueberry_muffin_dup almond_croissant banana_bread],
40
+ breakfast_dup: %i[avocado_toast_dup eggs_benedict french_toast],
41
+ cafe_drinks: %i[cafe_drip_coffee cafe_iced_tea cafe_lemonade cafe_hot_chocolate berry_blast_smoothie_dup],
42
+ cafe_grab_and_go: %i[gg_croissant gg_muffin gg_blt gg_yogurt_parfait gg_cold_brew],
43
+ },
44
+ bar_nightclub: {
45
+ draft_beer: %i[house_lager ipa stout wheat_beer],
46
+ cocktails: %i[margarita old_fashioned mojito espresso_martini],
47
+ spirits: %i[whiskey_neat vodka_soda tequila_shot rum_and_coke],
48
+ wine: %i[house_red house_white prosecco rose],
49
+ bar_snacks: %i[bar_loaded_fries sliders bar_wings pretzel_bites],
50
+ },
51
+ food_truck: {
52
+ tacos: %i[carne_asada_taco al_pastor_taco fish_taco veggie_taco birria_taco],
53
+ burritos_bowls: %i[classic_burrito burrito_bowl quesadilla truck_nachos],
54
+ truck_sides_drinks: %i[chips_and_guac elote rice_and_beans horchata jarritos],
55
+ },
56
+ fine_dining: {
57
+ first_course: %i[seared_foie_gras lobster_bisque tuna_tartare burrata_salad oysters_half_dozen],
58
+ main_course: %i[wagyu_ribeye chilean_sea_bass rack_of_lamb duck_breast truffle_risotto],
59
+ fine_desserts: %i[creme_brulee chocolate_souffle tasting_plate cheese_board fine_tiramisu],
60
+ },
61
+ pizzeria: {
62
+ pizzas: %i[margherita pepperoni supreme hawaiian bbq_chicken_pizza meat_lovers],
63
+ calzones: %i[classic_calzone meat_calzone stromboli spinach_calzone],
64
+ pizza_sides_drinks: %i[garlic_bread garden_salad caesar_salad garlic_knots fountain_drink cannoli],
65
+ },
66
+ retail_clothing: {
67
+ tops: %i[classic_tshirt button_down_shirt polo_shirt hoodie tank_top],
68
+ bottoms: %i[slim_fit_jeans chino_pants joggers shorts],
69
+ outerwear: %i[denim_jacket bomber_jacket puffer_vest rain_jacket],
70
+ accessories: %i[baseball_cap beanie canvas_belt scarf],
71
+ footwear: %i[canvas_sneakers leather_boots sandals running_shoes],
72
+ },
73
+ retail_general: {
74
+ electronics: %i[wireless_earbuds phone_charger bluetooth_speaker power_bank],
75
+ home_kitchen: %i[scented_candle throw_pillow kitchen_towel_set ceramic_mug],
76
+ personal_care: %i[hand_soap body_lotion lip_balm sunscreen],
77
+ office_supplies: %i[notebook pen_set desk_organizer sticky_notes],
78
+ snacks_beverages: %i[granola_bar_3pack retail_sparkling_water trail_mix dark_chocolate_bar],
79
+ },
80
+ salon_spa: {
81
+ haircuts: %i[womens_haircut mens_haircut childrens_haircut bang_trim],
82
+ color_services: %i[full_color partial_highlights full_highlights balayage],
83
+ spa_treatments: %i[swedish_massage deep_tissue_massage hot_stone_massage facial],
84
+ nail_services: %i[manicure pedicure gel_manicure acrylic_full_set],
85
+ },
86
+ }.freeze
87
+
88
+ # Expected category counts per business type (for spec validation).
89
+ CATEGORY_COUNTS = SEED_MAP.transform_values(&:size).freeze
90
+
91
+ # Expected item counts per category (for spec validation).
92
+ ITEM_COUNTS = SEED_MAP.each_with_object({}) do |(_, cats), hash|
93
+ cats.each { |cat_trait, items| hash[cat_trait] = items.size }
94
+ end.freeze
95
+
96
+ # Total counts across all business types.
97
+ TOTAL_BUSINESS_TYPES = SEED_MAP.size
98
+ TOTAL_CATEGORIES = SEED_MAP.values.sum(&:size)
99
+ TOTAL_ITEMS = SEED_MAP.values.flat_map(&:values).flatten.size
100
+
101
+ # Seed all (or one) business types with categories and items.
102
+ #
103
+ # @param business_type [Symbol, String, nil] Seed only this type, or all if nil.
104
+ # @return [Hash] Summary of created/found counts.
105
+ def self.seed!(business_type: nil)
106
+ new.seed!(business_type: business_type)
107
+ end
108
+
109
+ # @param business_type [Symbol, String, nil]
110
+ # @return [Hash] Summary with :business_types, :categories, :items,
111
+ # :created, :found counts.
112
+ def seed!(business_type: nil)
113
+ types_to_seed = resolve_types(business_type)
114
+
115
+ counts = { business_types: 0, categories: 0, items: 0, created: 0, found: 0 }
116
+
117
+ ActiveRecord::Base.transaction do
118
+ types_to_seed.each do |bt_trait, categories_map|
119
+ bt, was_new = seed_business_type(bt_trait)
120
+ counts[:business_types] += 1
121
+ was_new ? counts[:created] += 1 : counts[:found] += 1
122
+
123
+ categories_map.each do |cat_trait, item_traits|
124
+ cat, was_new = seed_category(cat_trait, bt)
125
+ counts[:categories] += 1
126
+ was_new ? counts[:created] += 1 : counts[:found] += 1
127
+
128
+ item_traits.each do |item_trait|
129
+ _, was_new = seed_item(item_trait, cat)
130
+ counts[:items] += 1
131
+ was_new ? counts[:created] += 1 : counts[:found] += 1
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ SquareSandboxSimulator.logger.info(
138
+ "Seeding complete: #{counts[:business_types]} business types, " \
139
+ "#{counts[:categories]} categories, #{counts[:items]} items " \
140
+ "(#{counts[:created]} created, #{counts[:found]} found)",
141
+ )
142
+
143
+ counts
144
+ end
145
+
146
+ private
147
+
148
+ # Resolve which business types to seed.
149
+ #
150
+ # @param business_type [Symbol, String, nil]
151
+ # @return [Hash] subset of SEED_MAP
152
+ # @raise [ArgumentError] if the business type is unknown
153
+ def resolve_types(business_type)
154
+ return SEED_MAP if business_type.nil?
155
+
156
+ key = business_type.to_sym
157
+ unless SEED_MAP.key?(key)
158
+ raise ArgumentError,
159
+ "Unknown business type: #{key}. Valid types: #{SEED_MAP.keys.join(", ")}"
160
+ end
161
+
162
+ { key => SEED_MAP[key] }
163
+ end
164
+
165
+ # Find or create a business type using factory attributes.
166
+ #
167
+ # @param trait [Symbol]
168
+ # @return [Array(Models::BusinessType, Boolean)] record and whether it was newly created
169
+ def seed_business_type(trait)
170
+ attrs = FactoryBot.attributes_for(:business_type, trait)
171
+ record = Models::BusinessType.find_or_create_by!(key: attrs[:key]) do |bt|
172
+ bt.assign_attributes(attrs)
173
+ end
174
+ [record, record.previously_new_record?]
175
+ end
176
+
177
+ # Traits that represent intentional duplicates -- always create
178
+ # a new record instead of reusing an existing one.
179
+ DUPLICATE_CATEGORY_TRAITS = %i[
180
+ coffee_espresso_dup pastries_dup breakfast_dup cafe_drinks cafe_grab_and_go
181
+ ].freeze
182
+
183
+ DUPLICATE_ITEM_TRAITS = %i[
184
+ house_drip_coffee_dup latte_dup iced_latte matcha_latte
185
+ croissant_dup blueberry_muffin_dup almond_croissant banana_bread
186
+ avocado_toast_dup eggs_benedict french_toast
187
+ cafe_drip_coffee cafe_iced_tea cafe_lemonade cafe_hot_chocolate berry_blast_smoothie_dup
188
+ gg_croissant gg_muffin gg_blt gg_yogurt_parfait gg_cold_brew
189
+ ].freeze
190
+
191
+ # Find or create a category using factory attributes.
192
+ # Duplicate traits always create a new record (mirroring real-world
193
+ # data where merchants have multiple categories with the same name).
194
+ #
195
+ # @param trait [Symbol]
196
+ # @param business_type [Models::BusinessType]
197
+ # @return [Array(Models::Category, Boolean)] record and whether it was newly created
198
+ def seed_category(trait, business_type)
199
+ attrs = FactoryBot.attributes_for(:category, trait)
200
+
201
+ if DUPLICATE_CATEGORY_TRAITS.include?(trait)
202
+ # Check by SKU-like hint (sort_order) to be idempotent even for dupes
203
+ existing = Models::Category.find_by(
204
+ name: attrs[:name], business_type: business_type, sort_order: attrs[:sort_order],
205
+ )
206
+ return [existing, false] if existing
207
+
208
+ record = Models::Category.create!(attrs.merge(business_type: business_type).except(:business_type_id))
209
+ [record, true]
210
+ else
211
+ record = Models::Category.find_or_create_by!(name: attrs[:name], business_type: business_type) do |cat|
212
+ cat.assign_attributes(attrs.except(:business_type_id))
213
+ end
214
+ [record, record.previously_new_record?]
215
+ end
216
+ end
217
+
218
+ # Find or create an item using factory attributes.
219
+ # Duplicate traits always create a new record.
220
+ #
221
+ # @param trait [Symbol]
222
+ # @param category [Models::Category]
223
+ # @return [Array(Models::Item, Boolean)] record and whether it was newly created
224
+ def seed_item(trait, category)
225
+ attrs = FactoryBot.attributes_for(:item, trait)
226
+
227
+ if DUPLICATE_ITEM_TRAITS.include?(trait)
228
+ # Idempotent by SKU -- each duplicate has a unique SKU
229
+ existing = Models::Item.find_by(sku: attrs[:sku])
230
+ return [existing, false] if existing
231
+
232
+ record = Models::Item.create!(attrs.merge(category: category).except(:category_id))
233
+ [record, true]
234
+ else
235
+ record = Models::Item.find_or_create_by!(name: attrs[:name], category: category) do |item|
236
+ item.assign_attributes(attrs.except(:category_id))
237
+ end
238
+ [record, record.previously_new_record?]
239
+ end
240
+ end
241
+ end
242
+ end