qube_rails 0.2.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 (88) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +161 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +967 -0
  5. data/app/assets/javascripts/qube_rails/question.js +457 -0
  6. data/app/assets/stylesheets/qube_rails.css +1115 -0
  7. data/app/controllers/concerns/qube_rails/run_authorization.rb +37 -0
  8. data/app/controllers/qube_rails/connections_controller.rb +141 -0
  9. data/app/controllers/qube_rails/questions_controller.rb +132 -0
  10. data/app/controllers/qube_rails/runs_controller.rb +54 -0
  11. data/app/controllers/qube_rails/storyboard_controller.rb +50 -0
  12. data/app/controllers/qube_rails/webhooks_controller.rb +99 -0
  13. data/app/jobs/qube_rails/prune_runs_job.rb +18 -0
  14. data/app/jobs/qube_rails/refresh_job.rb +18 -0
  15. data/app/jobs/qube_rails/webhook_job.rb +43 -0
  16. data/app/models/qube_rails/application_record.rb +17 -0
  17. data/app/models/qube_rails/connection.rb +303 -0
  18. data/app/models/qube_rails/notification.rb +12 -0
  19. data/app/models/qube_rails/queued_request.rb +66 -0
  20. data/app/models/qube_rails/quickbooks_record.rb +36 -0
  21. data/app/models/qube_rails/request_delivery.rb +12 -0
  22. data/app/models/qube_rails/run.rb +187 -0
  23. data/app/models/qube_rails/sync_mark.rb +21 -0
  24. data/app/views/qube_rails/_error.html.erb +13 -0
  25. data/app/views/qube_rails/_question.html.erb +21 -0
  26. data/app/views/qube_rails/_run_status.html.erb +22 -0
  27. data/app/views/qube_rails/question/_choice.html.erb +20 -0
  28. data/app/views/qube_rails/question/_details.html.erb +6 -0
  29. data/app/views/qube_rails/question/_field.html.erb +15 -0
  30. data/app/views/qube_rails/question/_footer.html.erb +7 -0
  31. data/app/views/qube_rails/question/_message.html.erb +2 -0
  32. data/app/views/qube_rails/question/_pick.html.erb +19 -0
  33. data/app/views/qube_rails/question/_picks.html.erb +6 -0
  34. data/app/views/qube_rails/question/_question.html.erb +24 -0
  35. data/app/views/qube_rails/question/fields/_boolean.html.erb +7 -0
  36. data/app/views/qube_rails/question/fields/_number.html.erb +7 -0
  37. data/app/views/qube_rails/question/fields/_rows.html.erb +23 -0
  38. data/app/views/qube_rails/question/fields/_search.html.erb +23 -0
  39. data/app/views/qube_rails/question/fields/_select.html.erb +8 -0
  40. data/app/views/qube_rails/question/fields/_text.html.erb +7 -0
  41. data/app/views/qube_rails/question/rows/_cell.html.erb +24 -0
  42. data/app/views/qube_rails/question/rows/_row.html.erb +22 -0
  43. data/app/views/qube_rails/question/rows/_segmented.html.erb +14 -0
  44. data/app/views/qube_rails/run_status/_detail.html.erb +7 -0
  45. data/app/views/qube_rails/run_status/_redeliver.html.erb +5 -0
  46. data/app/views/qube_rails/run_status/_retry.html.erb +4 -0
  47. data/app/views/qube_rails/run_status/_stall.html.erb +3 -0
  48. data/app/views/qube_rails/run_status/_status.html.erb +21 -0
  49. data/app/views/qube_rails/run_status/_summary.html.erb +2 -0
  50. data/app/views/qube_rails/storyboard/show.html.erb +86 -0
  51. data/config/locales/en.yml +46 -0
  52. data/config/routes.rb +35 -0
  53. data/db/migrate/20261025000000_create_qube_tables.rb +83 -0
  54. data/db/migrate/20261026230000_add_qube_sync_status_indexes.rb +13 -0
  55. data/db/migrate/20261027000000_create_qube_sync_marks.rb +17 -0
  56. data/db/migrate/20261028000000_create_qube_requests.rb +38 -0
  57. data/lib/generators/qube_rails/install_generator.rb +47 -0
  58. data/lib/generators/qube_rails/templates/connection_events.rb.tt +38 -0
  59. data/lib/generators/qube_rails/templates/initializer.rb.tt +82 -0
  60. data/lib/generators/qube_rails/workflows_generator.rb +77 -0
  61. data/lib/qube_rails/backend.rb +70 -0
  62. data/lib/qube_rails/cache_dedupe.rb +34 -0
  63. data/lib/qube_rails/configuration.rb +319 -0
  64. data/lib/qube_rails/connection_events.rb +167 -0
  65. data/lib/qube_rails/database_dedupe.rb +28 -0
  66. data/lib/qube_rails/engine.rb +33 -0
  67. data/lib/qube_rails/json_coder.rb +43 -0
  68. data/lib/qube_rails/operation.rb +464 -0
  69. data/lib/qube_rails/question_view.rb +219 -0
  70. data/lib/qube_rails/request.rb +289 -0
  71. data/lib/qube_rails/rows_view.rb +146 -0
  72. data/lib/qube_rails/run_status_view.rb +71 -0
  73. data/lib/qube_rails/storyboard/ask_about_add.json +60 -0
  74. data/lib/qube_rails/storyboard/choose.json +51 -0
  75. data/lib/qube_rails/storyboard/enum_select.json +56 -0
  76. data/lib/qube_rails/storyboard/missing_record.json +141 -0
  77. data/lib/qube_rails/storyboard/name_clash.json +59 -0
  78. data/lib/qube_rails/storyboard/near_match.json +77 -0
  79. data/lib/qube_rails/storyboard/number_boolean.json +54 -0
  80. data/lib/qube_rails/storyboard/record_search.json +90 -0
  81. data/lib/qube_rails/storyboard/refusal.json +53 -0
  82. data/lib/qube_rails/storyboard/rows.json +359 -0
  83. data/lib/qube_rails/storyboard.rb +165 -0
  84. data/lib/qube_rails/sync_status.rb +124 -0
  85. data/lib/qube_rails/version.rb +5 -0
  86. data/lib/qube_rails/view_helpers.rb +29 -0
  87. data/lib/qube_rails.rb +201 -0
  88. metadata +180 -0
data/lib/qube_rails.rb ADDED
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "qube_rails/version"
5
+ require "qube_rails/backend"
6
+ require "qube_rails/configuration"
7
+ require "qube_rails/cache_dedupe"
8
+ require "qube_rails/database_dedupe"
9
+ require "qube_rails/json_coder"
10
+ require "qube_rails/operation"
11
+ require "qube_rails/request"
12
+ require "qube_rails/sync_status"
13
+ require "qube_rails/connection_events"
14
+ require "qube_rails/question_view"
15
+ require "qube_rails/run_status_view"
16
+ require "qube_rails/view_helpers"
17
+ require "qube_rails/storyboard"
18
+
19
+ # QuBe Sync's Rails engine: the code that's the same in every Rails app's integration. Your app
20
+ # keeps its own: config/initializers/qube_rails.rb (QubeRails.configure), an operation class per
21
+ # way it starts a workflow (QubeRails::Operation), its class for a connection's events
22
+ # (QubeRails::ConnectionEvents), and `mount QubeRails::Engine => "/"` in config/routes.rb. The
23
+ # engine's own tables (qube_connections, qube_runs, qube_quickbooks_records) keep every
24
+ # connection, every run and every QuickBooks id, so your tables need no QuBe columns.
25
+ module QubeRails
26
+ # A run whose operation none of config.operations has any more (taken out after it started).
27
+ class UnknownOperation < StandardError; end
28
+
29
+ # A connection's event came before the row's qube_id was saved (create_connection hadn't
30
+ # returned yet, or its update hadn't committed: a simulated connection sends
31
+ # connection.connected at once). The job raises it so the queue retries once it's there.
32
+ class NotReady < StandardError; end
33
+
34
+ # Operation.start was given a connection QuBe Sync hasn't made yet (no qube_id): the customer
35
+ # hasn't finished onboarding, or QuBe Sync didn't answer it.
36
+ class NotConnected < ArgumentError; end
37
+
38
+ # An operation's input has no value for a reference QuickBooks requires (an invoice's
39
+ # customer_ref): the record has no QuickBooks link on the run's connection and no name to look
40
+ # it up by. The kit's mapped operations raise it from input, so the start fails, before anything
41
+ # is sent, saying which reference and which record.
42
+ class MissingReference < ArgumentError; end
43
+
44
+ # QuBe Sync refused a start (422 realtime_required): workflows run on realtime connections, and
45
+ # this one is standard. Its message says how to upgrade it.
46
+ class RealtimeRequired < StandardError; end
47
+
48
+ # Seconds after an event before a record's pages are told to refresh once more.
49
+ REFRESH_AGAIN = 5
50
+
51
+ class << self
52
+ def config
53
+ @config ||= Configuration.new
54
+ end
55
+
56
+ # In config/initializers/qube_rails.rb:
57
+ # QubeRails.configure do |config|
58
+ # config.operations = { "create_invoice" => "QuickbooksDesktop::CreateInvoice" }
59
+ # config.connection_events = "QuickbooksDesktop::ConnectionEvents"
60
+ # ...
61
+ # end
62
+ def configure
63
+ yield config
64
+ @client = nil
65
+ config
66
+ end
67
+
68
+ # QuBe Sync's API, through the qube_sync gem (QubeSync::Client): create_connection,
69
+ # start_run, answer, search, get_run, cancel_run, queue and the rest. A non-2xx answer raises
70
+ # QubeSync::APIError (status, code, message, body).
71
+ def client
72
+ @client ||= Backend.client(api_key: config.api_key, base_url: config.base_url,
73
+ on_retry: method(:log_retry))
74
+ end
75
+
76
+ # A call QuBe Sync didn't answer, sent again (safe calls only, within 60 seconds).
77
+ def log_retry(info)
78
+ why = info[:status] || (info[:error] && info[:error].class)
79
+ logger.warn("QuBe Sync: retrying #{info[:method]} #{info[:url]} (#{why}) " \
80
+ "in #{info[:delay].to_f.round(1)} s")
81
+ end
82
+
83
+ # Runs one verified delivery. kind is "run" (QUBE_WEBHOOK_URL/runs/<id>): the run's
84
+ # operation finds its record and handles the event (QubeRails::Operation#handle); or
85
+ # "connection" (QUBE_WEBHOOK_URL/connections/<id>): your config.connection_events class
86
+ # handles it for that connection; or "request" (QUBE_WEBHOOK_URL/requests/<id>): the request's
87
+ # class handles it (QubeRails::Request#handle). QubeRails::WebhookJob calls it; so does your own queue's
88
+ # worker when config.enqueue hands deliveries elsewhere.
89
+ #
90
+ # body is the delivery's raw body, which the webhook endpoint verified: parsed into its typed
91
+ # event here, and not verified again, since a job can run after its timestamp's 5 minutes are
92
+ # up.
93
+ def run(kind, id, body)
94
+ event = Backend.parse(body)
95
+ case kind.to_s
96
+ when "run" then run_event(id, event)
97
+ when "connection" then connection_event(id, event)
98
+ when "request" then request_event(id, event)
99
+ else raise ArgumentError, "QuBe Sync: a delivery is for a run, a connection or a request, not #{kind.inspect}"
100
+ end
101
+ end
102
+
103
+ # Tells the pages showing record's runs to refresh: the engine's partials subscribe with
104
+ # turbo_stream_from(record, :qube_rails). Only in an app with turbo-rails 2 (Turbo 8 page
105
+ # refreshes) and Action Cable; otherwise nothing. Once now, and once more REFRESH_AGAIN
106
+ # later (QubeRails::RefreshJob), for a page that was still loading the first time. A
107
+ # broadcast that fails is logged: the event is applied either way, and a reload shows it.
108
+ def broadcast_refresh(record)
109
+ return unless refresh_broadcasts?
110
+
111
+ broadcast_refresh_now(record)
112
+ QubeRails::RefreshJob.set(wait: REFRESH_AGAIN).perform_later(record)
113
+ rescue StandardError => e
114
+ logger.warn("QuBe Sync: couldn't tell the page for #{record.class.name} #{record.id} to refresh again " \
115
+ "(#{e.class})")
116
+ end
117
+
118
+ def broadcast_refresh_now(record)
119
+ return unless refresh_broadcasts?
120
+
121
+ Turbo::StreamsChannel.broadcast_refresh_to(record, :qube_rails)
122
+ rescue StandardError => e
123
+ logger.warn("QuBe Sync: couldn't tell the page for #{record.class.name} #{record.id} to refresh " \
124
+ "(#{e.class})")
125
+ end
126
+
127
+ def refresh_broadcasts?
128
+ defined?(Turbo::StreamsChannel) && Turbo::StreamsChannel.respond_to?(:broadcast_refresh_to)
129
+ end
130
+
131
+ def logger
132
+ Rails.logger
133
+ end
134
+
135
+ # What the screens say to a person, from I18n under qube_rails.* (config/locales/en.yml has
136
+ # every key): an app overrides or translates a key in its own locale files. A locale that
137
+ # lacks a key falls back to the engine's English.
138
+ def t(key, **options)
139
+ I18n.t(key, **options, scope: :qube_rails, raise: true)
140
+ rescue I18n::MissingTranslationData
141
+ I18n.t(key, **options, scope: :qube_rails, locale: :en)
142
+ end
143
+
144
+ # A run, connection or record that isn't there is dropped with a warning, not raised: the
145
+ # endpoint answered 404 unless the row existed, and the app saves a record before its run's
146
+ # row, so by the time a job runs, a missing one was deleted, and retrying would never
147
+ # succeed. An operation taken out of config.operations raises (UnknownOperation): putting it
148
+ # back and retrying does.
149
+ def run_event(id, event)
150
+ run = Run.find_by(id: id)
151
+ return dropped("run #{id} isn't there", event) unless run
152
+
153
+ operation = config.operation_class(run.operation)
154
+ raise UnknownOperation, "QuBe Sync: no operation has the slug #{run.operation.inspect}" unless operation
155
+
156
+ record = find_run_record(operation, run)
157
+ return dropped("run #{run.id}'s #{run.record_type} #{run.record_id} isn't there", event) unless record
158
+
159
+ operation.new(record, run).handle(event)
160
+ end
161
+
162
+ def request_event(id, event)
163
+ row = QueuedRequest.find_by(id: id)
164
+ return dropped("request #{id} isn't there", event) unless row
165
+
166
+ request_class = config.operation_class(row.operation)
167
+ raise UnknownOperation, "QuBe Sync: no operation has the slug #{row.operation.inspect}" unless request_class
168
+
169
+ record = find_run_record(request_class, row)
170
+ return dropped("request #{row.id}'s #{row.record_type} #{row.record_id} isn't there", event) unless record
171
+
172
+ request_class.new(record, row).handle(event)
173
+ end
174
+
175
+ def connection_event(id, event)
176
+ connection = Connection.find_by(id: id)
177
+ return dropped("connection #{id} isn't there", event) unless connection
178
+
179
+ config.connection_events_class.new(connection).handle(event)
180
+ end
181
+
182
+ def find_run_record(operation, run)
183
+ operation.record_for(run)
184
+ rescue ActiveRecord::RecordNotFound # a find_record written with find
185
+ nil
186
+ end
187
+
188
+ def dropped(why, event)
189
+ logger.warn("QuBe Sync: dropped #{event.event || event.type}: #{why} (deleted since, so it can't be applied)")
190
+ nil
191
+ end
192
+
193
+ # Forgets the settings and the client: for tests.
194
+ def reset!
195
+ @config = nil
196
+ @client = nil
197
+ end
198
+ end
199
+ end
200
+
201
+ require "qube_rails/engine" if defined?(Rails::Engine)
metadata ADDED
@@ -0,0 +1,180 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: qube_rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Garrett Lancaster
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: qube_sync
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 0.5.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 0.5.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: railties
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '7.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: activejob
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '7.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '7.0'
55
+ description: 'The code every Rails app''s QuBe Sync integration shares: the webhook
56
+ endpoint and its job, onboarding, the question routes and partial, and the Operation
57
+ and ConnectionEvents bases. Your app keeps its own operations, its connection class
58
+ and one initializer.'
59
+ email:
60
+ - support@qubesync.com
61
+ executables: []
62
+ extensions: []
63
+ extra_rdoc_files: []
64
+ files:
65
+ - CHANGELOG.md
66
+ - LICENSE.txt
67
+ - README.md
68
+ - app/assets/javascripts/qube_rails/question.js
69
+ - app/assets/stylesheets/qube_rails.css
70
+ - app/controllers/concerns/qube_rails/run_authorization.rb
71
+ - app/controllers/qube_rails/connections_controller.rb
72
+ - app/controllers/qube_rails/questions_controller.rb
73
+ - app/controllers/qube_rails/runs_controller.rb
74
+ - app/controllers/qube_rails/storyboard_controller.rb
75
+ - app/controllers/qube_rails/webhooks_controller.rb
76
+ - app/jobs/qube_rails/prune_runs_job.rb
77
+ - app/jobs/qube_rails/refresh_job.rb
78
+ - app/jobs/qube_rails/webhook_job.rb
79
+ - app/models/qube_rails/application_record.rb
80
+ - app/models/qube_rails/connection.rb
81
+ - app/models/qube_rails/notification.rb
82
+ - app/models/qube_rails/queued_request.rb
83
+ - app/models/qube_rails/quickbooks_record.rb
84
+ - app/models/qube_rails/request_delivery.rb
85
+ - app/models/qube_rails/run.rb
86
+ - app/models/qube_rails/sync_mark.rb
87
+ - app/views/qube_rails/_error.html.erb
88
+ - app/views/qube_rails/_question.html.erb
89
+ - app/views/qube_rails/_run_status.html.erb
90
+ - app/views/qube_rails/question/_choice.html.erb
91
+ - app/views/qube_rails/question/_details.html.erb
92
+ - app/views/qube_rails/question/_field.html.erb
93
+ - app/views/qube_rails/question/_footer.html.erb
94
+ - app/views/qube_rails/question/_message.html.erb
95
+ - app/views/qube_rails/question/_pick.html.erb
96
+ - app/views/qube_rails/question/_picks.html.erb
97
+ - app/views/qube_rails/question/_question.html.erb
98
+ - app/views/qube_rails/question/fields/_boolean.html.erb
99
+ - app/views/qube_rails/question/fields/_number.html.erb
100
+ - app/views/qube_rails/question/fields/_rows.html.erb
101
+ - app/views/qube_rails/question/fields/_search.html.erb
102
+ - app/views/qube_rails/question/fields/_select.html.erb
103
+ - app/views/qube_rails/question/fields/_text.html.erb
104
+ - app/views/qube_rails/question/rows/_cell.html.erb
105
+ - app/views/qube_rails/question/rows/_row.html.erb
106
+ - app/views/qube_rails/question/rows/_segmented.html.erb
107
+ - app/views/qube_rails/run_status/_detail.html.erb
108
+ - app/views/qube_rails/run_status/_redeliver.html.erb
109
+ - app/views/qube_rails/run_status/_retry.html.erb
110
+ - app/views/qube_rails/run_status/_stall.html.erb
111
+ - app/views/qube_rails/run_status/_status.html.erb
112
+ - app/views/qube_rails/run_status/_summary.html.erb
113
+ - app/views/qube_rails/storyboard/show.html.erb
114
+ - config/locales/en.yml
115
+ - config/routes.rb
116
+ - db/migrate/20261025000000_create_qube_tables.rb
117
+ - db/migrate/20261026230000_add_qube_sync_status_indexes.rb
118
+ - db/migrate/20261027000000_create_qube_sync_marks.rb
119
+ - db/migrate/20261028000000_create_qube_requests.rb
120
+ - lib/generators/qube_rails/install_generator.rb
121
+ - lib/generators/qube_rails/templates/connection_events.rb.tt
122
+ - lib/generators/qube_rails/templates/initializer.rb.tt
123
+ - lib/generators/qube_rails/workflows_generator.rb
124
+ - lib/qube_rails.rb
125
+ - lib/qube_rails/backend.rb
126
+ - lib/qube_rails/cache_dedupe.rb
127
+ - lib/qube_rails/configuration.rb
128
+ - lib/qube_rails/connection_events.rb
129
+ - lib/qube_rails/database_dedupe.rb
130
+ - lib/qube_rails/engine.rb
131
+ - lib/qube_rails/json_coder.rb
132
+ - lib/qube_rails/operation.rb
133
+ - lib/qube_rails/question_view.rb
134
+ - lib/qube_rails/request.rb
135
+ - lib/qube_rails/rows_view.rb
136
+ - lib/qube_rails/run_status_view.rb
137
+ - lib/qube_rails/storyboard.rb
138
+ - lib/qube_rails/storyboard/ask_about_add.json
139
+ - lib/qube_rails/storyboard/choose.json
140
+ - lib/qube_rails/storyboard/enum_select.json
141
+ - lib/qube_rails/storyboard/missing_record.json
142
+ - lib/qube_rails/storyboard/name_clash.json
143
+ - lib/qube_rails/storyboard/near_match.json
144
+ - lib/qube_rails/storyboard/number_boolean.json
145
+ - lib/qube_rails/storyboard/record_search.json
146
+ - lib/qube_rails/storyboard/refusal.json
147
+ - lib/qube_rails/storyboard/rows.json
148
+ - lib/qube_rails/sync_status.rb
149
+ - lib/qube_rails/version.rb
150
+ - lib/qube_rails/view_helpers.rb
151
+ homepage: https://qubesync.com
152
+ licenses:
153
+ - MIT
154
+ metadata:
155
+ homepage_uri: https://qubesync.com
156
+ documentation_uri: https://docs.qubesync.com
157
+ source_code_uri: https://github.com/qubeintegrations/qube_rails
158
+ changelog_uri: https://github.com/qubeintegrations/qube_rails/blob/main/CHANGELOG.md
159
+ rubygems_mfa_required: 'true'
160
+ post_install_message:
161
+ rdoc_options: []
162
+ require_paths:
163
+ - lib
164
+ required_ruby_version: !ruby/object:Gem::Requirement
165
+ requirements:
166
+ - - ">="
167
+ - !ruby/object:Gem::Version
168
+ version: 3.3.0
169
+ required_rubygems_version: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ requirements: []
175
+ rubygems_version: 3.5.22
176
+ signing_key:
177
+ specification_version: 4
178
+ summary: 'QuBe Sync''s Rails engine: the webhook endpoint, onboarding and question
179
+ routes for QuickBooks Desktop'
180
+ test_files: []