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
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module QubeRails
6
+ # One v2 operation your app queues itself, with no workflow, and what it does with that
7
+ # request's webhooks. Subclass it once per request, list it in config.operations by its SLUG,
8
+ # and see the README's "Plain requests".
9
+ #
10
+ # class FindCustomer < QubeRails::Request
11
+ # OPERATION = "customer.query"
12
+ # SLUG = "find_customer"
13
+ #
14
+ # def params
15
+ # { full_name: record.name }
16
+ # end
17
+ #
18
+ # def answered(event) ... end
19
+ # end
20
+ #
21
+ # row = QuickbooksDesktop::FindCustomer.queue(customer, connection)
22
+ class Request
23
+ # The QubeRails::QueuedRequest for record on connection. While the last one hasn't ended it's
24
+ # returned (reused?), sent again first if QuBe Sync never confirmed it.
25
+ def self.queue(record, connection)
26
+ unless made_by_qube_sync?(connection)
27
+ raise NotConnected, "QuBe Sync: #{name}.queue needs a connection QuBe Sync has made (one with a " \
28
+ "qube_id): the customer finishes onboarding first"
29
+ end
30
+
31
+ row, params_error = claim(record, connection)
32
+ raise params_error if params_error
33
+ return retry_queue(row) if row.reused?
34
+
35
+ send_queue(row)
36
+ end
37
+
38
+ # Sends a queue that got no answer again, with the same params and Idempotency-Key.
39
+ def self.retry_queue(row)
40
+ return row if row.qube_id
41
+ unless row.state == "queued"
42
+ raise ArgumentError, "QuBe Sync: request #{row.id} is #{row.state}: only a queue that got no answer is sent again"
43
+ end
44
+
45
+ send_queue(row)
46
+ end
47
+
48
+ # Under the connection row's lock, so a double click finds the same row.
49
+ def self.claim(record, connection)
50
+ connection.with_lock do
51
+ going = QubeRails::QueuedRequest.where(connection: connection).latest_for(record, self::SLUG)
52
+ next [going.tap { |row| row.reused = true }, nil] if going && !going.ended?
53
+
54
+ new_row(record, connection)
55
+ end
56
+ end
57
+ private_class_method :claim
58
+
59
+ def self.new_row(record, connection)
60
+ row = QubeRails::QueuedRequest.new(
61
+ connection: connection, operation: self::SLUG, record: record,
62
+ reference: "#{self::SLUG}-#{record.id}-#{SecureRandom.uuid}", state: "queued"
63
+ )
64
+ begin
65
+ # As JSON reads it back, so a resend sends exactly what the first did.
66
+ row.params = JSONCoder.load(JSONCoder.dump(new(record, row).params))
67
+ rescue StandardError, NotImplementedError => e
68
+ row.state = "failed"
69
+ row.ended_at = Time.now
70
+ row.save!
71
+ return [row, e]
72
+ end
73
+ row.save!
74
+ [row, nil]
75
+ end
76
+ private_class_method :new_row
77
+
78
+ def self.send_queue(row)
79
+ answer =
80
+ begin
81
+ QubeRails.client.queue(self::OPERATION, row.connection.qube_id, row.params || {},
82
+ webhook_url: row.webhook_url, idempotency_key: row.reference)
83
+ rescue Backend::APIError => e
84
+ mark_failed(row) if refused?(e)
85
+ raise
86
+ end
87
+ record_queue(row, answer.is_a?(Hash) ? answer : {})
88
+ row
89
+ end
90
+ private_class_method :send_queue
91
+
92
+ # A 4xx but 409 (the key in use by a call still in flight): nothing was queued.
93
+ def self.refused?(error)
94
+ error.status.between?(400, 499) && error.status != 409
95
+ end
96
+ private_class_method :refused?
97
+
98
+ def self.mark_failed(row)
99
+ now = Time.now
100
+ row.update_columns(state: "failed", ended_at: now, updated_at: now)
101
+ end
102
+ private_class_method :mark_failed
103
+
104
+ def self.record_queue(row, answer)
105
+ row.with_lock do
106
+ row.qube_id ||= answer["id"]
107
+ row.save!
108
+ end
109
+ end
110
+ private_class_method :record_queue
111
+
112
+ def self.made_by_qube_sync?(connection)
113
+ connection.is_a?(QubeRails::Connection) && !connection.qube_id.to_s.empty?
114
+ end
115
+ private_class_method :made_by_qube_sync?
116
+
117
+ # Write it to load the record your own way; left out, the row's record_type finds it.
118
+ def self.find_record(id)
119
+ raise NotImplementedError, "#{name}.find_record(#{id.inspect}) isn't written"
120
+ end
121
+
122
+ def self.record_for(row)
123
+ if method(:find_record).owner == QubeRails::Request.singleton_class
124
+ row.record
125
+ else
126
+ find_record(row.record_id)
127
+ end
128
+ end
129
+
130
+ attr_reader :record, :queued_request
131
+
132
+ def initialize(record, queued_request)
133
+ @record = record
134
+ @queued_request = queued_request
135
+ end
136
+
137
+ def connection
138
+ queued_request.connection
139
+ end
140
+
141
+ # The operation's params, from the record.
142
+ def params
143
+ raise NotImplementedError, "#{self.class.name}#params isn't written yet"
144
+ end
145
+
146
+ # One response_received page: event.response_json["data"], event.page.
147
+ def answered(event)
148
+ log_event(event)
149
+ end
150
+
151
+ # Every page 1..N answered. Called once.
152
+ def done; end
153
+
154
+ # error or discarded: event.error["user_message"] is safe to show.
155
+ def failed(event)
156
+ log_event(event)
157
+ end
158
+
159
+ # QuBe Sync keeps trying: show event.error["user_message"], never resend.
160
+ def retrying(event)
161
+ log_event(event)
162
+ end
163
+
164
+ # QuickBooks may have applied it. A query can be queued again; check a write first.
165
+ def timed_out(event)
166
+ failed(event)
167
+ end
168
+
169
+ # One webhook, from the job, under the row's lock. Deliveries come at least once, in any order.
170
+ def handle(event)
171
+ changed = false
172
+ queued_request.with_lock { changed = apply(event) }
173
+ QubeRails.broadcast_refresh(record) if changed
174
+ end
175
+
176
+ private
177
+
178
+ FINAL_STATES = %w[error timed_out discarded].freeze
179
+ private_constant :FINAL_STATES
180
+
181
+ def apply(event)
182
+ row = queued_request
183
+ unless this_request?(event)
184
+ QubeRails.logger.warn("QuBe Sync: #{self.class.name} request #{row.id} skipped #{event.type} " \
185
+ "#{event["id"].inspect}: not this request")
186
+ return false
187
+ end
188
+ return false unless take_once(event)
189
+
190
+ row.qube_id ||= event.id
191
+ case event.state
192
+ when "response_received" then page_answered(event)
193
+ when "retryable" then retryable(event)
194
+ when *FINAL_STATES then final(event)
195
+ else
196
+ QubeRails.logger.warn("QuBe Sync: #{self.class.name} request #{row.id}: unknown state #{event.state.inspect}")
197
+ end
198
+ row.save!
199
+ true
200
+ end
201
+
202
+ # Request events carry no connection id: the id is checked once the row has one.
203
+ def this_request?(event)
204
+ return false unless event.type == "request"
205
+
206
+ qube_id = queued_request.qube_id
207
+ qube_id.nil? || event.id == qube_id
208
+ end
209
+
210
+ # Records the delivery in this transaction: false for one taken already.
211
+ def take_once(event)
212
+ deliveries = QubeRails::RequestDelivery.where(request_id: queued_request.id)
213
+ key = event.dedupe_key.to_s
214
+ return false if deliveries.where(dedupe_key: key).exists?
215
+
216
+ QubeRails::RequestDelivery.transaction(requires_new: true) do
217
+ deliveries.create!(dedupe_key: key, page: page_of(event), state: event.state.to_s)
218
+ end
219
+ true
220
+ rescue ActiveRecord::RecordNotUnique
221
+ false
222
+ end
223
+
224
+ def page_answered(event)
225
+ row = queued_request
226
+ row.last_page = page_of(event) if %w[done not_applicable].include?(event.iteration_state)
227
+ row.state = "response_received" unless row.ended?
228
+ answered(event)
229
+
230
+ pages = answered_pages
231
+ row.pages_answered = pages.size
232
+ return if row.ended? || row.last_page.nil?
233
+ return unless pages.count { |page| page.between?(1, row.last_page) } == row.last_page
234
+
235
+ row.ended_at = Time.now
236
+ done
237
+ end
238
+
239
+ def retryable(event)
240
+ row = queued_request
241
+ if page_settled?(event)
242
+ QubeRails.logger.info("QuBe Sync: #{self.class.name} request #{row.id} page #{page_of(event)}: " \
243
+ "skipped a retryable that came after its answer")
244
+ return
245
+ end
246
+ unless row.ended?
247
+ row.state = "retryable"
248
+ row.error = event.error
249
+ end
250
+ retrying(event)
251
+ end
252
+
253
+ def final(event)
254
+ row = queued_request
255
+ unless row.ended?
256
+ row.state = event.state
257
+ row.error = event.error
258
+ row.ended_at = Time.now
259
+ end
260
+ event.state == "timed_out" ? timed_out(event) : failed(event)
261
+ end
262
+
263
+ def answered_pages
264
+ QubeRails::RequestDelivery.where(request_id: queued_request.id, state: "response_received")
265
+ .distinct.pluck(:page).compact
266
+ end
267
+
268
+ def page_settled?(event)
269
+ QubeRails::RequestDelivery.where(request_id: queued_request.id, page: page_of(event))
270
+ .where.not(state: "retryable").exists?
271
+ end
272
+
273
+ # An answer that wasn't paged is page 1.
274
+ def page_of(event)
275
+ event.page.is_a?(Integer) ? event.page : 1
276
+ end
277
+
278
+ def log_event(event)
279
+ code = event.error.is_a?(Hash) ? " #{event.error["error_code"] || event.error["error_type"]}" : ""
280
+ line = "QuBe Sync: #{self.class.name} #{record.id} (request #{queued_request.id}): " \
281
+ "#{event.state} page #{page_of(event)}#{code}"
282
+ if event.state == "response_received"
283
+ QubeRails.logger.info(line)
284
+ else
285
+ QubeRails.logger.warn(line)
286
+ end
287
+ end
288
+ end
289
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module QubeRails
6
+ # A rows field (add_transaction's question about missing records): the same columns asked of
7
+ # each row, posted as data[<field>][<row key>][<column>]. Shown as one block per record, its
8
+ # fields stacked: columns are FieldViews (one per column), rows RowViews. set_all is the column
9
+ # the "Set all to:" line sets on every row (a segmented one asked by two rows or more), or nil.
10
+ class RowsView
11
+ attr_reader :name, :label, :input_name, :search_url, :columns, :rows
12
+
13
+ def initialize(field, input_name:, search_url:)
14
+ @name = field["name"].to_s
15
+ @label = (field["label"] || @name).to_s
16
+ @input_name = input_name
17
+ @search_url = search_url
18
+ declared = Array(field["columns"]).select { |c| c.is_a?(Hash) }
19
+ @columns = declared.map { |column| column_field(column) }
20
+ @rows = Array(field["rows"]).select { |r| r.is_a?(Hash) }.each_with_index.map do |row, n|
21
+ RowView.new(row, declared, self, n)
22
+ end
23
+ end
24
+
25
+ # A column's control for one row (context :cell). Shown or not, it isn't required in the
26
+ # markup while hidden (the script sets required as it shows it).
27
+ def cell_field(column, row_key, row_index, shown)
28
+ col = column["name"].to_s
29
+ FieldView.new(name: col, type: FieldView.type_of(column, record_type: "search"),
30
+ label: column["label"] || col, input_name: "#{input_name}[#{row_key}][#{col}]",
31
+ dom_id: "qube-#{input_name.gsub(/[^A-Za-z0-9]+/, "-")}#{row_index}-#{col}".squeeze("-"),
32
+ required: column["required"] == true && shown, options: FieldView.options_of(column),
33
+ kind: column["kind"], search_url: search_url, search_field: "#{name}.#{col}",
34
+ row_key: row_key, when_values: when_of(column), context: :cell, disabled: !shown)
35
+ end
36
+
37
+ def when_of(column)
38
+ column["when"].is_a?(Hash) ? column["when"] : {}
39
+ end
40
+
41
+ # The column "Set all to:" sets, with its options: the first segmented one two rows or more ask.
42
+ def set_all
43
+ return nil if rows.size < 2
44
+
45
+ columns.find do |column|
46
+ column.type == "select" && column.options.size.between?(1, 3) &&
47
+ rows.count { |row| row.cells.any? { |cell| cell.column == column.name && cell.field } } >= 2
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def column_field(column)
54
+ col = column["name"].to_s
55
+ FieldView.new(name: col, type: FieldView.type_of(column, record_type: "search"), label: column["label"] || col,
56
+ required: column["required"] == true, options: FieldView.options_of(column),
57
+ kind: column["kind"], search_url: search_url, search_field: "#{name}.#{col}",
58
+ when_values: when_of(column), context: :cell)
59
+ end
60
+ end
61
+
62
+ # One record: its cells (the columns it asks, and a value it already has), and the values it
63
+ # already has (values_json for data-qube-values, which a column's "when" reads with the row's
64
+ # answers). A column the row neither asks nor has a value for has no cell.
65
+ class RowView
66
+ attr_reader :key, :label, :detail, :values, :cells, :index
67
+
68
+ def initialize(row, declared, rows, index = 0)
69
+ @key = row["key"].to_s
70
+ @label = (row["label"] || @key).to_s
71
+ @detail = row["detail"].to_s.strip.empty? ? nil : row["detail"].to_s
72
+ @values = row["values"].is_a?(Hash) ? row["values"] : {}
73
+ @index = index
74
+ asks = Array(row["columns"]).map(&:to_s)
75
+ @cells = declared.filter_map do |column|
76
+ col = column["name"].to_s
77
+ when_values = rows.when_of(column)
78
+ shown = applies?(when_values)
79
+ required = column["required"] == true
80
+ label = (column["label"] || col).to_s
81
+ if asks.include?(col)
82
+ CellView.new(column: col, label: label, field: rows.cell_field(column, @key, index, shown),
83
+ when_values: when_values, shown: shown, required: required)
84
+ elsif !@values[col].to_s.empty?
85
+ CellView.new(column: col, label: label, preset: preset_text(column, @values[col]), when_values: when_values,
86
+ shown: shown)
87
+ end
88
+ end
89
+ end
90
+
91
+ def values_json
92
+ values.to_json
93
+ end
94
+
95
+ # The segmented cells (the row's choice, such as create or use), then the rest.
96
+ def segmented_cells
97
+ cells.select(&:segmented?)
98
+ end
99
+
100
+ def field_cells
101
+ cells.reject(&:segmented?)
102
+ end
103
+
104
+ private
105
+
106
+ # Before the user picks anything, a column shows when its "when" holds for the row's values.
107
+ def applies?(when_values)
108
+ when_values.all? { |col, allowed| Array(allowed).include?(values[col]) }
109
+ end
110
+
111
+ # A value the row already has, shown as its enum label when it has one.
112
+ def preset_text(column, value)
113
+ entry = Array(column["enum"]).find { |e| e.is_a?(Hash) && e["value"] == value }
114
+ entry ? entry["label"].to_s : value.to_s
115
+ end
116
+ end
117
+
118
+ # One field of a row: a field to fill (field), or what the row already has (preset, read-only).
119
+ # A cell whose "when" doesn't hold yet is hidden (shown false: hidden, data-qube-hidden, its input
120
+ # disabled and not required). required is the column's, which the script restores as it shows it.
121
+ class CellView
122
+ attr_reader :column, :label, :field, :preset, :when, :shown, :required
123
+
124
+ def initialize(column:, label: nil, field: nil, preset: nil, when_values: {}, shown: true, required: false)
125
+ @column = column
126
+ @label = label || column
127
+ @field = field
128
+ @preset = preset
129
+ @when = when_values
130
+ @shown = shown
131
+ @required = required
132
+ end
133
+
134
+ def when_json
135
+ self.when.to_json
136
+ end
137
+
138
+ def hidden?
139
+ !shown
140
+ end
141
+
142
+ def segmented?
143
+ !field.nil? && field.segmented?
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QubeRails
4
+ # Where a record stands, as the run status partials render it: the override contract, kept
5
+ # stable across versions (README, "Styling"). status is QubeRails::SyncStatus's (:not_sent,
6
+ # :sending, :needs_answer, :in_quickbooks, :done, :failed, :cancelled); state the latest run's
7
+ # ("starting", "running", "awaiting_input", "completed", "failed" or "cancelled"), or nil.
8
+ # summary is a few words for the pill; reason, why it failed or was cancelled, as a sentence
9
+ # below it; detail, what to do about a failure.
10
+ class RunStatusView
11
+ attr_reader :status, :state, :summary, :reason, :detail, :quiet_text, :retry_url, :redeliver_url, :error, :stall,
12
+ :stall_text
13
+
14
+ # The stall reasons with words of their own (status.stalled.<reason>); any other is "other".
15
+ STALL_REASONS = %w[not_checking_in quickbooks_unavailable].freeze
16
+
17
+ # From a QubeRails::SyncStatus. urls answers retry_start_path(run) and redeliver_path(run):
18
+ # the engine's route proxy (qube_rails) in a view. connection is the one the status is about
19
+ # (else the latest run's): while it's stalled, a status waiting on QuickBooks (:sending) says
20
+ # why (stall, stall_text) and offers no redelivery, since the cause is known.
21
+ def self.from_sync(sync, urls, error: nil, connection: nil)
22
+ run = sync.run
23
+ state = run && (run.cancelled? ? "cancelled" : run.state)
24
+ reason = run && (sync.failed? || run.cancelled?) ? run.reason_text : nil
25
+ detail = sync.failed? && run ? run.error_detail : nil
26
+ connection ||= run && run.connection
27
+ stall = sync.status == :sending ? stall_of(connection) : nil
28
+ stuck = run && !stall ? run.looks_stuck? : false
29
+ new(status: sync.status, state: state,
30
+ summary: stall ? QubeRails.t("status.waiting_for_quickbooks") : sync.summary_text, reason: reason, detail: detail,
31
+ quiet_text: stuck ? QubeRails.t("status.quiet") : nil,
32
+ retry_url: sync.unconfirmed? ? urls.retry_start_path(run) : nil,
33
+ redeliver_url: stuck ? urls.redeliver_path(run) : nil, error: error, stall: stall)
34
+ end
35
+
36
+ # A stalled connection's reason as a Symbol (:other when it gives none), or nil.
37
+ def self.stall_of(connection)
38
+ return nil unless connection && connection.status == "stalled"
39
+
40
+ reason = connection.stall_reason.to_s
41
+ reason.empty? ? :other : reason.to_sym
42
+ end
43
+
44
+ def initialize(status:, state: nil, summary: nil, reason: nil, detail: nil, quiet_text: nil, retry_url: nil,
45
+ redeliver_url: nil, error: nil, stall: nil)
46
+ @status = status.to_sym
47
+ @state = state
48
+ @summary = summary
49
+ @reason = reason
50
+ @detail = detail
51
+ @quiet_text = quiet_text
52
+ @retry_url = retry_url
53
+ @redeliver_url = redeliver_url
54
+ @error = error
55
+ @stall = stall
56
+ @stall_text = stall && QubeRails.t("status.stalled.#{STALL_REASONS.include?(stall.to_s) ? stall : "other"}")
57
+ end
58
+
59
+ # Nothing is shown before the record is sent (the error aside).
60
+ def visible?
61
+ status != :not_sent
62
+ end
63
+
64
+ # The qube- classes on the status element: qube-sync-<status>, qube-run-<state>, and
65
+ # qube-connection-stalled while it waits on a stalled connection.
66
+ def css_class
67
+ ["qube-run-status", "qube-sync-#{status}", state && "qube-run-#{state}", stall && "qube-connection-stalled"]
68
+ .compact.join(" ")
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,60 @@
1
+ {
2
+ "title": "A lost answer",
3
+ "note": "add_transaction's ask_about_add: QuickBooks never answered the add, and the run couldn't settle whether it went in. Use a candidate, add it again, or cancel.",
4
+ "prompt": {
5
+ "id": "add_transaction / ask_about_add",
6
+ "kind": "decision",
7
+ "token": "Xh6rV8wBt4QjC6eE",
8
+ "message": "QuickBooks didn't confirm whether this invoice went in. If it's the one below, use it; if it isn't there, add it again.",
9
+ "data": {
10
+ "operation": "invoice",
11
+ "why": "lost_answer",
12
+ "candidates_complete": true,
13
+ "external_guid": "{6B0C3A52-6E1A-4F43-9B1F-0D3E3E1F7A10}",
14
+ "not_found": null
15
+ },
16
+ "details": [
17
+ {
18
+ "label": "Adding",
19
+ "value": "Invoice"
20
+ },
21
+ {
22
+ "label": "Number",
23
+ "value": "1042"
24
+ },
25
+ {
26
+ "label": "Date",
27
+ "value": "2026-10-20"
28
+ },
29
+ {
30
+ "label": "For",
31
+ "value": "Riverbend Electric"
32
+ }
33
+ ],
34
+ "options": [],
35
+ "choices": [
36
+ {
37
+ "id": "use_existing:1",
38
+ "label": "1042",
39
+ "description": null,
40
+ "detail": "2026-10-20 · Riverbend Electric · 1250.00",
41
+ "fields": []
42
+ },
43
+ {
44
+ "id": "add_again",
45
+ "label": "None of them: add it again",
46
+ "description": null,
47
+ "detail": null,
48
+ "fields": []
49
+ },
50
+ {
51
+ "id": "cancel",
52
+ "label": "Cancel",
53
+ "description": null,
54
+ "detail": null,
55
+ "fields": []
56
+ }
57
+ ]
58
+ },
59
+ "search": {}
60
+ }
@@ -0,0 +1,51 @@
1
+ {
2
+ "title": "A pick from a list",
3
+ "note": "resolve_transaction's ask: an option whose field has choose becomes one choice per item (use:1, use:2, ...), each with its detail; the server fills the field.",
4
+ "prompt": {
5
+ "id": "resolve_transaction / ask",
6
+ "kind": "decision",
7
+ "token": "Tc7mR3sWp9KdX2yA",
8
+ "message": "More than one invoice could be the one you meant. Pick it, say none of these is it, or cancel.",
9
+ "data": {
10
+ "ref_number": "1042"
11
+ },
12
+ "details": [
13
+ {
14
+ "label": "Invoice",
15
+ "value": "1042"
16
+ }
17
+ ],
18
+ "options": [],
19
+ "choices": [
20
+ {
21
+ "id": "use:1",
22
+ "label": "1042",
23
+ "description": null,
24
+ "detail": "2026-10-20 · Riverbend Electric · 1250.00",
25
+ "fields": []
26
+ },
27
+ {
28
+ "id": "use:2",
29
+ "label": "1042-B",
30
+ "description": null,
31
+ "detail": "2026-10-21 · Riverbend Electric · 310.50",
32
+ "fields": []
33
+ },
34
+ {
35
+ "id": "none",
36
+ "label": "None of these",
37
+ "description": null,
38
+ "detail": null,
39
+ "fields": []
40
+ },
41
+ {
42
+ "id": "cancel",
43
+ "label": "Cancel",
44
+ "description": null,
45
+ "detail": null,
46
+ "fields": []
47
+ }
48
+ ]
49
+ },
50
+ "search": {}
51
+ }
@@ -0,0 +1,56 @@
1
+ {
2
+ "title": "A fixed list",
3
+ "note": "A string field with an enum shows as a select, a blank first; the answer is the picked value. No shipped template asks one; a chart of your own can.",
4
+ "prompt": {
5
+ "id": "route_order / ask_route",
6
+ "kind": "decision",
7
+ "token": "Ue2nS5tYq1MfZ3bB",
8
+ "message": "Which list should this order go to?",
9
+ "data": {},
10
+ "details": [
11
+ {
12
+ "label": "Order",
13
+ "value": "SO-2207"
14
+ }
15
+ ],
16
+ "options": [],
17
+ "choices": [
18
+ {
19
+ "id": "route",
20
+ "label": "Send it",
21
+ "description": null,
22
+ "detail": null,
23
+ "fields": [
24
+ {
25
+ "name": "destination",
26
+ "type": "string",
27
+ "label": "Goes to",
28
+ "required": true,
29
+ "enum": [
30
+ {
31
+ "value": "sales_order",
32
+ "label": "Sales order"
33
+ },
34
+ {
35
+ "value": "estimate",
36
+ "label": "Estimate"
37
+ },
38
+ {
39
+ "value": "invoice",
40
+ "label": "Invoice"
41
+ }
42
+ ]
43
+ }
44
+ ]
45
+ },
46
+ {
47
+ "id": "cancel",
48
+ "label": "Cancel",
49
+ "description": null,
50
+ "detail": null,
51
+ "fields": []
52
+ }
53
+ ]
54
+ },
55
+ "search": {}
56
+ }