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/README.md ADDED
@@ -0,0 +1,967 @@
1
+ # qube_rails
2
+
3
+ QuBe Sync's Rails engine, for QuickBooks Desktop through [qubesync.com](https://qubesync.com). It
4
+ holds the part of an integration that's the same in every Rails app:
5
+
6
+ - tables of its own: your customers' **connections** (an account can have several, one per
7
+ company file), the **runs** your app starts (and any plain **requests**), and the **QuickBooks ids** it learns, each kept
8
+ against its connection;
9
+ - onboarding: make a connection for the signed-in account, or send one of its connections back
10
+ through QuBe Sync's setup;
11
+ - the webhook endpoint (verify the signature, find the row, enqueue) and its job (check the event
12
+ is that run's or that connection's, apply it, call your handler);
13
+ - the question routes (answer by choice, search a record field) and the question partial;
14
+ - `QubeRails::Operation` and `QubeRails::ConnectionEvents`, the bases of your classes.
15
+
16
+ Your app keeps its own code, which this gem never names: one initializer, an operation class per
17
+ way it starts a workflow, and a class for a connection's events. Your own tables need no QuBe
18
+ columns. Update the engine with `bundle update qube_rails`; your files stay as you wrote them.
19
+
20
+ Needs Ruby 3.3 or later and Rails 7.0 or later, and the [qube_sync](https://rubygems.org/gems/qube_sync)
21
+ gem 0.5.0 or later, which it installs. Works on PostgreSQL, MySQL and SQLite.
22
+
23
+ ## Install
24
+
25
+ ```ruby
26
+ # Gemfile
27
+ gem "qube_rails", ">= 0.2.0"
28
+ ```
29
+
30
+ ```sh
31
+ bundle install
32
+ bin/rails g qube_rails:install
33
+ bin/rails db:migrate
34
+ ```
35
+
36
+ The generator writes:
37
+
38
+ - `config/initializers/qube_rails.rb`, every setting, commented;
39
+ - `app/services/quickbooks_desktop/connection_events.rb`, your class for a connection's events;
40
+ - the engine's migration (`bin/rails qube_rails:install:migrations`), which adds
41
+ `qube_connections`, `qube_runs`, `qube_notifications` and `qube_quickbooks_records`;
42
+ - `mount QubeRails::Engine => "/"` in `config/routes.rb`;
43
+ - `<%= qube_rails_assets %>` in your layout's `<head>`, the question's behaviour script
44
+ ([Styling](#styling)).
45
+
46
+ Fill in `current_account` in the initializer before anything else: onboarding and the question
47
+ routes both need it.
48
+
49
+ ## Routes
50
+
51
+ Relative to where the engine is mounted (`"/"` above):
52
+
53
+ | Route | What it does |
54
+ | --- | --- |
55
+ | `POST quickbooks/connections` | Connect, for the signed-in account (`current_account`). Sends its newest connection that has never connected (`pending` or `setup_cancelled`) back through setup, so clicking Connect again doesn't pile up connections. Makes a new one (the row, then QuBe Sync's connection with the row's webhook URL and `config.connection_type`, `"realtime"` by default since workflows run only on realtime connections, then its `qube_id`, with an optional `name`) when the account has none, or with `new=1`. Redirects (303) to the setup. With `simulated=1`, outside production, it makes a new connection to a simulated QuickBooks company file instead, connected as it's made, and redirects back: for testing with no Windows computer. In production that answers 404. |
56
+ | `POST quickbooks/connections/:id/onboarding` | One of the signed-in account's connections, back through setup, with its webhook URL brought up to date (or made at QuBe Sync, for a row QuBe Sync never answered). Another account's connection answers 404. |
57
+ | `DELETE quickbooks/connections/:id` | Disconnects one of the signed-in account's connections: QuBe Sync deletes its connection (already gone counts), then the row goes, with its runs, requests and QuickBooks links. Refused while a run or request hasn't ended; another account's connection answers 404. Show its message with `render "qube_rails/error"`. |
58
+ | `POST webhooks/qube/runs/:id` | A run's webhooks. |
59
+ | `POST webhooks/qube/connections/:id` | A connection's webhooks. |
60
+ | `POST webhooks/qube/requests/:id` | A plain request's webhooks ([Plain requests](#plain-requests)). |
61
+ | `POST qube/questions/runs/:id` | Your user's answer to the run's question, from the partial. Behind your sign-in and CSRF. |
62
+ | `GET qube/questions/runs/:id/search` | A record field's matches, as JSON. Behind your sign-in. |
63
+ | `POST qube/runs/:id/redeliver` | A run that looks stuck (`Run#looks_stuck?`: confirmed, not ended, and quiet for 10 minutes while running or waiting on a question this app never got): asks QuBe Sync to send its latest event again, from the run status partial's "Check for updates" button. Behind your sign-in and CSRF, and `authorize_question`. |
64
+ | `POST qube/runs/:id/retry_start` | A start QuBe Sync never confirmed, sent again with the same reference, from the run status partial. Behind your sign-in and CSRF, and `authorize_question`. |
65
+ | `GET qube/storyboard`, `GET qube/storyboard/:screen` | Every screen the partials show, from sample data, in your layout ([Styling](#styling)). Only while `config.storyboard` is true (404 otherwise); behind your sign-in. Its search (`GET qube/storyboard/:screen/search`) answers from the samples and its forms post to a no-op (`POST qube/storyboard/:screen/answer`): nothing reaches QuBe Sync or the database. |
66
+
67
+ The webhook routes verify the signature (401 if it doesn't check out), answer 404 when the row
68
+ isn't there, enqueue, and answer 200. No sign-in, no CSRF, the raw body, any content type, up to
69
+ 5 MB. Each id in a URL is only a pointer: the job checks the signed event's reference and
70
+ connection id against the row (a request's event, its id).
71
+
72
+ Onboarding and the question routes inherit your `ApplicationController` (the `parent_controller`
73
+ setting), so its sign-in and CSRF protection apply. Your views reach the routes through the
74
+ engine's proxy: `qube_rails.connections_path`, `qube_rails.connection_onboarding_path(connection)`,
75
+ `qube_rails.connection_path(connection)` (with `method: :delete`).
76
+
77
+ Two buttons cover it: Connect, for an account setting up its first company file (or finishing a
78
+ setup it started), and, once it has one, Connect another company file:
79
+
80
+ ```erb
81
+ <%= button_to "Connect QuickBooks Desktop", qube_rails.connections_path,
82
+ form: { data: { turbo: false } } %>
83
+ <%= button_to "Connect another company file", qube_rails.connections_path,
84
+ params: { new: 1, name: "Second file" }, form: { data: { turbo: false } } %>
85
+ <% if Rails.env.development? %>
86
+ <%= button_to "Connect a simulated QuickBooks (for testing)", qube_rails.connections_path,
87
+ params: { simulated: 1 } %>
88
+ <% end %>
89
+ ```
90
+
91
+ Connect redirects to QuBe Sync's setup, on another site, so its forms need `data-turbo="false"`
92
+ in an app with Turbo (Rails 7 and later): Turbo submits a form with `fetch`, which the browser
93
+ won't follow to another origin, and the button would do nothing.
94
+
95
+ ## Settings
96
+
97
+ In `config/initializers/qube_rails.rb`, the only place the engine reads your app from:
98
+
99
+ ```ruby
100
+ QubeRails.configure do |config|
101
+ config.operations = { "create_invoice" => "QuickbooksDesktop::CreateInvoice" }
102
+ config.connection_events = "QuickbooksDesktop::ConnectionEvents"
103
+ config.current_account = ->(controller) { controller.current_user.account }
104
+ end
105
+ ```
106
+
107
+ | Setting | What it is | Default |
108
+ | --- | --- | --- |
109
+ | `operations` | slug => operation class *name* (resolved when used, so code reloading works) | none: raises |
110
+ | `connection_events` | your `QubeRails::ConnectionEvents` class's name | none: raises |
111
+ | `current_account` | `->(controller) { account }`: the signed-in customer's account, which owns its connections. nil answers 401. | none: raises |
112
+ | `authorize_question` | `->(controller, record, run) { true }` when the signed-in user may answer or search that run's question. The run carries its `operation` and its `connection` (whose `owner` connected it). Only `true` allows; anything else answers 404 (search: `[]` with 404), as for a missing run, and is logged. | allows only when `current_account` owns the run's connection (compared by type and id, so a nil or unsaved account never matches) |
113
+ | `enqueue` | `->(delivery) { ... }`, handing `{ kind:, id:, body: }` (kind `"run"` or `"connection"`) to a queue | ActiveJob: `QubeRails::WebhookJob` on `queue` |
114
+ | `queue` | the ActiveJob queue | `:default` |
115
+ | `job_attempts` | how many times `QubeRails::WebhookJob` runs a delivery whose work raised, with a growing wait, before it logs an error and raises to your adapter (read when the job class loads) | `10` |
116
+ | `dedupe` | where each run's taken notification delivery ids are kept: `once(run, delivery_id) { }`, or `seen?(key)` / `remember(key)` | `QubeRails::DatabaseDedupe`: the engine's `qube_notifications` table, in the handler's transaction |
117
+ | `parent_controller` | the controller onboarding and the questions inherit | `"ApplicationController"` |
118
+ | `ui_classes` | your CSS classes for the engine's screens, by piece (`{ button: "btn btn-primary" }`), added beside the `qube-` classes ([Styling](#styling)); an unknown piece raises, naming them | `{}` |
119
+ | `storyboard` | whether `GET qube/storyboard` shows every screen with sample data | `Rails.env.development?` |
120
+ | `api_key` | | `QUBE_API_KEY` |
121
+ | `base_url` | | `QUBE_URL`, else `https://qubesync.com` |
122
+ | `webhook_secret` | a secret, or a list while one rotates | `QUBE_WEBHOOK_SECRET` |
123
+ | `webhook_url` | the base of every webhook URL | `QUBE_WEBHOOK_URL` |
124
+
125
+ A setting your app hasn't written raises `QubeRails::NotConfigured`, naming the initializer, the
126
+ first time it's needed, so nothing silently passes. The environment is read at first use.
127
+ Secrets are never logged, and `QubeRails.config.inspect` hides them.
128
+
129
+ `controller` is the engine's controller handling the request, a subclass of your
130
+ `ApplicationController`, so `current_user` and your other helpers are on it.
131
+
132
+ Set `authorize_question` only to change who may answer. The default lets the account that owns
133
+ the run's connection answer it, which is right for most apps. Set your own to check more (that
134
+ this user may see the record) or less (staff who act for any account):
135
+
136
+ ```ruby
137
+ config.authorize_question = ->(controller, record, run) {
138
+ run.connection.owner == controller.current_user.account && controller.current_user.can_edit?(record)
139
+ }
140
+ ```
141
+
142
+ A notification (e.g. one page of a sync) is taken once per run by its `delivery_id`. By default the id
143
+ goes in `qube_notifications` inside the run row's lock and the same transaction as your
144
+ `notification` handler: a redelivery finds the row and is skipped, and a handler that raises rolls
145
+ its row back with its own writes, so the retry runs it again. `QubeRails::CacheDedupe` keeps the
146
+ ids in `Rails.cache` instead (`config.dedupe = QubeRails::CacheDedupe.new(Rails.cache, expires_in:
147
+ 1.day)`); it needs a cache your workers share (Redis, Solid Cache), and it isn't transactional: a
148
+ crash between the handler's commit and the cache write runs the handler again.
149
+
150
+ ## Background work
151
+
152
+ QuBe Sync's webhooks are handled in the background. The endpoint verifies a delivery, answers 200
153
+ at once, and hands the work to `QubeRails::WebhookJob`, an ActiveJob, so it runs on whatever queue
154
+ your app's ActiveJob uses (Sidekiq, Solid Queue, GoodJob...). Most apps change nothing here; set
155
+ `queue` to put it on a queue other than `:default`.
156
+
157
+ If your app's workers don't run ActiveJob (e.g. plain Sidekiq workers), set `enqueue` to hand the
158
+ delivery to your own worker, and have that worker call `QubeRails.run` with what it was given:
159
+
160
+ ```ruby
161
+ # config/initializers/qube_rails.rb
162
+ config.enqueue = ->(delivery) {
163
+ QubeDeliveryWorker.perform_async(delivery[:kind], delivery[:id], delivery[:body])
164
+ }
165
+
166
+ # app/workers/qube_delivery_worker.rb
167
+ class QubeDeliveryWorker
168
+ include Sidekiq::Job
169
+
170
+ def perform(kind, id, body)
171
+ QubeRails.run(kind, id, body)
172
+ end
173
+ end
174
+ ```
175
+
176
+ Retries are your queue's. QuBe Sync stops sending a delivery once your endpoint has answered 200,
177
+ so if the work fails later, your queue is the only thing that will run it again. The endpoint
178
+ answers 200 only once the enqueue has taken the delivery: when it returns `false` (ActiveJob 7.1+
179
+ does when the adapter couldn't enqueue) or raises, the endpoint answers 503, logged without the
180
+ body, and QuBe Sync sends it again.
181
+
182
+ `QubeRails.run` raises when it can't do the work (a handler of yours raised, the database is down,
183
+ the run's operation was taken out of `operations`, or a connection's event came before the
184
+ connection's id was saved: `QubeRails::NotReady`). `QubeRails::WebhookJob` retries it itself
185
+ (`retry_on`, with a growing wait), `job_attempts` times in all, since Solid Queue, GoodJob and
186
+ `:async` don't retry a job that raises. When the attempts run out it logs an error ("QuBe Sync:
187
+ gave up on a webhook ...") and raises, so your adapter sees the failure. With Sidekiq, its own
188
+ retries (25 by default) follow ActiveJob's, so a failing delivery is tried `job_attempts` times
189
+ and then Sidekiq's; set `job_attempts` to 1 to leave retrying to Sidekiq alone, or lower it. With
190
+ your own `enqueue`, your worker's retries are the only ones.
191
+
192
+ One case isn't retried: a delivery whose run, connection or record has been deleted from your
193
+ database since it arrived. The endpoint answers 404 unless the run or connection row exists, and
194
+ your app saves a record before starting its run, so a row that's missing by the time the job runs
195
+ was deleted, and retrying would never succeed. `QubeRails.run` logs a warning ("QuBe Sync:
196
+ dropped ...") and returns.
197
+
198
+ ## Environment
199
+
200
+ | Variable | What it is |
201
+ | --- | --- |
202
+ | `QUBE_API_KEY` | your app's API key; `qube env --write .env` writes it |
203
+ | `QUBE_WEBHOOK_SECRET` | your app's webhook secret; `qube env --write .env` writes it |
204
+ | `QUBE_URL` | `https://qubesync.com` unless you're told otherwise |
205
+ | `QUBE_WEBHOOK_URL` | the base of each run's and each connection's webhook URL: `qube listen --write-env .env` writes it in development, and in production it's your app's `/webhooks/qube` |
206
+
207
+ ## Connections
208
+
209
+ A `QubeRails::Connection` is one company file: `owner` (your account), `qube_id` (QuBe Sync's
210
+ id, nil until it answers), `name`, `status` (`pending`, `connected`, `setup_cancelled`,
211
+ `stalled`), `stall_reason` and `revision`. `QubeRails::Connection.owned_by(account)` lists an
212
+ account's.
213
+
214
+ Its own events (QUBE_WEBHOOK_URL/connections/<id>) update `status`, `stall_reason` and `revision`
215
+ when the event is for that connection (`qube_id`) and newer than the last one applied, under the
216
+ row's lock. Then your class runs, with `connection` and `owner`.
217
+
218
+ An event can come before the row has its `qube_id`: a simulated connection sends
219
+ `connection.connected` as it's made, before `create_connection` has answered. Such an event finds
220
+ QuBe Sync's connection made with the row's webhook URL and keeps its id, or raises
221
+ `QubeRails::NotReady`, so the job tries again shortly. Connect does the same for a row left
222
+ without an id (a crash between the create and the save), so one row never gets two connections.
223
+
224
+ Adopting lists the app's connections (`GET /api/v2/connections`, which isn't paged) and takes
225
+ only the one with the row's webhook URL that has never connected (`last_connected_at` is null,
226
+ or it's simulated, since a simulated connection connects as it's made),
227
+ was made at or after the row (its `created_at`, with five minutes allowed for clock difference),
228
+ and has the row's name when the row has one; if two qualify it takes neither. A connection with no
229
+ `created_at` (from a server before it sent one) is never adopted, and that's logged.
230
+ So a row id used again (MySQL 5.7 after a restart, a restored database) never takes an old
231
+ connection. The unpaged listing is acceptable because it only runs for a row without an id,
232
+ which is rare: a lost create, or an event that beat the save.
233
+
234
+ ```ruby
235
+ module QuickbooksDesktop
236
+ class ConnectionEvents < QubeRails::ConnectionEvents
237
+ # QuickBooks is connected: start syncing.
238
+ def connected(event)
239
+ InitialImport.start(owner, connection)
240
+ end
241
+
242
+ # They chose "Cancel setup": offer to set it up again.
243
+ def setup_cancelled(event)
244
+ log_event(event)
245
+ end
246
+
247
+ # connection.stall_reason: "not_checking_in" (turn on the QuickBooks computer and check the
248
+ # Web Connector runs) or "quickbooks_unavailable" (open QuickBooks with the company file).
249
+ def stalled(event)
250
+ AccountMailer.quickbooks_stalled(owner, connection).deliver_later
251
+ end
252
+
253
+ def resumed(event)
254
+ log_event(event)
255
+ end
256
+ end
257
+ end
258
+ ```
259
+
260
+ ### QuickBooks ids
261
+
262
+ Every ListID and TxnID your app learns is kept against its connection and its list family.
263
+ QuickBooks gives out ListIDs from one counter per family, so an id is unique only within its
264
+ family of one company file (verified on QuickBooks Enterprise 24.0): customers, vendors,
265
+ employees and other names share one, every item type shares another, each other list (accounts,
266
+ classes, terms...) has its own, and the same counter value turns up in several families.
267
+ TxnIDs are kept per transaction type.
268
+
269
+ ```ruby
270
+ connection.link(customer, kind: "customer", qb_id: list_id, edit_sequence: "171", name: "Riverbend Electric")
271
+ connection.qb_id_for(customer, kind: "customer") # => "80000001-1738310400", or nil
272
+ connection.find_linked(list_id, kind: "customer") # => your Customer, or nil
273
+ connection.find_linked(entity_ref_list_id) # any family; raises if the id is in several
274
+ connection.quickbooks_record(list_id, kind: "item") # => the row, with kind, edit_sequence and name
275
+ ```
276
+
277
+ A record has one id per kind on a connection; linking it again replaces it. One contact can be
278
+ linked as a customer and as a vendor (two QuickBooks names, two ids). An id already linked in
279
+ that family to another of your records moves to this one, and that's logged; linked again with
280
+ another kind of the same family (an item converted from service to non-inventory), the row's
281
+ kind changes. `qb_id_for` finds an item linked as `item_service` when asked for `item` or
282
+ `item_non_inventory`, but never hands out a customer's id as a vendor's.
283
+
284
+ ## Writing an operation
285
+
286
+ One class per way your app starts a workflow, listed in `config.operations` by its `SLUG`:
287
+
288
+ ```ruby
289
+ # app/services/quickbooks_desktop/create_invoice.rb
290
+ module QuickbooksDesktop
291
+ class CreateInvoice < QubeRails::Operation
292
+ WORKFLOW = "add_transaction"
293
+ SLUG = "create_invoice"
294
+
295
+ # Optional: your record, by the id its run keeps. Leave it out and the run finds the record
296
+ # by its type; write it to load what the input reads.
297
+ def self.find_record(id)
298
+ Invoice.includes(lines: :item).find_by(id: id)
299
+ end
300
+
301
+ # The run's input, from the record. without_nils leaves out a field your record doesn't have
302
+ # yet rather than sending it as null.
303
+ def input
304
+ without_nils({
305
+ operation: "invoice.create",
306
+ params: {
307
+ # Its ListID on this connection when it's linked, otherwise its name.
308
+ customer_ref: quickbooks_ref(record.customer, kind: "customer", name: record.customer&.name),
309
+ ref_number: record.number,
310
+ invoice_line_add: record.lines.map { |line|
311
+ { item_ref: quickbooks_ref(line.item, kind: "item", name: line.item&.name), amount: line.total&.to_f }
312
+ }
313
+ }
314
+ })
315
+ end
316
+
317
+ # How the run ended: event.outcome says which, event.output holds the result. Keep the
318
+ # QuickBooks ids it carries on the connection.
319
+ def ended(event)
320
+ # The records the add named, found or made, by the input's path: linked, so the next run
321
+ # refers to them by id. A cancelled ending can carry some too.
322
+ link_references(event) do |path, _entry|
323
+ if path == "customer_ref"
324
+ record.customer
325
+ elsif (line = path[/\Ainvoice_line_add\.(\d+)\.item_ref\z/, 1])
326
+ record.lines[line.to_i]&.item
327
+ end
328
+ end
329
+ return unless event.outcome == "added"
330
+
331
+ txn = event.dig("output", "record") || {}
332
+ connection.link(record, kind: "invoice", qb_id: txn["txn_id"],
333
+ edit_sequence: txn["edit_sequence"], name: txn["ref_number"])
334
+ record.update!(status: "sent")
335
+ end
336
+
337
+ # A run that failed, or that your app cancelled.
338
+ def failed(event)
339
+ record.update!(status: "failed")
340
+ end
341
+
342
+ # What a running workflow sends along (a sync's pages): taken once per delivery_id.
343
+ def notification(event)
344
+ log_event(event)
345
+ end
346
+
347
+ end
348
+ end
349
+ ```
350
+
351
+ ```ruby
352
+ run = QuickbooksDesktop::CreateInvoice.start(invoice, connection)
353
+ ```
354
+
355
+ `start` takes a connection QuBe Sync has made (it raises `QubeRails::NotConnected` for one with
356
+ no `qube_id`). It writes the run's row first, with a new reference and state `starting`, since a
357
+ webhook can come before it returns; starts the run with that reference as the idempotency key and
358
+ `QUBE_WEBHOOK_URL/runs/<run id>` as its webhook URL; keeps QuBe Sync's `qube_run_id`, `state`,
359
+ `run_url` and `cancel_url`; and returns the row. The row keeps the input it sent.
360
+
361
+ A start can fail two ways, and they're handled differently:
362
+
363
+ - **Refused** (QuBe Sync answered 4xx: bad input, a workflow that isn't published) or an input that
364
+ raised: nothing was started. The row is marked `failed` and the error raised; start again.
365
+ - **No answer** (a timeout, a 5xx, a dropped connection): the run may have started. The row stays
366
+ `starting`, with its reference and input, and the error is raised. Send it again with
367
+ `run.retry_start!` (or `QuickbooksDesktop::CreateInvoice.retry_start(run)`), never with a new
368
+ `start`: a new reference would start a second run, and a second invoice.
369
+
370
+ While the record's last run of the operation on that connection hasn't ended, `start` starts
371
+ nothing new: it returns that run (`run.reused?` is true), sending its start again first when
372
+ QuBe Sync hasn't confirmed it, with the same reference and the input it was started with. The
373
+ reference is the `Idempotency-Key`, so QuBe Sync answers a repeat with the run it already started,
374
+ and a double click never adds the invoice twice. A new reference is made only once the last run
375
+ has ended. The check and the new row (with its input) are made under the connection row's lock.
376
+
377
+ `retry_start!` resends the same reference as the `Idempotency-Key`, with the same workflow, input
378
+ and webhook URL. QuBe Sync keys a start by its app and that key, and fingerprints the call
379
+ (method, path, query and JSON body). The same key with the same call answers `201` with the run
380
+ the first call started, as it is now, plus `Idempotent-Replayed: true`, and starts nothing; the
381
+ same key with a different call is a `422`; a refused call (`400`, `403`, `404`, `422`) stores no
382
+ key, so retrying it runs the checks again. A key lives as long as its run: finished runs are
383
+ purged 60 days after they end. `retry_start!` returns a run that already has its `qube_run_id`
384
+ as it is, and refuses one that `failed`. (The gem's client also resends a start that got no
385
+ answer twice itself, within 60 seconds, with the same key.)
386
+
387
+ A `QubeRails::Run` keeps `operation`, `record`, `connection`, `reference`, `revision`, `state`,
388
+ `outcome`, `error`, `cancel_reason`, `ended_at`, and the open `question` and its `links`. Each event is applied under the run row's
389
+ lock: one whose reference isn't the run's, or whose `connection_id` isn't its connection's, is
390
+ skipped and logged; a notification is taken once; anything else only when its revision is newer
391
+ than the last applied. Handlers see `record`, `run` and `connection`. A handler you don't write
392
+ logs.
393
+
394
+ ## Plain requests
395
+
396
+ A workflow is the default: it finds or makes the records an invoice names, checks QuickBooks
397
+ before resending after a lost answer, and asks your user when something needs a choice. Use a
398
+ plain request (one v2 operation, `QubeSync::Client#queue`) for a one-off query, or a write your
399
+ app settles itself: you handle each answer, error and lost answer.
400
+
401
+ ```ruby
402
+ # app/services/quickbooks_desktop/find_customer.rb
403
+ module QuickbooksDesktop
404
+ class FindCustomer < QubeRails::Request
405
+ OPERATION = "customer.query" # a v2 operation id
406
+ SLUG = "find_customer"
407
+
408
+ def params
409
+ { full_name: record.name }
410
+ end
411
+
412
+ # Each answered page: event.response_json["data"], event.page.
413
+ def answered(event)
414
+ found = Array(event.response_json["data"]).first
415
+ connection.link(record, kind: "customer", qb_id: found["list_id"], name: found["full_name"]) if found
416
+ end
417
+
418
+ # Every page 1..N is in. Optional.
419
+ def done; end
420
+
421
+ # error or discarded: show event.error["user_message"].
422
+ def failed(event); end
423
+
424
+ # QuBe Sync keeps trying (QuickBooks closed, a dialog open). Never resend. Default: logs.
425
+ def retrying(event); end
426
+
427
+ # The answer never came back: QuickBooks may have applied it. Default: failed(event). A query
428
+ # can be queued again; check a write before resending it.
429
+ def timed_out(event); end
430
+ end
431
+ end
432
+ ```
433
+
434
+ List it in `config.operations` with your operations (`"find_customer" =>
435
+ "QuickbooksDesktop::FindCustomer"`), and queue it:
436
+
437
+ ```ruby
438
+ row = QuickbooksDesktop::FindCustomer.queue(customer, connection) # a QubeRails::QueuedRequest
439
+ ```
440
+
441
+ `queue` works as `start` does: under the connection row's lock, a request for the same record,
442
+ slug and connection that hasn't ended is returned (`row.reused?`) rather than queued again, sent
443
+ again first, with the same params and `Idempotency-Key`, when QuBe Sync never confirmed it (no
444
+ `qube_id`). A new request, with a new key, is made only once the last has ended. The key is the
445
+ row's `reference`, saved before the call. A refusal (a 4xx but 409) marks the row `failed` and
446
+ raises; a timeout, a 5xx or a 409 leaves it `queued` and raises: queue again, or call
447
+ `row.retry_queue!`. `params` that raise mark the row `failed` before anything is sent. `find_record`
448
+ works as an operation's.
449
+
450
+ The request's webhook URL is `QUBE_WEBHOOK_URL/requests/<row id>`. Each delivery is taken once,
451
+ by `page_request_id` and `state` (in `qube_request_deliveries`, written in the handler's
452
+ transaction), under the row's lock. An event whose `id` isn't the row's `qube_id` is skipped and
453
+ logged (request webhooks carry no connection id; before the queue's answer is saved, the first
454
+ event's `id` is kept). Pages can come in any order: `done` is called once, when every page from 1
455
+ to the one whose `iteration_state` is `done` is in (`not_applicable` is one page). A `retryable`
456
+ isn't final, and one that arrives after its page's answer is skipped.
457
+
458
+ A `QubeRails::QueuedRequest` keeps `operation`, `record`, `connection`, `reference`, `qube_id`,
459
+ `params`, `state` (`queued`, `retryable`, `response_received`, `error`, `timed_out`, `discarded`,
460
+ or `failed` for a refused queue), `error` (the webhook's), `pages_answered`, `last_page` and
461
+ `ended_at` (`ended?`, `done?`). It never keeps a response body: your handler stores what it needs.
462
+ A delivery whose handler raises rolls back with it, so the job's retry runs it again.
463
+
464
+ ## Typed workflow starts
465
+
466
+ ```sh
467
+ bin/rails g qube_rails:workflows # app/qube/qube_workflows.rb and sig/qube_workflows.rbs
468
+ bin/rails g qube_rails:workflows --check # in CI: exits 1 when the file is out of date
469
+ ```
470
+
471
+ The generator fetches your app's workflow input types from QuBe Sync with `config.api_key` and
472
+ writes `QubeWorkflows` (autoloaded from `app/qube`): a module per workflow, with a start method
473
+ and an input method per branch of its input, whose operation bodies have the types and field docs
474
+ of the qube_sync gem's operation methods. Check both files in, and run it again when a workflow is
475
+ published. An operation's `input` can return the workflow's input method:
476
+
477
+ ```ruby
478
+ def input
479
+ QubeWorkflows::AddTransaction.invoice_input(without_nils({
480
+ customer_ref: quickbooks_ref(record.customer, kind: "customer", name: record.customer&.name),
481
+ ref_number: record.number
482
+ }))
483
+ end
484
+ ```
485
+
486
+ It's still a Hash (the method leaves out nil inputs); its RBS type is
487
+ `QubeWorkflows::AddTransaction::invoice_input`. Outside an operation,
488
+ `QubeWorkflows.add_transaction.invoice(connection, params, reference:, webhook_url:)` starts a
489
+ run on `QubeRails.client` directly (a `QubeRails::Connection` or its `qube_id`), without a run row.
490
+ QuBe Sync still checks every start: a field the operation doesn't take is a 422 naming it
491
+ (`{"path": "input.params", "message": "unknown parameter: \"customer_rf\""}`).
492
+
493
+ - `--drafts` types each workflow's draft where it has one; `--check` fetches with whatever the file
494
+ was generated with.
495
+ - `--check` exits 1 when the served digest isn't the file's (naming the workflows that changed) or
496
+ QuBe Sync refuses the key, and warns and exits 0 when QuBe Sync can't be reached or answers 5xx.
497
+ - It needs the qube_sync gem (Ruby 3.3): an app on the kit's inline backend can't run it.
498
+
499
+ ## Run status
500
+
501
+ The engine keeps every ending on the run row before your handler runs, under the same lock:
502
+ `state`, `outcome`, a failure's `error` (as the event carried it: a sentence, or its code,
503
+ message and detail), a cancel's `cancel_reason`, and `ended_at`. So a run's result is there
504
+ whether or not your `ended` and `failed` handlers write anything; those are for your own records.
505
+ `run.ended?`, `run.failed?` and `run.cancelled?` say which (a run that completed on a
506
+ `cancelled` outcome, where your app chose to stop, counts as cancelled), and `run.summary_text`
507
+ says it in a sentence for your user: "Added to QuickBooks", "Done", "Couldn't sync",
508
+ "Cancelled", "Needs your answer", "Sending to QuickBooks", "Still waiting for QuickBooks"
509
+ or "Syncing with QuickBooks" (the words are I18n's: see [Wording and translations](#wording-and-translations)). `run.reason_text` says why it failed or was cancelled, as a sentence (the error's message, or
510
+ the reason your app gave), and nil otherwise.
511
+
512
+ A start QuBe Sync hasn't confirmed for a minute (`run.unconfirmed?`: still `starting`, after a
513
+ start that got no answer) reads "Still waiting for QuickBooks", and the partial offers a "Try
514
+ sending again" button. It posts to `POST qube/runs/:id/retry_start`, behind your sign-in and
515
+ CSRF, which runs the same `authorize_question` policy as the questions (404 otherwise), answers
516
+ 409 for a run that isn't `starting`, calls `run.retry_start!` (same reference, so a start that did
517
+ land answers with its run) and redirects back with 303.
518
+
519
+ Show the latest run's on the record's page:
520
+
521
+ ```erb
522
+ <%= render "qube_rails/run_status", record: @invoice, operation: "create_invoice" %>
523
+ ```
524
+
525
+ Its locals are `record` and `operation`. It shows the summary (`.qube-run-summary`, inside
526
+ `.qube-run-status.qube-run-<state>`) and, for a failure, the error's detail
527
+ (`.qube-run-error-detail`), and nothing for a record with no runs. Its pieces are
528
+ `qube_rails/run_status/*`: see [Styling](#styling) to restyle or replace any of them.
529
+
530
+ ## Sync status
531
+
532
+ Where one of your records stands with QuickBooks, per connection and operation, worked out from
533
+ the engine's own rows (the record's runs in `qube_runs`, its link in `qube_quickbooks_records`)
534
+ and never stored, so your tables need no status column:
535
+
536
+ ```ruby
537
+ status = connection.sync_status(invoice, operation: "create_invoice")
538
+ status.status # :not_sent, :sending, :needs_answer, :in_quickbooks, :done, :failed or :cancelled
539
+ status.qb_id # its QuickBooks id on this connection, when it's linked
540
+ status.error # a failed run's error
541
+ status.retry? # whether starting again makes sense (failed or cancelled, and not linked)
542
+ status.unconfirmed? # a start QuBe Sync hasn't confirmed: run.retry_start!
543
+ status.summary_text # "In QuickBooks", "Needs your answer", "Couldn't sync"
544
+ status.run&.reason_text # why it failed or was cancelled, as a sentence, or nil
545
+ ```
546
+
547
+ Per connection, since an invoice can be in one company file and not another.
548
+ `QubeRails::SyncStatus.latest(record, operation:)` reads it on the connection the latest run went
549
+ out on, for a page that doesn't say which.
550
+
551
+ When the run and the link disagree, the newest word wins:
552
+ 1. a latest run that hasn't ended: `:sending` (an unconfirmed start too) or `:needs_answer`;
553
+ 2. a latest run that failed or was cancelled after the link was made, or with no link:
554
+ `:failed` or `:cancelled`;
555
+ 3. a link: `:in_quickbooks`, always with its `qb_id`;
556
+ 4. a latest run that completed and linked nothing (an operation that doesn't link its record):
557
+ `:done`, which a new start may follow;
558
+ 5. otherwise `:not_sent`.
559
+
560
+ For list pages and filters, the connection has scopes over a model or relation of yours, one query
561
+ each (an `EXISTS` on the engine's indexes, by your primary key as text):
562
+
563
+ ```ruby
564
+ connection.not_in_quickbooks(account.invoices)
565
+ connection.in_quickbooks(Invoice)
566
+ connection.needing_answer(Invoice, operation: "create_invoice")
567
+ connection.sending(Invoice, operation: "create_invoice")
568
+ ```
569
+
570
+ The `qube_rails/run_status` partial shows it (`data-qube-sync-status`, `qube-sync-<status>`),
571
+ with `connection:` when the page is about one company file.
572
+
573
+ ## The page updates itself
574
+
575
+ When a run's event changes it (a question, an ending), the engine tells the record's pages to
576
+ refresh, in an app with turbo-rails 2 (Turbo 8) and Action Cable: the partials subscribe with
577
+ `turbo_stream_from(record, :qube_rails)`, and the job calls `Turbo::StreamsChannel.broadcast_refresh_to`,
578
+ then once more 5 seconds later (`QubeRails::RefreshJob`, on `config.queue`), for a page that was
579
+ still loading the first time (the redirect after a start, when the question comes at once).
580
+ Without turbo-rails, nothing is sent, and a reload shows it.
581
+
582
+ A run QuBe Sync confirmed that has had no news for 10 minutes, running or waiting on a question
583
+ this app never received, may have lost a webhook (the app was down longer than QuBe Sync's
584
+ retries). The partial then offers "Check for updates", which asks QuBe Sync to send the
585
+ run's latest event again (`client.redeliver_run`); it comes back through the webhook route, and
586
+ one already applied is skipped by its revision.
587
+
588
+ While the connection a record waits on is stalled (`connection.stalled`, kept on the row as
589
+ `status` and `stall_reason`), a status waiting on QuickBooks (`:sending`) reads "Waiting for
590
+ QuickBooks" with a warning dot, says why and what to do (`qube_rails/run_status/_stall`: the Web
591
+ Connector isn't checking in, or it can't open QuickBooks), and offers no "Check for updates": the
592
+ cause is known. A question waiting on your user isn't affected. When the connection stalls or
593
+ resumes, the engine tells the pages of the records with a run still going on it to refresh (by the
594
+ runs' connection index, at most 200), so the status changes without a reload.
595
+
596
+ An answer or a start retry that can't go through (the question changed, QuBe Sync refused the
597
+ answer's data, the start was confirmed meanwhile) goes back to the page with the reason in
598
+ `flash[:qube_rails_error]`, which the partials show once, so it shows under Turbo too.
599
+
600
+ ## Questions
601
+
602
+ When a run asks your user something, `Operation#question` keeps it on the run. Show it on the
603
+ record's page:
604
+
605
+ ```erb
606
+ <%= render "qube_rails/question", record: @invoice, operation: "create_invoice" %>
607
+ ```
608
+
609
+ Its locals are `record` and `operation` (the slug). It shows the open question of that record's
610
+ latest run of that operation, and nothing when that run isn't asking. Each choice is a form
611
+ posting to the run's question route with your CSRF token, and a record field's box searches
612
+ through your app, so the API key never reaches the browser. Its search boxes and rows fields need
613
+ the behaviour script (`qube_rails_assets` in your layout). Its pieces are `qube_rails/question/*`:
614
+ see [Styling](#styling) to restyle or replace any of them.
615
+
616
+ A rows field (`add_transaction`'s one question for every record QuickBooks doesn't have) shows as a
617
+ block per record, never a table and never scrolling sideways: the record's name, its choice (an enum
618
+ column of up to three options, such as create or use, as a segmented control), then only the fields
619
+ its answers so far call for (each column shown while its `when` holds), stacked in a grid, a search
620
+ per record column (sent with the row). "Set all to:" sets that choice on every record, and "Use for
621
+ all N" under a filled search or select copies it to the other records that ask it and are still
622
+ empty. Its answer is `{"records": {row key => {column => value}}}`.
623
+
624
+ An answer QuBe Sync can't take (422) shows its reason; one it says has changed (409, or any 400)
625
+ goes back to the page with a message; the run's next webhook says what came of it.
626
+
627
+ ## Styling
628
+
629
+ The partials render plain, semantic HTML with `qube-` classes. Make them match your app three ways,
630
+ from least to most work; [the storyboard](#the-storyboard) shows every screen at once while you do.
631
+
632
+ ### 1. The stylesheet and its variables
633
+
634
+ ```erb
635
+ <%# app/views/layouts/application.html.erb, in <head> %>
636
+ <%= qube_rails_assets stylesheet: true %>
637
+ ```
638
+
639
+ `qube_rails_assets` writes the behaviour script's tag (`qube_rails/question.js`, deferred, with
640
+ your CSP nonce when you have one) and, with `stylesheet: true`, the opt-in stylesheet's
641
+ (`qube_rails.css`). Your asset pipeline serves both: Propshaft as it is, Sprockets through the
642
+ engine's precompile list. Without `stylesheet: true` nothing of ours styles your page.
643
+
644
+ Every rule in `qube_rails.css` is inside `:where()`, so it has no specificity and any rule of
645
+ yours wins. It has no reset and no global styles. Theme it with CSS variables on `:root` or any
646
+ element around the screens:
647
+
648
+ ```css
649
+ :root {
650
+ --qube-accent: #4f46e5; /* the primary button, focus rings, highlights; default: a calm blue */
651
+ --qube-accent-text: #fff; /* text on the accent */
652
+ --qube-border: #e5e7eb; /* hairlines; default: the text colour, faded */
653
+ --qube-control-border: #d1d5db; /* inputs and secondary buttons */
654
+ --qube-radius: 8px; /* cards; controls take 2px less */
655
+ --qube-gap: 16px;
656
+ --qube-font: inherit; /* default: the system font stack */
657
+ --qube-font-size: 0.875rem;
658
+ --qube-muted: #6b7280;
659
+ --qube-error: #c2410c;
660
+ --qube-success: #15803d; /* the status dot once it's in QuickBooks */
661
+ --qube-warning: #d97706; /* the status dot while it needs an answer or waits on a stalled connection */
662
+ --qube-surface: #fff; /* cards, inputs, the matches; default: Canvas */
663
+ --qube-max-width: 52rem; /* the question card */
664
+ }
665
+ ```
666
+
667
+ Light and dark follow your page's `color-scheme`: with `color-scheme: light dark` (or `dark`) on your
668
+ page, the accent and status colours take their dark-mode shades and the surfaces follow `Canvas`,
669
+ so `prefers-color-scheme` carries through. An app whose dark mode is a class should set
670
+ `color-scheme: dark` with it, or set the variables there.
671
+
672
+ Motion is short and only where it says something: a button gives way on press (`scale(0.97)`),
673
+ the search box's matches drop in from the input (150ms; they close at once, and moving through them
674
+ with the keyboard is instant), a rows field's field fades in as it starts to apply, what "Set all to"
675
+ or "Use for all" fills flashes a tint, and a one-shot error fades in. All of it is transform and
676
+ opacity, on `cubic-bezier(0.23, 1, 0.32, 1)`; with `prefers-reduced-motion: reduce` the fades stay
677
+ and the movement goes. Hover effects apply only on devices with a fine pointer.
678
+
679
+ ### 2. Class hooks
680
+
681
+ `config.ui_classes` adds your CSS framework's classes beside the `qube-` ones, by piece:
682
+
683
+ ```ruby
684
+ # Bootstrap
685
+ config.ui_classes = {
686
+ button: "btn btn-primary", button_secondary: "btn btn-outline-secondary", input: "form-control",
687
+ select: "form-select", checkbox: "form-check-input", label: "form-label",
688
+ error: "alert alert-danger", status: "alert alert-light", search_result: "list-group-item list-group-item-action"
689
+ }
690
+ # Tailwind
691
+ config.ui_classes = { button: "rounded bg-indigo-600 px-3 py-1.5 text-white", input: "rounded border-gray-300" }
692
+ ```
693
+
694
+ | Piece | Where |
695
+ | --- | --- |
696
+ | `question` | the question's `<section>` |
697
+ | `message`, `details` | its sentence, its label/value table |
698
+ | `choice` | each choice's `<form>` |
699
+ | `button`, `button_secondary`, `button_ghost` | the one primary button (a form choice's submit, or a plain question's first choice; the retry button), the secondary ones (other exits, later forms, redeliver, Apply), and cancel (`button_ghost`, else `button_secondary`) |
700
+ | `picks`, `pick`, `pick_detail`, `pick_action` | the picks' list, each pick's option-row button (never `button`), its detail, its "Use this one" |
701
+ | `choice_footer`, `choice_divider` | the exits' row; the "or" between picks and a form |
702
+ | `choice_detail`, `choice_description` | a choice's detail (in its button) and description |
703
+ | `field`, `label` | a field's wrapper and its label (and the rows field's) |
704
+ | `input`, `select`, `checkbox` | text, number and search inputs; selects; checkboxes |
705
+ | `search`, `search_results`, `search_result` | a search box's wrapper, its matches' container, each match (made by the script) |
706
+ | `rows`, `row`, `row_header`, `row_fields` | a rows field's list of records, each record, its name, its fields' grid |
707
+ | `segmented`, `segment` | a record's choice as a segmented control, each option |
708
+ | `use_for_all` | the "Use for all N" button under a field |
709
+ | `status`, `summary`, `detail` | the run status, its sentence, a failure's detail |
710
+ | `stall` | why it waits on a stalled connection |
711
+ | `error` | a one-shot error |
712
+
713
+ Values are a String or an Array. An unknown piece raises when the initializer runs, naming these.
714
+
715
+ ### 3. Overriding a piece
716
+
717
+ Each piece is a small partial. Put a file at the same path in your app and Rails renders yours;
718
+ everything else stays the engine's. The entry points `render "qube_rails/question", record:,
719
+ operation:` and `render "qube_rails/run_status", record:, operation:` don't change.
720
+
721
+ | Partial | Local | Renders |
722
+ | --- | --- | --- |
723
+ | `qube_rails/question/_question` | `question` | the section: error, message, details, choices |
724
+ | `qube_rails/question/_message`, `_details` | `question` | the sentence; the label/value rows |
725
+ | `qube_rails/question/_picks`, `_pick` | `question`; `question`, `choice` | the picks' list; one pick, a form posting `choice` |
726
+ | `qube_rails/question/_choice` | `question`, `choice` | a form or exit choice: a form posting `choice` and `data[...]` with your CSRF token |
727
+ | `qube_rails/question/_footer` | `question` | the exits' row |
728
+ | `qube_rails/question/_field` | `field`, `control` | a field's wrapper and label around its control (HTML) |
729
+ | `qube_rails/question/fields/_text`, `_number`, `_boolean`, `_select`, `_search`, `_rows` | `field` | one field, by `field.type` |
730
+ | `qube_rails/question/rows/_row` | `rows`, `row` | one record: its name, its choice, its fields |
731
+ | `qube_rails/question/rows/_cell` | `cell` | one field of a record (shown while its `when` holds), or what it already has |
732
+ | `qube_rails/question/rows/_segmented` | `field` | a short enum as a segmented control (radios posted as `field.input_name`) |
733
+ | `qube_rails/run_status/_status` | `run_status` | the error, then the status: summary, detail, buttons |
734
+ | `qube_rails/run_status/_summary`, `_detail`, `_stall`, `_retry`, `_redeliver` | `run_status` | each part (`_stall` while its connection is stalled) |
735
+ | `qube_rails/_error` | `message` (optional) | a one-shot error; without `message:`, `flash[:qube_rails_error]` |
736
+
737
+ The locals are view-models, plain Ruby objects. Their fields are the contract, kept stable across
738
+ versions:
739
+
740
+ - `QubeRails::QuestionView`: `picks`, `forms`, `exits`, `plain?` (see "Choices" below), `message`, `details` (each `label`, `value`), `choices`, `token`,
741
+ `action_url`, `search_url`, `error` (QuBe Sync's refusal, or nil), `prompt` (as QuBe Sync sent it).
742
+ - `QubeRails::ChoiceView`: `id`, `label`, `detail`, `description`, `fields`, `index`, `action_url`,
743
+ `secondary?` (cancel), `kind` (`:pick`, `:form` or `:exit`), `style` (its button's: `:primary`,
744
+ `:secondary`, `:ghost`, or nil for a pick) and `button_piece` (its `ui_classes` piece).
745
+ - `QubeRails::FieldView`: `name`, `input_name` (post your value under it), `dom_id`, `type` (`text`,
746
+ `number`, `boolean`, `select`, `search` or `rows`), `label`, `required`, `options` (a select's,
747
+ each `value` and `label`), `kind` (a search's record kind), `search_url`, `search_field`, `row_key`,
748
+ `when`, `rows` (a `RowsView`), `disabled`, `partial`, and `labelled?`, `cell?` (a rows field's) and
749
+ `segmented?` (a cell's enum of up to three options).
750
+ - `QubeRails::RowsView`: `name`, `label`, `input_name`, `search_url`, `columns` (FieldViews), `rows`,
751
+ `set_all` (the column "Set all to:" sets, or nil).
752
+ `QubeRails::RowView`: `key`, `label`, `detail`, `values`, `values_json`, `index`, `cells` (the
753
+ columns it asks or already has a value for), `segmented_cells`, `field_cells`.
754
+ `QubeRails::CellView`: `column`, `label`, `field` (nil for a value the row already has), `preset`
755
+ (that value, read-only), `when`, `when_json`, `hidden?`, `required` (the column's; a hidden field
756
+ isn't required until shown), `segmented?`.
757
+ - `QubeRails::RunStatusView`: `status` (`:not_sent`, `:sending`, `:needs_answer`, `:in_quickbooks`,
758
+ `:done`, `:failed`, `:cancelled`), `state` (the run's), `summary` (a few words, for the pill), `reason` (why it failed or was cancelled, a sentence), `detail`, `quiet_text`,
759
+ `retry_url` and `redeliver_url` (nil when the button doesn't apply; post to them), `error`,
760
+ `stall` (the stalled connection's reason as a Symbol, such as `:not_checking_in` or
761
+ `:quickbooks_unavailable`, while a `:sending` status waits on it; else nil), `stall_text`,
762
+ `visible?`, `css_class` (with `qube-connection-stalled` then; the element also gets
763
+ `data-qube-stall="<reason>"`). `RunStatusView.from_sync(sync, urls, error:, connection:)` takes the
764
+ connection the page is about (the partial's `connection:` local), else the latest run's.
765
+
766
+ Choices show by kind. A pick (an item of a `choose` list: an id ending `:<n>`, no fields) is a quiet
767
+ option row; with no picks, a plain choice before the first form is one too (a name clash's "Use the
768
+ existing record"). Each form choice (one with fields) is its own section, "or" between it and what
769
+ comes before (`question.or`), its field's label leading and its submit the one filled button (only
770
+ the first form's; later ones are secondary). The exits (none of these, cancel) sit in a row under a
771
+ hairline, cancel as a ghost. A question with neither picks nor forms keeps its choices in one row,
772
+ the first primary. So a screen never has more than one filled button. The form gets
773
+ `data-qube-choice-kind`, the button `data-qube-button` (its style).
774
+
775
+ A field override posts its value as `field.input_name`, so the controller reads it as it reads
776
+ ours: a checkbox `"true"`, a number as typed, a rows cell `data[<field>][<row key>][<column>]`.
777
+
778
+ ### The behaviour script
779
+
780
+ `qube_rails/question.js` has one set of listeners on `document`, keyed only on `data-qube-*`
781
+ attributes. It works on any markup that carries them, after Turbo navigation too, needs no build and
782
+ no CSP nonce (it's a file, not an inline script), and loads once however often it's included.
783
+
784
+ - A search: an element with `data-qube-search="<search URL>"`, `data-qube-field="<field>"` and, in a
785
+ rows table, `data-qube-row="<row key>"`, holding a text input `data-qube-search-input`, a hidden
786
+ input `data-qube-search-value` (the one with the name, which gets the picked `value`) and a
787
+ container `data-qube-search-results`. Typing two characters or more asks the search URL; each
788
+ match is a button (classes from `data-qube-result-class`, `role="option"`); picking one (a click,
789
+ or the arrow keys and Enter) fills the hidden input and fires `change` on it and `qube:picked` on
790
+ the box. Escape, a click outside or leaving the box closes the matches. The box gets
791
+ `data-qube-open` while they show, and the input `aria-expanded` and `aria-activedescendant`. Until a
792
+ match is picked, the text input is invalid, so the form isn't sent. Its words come from the box's
793
+ `data-qube-text-no-matches`, `-searching`, `-search-failed` and `-pick-a-match` (English without).
794
+ - A rows field: `data-qube-rows` around it; each record `data-qube-row` with `data-qube-values` (JSON);
795
+ each field `data-qube-col="<column>"` with `data-qube-when` (JSON) and, for a required column,
796
+ `data-qube-required`; each input `data-qube-rows-input` (a radio counts when checked). A field
797
+ whose `when` doesn't hold is `hidden` (and `data-qube-hidden`), its inputs disabled and not
798
+ required, so it's neither sent nor asked for; the script toggles all three as answers change. Optional:
799
+ `data-qube-rows-set-all="<column>"` holding buttons `data-qube-set-all="<value>"` (sets that column on
800
+ every record offering the value), and a button `data-qube-use-for-all` in a field (shown, with the
801
+ table's `data-qube-text-use-for-all`, "Use for all %{count}", while the field has a value other
802
+ records' shown fields lack; copies it to them). What either fills gets `data-qube-filled` for the
803
+ stylesheet's tint.
804
+ - A form choice (`data-qube-choice-kind="form"`) whose required fields are all searches: its submit
805
+ is disabled until each has a pick, and again if the text is edited after (by the script only, so it
806
+ still posts without it).
807
+ - The storyboard: a button `data-qube-copy="<text>"` copies its text.
808
+
809
+ #### A custom combobox
810
+
811
+ Your own component can skip the script: it needs only the search route and the hidden input.
812
+
813
+ ```
814
+ GET <question action>/search?field=<field>&q=<2 to 100 characters>[&row=<row key>]
815
+ → 200 [{"value": "80000001-1700000000", "label": "Riverbend Electric", "detail": "Customer"}, ...]
816
+ ```
817
+
818
+ At most 20 matches, behind your sign-in; the question's action is `question.action_url`
819
+ (`field.search_url` is the whole URL). Post the picked `value` under `field.input_name`:
820
+
821
+ ```erb
822
+ <%# app/views/qube_rails/question/fields/_search.html.erb %>
823
+ <div class="field">
824
+ <%= label_tag field.dom_id, field.label %>
825
+ <%= render "shared/combobox", id: field.dom_id, name: field.input_name, required: field.required,
826
+ url: field.search_url, params: { field: field.search_field, row: field.row_key }.compact %>
827
+ </div>
828
+ ```
829
+
830
+ where `shared/combobox` is your component, sending `q` with those params and submitting the
831
+ chosen `value` in a hidden input named `name`.
832
+
833
+ ### The storyboard
834
+
835
+ `GET /qube/storyboard` shows every screen your users can meet, from sample data, in your own
836
+ layout, so your CSS and `ui_classes` apply: the run status (not sent, starting, an unconfirmed start
837
+ with its retry button, running, waiting on a stalled connection (QuickBooks not checking in, or
838
+ closed), waiting for an answer, looking stuck with its redeliver button, in
839
+ QuickBooks, done, failed with its detail, cancelled), the one-shot error, and a question of each
840
+ kind (a near match, a record search, a pick from a list, a fixed list, number and yes/no fields, a
841
+ name clash, a lost answer, a missing customer, records not in QuickBooks yet, and a refused answer). Each screen is a section with
842
+ the screen on a flat canvas, with a note and its "Override files" (the partials to override for it, each with a
843
+ Copy button); the canvas can be shown as your page is, light or dark. The list of screens links to
844
+ each one. `GET /qube/storyboard/<screen>` shows one.
845
+
846
+ It's on while `config.storyboard` is true (by default, in development), behind your sign-in. It
847
+ calls nothing: its searches answer from the samples (`lib/qube_rails/storyboard/*.json`, prompts
848
+ shaped as QuBe Sync sends them), its forms post to a no-op that comes back with "Storyboard:
849
+ nothing was sent.", and no row is written.
850
+
851
+ ## Wording and translations
852
+
853
+ Everything the screens say to your users comes from I18n, under `qube_rails.*`, with the engine's
854
+ English in its `config/locales/en.yml`. Nothing names QuBe Sync: to your users it's QuickBooks. To
855
+ reword a string, set its key in your own locale file (yours load after the engine's); to translate
856
+ them, add your locale's keys. A key your locale lacks falls back to the engine's English.
857
+
858
+ ```yaml
859
+ # config/locales/qube_rails.en.yml
860
+ en:
861
+ qube_rails:
862
+ status:
863
+ running: "Syncing with your accounts"
864
+ errors:
865
+ unreachable: "Couldn't reach the accounting sync just now. Try again in a minute."
866
+ ```
867
+
868
+ | Key | English | Where |
869
+ | --- | --- | --- |
870
+ | `status.in_quickbooks` | In QuickBooks | the status, linked |
871
+ | `status.added` | Added to QuickBooks | the status, an add that completed |
872
+ | `status.done`, `status.done_with` | Done; Done: %{outcome} | the status, completed |
873
+ | `status.failed` | Couldn't sync | the status, failed (why is `reason_text`, below it) |
874
+ | `status.not_started` | it couldn't be sent | a failure's message for a start that never went out |
875
+ | `status.cancelled` | Cancelled | the status, cancelled (the reason, if any, below it) |
876
+ | `status.cancel_reason` | it was stopped before it finished | `Run#cancel_reason_text` with no reason |
877
+ | `status.needs_answer` | Needs your answer | the status, asking a question |
878
+ | `status.starting` | Sending to QuickBooks | the status, just started |
879
+ | `status.unconfirmed` | Still waiting for QuickBooks | the status, a start unconfirmed for a minute |
880
+ | `status.running` | Syncing with QuickBooks | the status, running |
881
+ | `status.quiet` | No update from QuickBooks for a while. | above the redeliver button |
882
+ | `status.check_for_updates` | Check for updates | the redeliver button |
883
+ | `status.try_again` | Try sending again | the retry button |
884
+ | `status.waiting_for_quickbooks` | Waiting for QuickBooks | the status while its connection is stalled |
885
+ | `status.stalled.not_checking_in` | QuickBooks hasn't checked in for a while, so this is waiting. Make sure the computer with QuickBooks is on and the Web Connector is running. | why, when the Web Connector isn't calling in |
886
+ | `status.stalled.quickbooks_unavailable` | The Web Connector is checking in but can't open QuickBooks. Open the company file in QuickBooks on that computer. | why, when QuickBooks is closed |
887
+ | `status.stalled.other` | Syncing with QuickBooks has stopped for now. Check the computer with QuickBooks and its Web Connector. | any other stall reason |
888
+ | `errors.not_starting` | This was already sent, so there's nothing to try again. | a retry of a start that isn't waiting |
889
+ | `errors.not_quiet` | This was updated recently. Check again in a few minutes. | a redeliver of a run that isn't quiet |
890
+ | `errors.no_update_yet` | There's no update yet. Check again in a few minutes. | a redeliver with nothing to send (409) |
891
+ | `errors.checked_just_now` | That was checked a moment ago. Try again in a minute. | a redeliver asked too soon (429) |
892
+ | `errors.unreachable` | Couldn't reach QuickBooks' sync service just now. Try again in a minute. | a redeliver QuBe Sync didn't answer |
893
+ | `errors.disconnect_unreachable` | Couldn't reach QuickBooks' sync service just now. Try disconnecting again in a minute. | a disconnect QuBe Sync didn't answer |
894
+ | `errors.still_syncing` | Something is still syncing with this company file. Let it finish, or answer its question, then disconnect. | a disconnect while work is going |
895
+ | `errors.question_changed` | This question changed after the page loaded, so your answer wasn't sent. The page now shows the latest. | an answer to a changed question |
896
+ | `errors.answer_refused`, `errors.answer_refused_with` | That answer couldn't be used. Check it and try again.; That answer couldn't be used: %{detail} | an answer QuBe Sync refused (422) |
897
+ | `question.set_all` | Set all to: | before a rows field's buttons that set every record's choice (each an option's own label) |
898
+ | `question.use_for_all` | Use for all %{count} | under a filled field other records ask too |
899
+ | `question.or` | or | between picks and a form choice |
900
+ | `question.use_pick` | Use this one | at the end of each pick's row: what clicking it does |
901
+ | `question.pick_a_match` | Pick one of the matches. | a search box with text but no pick, on submit |
902
+ | `question.no_matches`, `question.searching`, `question.search_failed` | No matches; Searching…; Search isn't available right now. Try again in a moment. | the search box's matches |
903
+
904
+ `QubeRails.t(key, **options)` looks one up as the engine does. A question's own words (its message,
905
+ details, choices and field labels) are QuBe Sync's prompt, in English. The route constants
906
+ (`RunsController::NOT_QUIET` and the rest) are these keys, as Symbols.
907
+
908
+ ## Storage
909
+
910
+ Rows go with their run: deleting a run deletes its notifications, and deleting a plain request
911
+ its deliveries. Disconnecting a connection deletes its runs and requests. Runs are kept until you say
912
+ otherwise. To prune ended ones, set `config.prune_runs_after = 90.days` and schedule
913
+ `QubeRails::PruneRunsJob` (Solid Queue's `config/recurring.yml`, say):
914
+
915
+ ```yaml
916
+ prune_qube_runs:
917
+ class: QubeRails::PruneRunsJob
918
+ schedule: every day at 4am
919
+ ```
920
+
921
+ It deletes only ended runs (`Run.prunable`) and ended plain requests (`QueuedRequest.prunable`,
922
+ with their deliveries), never within a day of their end: a webhook for a
923
+ deleted run answers 404, which QuBe Sync retries for hours. What a record's page shows stays
924
+ right: `in_quickbooks` comes from `qube_quickbooks_records`, which are never pruned, and a sync's
925
+ next `since` from `qube_sync_marks` (`connection.last_synced_at(workflow)`, or
926
+ `last_synced_at` in an operation), which a completed run moves forward. A pruned run's record
927
+ whose run had failed, or completed without a link, shows `not_sent` again.
928
+
929
+
930
+ `qube_runs.question` and `qube_runs.links` are JSON kept as text (`QubeRails::JSONCoder`), not a
931
+ native json column: the engine runs on PostgreSQL, MySQL and SQLite, from Rails 5.2 (vendored into
932
+ older apps) to 8.1, and the native types and what each adapter returns differ. Nothing queries
933
+ inside them. Polymorphic ids (`owner_id`, `record_id`) are strings, so integer and UUID keys both
934
+ fit.
935
+
936
+ ## Upgrading
937
+
938
+ From the integration kit's generated code: keep your operation classes (now
939
+ `QuickbooksDesktop::... < QubeRails::Operation`; `start` takes the connection row and handlers
940
+ gain `run` and `connection`), move `account_id`, `authorize_question!` and
941
+ `QubeWebhookJob::OPERATIONS` into the initializer, move the connection handlers into your
942
+ `QuickbooksDesktop::ConnectionEvents`, and delete the generated controllers, job, partial,
943
+ `QubeSync` module and routes in favour of the mount line. Copy each account's connection into
944
+ `qube_connections` (its owner, `qube_id` and status) and drop the `qube_*` columns from your
945
+ tables once nothing reads them.
946
+
947
+ From qube_rails 0.1.0 (v1): 0.2.0 shares nothing with it. Its models and tables are yours to keep
948
+ or drop.
949
+
950
+ ## Development
951
+
952
+ ```sh
953
+ bundle install && bundle exec rspec # RAILS_VERSION=7.0 for the oldest Rails
954
+ DB=postgres DATABASE_URL=postgres://... bundle exec rspec # or DB=mysql: the scopes' SQL there
955
+ bin/syntax-check-2.7 # every shipped file parses on Ruby 2.7
956
+ ```
957
+
958
+ Until qube_sync 0.5.0 is published, the Gemfile takes it from `../wt-qube_sync_rb` (or
959
+ `QUBE_SYNC_PATH`) when that checkout is there.
960
+
961
+ The source is kept Ruby 2.7 and Rails 5.2 compatible, and every call to qube_sync goes through
962
+ `QubeRails::Backend`, so QuBe Sync's kit can vendor the same files, with an inline backend, into
963
+ an app below the gem's Ruby 3.3 floor.
964
+
965
+ ## License
966
+
967
+ MIT