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,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SquareSandboxSimulator
4
+ module Services
5
+ module Square
6
+ # Manages Square orders
7
+ class OrderService < BaseService
8
+ # Create an order
9
+ # @param line_items [Array<Hash>] Array of line item hashes
10
+ # @param discounts [Array<Hash>] Array of discount hashes
11
+ # @param taxes [Array<Hash>] Array of tax hashes
12
+ # @param customer_id [String, nil] Customer ID to associate
13
+ # @return [Hash, nil] Order response
14
+ def create_order(line_items:, discounts: [], taxes: [], customer_id: nil)
15
+ logger.info "Creating order with #{line_items.size} line items..."
16
+
17
+ order_data = {
18
+ "location_id" => config.location_id,
19
+ "line_items" => line_items,
20
+ }
21
+
22
+ order_data["discounts"] = discounts if discounts.any?
23
+ order_data["taxes"] = taxes if taxes.any?
24
+ order_data["customer_id"] = customer_id if customer_id
25
+
26
+ payload = { "order" => order_data }
27
+
28
+ response = request(:post, "orders",
29
+ payload: payload,
30
+ resource_type: "Order")
31
+
32
+ order = response&.dig("order")
33
+ logger.info "Order created: #{order["id"]}" if order
34
+ order
35
+ end
36
+
37
+ # Get a single order (Square uses batch-retrieve for single orders)
38
+ # @param order_id [String] The order ID
39
+ # @return [Hash, nil] Order response
40
+ def get_order(order_id)
41
+ logger.info "Fetching order: #{order_id}"
42
+
43
+ payload = {
44
+ "order_ids" => [order_id],
45
+ }
46
+
47
+ response = request(:post, "orders/batch-retrieve",
48
+ payload: payload,
49
+ resource_type: "Order",
50
+ resource_id: order_id)
51
+
52
+ orders = response&.dig("orders") || []
53
+ orders.first
54
+ end
55
+
56
+ # Search orders with filters
57
+ # @param filters [Hash] Search filters
58
+ # @return [Array<Hash>] Matching orders
59
+ def search_orders(filters: {})
60
+ logger.info "Searching orders..."
61
+
62
+ payload = {
63
+ "location_ids" => [config.location_id],
64
+ }
65
+ payload["query"] = { "filter" => filters } if filters.any?
66
+
67
+ response = request(:post, "orders/search",
68
+ payload: payload,
69
+ resource_type: "Order")
70
+
71
+ orders = response&.dig("orders") || []
72
+ logger.info "Found #{orders.size} orders"
73
+ orders
74
+ end
75
+
76
+ # Update an order
77
+ # @param order_id [String] The order ID
78
+ # @param fields [Hash] Fields to update on the order object
79
+ # @param version [Integer] Current order version for optimistic concurrency
80
+ # @return [Hash, nil] Updated order
81
+ def update_order(order_id, fields:, version:)
82
+ logger.info "Updating order: #{order_id}"
83
+
84
+ order_data = fields.merge(
85
+ "location_id" => config.location_id,
86
+ "version" => version,
87
+ )
88
+
89
+ payload = { "order" => order_data }
90
+
91
+ response = request(:put, "orders/#{order_id}",
92
+ payload: payload,
93
+ resource_type: "Order",
94
+ resource_id: order_id)
95
+
96
+ response&.dig("order")
97
+ end
98
+
99
+ # Calculate order totals without creating
100
+ # @param line_items [Array<Hash>] Array of line item hashes
101
+ # @return [Hash, nil] Calculated order
102
+ def calculate_order(line_items:)
103
+ logger.info "Calculating order..."
104
+
105
+ payload = {
106
+ "order" => {
107
+ "location_id" => config.location_id,
108
+ "line_items" => line_items,
109
+ },
110
+ }
111
+
112
+ response = request(:post, "orders/calculate",
113
+ payload: payload,
114
+ resource_type: "Order")
115
+
116
+ response&.dig("order")
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SquareSandboxSimulator
4
+ module Services
5
+ module Square
6
+ # Manages Square payments and refunds
7
+ class PaymentService < BaseService
8
+ # Create a payment
9
+ # @param order_id [String] The order ID
10
+ # @param amount [Integer] Amount in cents
11
+ # @param source_id [String] Payment source (default: sandbox card nonce)
12
+ # @param tip_amount [Integer] Tip amount in cents
13
+ # @return [Hash, nil] Payment response
14
+ def create_payment(order_id:, amount:, source_id: "cnon:card-nonce-ok", tip_amount: 0)
15
+ logger.info "Creating payment for order #{order_id}: $#{amount / 100.0}"
16
+
17
+ payload = {
18
+ "source_id" => source_id,
19
+ "amount_money" => {
20
+ "amount" => amount,
21
+ "currency" => "USD",
22
+ },
23
+ "order_id" => order_id,
24
+ "location_id" => config.location_id,
25
+ }
26
+
27
+ if tip_amount.positive?
28
+ payload["tip_money"] = {
29
+ "amount" => tip_amount,
30
+ "currency" => "USD",
31
+ }
32
+ end
33
+
34
+ response = request(:post, "payments",
35
+ payload: payload,
36
+ resource_type: "Payment")
37
+
38
+ payment = response&.dig("payment")
39
+ logger.info "Payment created: #{payment["id"]} (status: #{payment["status"]})" if payment
40
+
41
+ payment
42
+ end
43
+
44
+ # Create a cash payment using external payment source
45
+ # @param order_id [String] The order ID
46
+ # @param amount [Integer] Amount in cents
47
+ # @return [Hash, nil] Payment response
48
+ def create_cash_payment(order_id:, amount:)
49
+ logger.info "Creating cash payment for order #{order_id}: $#{amount / 100.0}"
50
+
51
+ payload = {
52
+ "source_id" => "EXTERNAL",
53
+ "external_details" => {
54
+ "type" => "CASH",
55
+ "source" => "Cash Register",
56
+ },
57
+ "amount_money" => {
58
+ "amount" => amount,
59
+ "currency" => "USD",
60
+ },
61
+ "order_id" => order_id,
62
+ "location_id" => config.location_id,
63
+ }
64
+
65
+ response = request(:post, "payments",
66
+ payload: payload,
67
+ resource_type: "Payment")
68
+
69
+ payment = response&.dig("payment")
70
+ logger.info "Cash payment created: #{payment["id"]}" if payment
71
+
72
+ payment
73
+ end
74
+
75
+ # Get a single payment
76
+ # @param payment_id [String] The payment ID
77
+ # @return [Hash, nil] Payment response
78
+ def get_payment(payment_id)
79
+ logger.info "Fetching payment: #{payment_id}"
80
+ response = request(:get, "payments/#{payment_id}",
81
+ resource_type: "Payment",
82
+ resource_id: payment_id)
83
+ response&.dig("payment")
84
+ end
85
+
86
+ # List payments
87
+ # @return [Array<Hash>] Array of payments
88
+ def list_payments
89
+ logger.info "Listing payments..."
90
+ response = request(:get, "payments")
91
+ payments = response&.dig("payments") || []
92
+ logger.info "Found #{payments.size} payments"
93
+ payments
94
+ end
95
+
96
+ # Refund a payment
97
+ # @param payment_id [String] The payment ID to refund
98
+ # @param amount [Integer] Amount to refund in cents
99
+ # @param reason [String] Reason for refund
100
+ # @return [Hash, nil] Refund response
101
+ def refund_payment(payment_id:, amount:, reason:)
102
+ logger.info "Refunding payment #{payment_id}: $#{amount / 100.0} (#{reason})"
103
+
104
+ payload = {
105
+ "payment_id" => payment_id,
106
+ "amount_money" => {
107
+ "amount" => amount,
108
+ "currency" => "USD",
109
+ },
110
+ "reason" => reason,
111
+ }
112
+
113
+ response = request(:post, "refunds",
114
+ payload: payload,
115
+ resource_type: "Refund")
116
+
117
+ refund = response&.dig("refund")
118
+ logger.info "Refund created: #{refund["id"]} (status: #{refund["status"]})" if refund
119
+
120
+ refund
121
+ end
122
+
123
+ # Get a single refund
124
+ # @param refund_id [String] The refund ID
125
+ # @return [Hash, nil] Refund response
126
+ def get_refund(refund_id)
127
+ logger.info "Fetching refund: #{refund_id}"
128
+ response = request(:get, "refunds/#{refund_id}",
129
+ resource_type: "Refund",
130
+ resource_id: refund_id)
131
+ response&.dig("refund")
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SquareSandboxSimulator
4
+ module Services
5
+ module Square
6
+ # Central manager for all Square services
7
+ # Provides thread-safe, lazy-loaded access to all service classes
8
+ class ServicesManager
9
+ attr_reader :config
10
+
11
+ def initialize(config: nil)
12
+ @config = config || SquareSandboxSimulator.configuration
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def catalog
17
+ thread_safe_memoize(:@catalog) { CatalogService.new(config: config) }
18
+ end
19
+
20
+ def order
21
+ thread_safe_memoize(:@order) { OrderService.new(config: config) }
22
+ end
23
+
24
+ def payment
25
+ thread_safe_memoize(:@payment) { PaymentService.new(config: config) }
26
+ end
27
+
28
+ def customer
29
+ thread_safe_memoize(:@customer) { CustomerService.new(config: config) }
30
+ end
31
+
32
+ def team
33
+ thread_safe_memoize(:@team) { TeamService.new(config: config) }
34
+ end
35
+
36
+ # Clear all cached service instances
37
+ def clear_services
38
+ @mutex.synchronize do
39
+ instance_variables.each do |var|
40
+ next if %i[@config @mutex].include?(var)
41
+
42
+ instance_variable_set(var, nil)
43
+ end
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ # Thread-safe memoization pattern
50
+ def thread_safe_memoize(ivar_name)
51
+ # Fast path: return if already set
52
+ value = instance_variable_get(ivar_name)
53
+ return value if value
54
+
55
+ # Slow path: synchronize and check again
56
+ @mutex.synchronize do
57
+ value = instance_variable_get(ivar_name)
58
+ return value if value
59
+
60
+ value = yield
61
+ instance_variable_set(ivar_name, value)
62
+ value
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faker"
4
+
5
+ module SquareSandboxSimulator
6
+ module Services
7
+ module Square
8
+ # Manages Square team members
9
+ class TeamService < BaseService
10
+ # Create a team member
11
+ # @param given_name [String] First name
12
+ # @param family_name [String] Last name
13
+ # @param email [String, nil] Email address
14
+ # @return [Hash, nil] Team member response
15
+ def create_team_member(given_name:, family_name:, email: nil)
16
+ logger.info "Creating team member: #{given_name} #{family_name}"
17
+
18
+ team_member_data = {
19
+ "given_name" => given_name,
20
+ "family_name" => family_name,
21
+ }
22
+ team_member_data["email_address"] = email if email
23
+
24
+ # Assign to the configured location
25
+ payload = {
26
+ "team_member" => team_member_data,
27
+ }
28
+
29
+ response = request(:post, "team-members",
30
+ payload: payload,
31
+ resource_type: "TeamMember")
32
+
33
+ member = response&.dig("team_member")
34
+ logger.info "Team member created: #{member["id"]}" if member
35
+ member
36
+ end
37
+
38
+ # Search team members
39
+ # @return [Array<Hash>] Array of team members
40
+ def search_team_members
41
+ logger.info "Searching team members..."
42
+
43
+ payload = {
44
+ "query" => {
45
+ "filter" => {
46
+ "location_ids" => [config.location_id],
47
+ "status" => "ACTIVE",
48
+ },
49
+ },
50
+ }
51
+
52
+ response = request(:post, "team-members/search",
53
+ payload: payload,
54
+ resource_type: "TeamMember")
55
+
56
+ members = response&.dig("team_members") || []
57
+ logger.info "Found #{members.size} team members"
58
+ members
59
+ end
60
+
61
+ # Default team member data for deterministic setup
62
+ DEFAULT_TEAM_MEMBERS = [
63
+ { given: "Alex", family: "Manager" },
64
+ { given: "Jordan", family: "Server" },
65
+ { given: "Casey", family: "Cook" },
66
+ { given: "Riley", family: "Host" },
67
+ { given: "Morgan", family: "Bartender" },
68
+ ].freeze
69
+
70
+ # Create sample team members if needed (idempotent)
71
+ # @param count [Integer] Minimum number of team members to ensure exist
72
+ # @return [Array<Hash>] All team members
73
+ def ensure_team_members(count: 5)
74
+ existing = search_team_members
75
+ return existing if existing.size >= count
76
+
77
+ needed = count - existing.size
78
+ logger.info "Creating #{needed} sample team members..."
79
+
80
+ new_members = []
81
+ needed.times do |i|
82
+ idx = existing.size + i
83
+ if idx < DEFAULT_TEAM_MEMBERS.size
84
+ member_data = DEFAULT_TEAM_MEMBERS[idx]
85
+ given = member_data[:given]
86
+ family = member_data[:family]
87
+ else
88
+ given = Faker::Name.first_name
89
+ family = Faker::Name.last_name
90
+ end
91
+
92
+ safe_given = given.downcase.gsub(/[^a-z0-9]/, "")
93
+ safe_family = family.downcase.gsub(/[^a-z0-9]/, "")
94
+
95
+ member = create_team_member(
96
+ given_name: given,
97
+ family_name: family,
98
+ email: "#{safe_given}.#{safe_family}@example.com",
99
+ )
100
+ new_members << member if member
101
+ end
102
+
103
+ existing + new_members
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SquareSandboxSimulator
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require "logger"
5
+ require "json"
6
+ require "rest-client"
7
+ require "dotenv"
8
+
9
+ # Load environment variables
10
+ Dotenv.load
11
+
12
+ module SquareSandboxSimulator
13
+ VERSION = "0.1.0"
14
+
15
+ class Error < StandardError; end
16
+ class ConfigurationError < Error; end
17
+ class ApiError < Error; end
18
+
19
+ class << self
20
+ attr_writer :configuration
21
+
22
+ def configuration
23
+ @configuration ||= Configuration.new
24
+ end
25
+
26
+ def configure
27
+ yield(configuration)
28
+ end
29
+
30
+ def logger
31
+ configuration.logger
32
+ end
33
+
34
+ def root
35
+ File.expand_path("..", __dir__)
36
+ end
37
+ end
38
+ end
39
+
40
+ # Set up Zeitwerk autoloader
41
+ loader = Zeitwerk::Loader.for_gem
42
+ # Migrations and factories follow ActiveRecord conventions, not Zeitwerk naming
43
+ loader.ignore(File.expand_path("square_sandbox_simulator/db", __dir__))
44
+ loader.setup
45
+
46
+ # Eager load all autoloaded constants
47
+ loader.eager_load