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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +161 -0
- data/LICENSE.txt +21 -0
- data/README.md +967 -0
- data/app/assets/javascripts/qube_rails/question.js +457 -0
- data/app/assets/stylesheets/qube_rails.css +1115 -0
- data/app/controllers/concerns/qube_rails/run_authorization.rb +37 -0
- data/app/controllers/qube_rails/connections_controller.rb +141 -0
- data/app/controllers/qube_rails/questions_controller.rb +132 -0
- data/app/controllers/qube_rails/runs_controller.rb +54 -0
- data/app/controllers/qube_rails/storyboard_controller.rb +50 -0
- data/app/controllers/qube_rails/webhooks_controller.rb +99 -0
- data/app/jobs/qube_rails/prune_runs_job.rb +18 -0
- data/app/jobs/qube_rails/refresh_job.rb +18 -0
- data/app/jobs/qube_rails/webhook_job.rb +43 -0
- data/app/models/qube_rails/application_record.rb +17 -0
- data/app/models/qube_rails/connection.rb +303 -0
- data/app/models/qube_rails/notification.rb +12 -0
- data/app/models/qube_rails/queued_request.rb +66 -0
- data/app/models/qube_rails/quickbooks_record.rb +36 -0
- data/app/models/qube_rails/request_delivery.rb +12 -0
- data/app/models/qube_rails/run.rb +187 -0
- data/app/models/qube_rails/sync_mark.rb +21 -0
- data/app/views/qube_rails/_error.html.erb +13 -0
- data/app/views/qube_rails/_question.html.erb +21 -0
- data/app/views/qube_rails/_run_status.html.erb +22 -0
- data/app/views/qube_rails/question/_choice.html.erb +20 -0
- data/app/views/qube_rails/question/_details.html.erb +6 -0
- data/app/views/qube_rails/question/_field.html.erb +15 -0
- data/app/views/qube_rails/question/_footer.html.erb +7 -0
- data/app/views/qube_rails/question/_message.html.erb +2 -0
- data/app/views/qube_rails/question/_pick.html.erb +19 -0
- data/app/views/qube_rails/question/_picks.html.erb +6 -0
- data/app/views/qube_rails/question/_question.html.erb +24 -0
- data/app/views/qube_rails/question/fields/_boolean.html.erb +7 -0
- data/app/views/qube_rails/question/fields/_number.html.erb +7 -0
- data/app/views/qube_rails/question/fields/_rows.html.erb +23 -0
- data/app/views/qube_rails/question/fields/_search.html.erb +23 -0
- data/app/views/qube_rails/question/fields/_select.html.erb +8 -0
- data/app/views/qube_rails/question/fields/_text.html.erb +7 -0
- data/app/views/qube_rails/question/rows/_cell.html.erb +24 -0
- data/app/views/qube_rails/question/rows/_row.html.erb +22 -0
- data/app/views/qube_rails/question/rows/_segmented.html.erb +14 -0
- data/app/views/qube_rails/run_status/_detail.html.erb +7 -0
- data/app/views/qube_rails/run_status/_redeliver.html.erb +5 -0
- data/app/views/qube_rails/run_status/_retry.html.erb +4 -0
- data/app/views/qube_rails/run_status/_stall.html.erb +3 -0
- data/app/views/qube_rails/run_status/_status.html.erb +21 -0
- data/app/views/qube_rails/run_status/_summary.html.erb +2 -0
- data/app/views/qube_rails/storyboard/show.html.erb +86 -0
- data/config/locales/en.yml +46 -0
- data/config/routes.rb +35 -0
- data/db/migrate/20261025000000_create_qube_tables.rb +83 -0
- data/db/migrate/20261026230000_add_qube_sync_status_indexes.rb +13 -0
- data/db/migrate/20261027000000_create_qube_sync_marks.rb +17 -0
- data/db/migrate/20261028000000_create_qube_requests.rb +38 -0
- data/lib/generators/qube_rails/install_generator.rb +47 -0
- data/lib/generators/qube_rails/templates/connection_events.rb.tt +38 -0
- data/lib/generators/qube_rails/templates/initializer.rb.tt +82 -0
- data/lib/generators/qube_rails/workflows_generator.rb +77 -0
- data/lib/qube_rails/backend.rb +70 -0
- data/lib/qube_rails/cache_dedupe.rb +34 -0
- data/lib/qube_rails/configuration.rb +319 -0
- data/lib/qube_rails/connection_events.rb +167 -0
- data/lib/qube_rails/database_dedupe.rb +28 -0
- data/lib/qube_rails/engine.rb +33 -0
- data/lib/qube_rails/json_coder.rb +43 -0
- data/lib/qube_rails/operation.rb +464 -0
- data/lib/qube_rails/question_view.rb +219 -0
- data/lib/qube_rails/request.rb +289 -0
- data/lib/qube_rails/rows_view.rb +146 -0
- data/lib/qube_rails/run_status_view.rb +71 -0
- data/lib/qube_rails/storyboard/ask_about_add.json +60 -0
- data/lib/qube_rails/storyboard/choose.json +51 -0
- data/lib/qube_rails/storyboard/enum_select.json +56 -0
- data/lib/qube_rails/storyboard/missing_record.json +141 -0
- data/lib/qube_rails/storyboard/name_clash.json +59 -0
- data/lib/qube_rails/storyboard/near_match.json +77 -0
- data/lib/qube_rails/storyboard/number_boolean.json +54 -0
- data/lib/qube_rails/storyboard/record_search.json +90 -0
- data/lib/qube_rails/storyboard/refusal.json +53 -0
- data/lib/qube_rails/storyboard/rows.json +359 -0
- data/lib/qube_rails/storyboard.rb +165 -0
- data/lib/qube_rails/sync_status.rb +124 -0
- data/lib/qube_rails/version.rb +5 -0
- data/lib/qube_rails/view_helpers.rb +29 -0
- data/lib/qube_rails.rb +201 -0
- 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
|