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
|
@@ -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 %>
|