qube_rails 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +161 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +967 -0
  5. data/app/assets/javascripts/qube_rails/question.js +457 -0
  6. data/app/assets/stylesheets/qube_rails.css +1115 -0
  7. data/app/controllers/concerns/qube_rails/run_authorization.rb +37 -0
  8. data/app/controllers/qube_rails/connections_controller.rb +141 -0
  9. data/app/controllers/qube_rails/questions_controller.rb +132 -0
  10. data/app/controllers/qube_rails/runs_controller.rb +54 -0
  11. data/app/controllers/qube_rails/storyboard_controller.rb +50 -0
  12. data/app/controllers/qube_rails/webhooks_controller.rb +99 -0
  13. data/app/jobs/qube_rails/prune_runs_job.rb +18 -0
  14. data/app/jobs/qube_rails/refresh_job.rb +18 -0
  15. data/app/jobs/qube_rails/webhook_job.rb +43 -0
  16. data/app/models/qube_rails/application_record.rb +17 -0
  17. data/app/models/qube_rails/connection.rb +303 -0
  18. data/app/models/qube_rails/notification.rb +12 -0
  19. data/app/models/qube_rails/queued_request.rb +66 -0
  20. data/app/models/qube_rails/quickbooks_record.rb +36 -0
  21. data/app/models/qube_rails/request_delivery.rb +12 -0
  22. data/app/models/qube_rails/run.rb +187 -0
  23. data/app/models/qube_rails/sync_mark.rb +21 -0
  24. data/app/views/qube_rails/_error.html.erb +13 -0
  25. data/app/views/qube_rails/_question.html.erb +21 -0
  26. data/app/views/qube_rails/_run_status.html.erb +22 -0
  27. data/app/views/qube_rails/question/_choice.html.erb +20 -0
  28. data/app/views/qube_rails/question/_details.html.erb +6 -0
  29. data/app/views/qube_rails/question/_field.html.erb +15 -0
  30. data/app/views/qube_rails/question/_footer.html.erb +7 -0
  31. data/app/views/qube_rails/question/_message.html.erb +2 -0
  32. data/app/views/qube_rails/question/_pick.html.erb +19 -0
  33. data/app/views/qube_rails/question/_picks.html.erb +6 -0
  34. data/app/views/qube_rails/question/_question.html.erb +24 -0
  35. data/app/views/qube_rails/question/fields/_boolean.html.erb +7 -0
  36. data/app/views/qube_rails/question/fields/_number.html.erb +7 -0
  37. data/app/views/qube_rails/question/fields/_rows.html.erb +23 -0
  38. data/app/views/qube_rails/question/fields/_search.html.erb +23 -0
  39. data/app/views/qube_rails/question/fields/_select.html.erb +8 -0
  40. data/app/views/qube_rails/question/fields/_text.html.erb +7 -0
  41. data/app/views/qube_rails/question/rows/_cell.html.erb +24 -0
  42. data/app/views/qube_rails/question/rows/_row.html.erb +22 -0
  43. data/app/views/qube_rails/question/rows/_segmented.html.erb +14 -0
  44. data/app/views/qube_rails/run_status/_detail.html.erb +7 -0
  45. data/app/views/qube_rails/run_status/_redeliver.html.erb +5 -0
  46. data/app/views/qube_rails/run_status/_retry.html.erb +4 -0
  47. data/app/views/qube_rails/run_status/_stall.html.erb +3 -0
  48. data/app/views/qube_rails/run_status/_status.html.erb +21 -0
  49. data/app/views/qube_rails/run_status/_summary.html.erb +2 -0
  50. data/app/views/qube_rails/storyboard/show.html.erb +86 -0
  51. data/config/locales/en.yml +46 -0
  52. data/config/routes.rb +35 -0
  53. data/db/migrate/20261025000000_create_qube_tables.rb +83 -0
  54. data/db/migrate/20261026230000_add_qube_sync_status_indexes.rb +13 -0
  55. data/db/migrate/20261027000000_create_qube_sync_marks.rb +17 -0
  56. data/db/migrate/20261028000000_create_qube_requests.rb +38 -0
  57. data/lib/generators/qube_rails/install_generator.rb +47 -0
  58. data/lib/generators/qube_rails/templates/connection_events.rb.tt +38 -0
  59. data/lib/generators/qube_rails/templates/initializer.rb.tt +82 -0
  60. data/lib/generators/qube_rails/workflows_generator.rb +77 -0
  61. data/lib/qube_rails/backend.rb +70 -0
  62. data/lib/qube_rails/cache_dedupe.rb +34 -0
  63. data/lib/qube_rails/configuration.rb +319 -0
  64. data/lib/qube_rails/connection_events.rb +167 -0
  65. data/lib/qube_rails/database_dedupe.rb +28 -0
  66. data/lib/qube_rails/engine.rb +33 -0
  67. data/lib/qube_rails/json_coder.rb +43 -0
  68. data/lib/qube_rails/operation.rb +464 -0
  69. data/lib/qube_rails/question_view.rb +219 -0
  70. data/lib/qube_rails/request.rb +289 -0
  71. data/lib/qube_rails/rows_view.rb +146 -0
  72. data/lib/qube_rails/run_status_view.rb +71 -0
  73. data/lib/qube_rails/storyboard/ask_about_add.json +60 -0
  74. data/lib/qube_rails/storyboard/choose.json +51 -0
  75. data/lib/qube_rails/storyboard/enum_select.json +56 -0
  76. data/lib/qube_rails/storyboard/missing_record.json +141 -0
  77. data/lib/qube_rails/storyboard/name_clash.json +59 -0
  78. data/lib/qube_rails/storyboard/near_match.json +77 -0
  79. data/lib/qube_rails/storyboard/number_boolean.json +54 -0
  80. data/lib/qube_rails/storyboard/record_search.json +90 -0
  81. data/lib/qube_rails/storyboard/refusal.json +53 -0
  82. data/lib/qube_rails/storyboard/rows.json +359 -0
  83. data/lib/qube_rails/storyboard.rb +165 -0
  84. data/lib/qube_rails/sync_status.rb +124 -0
  85. data/lib/qube_rails/version.rb +5 -0
  86. data/lib/qube_rails/view_helpers.rb +29 -0
  87. data/lib/qube_rails.rb +201 -0
  88. metadata +180 -0
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "time"
5
+
6
+ module QubeRails
7
+ # One QuickBooks Desktop connection: a company file on a customer's computer, reached through
8
+ # the Web Connector. Its owner is the account (or shop, or whatever your app's customer is)
9
+ # that connected it, and an owner can have any number: two company files, or a new file after
10
+ # a reconnect.
11
+ #
12
+ # qube_id is QuBe Sync's id for it, nil until QuBe Sync answers the create. status is
13
+ # "pending" until the setup ends, then "connected", "setup_cancelled" or "stalled" (with
14
+ # stall_reason), kept up to date by its own webhooks (QubeRails::ConnectionEvents). revision
15
+ # is the last of those applied.
16
+ #
17
+ # The QuickBooks ids your app learns are kept against it, by list family (link, qb_id_for,
18
+ # find_linked), since a ListID is unique only within one list family of one company file.
19
+ class Connection < ApplicationRecord
20
+ self.table_name = "qube_connections"
21
+
22
+ # The ones that have never connected, newest first: what Connect sends back through setup
23
+ # rather than making another.
24
+ scope :never_connected, -> { where(status: %w[pending setup_cancelled]).order(created_at: :desc, id: :desc) }
25
+
26
+ STATUSES = %w[pending connected setup_cancelled stalled].freeze
27
+
28
+ belongs_to :owner, polymorphic: true
29
+ has_many :runs, class_name: "QubeRails::Run", inverse_of: :connection
30
+ has_many :queued_requests, class_name: "QubeRails::QueuedRequest", inverse_of: :connection
31
+ has_many :quickbooks_records, class_name: "QubeRails::QuickbooksRecord", inverse_of: :connection
32
+
33
+ validates :status, inclusion: { in: STATUSES }
34
+
35
+ # The connections owner has, whatever its key's type.
36
+ # The row, with its runs (and their notifications), its requests and its QuickBooks links: a connection
37
+ # QuBe Sync no longer has. ConnectionsController#destroy deletes QuBe Sync's first.
38
+ def disconnect!
39
+ transaction do
40
+ QubeRails.config.connection_events_class.new(self).disconnected
41
+ runs.find_each(&:destroy!)
42
+ QubeRails::QueuedRequest.where(connection_id: id).delete_all # its deliveries cascade
43
+ QubeRails::QuickbooksRecord.where(connection_id: id).delete_all
44
+ destroy!
45
+ end
46
+ end
47
+
48
+ # When its last completed run of workflow started (nil before one): a sync's next since.
49
+ def last_synced_at(workflow)
50
+ QubeRails::SyncMark.find_by(connection_id: id, workflow: workflow.to_s)&.started_at
51
+ end
52
+
53
+ def self.owned_by(owner)
54
+ type, id = key_for(owner)
55
+ where(owner_type: type, owner_id: id)
56
+ end
57
+
58
+ # Where its events go: QUBE_WEBHOOK_URL/connections/<id>.
59
+ def webhook_url
60
+ "#{QubeRails.config.webhook_url}/connections/#{ERB::Util.url_encode(id.to_s)}"
61
+ end
62
+
63
+ def connected?
64
+ status == "connected"
65
+ end
66
+
67
+ # Stalled because the Web Connector isn't checking in (the QuickBooks computer is off, or
68
+ # the Web Connector closed), rather than because it can't open QuickBooks.
69
+ def not_checking_in?
70
+ status == "stalled" && stall_reason == "not_checking_in"
71
+ end
72
+
73
+ # Clock difference allowed between this app and QuBe Sync.
74
+ CLOCK_SKEW_SECONDS = 5 * 60
75
+
76
+ # For a row without a qube_id: QuBe Sync's connection made for it, when a create landed but
77
+ # its id was never saved (it didn't commit, or the process died between the call and the
78
+ # save). Saves and returns the id, or nil, so a second connection is never made for one row.
79
+ #
80
+ # It lists the app's connections (GET /api/v2/connections, which isn't paged) and adopts one
81
+ # only when exactly one has this row's webhook URL, is unclaimed (it has never connected, or
82
+ # it's simulated: a simulated connection connects as it's made), was made at or after this row
83
+ # (its created_at), and has the row's name when the row has one. So a row id used again (MySQL 5.7 after a restart, a restored database) never takes
84
+ # an old connection. A connection with no created_at (a server from before v2 sent it) is
85
+ # never adopted. The listing only runs for a row without an id, which is rare.
86
+ def adopt_qube_connection
87
+ return qube_id unless qube_id.to_s.empty?
88
+
89
+ listed = Array(QubeRails.client.list_connections).select { |made| made.is_a?(Hash) }
90
+ if listed.any? { |made| made["created_at"].to_s.empty? && same_url?(made) }
91
+ QubeRails.logger.warn("QuBe Sync: connection #{id}: QuBe Sync's connections carry no created_at, so " \
92
+ "none is adopted; a new one is made instead")
93
+ end
94
+ candidates = listed.select { |made| made_for_this_row?(made) }
95
+ if candidates.size > 1
96
+ QubeRails.logger.warn("QuBe Sync: connection #{id} has no qube_id and #{candidates.size} of QuBe Sync's " \
97
+ "connections could be its; adopted none")
98
+ return nil
99
+ end
100
+ found = candidates.first
101
+ return nil unless found
102
+
103
+ QubeRails.logger.info("QuBe Sync: connection #{id} adopted QuBe Sync's connection #{found["id"]}, " \
104
+ "made for it before its id was saved")
105
+ update!(qube_id: found["id"])
106
+ qube_id
107
+ end
108
+
109
+ # Keeps record's QuickBooks id for kind ("invoice", "customer", "item_service"...) on this
110
+ # connection, with its EditSequence and its name (FullName, or RefNumber), and returns the
111
+ # row. A record has one id per kind, so linking it again replaces its id. An id already linked
112
+ # in that kind's family (QuickbooksRecord.family_for) to another of your records moves to this
113
+ # one, and that's logged: the same QuickBooks record can stand for one of yours at a time. The
114
+ # same record and id linked with another kind of the family (an item converted from service
115
+ # to non-inventory) changes the row's kind.
116
+ def link(record, kind:, qb_id:, edit_sequence: nil, name: nil)
117
+ kind = kind.to_s
118
+ qb_id = qb_id.to_s
119
+ raise ArgumentError, "link needs a qb_id" if qb_id.empty?
120
+
121
+ family = QuickbooksRecord.family_for(kind)
122
+ type, id = self.class.key_for(record)
123
+ attempts = 0
124
+ begin
125
+ # A savepoint, so a unique-key race inside a caller's transaction (the job's row lock)
126
+ # can be retried.
127
+ transaction(requires_new: true) do
128
+ taken = quickbooks_records.lock.find_by(list: family, qb_id: qb_id)
129
+ mine = quickbooks_records.lock.find_by(kind: kind, record_type: type, record_id: id)
130
+ if replaced?(mine, by: taken)
131
+ QubeRails.logger.info("QuBe Sync: connection #{self.id}: #{type} #{id}'s #{kind} " \
132
+ "#{mine.qb_id} replaced by #{qb_id}")
133
+ mine.destroy!
134
+ end
135
+ if moved?(taken, to: [type, id])
136
+ QubeRails.logger.warn("QuBe Sync: connection #{self.id}: #{kind} #{qb_id} moved from " \
137
+ "#{taken.record_type} #{taken.record_id} to #{type} #{id}")
138
+ end
139
+ row = taken || mine || quickbooks_records.build
140
+ row.assign_attributes(kind: kind, qb_id: qb_id, record_type: type, record_id: id)
141
+ row.edit_sequence = edit_sequence unless edit_sequence.nil?
142
+ row.name = name unless name.nil?
143
+ row.save!
144
+ row
145
+ end
146
+ rescue ActiveRecord::RecordNotUnique
147
+ # Another worker linked the same record or id at the same moment: once more, now it's
148
+ # there to find.
149
+ attempts += 1
150
+ retry if attempts < 2
151
+ raise
152
+ end
153
+ end
154
+
155
+ # record's QuickBooks id for kind on this connection, or nil: its link of that kind, else,
156
+ # outside the name family, its one link in that kind's family (an item linked as
157
+ # item_service, asked for as item or item_non_inventory). In the name family each kind is its
158
+ # own QuickBooks record (a customer and a vendor are two names), so a customer's id is never
159
+ # handed out as a vendor's. Raises ArgumentError when the family has several and none is that
160
+ # kind.
161
+ def qb_id_for(record, kind:)
162
+ type, id = self.class.key_for(record)
163
+ mine = quickbooks_records.where(record_type: type, record_id: id)
164
+ exact = mine.where(kind: kind.to_s).limit(1).pluck(:qb_id).first
165
+ family = QuickbooksRecord.family_for(kind)
166
+ return exact if exact || family == "name"
167
+
168
+ row = only_one(mine.where(list: family), "#{type} #{id}'s #{kind}")
169
+ row && row.qb_id
170
+ end
171
+
172
+ # Where record stands with QuickBooks on this connection for operation (its SLUG): a
173
+ # QubeRails::SyncStatus, worked out from its runs and its link, never stored.
174
+ def sync_status(record, operation:)
175
+ SyncStatus.for(self, record, operation: operation)
176
+ end
177
+
178
+ # Your records in scope (a model or a relation) with a QuickBooks link on this connection, or
179
+ # without one: for list pages and filters, one query each. They join on
180
+ # qube_quickbooks_records' record index, by your table's primary key as text.
181
+ def in_quickbooks(scope)
182
+ scope.where(linked_here(scope))
183
+ end
184
+
185
+ def not_in_quickbooks(scope)
186
+ scope.where.not(linked_here(scope))
187
+ end
188
+
189
+ # Your records in scope whose run of operation on this connection is waiting for your user's
190
+ # answer, or still on its way (starting or running): their latest run, since a start returns
191
+ # the run that hasn't ended rather than starting another.
192
+ def needing_answer(scope, operation:)
193
+ scope.where(run_here(scope, operation, %w[awaiting_input]))
194
+ end
195
+
196
+ def sending(scope, operation:)
197
+ scope.where(run_here(scope, operation, %w[starting running]))
198
+ end
199
+
200
+ # Your record linked to that QuickBooks id, or nil.
201
+ def find_linked(qb_id, kind: nil)
202
+ row = quickbooks_record(qb_id, kind: kind)
203
+ row && row.record
204
+ end
205
+
206
+ # The row for that QuickBooks id (with its kind, edit_sequence and name), or nil. With a kind,
207
+ # it's looked up in that kind's family; without one, in every family, and an id found in more
208
+ # than one (the same counter value is a customer and an account) raises ArgumentError, so the
209
+ # caller says which kind it means.
210
+ def quickbooks_record(qb_id, kind: nil)
211
+ rows = quickbooks_records.where(qb_id: qb_id.to_s)
212
+ return rows.find_by(list: QuickbooksRecord.family_for(kind)) if kind
213
+
214
+ only_one(rows, "QuickBooks id #{qb_id}")
215
+ end
216
+
217
+ private
218
+
219
+ # The record's own link is a different row from the one holding this qb_id, so it gives way.
220
+ def replaced?(mine, by:)
221
+ !mine.nil? && !by.nil? && by.id != mine.id
222
+ end
223
+
224
+ # This qb_id was linked to another of your records.
225
+ def moved?(taken, to:)
226
+ !taken.nil? && !taken.record_id.nil? && [taken.record_type, taken.record_id] != to
227
+ end
228
+
229
+ def same_url?(made)
230
+ made["webhook_url"].to_s.chomp("/") == webhook_url
231
+ end
232
+
233
+ def made_for_this_row?(made)
234
+ return false if made["id"].to_s.empty?
235
+ return false unless same_url?(made)
236
+ return false unless unclaimed?(made)
237
+ return false unless name.to_s.empty? || made["name"] == name
238
+
239
+ made_at = parse_time(made["created_at"])
240
+ return false unless made_at && created_at
241
+
242
+ made_at >= created_at - CLOCK_SKEW_SECONDS
243
+ end
244
+
245
+ # Nobody can have set it up: it has never connected, or it's simulated and connected itself
246
+ # when it was made. Either way, only the lost answer to the create carried its setup link.
247
+ def unclaimed?(made)
248
+ made["last_connected_at"].nil? || made["type"] == "simulated"
249
+ end
250
+
251
+ def parse_time(value)
252
+ Time.iso8601(value.to_s)
253
+ rescue ArgumentError
254
+ nil
255
+ end
256
+
257
+ # EXISTS a link on this connection for each row of scope's table.
258
+ def linked_here(scope)
259
+ model = scope.all.klass
260
+ QuickbooksRecord.where(connection_id: id, record_type: model.base_class.name)
261
+ .where("qube_quickbooks_records.record_id = #{id_as_text(model, QuickbooksRecord)}")
262
+ .arel.exists
263
+ end
264
+
265
+ # EXISTS a run of operation on this connection, in one of states, for each row of scope's
266
+ # table.
267
+ def run_here(scope, operation, states)
268
+ model = scope.all.klass
269
+ Run.where(connection_id: id, operation: operation.to_s, record_type: model.base_class.name,
270
+ state: states)
271
+ .where("qube_runs.record_id = #{id_as_text(model, Run)}")
272
+ .arel.exists
273
+ end
274
+
275
+ # The app's primary key as the text the engine keeps ids as (integer or UUID alike), to
276
+ # compare with engine_model's record_id. Elsewhere a VARCHAR, which compares without padding.
277
+ # MySQL casts only to CHAR, in the connection's collation, which can differ from the
278
+ # column's (an "illegal mix of collations"), so it's given the column's.
279
+ def id_as_text(model, engine_model)
280
+ db = model.connection
281
+ column = "#{db.quote_table_name(model.table_name)}.#{db.quote_column_name(model.primary_key)}"
282
+ return "CAST(#{column} AS VARCHAR(255))" unless mysql?(db)
283
+
284
+ collation = engine_model.columns_hash["record_id"].collation
285
+ return "CAST(#{column} AS CHAR)" unless collation
286
+
287
+ "CONVERT(CAST(#{column} AS CHAR) USING #{collation.split("_").first}) COLLATE #{collation}"
288
+ end
289
+
290
+ def mysql?(db)
291
+ db.adapter_name.match?(/mysql|trilogy/i)
292
+ end
293
+
294
+ def only_one(scope, what)
295
+ rows = scope.limit(2).to_a
296
+ if rows.size > 1
297
+ kinds = scope.pluck(:kind).sort.join(", ")
298
+ raise ArgumentError, "QuBe Sync: #{what} is linked as more than one kind (#{kinds}): pass kind:"
299
+ end
300
+ rows.first
301
+ end
302
+ end
303
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QubeRails
4
+ # A notification a run's handler has taken, by its delivery_id: QubeRails::DatabaseDedupe
5
+ # writes it in the same transaction as the handler, so a redelivery is skipped and a handler
6
+ # that raised runs again. Deleted with its run.
7
+ class Notification < ApplicationRecord
8
+ self.table_name = "qube_notifications"
9
+
10
+ belongs_to :run, class_name: "QubeRails::Run"
11
+ end
12
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module QubeRails
6
+ # One plain v2 request (QubeRails::Request.queue) for one of your records, on one connection.
7
+ # Written before it's sent; its webhook URL is QUBE_WEBHOOK_URL/requests/<id>.
8
+ #
9
+ # state: "queued", "retryable", "response_received", "error", "timed_out", "discarded", or
10
+ # "failed" (QuBe Sync refused the queue, or params raised). ended_at is set once it's over:
11
+ # every page answered, or a final error.
12
+ class QueuedRequest < ApplicationRecord
13
+ self.table_name = "qube_requests"
14
+
15
+ belongs_to :connection, class_name: "QubeRails::Connection", inverse_of: :queued_requests
16
+ belongs_to :record, polymorphic: true
17
+ has_many :deliveries, class_name: "QubeRails::RequestDelivery", foreign_key: :request_id,
18
+ inverse_of: :queued_request, dependent: :delete_all
19
+
20
+ JSONCoder.serialize(self, :params, :error)
21
+
22
+ # Set by Request.queue when it returned this row rather than queueing another. Not saved.
23
+ attr_writer :reused
24
+
25
+ def reused?
26
+ @reused == true
27
+ end
28
+
29
+ def self.latest_for(record, operation)
30
+ type, id = key_for(record)
31
+ where(record_type: type, record_id: id, operation: operation.to_s)
32
+ .order(created_at: :desc, id: :desc).first
33
+ end
34
+
35
+ # Ended requests older than older_than (at least a day, as Run.prunable).
36
+ def self.prunable(older_than:)
37
+ cutoff = [older_than, 1.day].max.ago
38
+ where.not(ended_at: nil).where("ended_at < ?", cutoff)
39
+ end
40
+
41
+ def ended?
42
+ !ended_at.nil?
43
+ end
44
+
45
+ # Every page answered.
46
+ def done?
47
+ ended? && state == "response_received"
48
+ end
49
+
50
+ def webhook_url
51
+ "#{QubeRails.config.webhook_url}/requests/#{ERB::Util.url_encode(id.to_s)}"
52
+ end
53
+
54
+ # Sends a queue QuBe Sync never confirmed again: same params and Idempotency-Key.
55
+ def retry_queue!
56
+ request_class = QubeRails.config.operation_class(operation_slug)
57
+ raise UnknownOperation, "QuBe Sync: no operation has the slug #{operation_slug.inspect}" unless request_class
58
+
59
+ request_class.retry_queue(self)
60
+ end
61
+
62
+ def operation_slug
63
+ self[:operation]
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QubeRails
4
+ # A QuickBooks record your app knows on one connection: its kind ("customer", "item_service",
5
+ # "invoice"...), its list family (list), its ListID or TxnID (qb_id), its EditSequence and its
6
+ # name, and which of your records it stands for, if any. Written through Connection#link.
7
+ #
8
+ # QuickBooks makes a ListID unique within a list family, not within one kind (verified on
9
+ # QuickBooks Enterprise 24.0): customers, vendors, employees and other names share one counter,
10
+ # every item type shares another, and each other list (accounts, classes, terms...) has its
11
+ # own, while the same counter value turns up in several families. So a row is unique by
12
+ # connection, family and qb_id, and by connection, kind and your record (one contact can be
13
+ # both a customer and a vendor, as two QuickBooks names).
14
+ class QuickbooksRecord < ApplicationRecord
15
+ self.table_name = "qube_quickbooks_records"
16
+
17
+ NAME_KINDS = %w[customer vendor employee other_name].freeze
18
+
19
+ belongs_to :connection, class_name: "QubeRails::Connection", inverse_of: :quickbooks_records
20
+ belongs_to :record, polymorphic: true, optional: true
21
+
22
+ before_validation { self.list = self.class.family_for(kind) }
23
+
24
+ # The list family a kind's ids are unique within: "name" for customers, vendors, employees
25
+ # and other names; "item" for "item" and every item_* kind; for anything else (an account, a
26
+ # class, a transaction, a kind this version doesn't know) the kind itself. TxnIDs are taken as
27
+ # unique per transaction type until QuickBooks shows otherwise.
28
+ def self.family_for(kind)
29
+ kind = kind.to_s
30
+ return "name" if NAME_KINDS.include?(kind)
31
+ return "item" if kind == "item" || kind.start_with?("item_")
32
+
33
+ kind
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QubeRails
4
+ # A request webhook the handler has taken, by page_request_id:state, written in the handler's
5
+ # transaction. Deleted with its request.
6
+ class RequestDelivery < ApplicationRecord
7
+ self.table_name = "qube_request_deliveries"
8
+
9
+ belongs_to :queued_request, class_name: "QubeRails::QueuedRequest", foreign_key: :request_id,
10
+ inverse_of: :deliveries
11
+ end
12
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module QubeRails
6
+ # One workflow run, of one operation, for one of your records, on one connection. Written
7
+ # before the run starts (Operation.start), so a webhook that arrives first finds it; its
8
+ # webhook URL is QUBE_WEBHOOK_URL/runs/<id>.
9
+ #
10
+ # reference is new for every run and sent with it, and every event is checked against it and
11
+ # the connection's qube_id. revision is the last event applied; state ("starting", then the
12
+ # run's own: "running", "awaiting_input", "completed", "failed", "cancelled") and outcome are
13
+ # the run's as of that event. question and links are the question it's asking, if any, for the
14
+ # question partial. input is what the run was started with. A start QuBe Sync refused (a 4xx),
15
+ # or whose input raised, leaves state "failed"; one that got no answer (a timeout, a 5xx, a
16
+ # dropped connection) stays "starting", for retry_start!.
17
+ class Run < ApplicationRecord
18
+ self.table_name = "qube_runs"
19
+
20
+ belongs_to :connection, class_name: "QubeRails::Connection", inverse_of: :runs
21
+ belongs_to :record, polymorphic: true
22
+ has_many :notifications, class_name: "QubeRails::Notification", dependent: :delete_all
23
+
24
+ JSONCoder.serialize(self, :input, :error, :question, :links)
25
+
26
+ ENDED_STATES = %w[completed failed cancelled].freeze
27
+
28
+ # Set by Operation.start when it returned this run, one that hadn't ended, rather than starting
29
+ # another: the record's run already on its way. Not saved.
30
+ attr_writer :reused
31
+
32
+ def reused?
33
+ @reused == true
34
+ end
35
+
36
+ # record's newest run of operation, or nil: the one whose question the partial shows.
37
+ def self.latest_for(record, operation)
38
+ type, id = key_for(record)
39
+ where(record_type: type, record_id: id, operation: operation.to_s)
40
+ .order(created_at: :desc, id: :desc).first
41
+ end
42
+
43
+ # Sends a start that didn't get an answer again: same reference (the Idempotency-Key),
44
+ # workflow, input and webhook URL, so QuBe Sync answers with the run the first one started,
45
+ # if it did, instead of starting another. See Operation.retry_start.
46
+ def retry_start!
47
+ operation = QubeRails.config.operation_class(operation_slug)
48
+ raise UnknownOperation, "QuBe Sync: no operation has the slug #{operation_slug.inspect}" unless operation
49
+
50
+ operation.retry_start(self)
51
+ end
52
+
53
+ def webhook_url
54
+ "#{QubeRails.config.webhook_url}/runs/#{ERB::Util.url_encode(id.to_s)}"
55
+ end
56
+
57
+ def humanize(word)
58
+ text = word.to_s.tr("_", " ")
59
+ text.empty? ? text : text[0].upcase + text[1..-1]
60
+ end
61
+ private :humanize
62
+
63
+ def operation_slug
64
+ self[:operation]
65
+ end
66
+
67
+ # Whether it has ended: completed, failed or cancelled.
68
+ # Ended runs older than older_than (at least a day: a late webhook for a deleted run is
69
+ # retried for hours). A run that failed before it started has no ended_at.
70
+ def self.prunable(older_than:)
71
+ cutoff = [older_than, 1.day].max.ago
72
+ where(state: ENDED_STATES).where("COALESCE(ended_at, updated_at) < ?", cutoff)
73
+ end
74
+
75
+ def ended?
76
+ ENDED_STATES.include?(state)
77
+ end
78
+
79
+ def failed?
80
+ state == "failed"
81
+ end
82
+
83
+ # Cancelled by your app (state "cancelled"), or a chart that ended on its own "cancelled"
84
+ # outcome (the app chose to stop): neither is a success.
85
+ def cancelled?
86
+ state == "cancelled" || (state == "completed" && outcome == "cancelled")
87
+ end
88
+
89
+ # How long a start may go unconfirmed before the status says so and offers to send it again.
90
+ UNCONFIRMED_AFTER_SECONDS = 60
91
+
92
+ # How long a confirmed run may go without news before the status offers to have QuBe Sync
93
+ # send its latest event again (QubeRails::RunsController#redeliver).
94
+ QUIET_AFTER_SECONDS = 10 * 60
95
+
96
+ # A run QuBe Sync confirmed that hasn't been heard from for QUIET_AFTER_SECONDS: running,
97
+ # or waiting on a question this app never received. Its webhook may have been lost (the app
98
+ # was down longer than QuBe Sync's retries), so the status offers to send it again.
99
+ def looks_stuck?
100
+ quiet_since?(Time.now - QUIET_AFTER_SECONDS) && (state == "running" || question_missing?)
101
+ end
102
+
103
+ # A start QuBe Sync hasn't confirmed for a minute: send it again with retry_start!.
104
+ def quiet_since?(time)
105
+ !qube_run_id.to_s.empty? && !ended? && updated_at && updated_at < time
106
+ end
107
+ private :quiet_since?
108
+
109
+ def question_missing?
110
+ state == "awaiting_input" && question.nil?
111
+ end
112
+ private :question_missing?
113
+
114
+ def unconfirmed?
115
+ state == "starting" && created_at && created_at < Time.now - UNCONFIRMED_AFTER_SECONDS
116
+ end
117
+
118
+ # Where it stands, in a few words for a person: "Added to QuickBooks", "Done", "Couldn't
119
+ # sync", "Cancelled", "Needs your answer", "Sending to QuickBooks", "Still waiting for
120
+ # QuickBooks" (a start unconfirmed for a minute) or "Syncing with QuickBooks". Why it failed
121
+ # or was cancelled is reason_text. The words are I18n's, under qube_rails.status.
122
+ def summary_text
123
+ return QubeRails.t("status.cancelled") if cancelled?
124
+
125
+ case state
126
+ when "starting"
127
+ QubeRails.t(unconfirmed? ? "status.unconfirmed" : "status.starting")
128
+ when "completed"
129
+ case outcome.to_s
130
+ when "added" then QubeRails.t("status.added")
131
+ when "", "completed" then QubeRails.t("status.done")
132
+ else QubeRails.t("status.done_with", outcome: humanize(outcome))
133
+ end
134
+ when "failed" then QubeRails.t("status.failed")
135
+ when "awaiting_input" then QubeRails.t("status.needs_answer")
136
+ else QubeRails.t("status.running")
137
+ end
138
+ end
139
+
140
+ # Why it failed (its error message) or was cancelled (the reason it carried), as a sentence,
141
+ # or nil.
142
+ def reason_text
143
+ text = if cancelled? then cancel_reason.to_s.strip
144
+ elsif state == "failed" then error_message.to_s.strip
145
+ end
146
+ return nil if text.nil? || text.empty?
147
+
148
+ text = text[0].upcase + text[1..]
149
+ text.match?(/[.!?]\z/) ? text : "#{text}."
150
+ end
151
+
152
+ # Why it was cancelled: the reason it carried, else what a cancelled outcome means.
153
+ def cancel_reason_text
154
+ reason = cancel_reason.to_s.strip
155
+ reason.empty? ? QubeRails.t("status.cancel_reason") : reason
156
+ end
157
+
158
+ # A failed run's message: its error's own (a sentence, or the message of a code, message and
159
+ # detail), else its outcome in words, or nil.
160
+ def error_message
161
+ text =
162
+ case error
163
+ when String then error
164
+ when Hash then error["message"] || error["detail"]
165
+ end
166
+ text = text.to_s.strip
167
+ return text unless text.empty?
168
+ return QubeRails.t("status.not_started") if outcome.nil? && ended_at.nil?
169
+
170
+ outcome.to_s.empty? || outcome == "failed" ? nil : humanize(outcome)
171
+ end
172
+
173
+ # The error's detail, when it carries one apart from its message.
174
+ def error_detail
175
+ return nil unless error.is_a?(Hash)
176
+
177
+ detail = error["detail"]
178
+ detail = detail.to_s.strip
179
+ detail.empty? || detail == error_message ? nil : detail
180
+ end
181
+
182
+ # The question it's asking, as its webhook's prompt had it, or nil.
183
+ def open_question
184
+ question.is_a?(Hash) ? question : nil
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QubeRails
4
+ # When a connection's last completed run of a workflow started (Connection#last_synced_at).
5
+ class SyncMark < ApplicationRecord
6
+ self.table_name = "qube_sync_marks"
7
+
8
+ belongs_to :connection, class_name: "QubeRails::Connection"
9
+
10
+ # Moves the mark to started_at, never back: runs can complete out of order.
11
+ def self.advance(connection, workflow, started_at)
12
+ mark = find_or_create_by!(connection_id: connection.id, workflow: workflow) do |made|
13
+ made.started_at = started_at
14
+ end
15
+ where(id: mark.id).where("started_at < ?", started_at)
16
+ .update_all(started_at: started_at, updated_at: Time.current)
17
+ rescue ActiveRecord::RecordNotUnique
18
+ retry
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,13 @@
1
+ <%# A message from one of the engine's routes, shown once. Without a message local it takes
2
+ flash[:qube_rails_error], for a page without the run partials:
3
+ render "qube_rails/error"
4
+ The question and run status pieces pass theirs (message: nil shows nothing). %>
5
+ <% if local_assigns.key?(:message) %>
6
+ <% message = local_assigns[:message] %>
7
+ <% else %>
8
+ <% message = flash[:qube_rails_error] %>
9
+ <% flash.delete(:qube_rails_error) %>
10
+ <% end %>
11
+ <% if message.present? %>
12
+ <p class="<%= qube_classes(:error, "qube-error") %>" role="alert"><%= message %></p>
13
+ <% end %>
@@ -0,0 +1,21 @@
1
+ <%# The question a QuBe Sync run is asking about record, with a form per choice, or nothing
2
+ when it isn't asking. Render it on the record's page, with its operation's slug:
3
+ render "qube_rails/question", record: @invoice, operation: "create_invoice"
4
+ It builds a QubeRails::QuestionView from record's latest run of that operation and renders
5
+ the qube_rails/question/* pieces; override a piece by putting a file at its path in your
6
+ app (README, "Styling"). Its search boxes and rows table need the behaviour script
7
+ (qube_rails_assets in your layout). %>
8
+ <% unless QubeRails.config.operation_class(operation) %>
9
+ <% raise ArgumentError, "QuBe Sync: no operation has the slug #{operation.inspect} (config.operations)" %>
10
+ <% end %>
11
+ <% run = QubeRails::Run.latest_for(record, operation) %>
12
+ <%# Refreshed when the run's next event comes, in an app with turbo-rails (QubeRails.broadcast_refresh). %>
13
+ <%= turbo_stream_from(record, :qube_rails) if respond_to?(:turbo_stream_from) %>
14
+ <% error = flash[:qube_rails_error] %>
15
+ <% flash.delete(:qube_rails_error) # shown once, by whichever partial comes first %>
16
+ <% question = QubeRails::QuestionView.from_run(run, qube_rails, error: error) %>
17
+ <% if question %>
18
+ <%= render "qube_rails/question/question", question: question %>
19
+ <% else %>
20
+ <%= render "qube_rails/error", message: error %>
21
+ <% end %>