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,359 @@
1
+ {
2
+ "title": "Records not in QuickBooks yet",
3
+ "note": "add_transaction's ask_missing: a block per missing record, its fields stacked, each shown while its \"when\" holds. Hardware Kit's add says it's an inventory part, so its type is shown, not asked. \"Set all to:\" sets every record's choice; \"Use for all\" copies a picked value to the others.",
4
+ "prompt": {
5
+ "id": "add_transaction / ask_missing",
6
+ "kind": "decision",
7
+ "token": "Yj7sW9xCu5RkD7fF",
8
+ "message": "Some records this transaction uses aren't in QuickBooks yet. For each one, create it or pick one QuickBooks already has.",
9
+ "data": {
10
+ "operation": "invoice",
11
+ "missing": [
12
+ {
13
+ "kind": "customer",
14
+ "full_name": "Lone Star Supply",
15
+ "paths": [
16
+ "customer_ref"
17
+ ]
18
+ },
19
+ {
20
+ "kind": "item",
21
+ "full_name": "Vinyl Banner 3x6",
22
+ "paths": [
23
+ "invoice_line_add.0.item_ref"
24
+ ]
25
+ },
26
+ {
27
+ "kind": "item",
28
+ "full_name": "Hardware Kit",
29
+ "paths": [
30
+ "invoice_line_add.1.item_ref"
31
+ ]
32
+ }
33
+ ]
34
+ },
35
+ "details": [
36
+ {
37
+ "label": "Adding",
38
+ "value": "Invoice"
39
+ },
40
+ {
41
+ "label": "Number",
42
+ "value": "1042"
43
+ },
44
+ {
45
+ "label": "For",
46
+ "value": "Lone Star Supply"
47
+ }
48
+ ],
49
+ "options": [],
50
+ "choices": [
51
+ {
52
+ "id": "proceed",
53
+ "label": "Add the transaction",
54
+ "description": null,
55
+ "detail": null,
56
+ "fields": [
57
+ {
58
+ "name": "records",
59
+ "type": "rows",
60
+ "label": "Not in QuickBooks yet",
61
+ "required": true,
62
+ "columns": [
63
+ {
64
+ "name": "action",
65
+ "type": "string",
66
+ "label": "What to do",
67
+ "required": true,
68
+ "kind": null,
69
+ "enum": [
70
+ {
71
+ "value": "create",
72
+ "label": "Create it"
73
+ },
74
+ {
75
+ "value": "use",
76
+ "label": "Use an existing one"
77
+ }
78
+ ],
79
+ "when": null
80
+ },
81
+ {
82
+ "name": "list_id",
83
+ "type": "record",
84
+ "label": "Existing record",
85
+ "required": true,
86
+ "kind": null,
87
+ "enum": null,
88
+ "when": {
89
+ "action": [
90
+ "use"
91
+ ]
92
+ }
93
+ },
94
+ {
95
+ "name": "item_type",
96
+ "type": "string",
97
+ "label": "Item type",
98
+ "required": true,
99
+ "kind": null,
100
+ "enum": [
101
+ {
102
+ "value": "item_service",
103
+ "label": "Service"
104
+ },
105
+ {
106
+ "value": "item_non_inventory",
107
+ "label": "Non-inventory part"
108
+ },
109
+ {
110
+ "value": "item_inventory",
111
+ "label": "Inventory part"
112
+ },
113
+ {
114
+ "value": "item_other_charge",
115
+ "label": "Other charge"
116
+ }
117
+ ],
118
+ "when": {
119
+ "action": [
120
+ "create"
121
+ ]
122
+ }
123
+ },
124
+ {
125
+ "name": "entity_type",
126
+ "type": "string",
127
+ "label": "Add it as",
128
+ "required": true,
129
+ "kind": null,
130
+ "enum": [
131
+ {
132
+ "value": "customer",
133
+ "label": "Customer"
134
+ },
135
+ {
136
+ "value": "vendor",
137
+ "label": "Vendor"
138
+ },
139
+ {
140
+ "value": "other_name",
141
+ "label": "Other name"
142
+ }
143
+ ],
144
+ "when": {
145
+ "action": [
146
+ "create"
147
+ ]
148
+ }
149
+ },
150
+ {
151
+ "name": "income_account",
152
+ "type": "record",
153
+ "label": "Income account",
154
+ "required": true,
155
+ "kind": "account",
156
+ "enum": null,
157
+ "when": {
158
+ "action": [
159
+ "create"
160
+ ]
161
+ }
162
+ },
163
+ {
164
+ "name": "asset_account",
165
+ "type": "record",
166
+ "label": "Inventory asset account",
167
+ "required": true,
168
+ "kind": "account",
169
+ "enum": null,
170
+ "when": {
171
+ "action": [
172
+ "create"
173
+ ],
174
+ "item_type": [
175
+ "item_inventory"
176
+ ]
177
+ }
178
+ },
179
+ {
180
+ "name": "cogs_account",
181
+ "type": "record",
182
+ "label": "Cost of goods sold account",
183
+ "required": true,
184
+ "kind": "account",
185
+ "enum": null,
186
+ "when": {
187
+ "action": [
188
+ "create"
189
+ ],
190
+ "item_type": [
191
+ "item_inventory"
192
+ ]
193
+ }
194
+ }
195
+ ],
196
+ "rows": [
197
+ {
198
+ "key": "customer_ref",
199
+ "label": "Lone Star Supply",
200
+ "detail": "Customer",
201
+ "columns": [
202
+ "action",
203
+ "list_id"
204
+ ],
205
+ "values": {},
206
+ "kinds": {
207
+ "list_id": "customer"
208
+ }
209
+ },
210
+ {
211
+ "key": "invoice_line_add.0.item_ref",
212
+ "label": "Vinyl Banner 3x6",
213
+ "detail": "Item",
214
+ "columns": [
215
+ "action",
216
+ "list_id",
217
+ "item_type",
218
+ "income_account",
219
+ "asset_account",
220
+ "cogs_account"
221
+ ],
222
+ "values": {},
223
+ "kinds": {
224
+ "list_id": "item",
225
+ "income_account": "account",
226
+ "asset_account": "account",
227
+ "cogs_account": "account"
228
+ }
229
+ },
230
+ {
231
+ "key": "invoice_line_add.1.item_ref",
232
+ "label": "Hardware Kit",
233
+ "detail": "Item",
234
+ "columns": [
235
+ "action",
236
+ "list_id",
237
+ "income_account",
238
+ "asset_account",
239
+ "cogs_account"
240
+ ],
241
+ "values": {
242
+ "item_type": "item_inventory"
243
+ },
244
+ "kinds": {
245
+ "list_id": "item",
246
+ "income_account": "account",
247
+ "asset_account": "account",
248
+ "cogs_account": "account"
249
+ }
250
+ }
251
+ ]
252
+ }
253
+ ]
254
+ },
255
+ {
256
+ "id": "cancel",
257
+ "label": "Cancel",
258
+ "description": "Stop here. Nothing is created.",
259
+ "detail": null,
260
+ "fields": []
261
+ }
262
+ ]
263
+ },
264
+ "search": {
265
+ "records.list_id": [
266
+ {
267
+ "value": "80000001-1700000000",
268
+ "label": "Riverbend Electric",
269
+ "detail": "Customer"
270
+ },
271
+ {
272
+ "value": "80000002-1700000000",
273
+ "label": "Riverbend Supply Co.",
274
+ "detail": "Customer"
275
+ },
276
+ {
277
+ "value": "80000003-1700000000",
278
+ "label": "Lone Star Supply",
279
+ "detail": "Customer"
280
+ },
281
+ {
282
+ "value": "80000011-1700000000",
283
+ "label": "Vinyl Banner 3x6",
284
+ "detail": "Inventory part"
285
+ },
286
+ {
287
+ "value": "80000012-1700000000",
288
+ "label": "Hardware Kit",
289
+ "detail": "Non-inventory part"
290
+ }
291
+ ],
292
+ "records.income_account": [
293
+ {
294
+ "value": "8000000A-1700000000",
295
+ "label": "Sales",
296
+ "detail": "Income"
297
+ },
298
+ {
299
+ "value": "8000000B-1700000000",
300
+ "label": "Sales:Signs",
301
+ "detail": "Income"
302
+ },
303
+ {
304
+ "value": "80000005-1700000000",
305
+ "label": "Inventory Asset",
306
+ "detail": "Other current asset"
307
+ },
308
+ {
309
+ "value": "80000006-1700000000",
310
+ "label": "Cost of Goods Sold",
311
+ "detail": "Cost of goods sold"
312
+ }
313
+ ],
314
+ "records.asset_account": [
315
+ {
316
+ "value": "8000000A-1700000000",
317
+ "label": "Sales",
318
+ "detail": "Income"
319
+ },
320
+ {
321
+ "value": "8000000B-1700000000",
322
+ "label": "Sales:Signs",
323
+ "detail": "Income"
324
+ },
325
+ {
326
+ "value": "80000005-1700000000",
327
+ "label": "Inventory Asset",
328
+ "detail": "Other current asset"
329
+ },
330
+ {
331
+ "value": "80000006-1700000000",
332
+ "label": "Cost of Goods Sold",
333
+ "detail": "Cost of goods sold"
334
+ }
335
+ ],
336
+ "records.cogs_account": [
337
+ {
338
+ "value": "8000000A-1700000000",
339
+ "label": "Sales",
340
+ "detail": "Income"
341
+ },
342
+ {
343
+ "value": "8000000B-1700000000",
344
+ "label": "Sales:Signs",
345
+ "detail": "Income"
346
+ },
347
+ {
348
+ "value": "80000005-1700000000",
349
+ "label": "Inventory Asset",
350
+ "detail": "Other current asset"
351
+ },
352
+ {
353
+ "value": "80000006-1700000000",
354
+ "label": "Cost of Goods Sold",
355
+ "detail": "Cost of goods sold"
356
+ }
357
+ ]
358
+ }
359
+ }
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module QubeRails
6
+ # Every screen the engine's partials show, from sample data, for GET qube/storyboard: the run
7
+ # status in each state, the one-shot error, and a question of each kind (lib/qube_rails/
8
+ # storyboard/*.json, prompts shaped as QuBe Sync sends them). Nothing is read from or written
9
+ # to QuBe Sync or the database: runs are built, never saved.
10
+ module Storyboard
11
+ DIR = File.expand_path("storyboard", __dir__)
12
+
13
+ Screen = Struct.new(:key, :group, :title, :note, :partials, :kind, :sample)
14
+
15
+ QUESTIONS = %w[near_match record_search choose enum_select number_boolean name_clash ask_about_add missing_record rows
16
+ refusal].freeze
17
+
18
+ # key, title, SyncStatus status, the run's attributes (ages in seconds), extra pieces, note.
19
+ RUN_STATUSES = [
20
+ ["not_sent", "Not sent", :not_sent, nil, [],
21
+ "A record with no run: the run status partial renders nothing."],
22
+ ["starting", "Starting", :sending, { state: "starting", age: 5 }, [],
23
+ "A start QuBe Sync is confirming."],
24
+ ["unconfirmed", "A start not confirmed", :sending, { state: "starting", age: 120 }, ["run_status/retry"],
25
+ "QuBe Sync hasn't confirmed the start for a minute: the retry button sends it again, same reference."],
26
+ ["running", "Running", :sending, { state: "running", qube_run_id: "run-sample", age: 30 }, [],
27
+ "QuBe Sync confirmed it; the Web Connector is working through it."],
28
+ ["stalled_not_checking_in", "Waiting: QuickBooks isn't checking in", :sending,
29
+ { state: "running", qube_run_id: "run-sample", age: 30 * 60, stall: "not_checking_in" }, ["run_status/stall"],
30
+ "A run going on a connection QuBe Sync says stalled (not_checking_in): the Web Connector hasn't called in. " \
31
+ "No redeliver button: the cause is known."],
32
+ ["stalled_quickbooks_unavailable", "Waiting: QuickBooks is closed", :sending,
33
+ { state: "running", qube_run_id: "run-sample", age: 30 * 60, stall: "quickbooks_unavailable" },
34
+ ["run_status/stall"],
35
+ "The same, stalled as quickbooks_unavailable: the Web Connector calls in but can't open the company file."],
36
+ ["needs_answer", "Waiting for an answer", :needs_answer,
37
+ { state: "awaiting_input", qube_run_id: "run-sample", age: 30, question: { "message" => "?" } }, [],
38
+ "The run asks a question: the question partial shows it (below)."],
39
+ ["looks_stuck", "Looks stuck", :sending, { state: "running", qube_run_id: "run-sample", age: 15 * 60 },
40
+ ["run_status/redeliver"],
41
+ "No news for 10 minutes: the redeliver button asks QuBe Sync to send its latest event again."],
42
+ ["in_quickbooks", "In QuickBooks", :in_quickbooks, { state: "completed", outcome: "added", ended: true }, [],
43
+ "The record is linked to its QuickBooks id."],
44
+ ["done", "Done", :done, { state: "completed", outcome: "completed", ended: true }, [],
45
+ "Completed, linking nothing (an operation that doesn't link its record)."],
46
+ ["failed", "Failed", :failed,
47
+ { state: "failed", ended: true,
48
+ error: { "message" => "QuickBooks refused the invoice because Riverbend Electric is inactive",
49
+ "detail" => "Make Riverbend Electric active in QuickBooks, then send it again." } },
50
+ ["run_status/detail"], "A failure: the pill says it couldn't sync; below it, why (the error's message) and what to do (its detail)."],
51
+ ["cancelled", "Cancelled", :cancelled,
52
+ { state: "cancelled", ended: true, cancel_reason: "The order was cancelled in the app." }, [],
53
+ "Cancelled by your app, with its reason."]
54
+ ].freeze
55
+
56
+ # Stands in for the engine's routes: every form posts to the storyboard's no-op answer.
57
+ class Urls
58
+ def initialize(answer_path)
59
+ @answer_path = answer_path
60
+ end
61
+
62
+ def question_path(_run)
63
+ @answer_path
64
+ end
65
+
66
+ def retry_start_path(_run)
67
+ @answer_path
68
+ end
69
+
70
+ def redeliver_path(_run)
71
+ @answer_path
72
+ end
73
+ end
74
+
75
+ class << self
76
+ def screens
77
+ run_status_screens + [error_screen] + QUESTIONS.map { |key| question_screen(key) }
78
+ end
79
+
80
+ def find(key)
81
+ screens.find { |screen| screen.key == key.to_s }
82
+ end
83
+
84
+ def run_status_view(screen, urls)
85
+ RunStatusView.from_sync(sync_status(screen.sample), urls)
86
+ end
87
+
88
+ def question_view(screen, answer_path, search_path)
89
+ QuestionView.new(screen.sample["prompt"], action_url: answer_path, search_url: search_path,
90
+ error: screen.sample["error"])
91
+ end
92
+
93
+ # The sample's matches for a search: at most 20 whose label holds q.
94
+ def matches(screen, field, q)
95
+ list = screen && screen.kind == :question ? Array(screen.sample["search"][field.to_s]) : []
96
+ q = q.to_s.strip.downcase
97
+ list.select { |m| m["label"].to_s.downcase.include?(q) }.first(20)
98
+ end
99
+
100
+ # The file an app puts at that path to override a piece.
101
+ def override_path(partial)
102
+ dir, _, name = partial.rpartition("/")
103
+ "app/views/qube_rails/#{dir.empty? ? "" : "#{dir}/"}_#{name}.html.erb"
104
+ end
105
+
106
+ private
107
+
108
+ def run_status_screens
109
+ RUN_STATUSES.map do |key, title, status, run, pieces, note|
110
+ partials = %w[run_status/status run_status/summary] + pieces
111
+ partials = [] if status == :not_sent
112
+ Screen.new(key, "Run status", title, note, partials, :run_status, { status: status, run: run })
113
+ end
114
+ end
115
+
116
+ def error_screen
117
+ Screen.new("error", "Run status", "A one-shot error",
118
+ "A route's message (flash[:qube_rails_error]), shown once by whichever partial comes first, " \
119
+ "or by render \"qube_rails/error\" on a page without them.", ["error"], :error,
120
+ { message: QubeRails.t("errors.question_changed") })
121
+ end
122
+
123
+ def question_screen(key)
124
+ sample = JSON.parse(File.read(File.join(DIR, "#{key}.json")))
125
+ Screen.new(key, "Questions", sample["title"], sample["note"], question_partials(sample), :question, sample)
126
+ end
127
+
128
+ def question_partials(sample)
129
+ prompt = sample["prompt"]
130
+ list = %w[question/question question/message]
131
+ list << "question/details" if Array(prompt["details"]).any?
132
+ view = QuestionView.new(prompt, action_url: "", search_url: "")
133
+ list.concat(%w[question/picks question/pick]) if view.picks.any?
134
+ list << "question/choice" if view.forms.any? || view.exits.any?
135
+ list << "question/footer" if view.exits.any?
136
+ list << "error" if sample["error"]
137
+ fields = Array(prompt["choices"]).flat_map { |choice| Array(choice["fields"]) }
138
+ fields.each do |field|
139
+ type = FieldView.type_of(field, record_type: "search")
140
+ list << "question/field" unless type == "rows"
141
+ list << "question/fields/#{type}"
142
+ next unless type == "rows"
143
+
144
+ list.concat(%w[question/rows/row question/rows/cell question/rows/segmented question/field])
145
+ Array(field["columns"]).each { |c| list << "question/fields/#{FieldView.type_of(c, record_type: "search")}" }
146
+ end
147
+ list.uniq
148
+ end
149
+
150
+ def sync_status(sample)
151
+ attrs = sample[:run]
152
+ return SyncStatus.new(:not_sent, nil, nil) unless attrs
153
+
154
+ now = Time.now
155
+ created = now - attrs[:age].to_i
156
+ run = Run.new(state: attrs[:state], outcome: attrs[:outcome], error: attrs[:error],
157
+ cancel_reason: attrs[:cancel_reason], qube_run_id: attrs[:qube_run_id],
158
+ question: attrs[:question], operation: "storyboard", reference: "storyboard",
159
+ created_at: created, updated_at: created, ended_at: attrs[:ended] ? now : nil)
160
+ run.connection = Connection.new(status: attrs[:stall] ? "stalled" : "connected", stall_reason: attrs[:stall])
161
+ SyncStatus.new(sample[:status], run, nil)
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QubeRails
4
+ # Where one of your records stands with QuickBooks on one connection, for one operation,
5
+ # worked out from what the engine already keeps: the record's runs (qube_runs) and its
6
+ # QuickBooks link (qube_quickbooks_records). Nothing is stored for it, so it can't go stale,
7
+ # and your tables need no status column.
8
+ #
9
+ # status = connection.sync_status(invoice, operation: "create_invoice")
10
+ # status.status # => :not_sent, :sending, :needs_answer, :in_quickbooks, :done, :failed or
11
+ # # :cancelled
12
+ # status.qb_id # the record's QuickBooks id on this connection, when it's linked
13
+ # status.error # a failed run's error ({ "message" => ..., ... }), else nil
14
+ # status.retry? # whether starting it again makes sense
15
+ #
16
+ # Per connection: an invoice can be in one company file and not another.
17
+ #
18
+ # The run and the link can disagree (a record linked earlier, with a new run since), so the
19
+ # newest word wins, in this order:
20
+ # 1. the latest run hasn't ended: :sending (starting or running, an unconfirmed start too:
21
+ # unconfirmed? says so) or :needs_answer (awaiting input), whatever the link says;
22
+ # 2. the latest run failed or was cancelled after the link was made (or there's no link):
23
+ # :failed or :cancelled;
24
+ # 3. the record is linked: :in_quickbooks, always with its qb_id;
25
+ # 4. the latest run completed and linked nothing (an operation that doesn't link its record):
26
+ # :done, which a start may follow, since nothing in QuickBooks stands for the record;
27
+ # 5. neither: :not_sent.
28
+ class SyncStatus
29
+ STATUSES = %i[not_sent sending needs_answer in_quickbooks done failed cancelled].freeze
30
+
31
+ attr_reader :status, :run, :link
32
+
33
+ # record's status on connection for operation (its SLUG).
34
+ def self.for(connection, record, operation:)
35
+ run = QubeRails::Run.where(connection: connection).latest_for(record, operation)
36
+ type, id = QubeRails::ApplicationRecord.key_for(record)
37
+ link = connection.quickbooks_records.where(record_type: type, record_id: id)
38
+ .order(updated_at: :desc, id: :desc).first
39
+ new(status_of(run, link), run, link)
40
+ end
41
+
42
+ # record's status on the connection its latest run of operation went out on: what a record's
43
+ # page shows when it doesn't say which connection. :not_sent when it has no run.
44
+ def self.latest(record, operation:)
45
+ run = QubeRails::Run.latest_for(record, operation)
46
+ return new(:not_sent, nil, nil) unless run
47
+
48
+ self.for(run.connection, record, operation: operation)
49
+ end
50
+
51
+ def self.status_of(run, link)
52
+ return in_flight(run) if run && !run.ended?
53
+ return stopped(run) if stopped_since_link?(run, link)
54
+ return :in_quickbooks if link
55
+ return :done if run&.state == "completed"
56
+
57
+ :not_sent
58
+ end
59
+ private_class_method :status_of
60
+
61
+ def self.in_flight(run)
62
+ run.state == "awaiting_input" ? :needs_answer : :sending
63
+ end
64
+ private_class_method :in_flight
65
+
66
+ def self.stopped(run)
67
+ run.cancelled? ? :cancelled : :failed
68
+ end
69
+ private_class_method :stopped
70
+
71
+ # The latest run failed or was cancelled, and the link (if any) is older than its end.
72
+ def self.stopped_since_link?(run, link)
73
+ return false unless run && (run.failed? || run.cancelled?)
74
+
75
+ link.nil? || link.updated_at <= (run.ended_at || run.updated_at)
76
+ end
77
+ private_class_method :stopped_since_link?
78
+
79
+ def initialize(status, run, link)
80
+ @status = status
81
+ @run = run
82
+ @link = link
83
+ end
84
+
85
+ STATUSES.each do |name|
86
+ define_method(:"#{name}?") { status == name }
87
+ end
88
+
89
+ # The record's QuickBooks id on this connection, when it's linked.
90
+ def qb_id
91
+ link&.qb_id
92
+ end
93
+
94
+ # A failed run's error, as its event carried it.
95
+ def error
96
+ run&.error if failed?
97
+ end
98
+
99
+ # A start QuBe Sync hasn't confirmed for a minute: run.retry_start! sends it again.
100
+ def unconfirmed?
101
+ sending? && run.unconfirmed?
102
+ end
103
+
104
+ # Whether starting the operation again makes sense: after a failure or a cancellation, unless
105
+ # the record is in QuickBooks on this connection anyway (an add sent again would add it twice).
106
+ # Not while a run is going: a start then returns that run.
107
+ def retry?
108
+ (failed? || cancelled?) && link.nil?
109
+ end
110
+
111
+ # Where it stands, in one plain sentence for a person (the run's, QubeRails::Run#summary_text,
112
+ # when a run says it), or nil when it hasn't been sent.
113
+ def summary_text
114
+ return nil if not_sent?
115
+ return QubeRails.t("status.in_quickbooks") if in_quickbooks? && run&.state != "completed"
116
+
117
+ run.summary_text
118
+ end
119
+
120
+ def to_s
121
+ status.to_s
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QubeRails
4
+ VERSION = "0.2.0"
5
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QubeRails
4
+ # Helpers in every view of your app (the engine includes them into ActionView).
5
+ module ViewHelpers
6
+ # The question's behaviour script, and with stylesheet: true the engine's opt-in stylesheet,
7
+ # for your layout's <head>:
8
+ # <%= qube_rails_assets stylesheet: true %>
9
+ # Served by your asset pipeline (Propshaft or Sprockets) as qube_rails/question.js and
10
+ # qube_rails.css.
11
+ def qube_rails_assets(stylesheet: false)
12
+ tags = []
13
+ tags << stylesheet_link_tag("qube_rails", media: "all") if stylesheet
14
+ tags << javascript_include_tag("qube_rails/question", defer: true, nonce: true)
15
+ safe_join(tags, "\n")
16
+ end
17
+
18
+ # The classes for a piece of the engine's screens: its qube- classes, then the app's
19
+ # config.ui_classes for that piece.
20
+ def qube_classes(piece, *base)
21
+ extra = QubeRails.config.ui_classes[piece.to_sym]
22
+ unless QubeRails::Configuration::UI_PIECES.include?(piece.to_sym)
23
+ raise ArgumentError, "QuBe Sync: no ui_classes piece #{piece.inspect}"
24
+ end
25
+
26
+ (base.flatten.compact + [extra]).reject { |c| c.nil? || c.empty? }.join(" ")
27
+ end
28
+ end
29
+ end