qube_sync 0.3.0 → 0.4.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 (260) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/README.md +235 -75
  4. data/Rakefile +12 -0
  5. data/lib/qube_sync/client.rb +579 -0
  6. data/lib/qube_sync/operations/account.rb +337 -0
  7. data/lib/qube_sync/operations/account_tax_line_info.rb +30 -0
  8. data/lib/qube_sync/operations/aging_report.rb +160 -0
  9. data/lib/qube_sync/operations/alert.rb +91 -0
  10. data/lib/qube_sync/operations/ar_refund_credit_card.rb +399 -0
  11. data/lib/qube_sync/operations/bar_code.rb +37 -0
  12. data/lib/qube_sync/operations/bill.rb +1073 -0
  13. data/lib/qube_sync/operations/bill_payment_check.rb +413 -0
  14. data/lib/qube_sync/operations/bill_payment_credit_card.rb +315 -0
  15. data/lib/qube_sync/operations/bill_to_pay.rb +66 -0
  16. data/lib/qube_sync/operations/billing_rate.rb +172 -0
  17. data/lib/qube_sync/operations/budget_summary_report.rb +80 -0
  18. data/lib/qube_sync/operations/build_assembly.rb +318 -0
  19. data/lib/qube_sync/operations/charge.rb +452 -0
  20. data/lib/qube_sync/operations/check.rb +1055 -0
  21. data/lib/qube_sync/operations/class.rb +176 -0
  22. data/lib/qube_sync/operations/cleared_status.rb +39 -0
  23. data/lib/qube_sync/operations/company.rb +39 -0
  24. data/lib/qube_sync/operations/company_activity.rb +31 -0
  25. data/lib/qube_sync/operations/credit_card_charge.rb +1000 -0
  26. data/lib/qube_sync/operations/credit_card_credit.rb +1000 -0
  27. data/lib/qube_sync/operations/credit_memo.rb +1243 -0
  28. data/lib/qube_sync/operations/currency.rb +195 -0
  29. data/lib/qube_sync/operations/custom_detail_report.rb +176 -0
  30. data/lib/qube_sync/operations/custom_summary_report.rb +180 -0
  31. data/lib/qube_sync/operations/customer.rb +744 -0
  32. data/lib/qube_sync/operations/customer_msg.rb +117 -0
  33. data/lib/qube_sync/operations/customer_type.rb +133 -0
  34. data/lib/qube_sync/operations/data_event_recovery_info.rb +54 -0
  35. data/lib/qube_sync/operations/data_ext.rb +201 -0
  36. data/lib/qube_sync/operations/data_ext_def.rb +179 -0
  37. data/lib/qube_sync/operations/date_driven_terms.rb +131 -0
  38. data/lib/qube_sync/operations/deposit.rb +555 -0
  39. data/lib/qube_sync/operations/employee.rb +577 -0
  40. data/lib/qube_sync/operations/employee_default.rb +100 -0
  41. data/lib/qube_sync/operations/entity.rb +118 -0
  42. data/lib/qube_sync/operations/estimate.rb +1055 -0
  43. data/lib/qube_sync/operations/form1099_category_account_mapping.rb +90 -0
  44. data/lib/qube_sync/operations/general_detail_report.rb +185 -0
  45. data/lib/qube_sync/operations/general_summary_report.rb +180 -0
  46. data/lib/qube_sync/operations/host.rb +36 -0
  47. data/lib/qube_sync/operations/inventory_adjustment.rb +482 -0
  48. data/lib/qube_sync/operations/inventory_site.rb +214 -0
  49. data/lib/qube_sync/operations/invoice.rb +1208 -0
  50. data/lib/qube_sync/operations/item.rb +115 -0
  51. data/lib/qube_sync/operations/item_assemblies_can_build.rb +44 -0
  52. data/lib/qube_sync/operations/item_discount.rb +319 -0
  53. data/lib/qube_sync/operations/item_fixed_asset.rb +317 -0
  54. data/lib/qube_sync/operations/item_group.rb +253 -0
  55. data/lib/qube_sync/operations/item_inventory.rb +446 -0
  56. data/lib/qube_sync/operations/item_inventory_assembly.rb +483 -0
  57. data/lib/qube_sync/operations/item_non_inventory.rb +461 -0
  58. data/lib/qube_sync/operations/item_other_charge.rb +434 -0
  59. data/lib/qube_sync/operations/item_payment.rb +268 -0
  60. data/lib/qube_sync/operations/item_receipt.rb +1011 -0
  61. data/lib/qube_sync/operations/item_sales_tax.rb +270 -0
  62. data/lib/qube_sync/operations/item_sales_tax_group.rb +200 -0
  63. data/lib/qube_sync/operations/item_service.rb +455 -0
  64. data/lib/qube_sync/operations/item_sites.rb +96 -0
  65. data/lib/qube_sync/operations/item_subtotal.rb +182 -0
  66. data/lib/qube_sync/operations/job_report.rb +156 -0
  67. data/lib/qube_sync/operations/job_type.rb +131 -0
  68. data/lib/qube_sync/operations/journal_entry.rb +518 -0
  69. data/lib/qube_sync/operations/lead.rb +216 -0
  70. data/lib/qube_sync/operations/list.rb +42 -0
  71. data/lib/qube_sync/operations/list_deleted.rb +57 -0
  72. data/lib/qube_sync/operations/list_display.rb +59 -0
  73. data/lib/qube_sync/operations/list_merge.rb +55 -0
  74. data/lib/qube_sync/operations/other_name.rb +245 -0
  75. data/lib/qube_sync/operations/paycheck.rb +139 -0
  76. data/lib/qube_sync/operations/payment_method.rb +128 -0
  77. data/lib/qube_sync/operations/payroll_detail_report.rb +161 -0
  78. data/lib/qube_sync/operations/payroll_item.rb +75 -0
  79. data/lib/qube_sync/operations/payroll_item_addition.rb +98 -0
  80. data/lib/qube_sync/operations/payroll_item_company_contribution.rb +134 -0
  81. data/lib/qube_sync/operations/payroll_item_deduction.rb +124 -0
  82. data/lib/qube_sync/operations/payroll_item_direct_deposit.rb +70 -0
  83. data/lib/qube_sync/operations/payroll_item_non_wage.rb +93 -0
  84. data/lib/qube_sync/operations/payroll_item_tax.rb +123 -0
  85. data/lib/qube_sync/operations/payroll_item_wage.rb +168 -0
  86. data/lib/qube_sync/operations/payroll_item_workers_compensation.rb +108 -0
  87. data/lib/qube_sync/operations/payroll_last_period.rb +30 -0
  88. data/lib/qube_sync/operations/payroll_liability_adjustment.rb +204 -0
  89. data/lib/qube_sync/operations/payroll_liability_check.rb +137 -0
  90. data/lib/qube_sync/operations/payroll_prior_payment.rb +178 -0
  91. data/lib/qube_sync/operations/payroll_summary_report.rb +157 -0
  92. data/lib/qube_sync/operations/payroll_year_to_date_adjustment.rb +211 -0
  93. data/lib/qube_sync/operations/preferences.rb +61 -0
  94. data/lib/qube_sync/operations/price_level.rb +305 -0
  95. data/lib/qube_sync/operations/purchase_order.rb +1038 -0
  96. data/lib/qube_sync/operations/qb_command_exec.rb +37 -0
  97. data/lib/qube_sync/operations/receive_payment.rb +627 -0
  98. data/lib/qube_sync/operations/receive_payment_to_deposit.rb +38 -0
  99. data/lib/qube_sync/operations/sales_order.rb +1045 -0
  100. data/lib/qube_sync/operations/sales_receipt.rb +1337 -0
  101. data/lib/qube_sync/operations/sales_rep.rb +172 -0
  102. data/lib/qube_sync/operations/sales_tax_code.rb +205 -0
  103. data/lib/qube_sync/operations/sales_tax_payable.rb +37 -0
  104. data/lib/qube_sync/operations/sales_tax_payment_check.rb +375 -0
  105. data/lib/qube_sync/operations/sales_tax_return.rb +35 -0
  106. data/lib/qube_sync/operations/sales_tax_return_line.rb +43 -0
  107. data/lib/qube_sync/operations/ship_method.rb +116 -0
  108. data/lib/qube_sync/operations/special_account.rb +55 -0
  109. data/lib/qube_sync/operations/special_item.rb +44 -0
  110. data/lib/qube_sync/operations/standard_terms.rb +125 -0
  111. data/lib/qube_sync/operations/template.rb +36 -0
  112. data/lib/qube_sync/operations/terms.rb +91 -0
  113. data/lib/qube_sync/operations/time_report.rb +127 -0
  114. data/lib/qube_sync/operations/time_tracking.rb +303 -0
  115. data/lib/qube_sync/operations/to_do.rb +260 -0
  116. data/lib/qube_sync/operations/transaction.rb +356 -0
  117. data/lib/qube_sync/operations/transfer.rb +213 -0
  118. data/lib/qube_sync/operations/transfer_inventory.rb +384 -0
  119. data/lib/qube_sync/operations/txn.rb +38 -0
  120. data/lib/qube_sync/operations/txn_deleted.rb +54 -0
  121. data/lib/qube_sync/operations/txn_display.rb +76 -0
  122. data/lib/qube_sync/operations/txn_void.rb +36 -0
  123. data/lib/qube_sync/operations/unit_of_measure_set.rb +149 -0
  124. data/lib/qube_sync/operations/vehicle.rb +152 -0
  125. data/lib/qube_sync/operations/vehicle_mileage.rb +191 -0
  126. data/lib/qube_sync/operations/vendor.rb +661 -0
  127. data/lib/qube_sync/operations/vendor_credit.rb +1008 -0
  128. data/lib/qube_sync/operations/vendor_type.rb +131 -0
  129. data/lib/qube_sync/operations/workers_comp_code.rb +184 -0
  130. data/lib/qube_sync/operations.rb +1165 -0
  131. data/lib/qube_sync/version.rb +1 -1
  132. data/lib/qube_sync.rb +1 -0
  133. data/sig/qube_sync/client.rbs +82 -0
  134. data/sig/qube_sync/operations/account.rbs +65 -0
  135. data/sig/qube_sync/operations/account_tax_line_info.rbs +13 -0
  136. data/sig/qube_sync/operations/aging_report.rbs +29 -0
  137. data/sig/qube_sync/operations/alert.rbs +53 -0
  138. data/sig/qube_sync/operations/ar_refund_credit_card.rbs +53 -0
  139. data/sig/qube_sync/operations/bar_code.rbs +16 -0
  140. data/sig/qube_sync/operations/bill.rbs +78 -0
  141. data/sig/qube_sync/operations/bill_payment_check.rbs +62 -0
  142. data/sig/qube_sync/operations/bill_payment_credit_card.rbs +45 -0
  143. data/sig/qube_sync/operations/bill_to_pay.rbs +19 -0
  144. data/sig/qube_sync/operations/billing_rate.rbs +40 -0
  145. data/sig/qube_sync/operations/budget_summary_report.rbs +22 -0
  146. data/sig/qube_sync/operations/build_assembly.rbs +64 -0
  147. data/sig/qube_sync/operations/charge.rbs +80 -0
  148. data/sig/qube_sync/operations/check.rbs +81 -0
  149. data/sig/qube_sync/operations/class.rbs +43 -0
  150. data/sig/qube_sync/operations/cleared_status.rbs +16 -0
  151. data/sig/qube_sync/operations/company.rbs +15 -0
  152. data/sig/qube_sync/operations/company_activity.rbs +14 -0
  153. data/sig/qube_sync/operations/credit_card_charge.rbs +69 -0
  154. data/sig/qube_sync/operations/credit_card_credit.rbs +69 -0
  155. data/sig/qube_sync/operations/credit_memo.rbs +162 -0
  156. data/sig/qube_sync/operations/currency.rbs +52 -0
  157. data/sig/qube_sync/operations/custom_detail_report.rbs +31 -0
  158. data/sig/qube_sync/operations/custom_summary_report.rbs +33 -0
  159. data/sig/qube_sync/operations/customer.rbs +174 -0
  160. data/sig/qube_sync/operations/customer_msg.rbs +31 -0
  161. data/sig/qube_sync/operations/customer_type.rbs +32 -0
  162. data/sig/qube_sync/operations/data_event_recovery_info.rbs +20 -0
  163. data/sig/qube_sync/operations/data_ext.rbs +53 -0
  164. data/sig/qube_sync/operations/data_ext_def.rbs +51 -0
  165. data/sig/qube_sync/operations/date_driven_terms.rbs +35 -0
  166. data/sig/qube_sync/operations/deposit.rbs +100 -0
  167. data/sig/qube_sync/operations/employee.rbs +173 -0
  168. data/sig/qube_sync/operations/employee_default.rbs +68 -0
  169. data/sig/qube_sync/operations/entity.rbs +24 -0
  170. data/sig/qube_sync/operations/estimate.rbs +148 -0
  171. data/sig/qube_sync/operations/form1099_category_account_mapping.rbs +33 -0
  172. data/sig/qube_sync/operations/general_detail_report.rbs +31 -0
  173. data/sig/qube_sync/operations/general_summary_report.rbs +32 -0
  174. data/sig/qube_sync/operations/host.rbs +19 -0
  175. data/sig/qube_sync/operations/inventory_adjustment.rbs +105 -0
  176. data/sig/qube_sync/operations/inventory_site.rbs +66 -0
  177. data/sig/qube_sync/operations/invoice.rbs +144 -0
  178. data/sig/qube_sync/operations/item.rbs +25 -0
  179. data/sig/qube_sync/operations/item_assemblies_can_build.rbs +15 -0
  180. data/sig/qube_sync/operations/item_discount.rbs +62 -0
  181. data/sig/qube_sync/operations/item_fixed_asset.rbs +97 -0
  182. data/sig/qube_sync/operations/item_group.rbs +62 -0
  183. data/sig/qube_sync/operations/item_inventory.rbs +87 -0
  184. data/sig/qube_sync/operations/item_inventory_assembly.rbs +94 -0
  185. data/sig/qube_sync/operations/item_non_inventory.rbs +64 -0
  186. data/sig/qube_sync/operations/item_other_charge.rbs +59 -0
  187. data/sig/qube_sync/operations/item_payment.rbs +55 -0
  188. data/sig/qube_sync/operations/item_receipt.rbs +71 -0
  189. data/sig/qube_sync/operations/item_sales_tax.rbs +57 -0
  190. data/sig/qube_sync/operations/item_sales_tax_group.rbs +50 -0
  191. data/sig/qube_sync/operations/item_service.rbs +62 -0
  192. data/sig/qube_sync/operations/item_sites.rbs +26 -0
  193. data/sig/qube_sync/operations/item_subtotal.rbs +48 -0
  194. data/sig/qube_sync/operations/job_report.rbs +28 -0
  195. data/sig/qube_sync/operations/job_type.rbs +32 -0
  196. data/sig/qube_sync/operations/journal_entry.rbs +88 -0
  197. data/sig/qube_sync/operations/lead.rbs +91 -0
  198. data/sig/qube_sync/operations/list.rbs +15 -0
  199. data/sig/qube_sync/operations/list_deleted.rbs +17 -0
  200. data/sig/qube_sync/operations/list_display.rbs +21 -0
  201. data/sig/qube_sync/operations/list_merge.rbs +22 -0
  202. data/sig/qube_sync/operations/other_name.rbs +71 -0
  203. data/sig/qube_sync/operations/paycheck.rbs +27 -0
  204. data/sig/qube_sync/operations/payment_method.rbs +33 -0
  205. data/sig/qube_sync/operations/payroll_detail_report.rbs +29 -0
  206. data/sig/qube_sync/operations/payroll_item.rbs +23 -0
  207. data/sig/qube_sync/operations/payroll_item_addition.rbs +48 -0
  208. data/sig/qube_sync/operations/payroll_item_company_contribution.rbs +58 -0
  209. data/sig/qube_sync/operations/payroll_item_deduction.rbs +54 -0
  210. data/sig/qube_sync/operations/payroll_item_direct_deposit.rbs +30 -0
  211. data/sig/qube_sync/operations/payroll_item_non_wage.rbs +23 -0
  212. data/sig/qube_sync/operations/payroll_item_tax.rbs +58 -0
  213. data/sig/qube_sync/operations/payroll_item_wage.rbs +48 -0
  214. data/sig/qube_sync/operations/payroll_item_workers_compensation.rbs +42 -0
  215. data/sig/qube_sync/operations/payroll_last_period.rbs +13 -0
  216. data/sig/qube_sync/operations/payroll_liability_adjustment.rbs +59 -0
  217. data/sig/qube_sync/operations/payroll_liability_check.rbs +27 -0
  218. data/sig/qube_sync/operations/payroll_prior_payment.rbs +48 -0
  219. data/sig/qube_sync/operations/payroll_summary_report.rbs +30 -0
  220. data/sig/qube_sync/operations/payroll_year_to_date_adjustment.rbs +66 -0
  221. data/sig/qube_sync/operations/preferences.rbs +26 -0
  222. data/sig/qube_sync/operations/price_level.rbs +57 -0
  223. data/sig/qube_sync/operations/purchase_order.rbs +159 -0
  224. data/sig/qube_sync/operations/qb_command_exec.rbs +22 -0
  225. data/sig/qube_sync/operations/receive_payment.rbs +102 -0
  226. data/sig/qube_sync/operations/receive_payment_to_deposit.rbs +15 -0
  227. data/sig/qube_sync/operations/sales_order.rbs +153 -0
  228. data/sig/qube_sync/operations/sales_receipt.rbs +139 -0
  229. data/sig/qube_sync/operations/sales_rep.rbs +43 -0
  230. data/sig/qube_sync/operations/sales_tax_code.rbs +49 -0
  231. data/sig/qube_sync/operations/sales_tax_payable.rbs +16 -0
  232. data/sig/qube_sync/operations/sales_tax_payment_check.rbs +64 -0
  233. data/sig/qube_sync/operations/sales_tax_return.rbs +14 -0
  234. data/sig/qube_sync/operations/sales_tax_return_line.rbs +15 -0
  235. data/sig/qube_sync/operations/ship_method.rbs +31 -0
  236. data/sig/qube_sync/operations/special_account.rbs +16 -0
  237. data/sig/qube_sync/operations/special_item.rbs +17 -0
  238. data/sig/qube_sync/operations/standard_terms.rbs +34 -0
  239. data/sig/qube_sync/operations/template.rbs +15 -0
  240. data/sig/qube_sync/operations/terms.rbs +23 -0
  241. data/sig/qube_sync/operations/time_report.rbs +26 -0
  242. data/sig/qube_sync/operations/time_tracking.rbs +57 -0
  243. data/sig/qube_sync/operations/to_do.rbs +59 -0
  244. data/sig/qube_sync/operations/transaction.rbs +39 -0
  245. data/sig/qube_sync/operations/transfer.rbs +47 -0
  246. data/sig/qube_sync/operations/transfer_inventory.rbs +76 -0
  247. data/sig/qube_sync/operations/txn.rbs +15 -0
  248. data/sig/qube_sync/operations/txn_deleted.rbs +17 -0
  249. data/sig/qube_sync/operations/txn_display.rbs +22 -0
  250. data/sig/qube_sync/operations/txn_void.rbs +15 -0
  251. data/sig/qube_sync/operations/types.rbs +434 -0
  252. data/sig/qube_sync/operations/unit_of_measure_set.rbs +51 -0
  253. data/sig/qube_sync/operations/vehicle.rbs +43 -0
  254. data/sig/qube_sync/operations/vehicle_mileage.rbs +38 -0
  255. data/sig/qube_sync/operations/vendor.rbs +142 -0
  256. data/sig/qube_sync/operations/vendor_credit.rbs +70 -0
  257. data/sig/qube_sync/operations/vendor_type.rbs +32 -0
  258. data/sig/qube_sync/operations/workers_comp_code.rbs +52 -0
  259. data/sig/qube_sync/operations.rbs +147 -0
  260. metadata +255 -2
@@ -0,0 +1,579 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "securerandom"
6
+ require "time"
7
+ require "uri"
8
+ require_relative "version"
9
+
10
+ module QubeSync
11
+ class Error < StandardError; end
12
+
13
+ # Any answer from QuBe Sync outside 2xx, after the retries.
14
+ #
15
+ # - `status`: the HTTP status, an Integer.
16
+ # - `errors`: the body's `errors` as parsed (a String, a Hash or an Array), or nil when the
17
+ # body isn't JSON or has none.
18
+ # - `code`: `errors["code"]` when `errors` is a Hash with a String `code` (`"prompt_changed"`,
19
+ # `"not_awaiting_input"`...), else nil.
20
+ # - `message`: one line, `"QuBe Sync answered <status>: <text>"`.
21
+ # - `body`: the raw response body.
22
+ # - `request_id`: the `x-request-id` response header, for support, or nil.
23
+ #
24
+ # It never holds the API key or the Authorization header.
25
+ class APIError < Error
26
+ MAX_RAW = 500
27
+
28
+ attr_reader :status, :errors, :code, :body, :request_id
29
+
30
+ def initialize(status:, body: "", request_id: nil)
31
+ @status = Integer(status)
32
+ @body = body.to_s
33
+ @request_id = request_id
34
+ parsed = parse_json(@body)
35
+ @errors = parsed.is_a?(Hash) ? parsed["errors"] : nil
36
+ @code = @errors.is_a?(Hash) && @errors["code"].is_a?(String) ? @errors["code"] : nil
37
+ super("QuBe Sync answered #{@status}: #{summary}")
38
+ end
39
+
40
+ private
41
+
42
+ def parse_json(text)
43
+ JSON.parse(text)
44
+ rescue JSON::ParserError
45
+ nil
46
+ end
47
+
48
+ def summary
49
+ text =
50
+ case @errors
51
+ when String
52
+ @errors
53
+ when Hash
54
+ if @errors["detail"].is_a?(String)
55
+ @errors["detail"]
56
+ else
57
+ @errors.map { |field, messages| "#{field}: #{Array(messages).join(", ")}" }.join("; ")
58
+ end
59
+ when Array
60
+ @errors.map do |problem|
61
+ if problem.is_a?(Hash)
62
+ "#{problem["path"]}: #{problem["message"]}"
63
+ else
64
+ problem.to_s
65
+ end
66
+ end.join("; ")
67
+ end
68
+
69
+ text = raw_summary if text.nil? || text.strip.empty?
70
+ text.gsub(/\s+/, " ").strip
71
+ end
72
+
73
+ def raw_summary
74
+ raw = @body.strip
75
+ raw.empty? ? "(no body)" : raw[0, MAX_RAW]
76
+ end
77
+ end
78
+
79
+ # A client for the QuBe Sync v2 API.
80
+ #
81
+ # client = QubeSync::Client.new(api_key: ENV.fetch("QUBE_API_KEY"))
82
+ # connection = client.create_connection(webhook_url: "https://myapp.example/qube/connection")
83
+ # connection["links"]["onboarding"] # send the customer here
84
+ #
85
+ # Every method returns the response's `data` (a Hash or an Array with String keys) unless its
86
+ # documentation says otherwise, and nil for an empty answer. Anything outside 2xx raises
87
+ # QubeSync::APIError. A connection that still fails after the retries raises Ruby's own error
88
+ # (Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED...).
89
+ #
90
+ # It uses only the standard library: `require "qube_sync/client"` loads neither Faraday nor
91
+ # the v1 functions. It never reads the environment.
92
+ class Client
93
+ DEFAULT_BASE_URL = "https://qubesync.com"
94
+ API_PATH = "/api/v2"
95
+ RETRY_STATUSES = [429, 502, 503, 504].freeze
96
+ RETRY_AFTER_STATUSES = [429, 503].freeze
97
+ MAX_RETRY_AFTER = 30
98
+ # A retry is made only when at least this many seconds are left before total_timeout.
99
+ MIN_TIME_LEFT = 1
100
+ RETRY_SAFE_METHODS = %w[GET PUT DELETE].freeze
101
+ METHODS = %w[GET POST PUT DELETE PATCH].freeze
102
+ QUEUE_METHODS = %w[GET POST PUT DELETE].freeze
103
+ NETWORK_ERRORS = [
104
+ Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
105
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPIPE,
106
+ Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ETIMEDOUT,
107
+ EOFError, IOError, SocketError
108
+ ].freeze
109
+ # Failures after the request may have reached QuBe Sync: its answer was lost, so the request
110
+ # may have been carried out.
111
+ ANSWER_LOST_ERRORS = [
112
+ Net::ReadTimeout, Net::WriteTimeout, Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPIPE, EOFError, IOError
113
+ ].freeze
114
+ ANSWER_LOST_STATUSES = [502, 504].freeze
115
+
116
+ attr_reader :base_url, :open_timeout, :read_timeout, :max_retries, :total_timeout
117
+
118
+ # `api_key` is required. `base_url` is the site, without `/api/v2`. `open_timeout` and
119
+ # `read_timeout` are seconds, per attempt. `max_retries` is how many times a retry-safe
120
+ # request is sent again. `total_timeout` (seconds) bounds a whole call, retries and their
121
+ # waits included. `on_retry` is a callable, called before each retry's wait with a frozen
122
+ # Hash: `attempt`, `max_retries`, `delay`, `elapsed`, `method`, `url` (without its query),
123
+ # `status` and `error`; an exception it raises stops the call and reaches the caller.
124
+ # `sleep` (a callable taking seconds) and `clock` (a callable returning monotonic seconds)
125
+ # are for tests.
126
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL, open_timeout: 5, read_timeout: 30, max_retries: 2,
127
+ total_timeout: 60, on_retry: nil, sleep: nil, clock: nil)
128
+ unless api_key.is_a?(String) && !api_key.strip.empty?
129
+ raise ArgumentError, "api_key is required"
130
+ end
131
+ unless max_retries.is_a?(Integer) && max_retries >= 0
132
+ raise ArgumentError, "max_retries must be a non-negative Integer"
133
+ end
134
+ unless total_timeout.is_a?(Numeric) && total_timeout.positive?
135
+ raise ArgumentError, "total_timeout must be a positive number of seconds"
136
+ end
137
+ unless on_retry.nil? || on_retry.respond_to?(:call)
138
+ raise ArgumentError, "on_retry must respond to call"
139
+ end
140
+
141
+ @base_url = base_url.to_s.sub(%r{/+\z}, "")
142
+ @base_uri = parse_http_uri(@base_url)
143
+ raise ArgumentError, "base_url must be an absolute http or https URL" unless @base_uri
144
+
145
+ @authorization = "Basic #{["#{api_key}:"].pack("m0")}"
146
+ @open_timeout = open_timeout
147
+ @read_timeout = read_timeout
148
+ @max_retries = max_retries
149
+ @total_timeout = total_timeout
150
+ @on_retry = on_retry
151
+ @sleep = sleep || ->(seconds) { Kernel.sleep(seconds) }
152
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
153
+ end
154
+
155
+ def inspect
156
+ "#<#{self.class.name} base_url=#{@base_url}>"
157
+ end
158
+ alias to_s inspect
159
+
160
+ # -- Connections ------------------------------------------------------------------------
161
+
162
+ def create_connection(webhook_url:, name: nil)
163
+ call(:post, "/connections", compact(webhook_url: webhook_url, name: name))
164
+ end
165
+
166
+ def get_connection(id)
167
+ call(:get, "/connections/#{segment(id)}")
168
+ end
169
+
170
+ def list_connections
171
+ call(:get, "/connections")
172
+ end
173
+
174
+ def update_connection(id, webhook_url: nil, name: nil)
175
+ call(:put, "/connections/#{segment(id)}", compact(webhook_url: webhook_url, name: name))
176
+ end
177
+
178
+ def delete_connection(id)
179
+ call(:delete, "/connections/#{segment(id)}")
180
+ end
181
+
182
+ def regenerate_onboarding(id)
183
+ call(:post, "/connections/#{segment(id)}/onboarding-url")
184
+ end
185
+
186
+ # Returns `{"id" => ..., "password" => ...}`.
187
+ def set_password(id, password: nil)
188
+ body = password.nil? ? nil : {password: password}
189
+ call(:post, "/connections/#{segment(id)}/password", body)
190
+ end
191
+
192
+ # Returns the QWC file's XML as a String.
193
+ def get_qwc(id)
194
+ body = request(:post, "/connections/#{segment(id)}/qwc")
195
+ body.is_a?(Hash) ? body["qwc"] : nil
196
+ end
197
+
198
+ # -- Workflow runs ----------------------------------------------------------------------
199
+
200
+ # Sends `reference` as the Idempotency-Key, so a retry never starts a second run.
201
+ def start_run(connection_id, workflow, input, reference:, webhook_url:, version: nil)
202
+ body = compact(workflow: workflow, reference: reference, input: input, webhook_url: webhook_url, version: version)
203
+ call(:post, "/connections/#{segment(connection_id)}/workflow-runs", body,
204
+ headers: {"Idempotency-Key" => reference.to_s})
205
+ end
206
+
207
+ # `get_run(run["links"]["run"])` or `get_run(connection_id, run_id)`.
208
+ def get_run(run_url_or_connection_id, run_id = nil)
209
+ if run_id.nil?
210
+ call(:get, run_url_or_connection_id)
211
+ else
212
+ call(:get, "/connections/#{segment(run_url_or_connection_id)}/workflow-runs/#{segment(run_id)}")
213
+ end
214
+ end
215
+
216
+ def list_runs(connection_id, reference: nil, state: nil, outcome: nil)
217
+ call(:get, "/connections/#{segment(connection_id)}/workflow-runs",
218
+ query: {reference: reference, state: state, outcome: outcome})
219
+ end
220
+
221
+ # `cancel_url` is the run's `links.cancel`.
222
+ def cancel_run(cancel_url, reason: nil)
223
+ call(:post, cancel_url, compact(reason: reason))
224
+ end
225
+
226
+ # Returns the whole body: `{"data" => [...], "next_after" => ...}`.
227
+ def run_events(connection_id, run_id, after: nil, limit: nil)
228
+ request(:get, "/connections/#{segment(connection_id)}/workflow-runs/#{segment(run_id)}/events",
229
+ query: {after: after, limit: limit})
230
+ end
231
+
232
+ # -- Questions --------------------------------------------------------------------------
233
+
234
+ # `decisions_url` is the run's `links.decisions`. It's retried like a GET when the link
235
+ # carries a `prompt_token` (a run's link always does while it asks), since the token ties the
236
+ # answer to one question. A retry after a lost answer may raise APIError 409 with code
237
+ # `prompt_changed`: the question has been answered, by the first attempt or by someone else.
238
+ # Without a `prompt_token` it's sent once.
239
+ def answer(decisions_url, choice:, data: nil)
240
+ call(:post, decisions_url, compact(choice: choice, data: data), retry_safe: prompt_token?(decisions_url))
241
+ end
242
+
243
+ # `search_url` is the run's `links.search`. Returns the matches, an Array.
244
+ def search(search_url, field:, q:)
245
+ call(:get, search_url, query: {field: field, q: q})
246
+ end
247
+
248
+ # -- Queued requests --------------------------------------------------------------------
249
+
250
+ # Queues a QuickBooks operation by its id, as `client.customer.create(...)` and the other
251
+ # entity methods do: `queue("customer.create", connection_id, {name: "Acme"}, webhook_url:)`.
252
+ # The params go as the JSON body or the query string, as the operation takes them, and
253
+ # `webhook_url` in the query string. Without `idempotency_key:`, the client makes a UUID for
254
+ # this call and sends the same one on its retries.
255
+ #
256
+ # For an operation newer than this version of the gem, pass `method:` (`:post`, `:get`,
257
+ # `:put`, `:delete`) and `path:`, relative to the connection (`"customers"`); a GET sends the
258
+ # params as the query string, any other method as the body. For a known id they replace the
259
+ # index's.
260
+ def queue(operation_id, connection_id, params = {}, webhook_url:, idempotency_key: nil, method: nil, path: nil)
261
+ entry = Operations::INDEX[operation_id.to_s]
262
+ if entry.nil? && (method.nil? || path.nil?)
263
+ raise ArgumentError, "unknown operation #{operation_id.to_s.inspect}: pass method: and path: for an operation " \
264
+ "this version of qube_sync doesn't know"
265
+ end
266
+
267
+ verb = (method || entry[:method]).to_s.upcase
268
+ raise ArgumentError, "method must be one of :get, :post, :put, :delete" unless QUEUE_METHODS.include?(verb)
269
+
270
+ params_in = (method.nil? && entry) ? entry[:params_in] : (verb == "GET" ? :query : :body)
271
+ full_path = "/connections/#{segment(connection_id)}/#{operation_path(path || entry[:path])}"
272
+ params ||= {}
273
+ query = {webhook_url: webhook_url}
274
+ body = nil
275
+ if params_in == :query
276
+ query = query.merge(params.to_h.reject { |name, _| name.to_s == "webhook_url" })
277
+ else
278
+ body = params
279
+ end
280
+
281
+ call(verb, full_path, body, query: query, headers: {"Idempotency-Key" => key_for(idempotency_key)})
282
+ end
283
+
284
+ # Queues qbXML as written.
285
+ def queue_qbxml(connection_id, request_xml, webhook_url:, idempotency_key: nil)
286
+ call(:post, "/connections/#{segment(connection_id)}/queued-requests", {request_xml: request_xml},
287
+ query: {webhook_url: webhook_url}, headers: {"Idempotency-Key" => key_for(idempotency_key)})
288
+ end
289
+
290
+ def get_request(connection_id, id)
291
+ call(:get, "/connections/#{segment(connection_id)}/queued-requests/#{segment(id)}")
292
+ end
293
+
294
+ def request_pages(connection_id, id)
295
+ call(:get, "/connections/#{segment(connection_id)}/queued-requests/#{segment(id)}/pages")
296
+ end
297
+
298
+ # Returns the whole body: `{"data" => [...], "meta" => {"page" => 1, "total_pages" => 3}}`.
299
+ def list_requests(connection_id, page: nil, page_size: nil, state: nil, webhook_state: nil, sort: nil,
300
+ sort_direction: nil, search_phrase: nil)
301
+ query = {
302
+ page: page, page_size: page_size, state: state, webhook_state: webhook_state, sort: sort,
303
+ sort_direction: sort_direction, search_phrase: search_phrase
304
+ }
305
+ request(:get, "/connections/#{segment(connection_id)}/queued-requests", query: query)
306
+ end
307
+
308
+ # Yields every request on every page, fetching page after page. Takes `list_requests`'
309
+ # filters. Without a block, returns an Enumerator.
310
+ def each_request(connection_id, **filters, &block)
311
+ return enum_for(:each_request, connection_id, **filters) unless block
312
+
313
+ page = filters.delete(:page) || 1
314
+ loop do
315
+ body = list_requests(connection_id, **filters, page: page)
316
+ data = body.is_a?(Hash) ? Array(body["data"]) : []
317
+ break if data.empty?
318
+
319
+ data.each(&block)
320
+ total_pages = body.dig("meta", "total_pages")
321
+ break if !total_pages.is_a?(Integer) || page >= total_pages
322
+
323
+ page += 1
324
+ end
325
+ nil
326
+ end
327
+
328
+ def discard_request(connection_id, id)
329
+ call(:post, "/connections/#{segment(connection_id)}/queued-requests/#{segment(id)}/discard")
330
+ end
331
+
332
+ # -- Anything else ----------------------------------------------------------------------
333
+
334
+ # Sends any request and returns the parsed JSON body whole (not just `data`), or nil for an
335
+ # empty answer. `path_or_url` is a path under /api/v2 (starting with "/") or an absolute URL
336
+ # on this client's own origin. `body` is sent as JSON (a String as is).
337
+ def request(method, path_or_url, body = nil, query: {}, headers: {})
338
+ perform(method, path_or_url, body, query: query, headers: headers)
339
+ end
340
+
341
+ private
342
+
343
+ def perform(method, path_or_url, body, query:, headers:, retry_safe: false)
344
+ verb = method.to_s.upcase
345
+ raise ArgumentError, "unsupported method #{method.inspect}" unless METHODS.include?(verb)
346
+
347
+ uri = build_uri(path_or_url, query)
348
+ request_headers = {
349
+ "Accept" => "application/json",
350
+ "User-Agent" => "qube_sync-ruby/#{QubeSync::VERSION}"
351
+ }
352
+ payload = nil
353
+ unless body.nil?
354
+ request_headers["Content-Type"] = "application/json"
355
+ payload = body.is_a?(String) ? body : JSON.generate(body)
356
+ end
357
+ headers.each do |name, value|
358
+ next if name.to_s.casecmp?("authorization")
359
+
360
+ request_headers[name.to_s] = value.to_s
361
+ end
362
+ request_headers["Authorization"] = @authorization
363
+
364
+ retry_safe ||= RETRY_SAFE_METHODS.include?(verb) ||
365
+ request_headers.any? { |name, value| name.casecmp?("idempotency-key") && !value.empty? }
366
+
367
+ send_with_retries(verb, uri, request_headers, payload, retry_safe)
368
+ end
369
+
370
+ def call(method, path_or_url, body = nil, query: {}, headers: {}, retry_safe: false)
371
+ parsed = perform(method, path_or_url, body, query: query, headers: headers, retry_safe: retry_safe)
372
+ parsed.is_a?(Hash) ? parsed["data"] : parsed
373
+ end
374
+
375
+ def send_with_retries(verb, uri, headers, payload, retry_safe)
376
+ started = @clock.call
377
+ attempt = 0
378
+ answer_lost = false
379
+ loop do
380
+ error = nil
381
+ response = nil
382
+ time_left = @total_timeout - (@clock.call - started)
383
+ begin
384
+ response = send_once(verb, uri, headers, payload, time_left)
385
+ rescue *NETWORK_ERRORS => e
386
+ error = e
387
+ end
388
+
389
+ status = response&.code.to_i
390
+ # A retried DELETE that finds nothing, after an earlier attempt's answer was lost, did
391
+ # what it was asked: that attempt deleted it.
392
+ return nil if verb == "DELETE" && status == 404 && answer_lost
393
+
394
+ answer_lost ||= ANSWER_LOST_ERRORS.any? { |klass| error.is_a?(klass) } ||
395
+ ANSWER_LOST_STATUSES.include?(status)
396
+ retryable = error || RETRY_STATUSES.include?(status)
397
+ if retryable && retry_safe && attempt < @max_retries
398
+ delay = error ? backoff(attempt) : retry_delay(attempt, status, response)
399
+ elapsed = @clock.call - started
400
+ if @total_timeout - elapsed - delay >= MIN_TIME_LEFT
401
+ notify_retry(attempt + 1, delay, elapsed, verb, uri, error ? nil : status, error)
402
+ @sleep.call(delay)
403
+ attempt += 1
404
+ next
405
+ end
406
+ end
407
+
408
+ raise error if error
409
+ return parse_body(response) if (200..299).cover?(status)
410
+
411
+ raise APIError.new(status: status, body: response.body, request_id: response["x-request-id"])
412
+ end
413
+ end
414
+
415
+ def notify_retry(attempt, delay, elapsed, verb, uri, status, error)
416
+ return unless @on_retry
417
+
418
+ url = uri.dup
419
+ url.query = nil
420
+ url.fragment = nil
421
+ @on_retry.call({
422
+ attempt: attempt, max_retries: @max_retries, delay: delay.to_f, elapsed: elapsed.to_f,
423
+ method: verb, url: url.to_s, status: status, error: error
424
+ }.freeze)
425
+ end
426
+
427
+ # Each attempt's timeouts are the configured ones, cut to the time left before total_timeout.
428
+ def send_once(verb, uri, headers, payload, time_left)
429
+ time_left = [time_left, 0.001].max
430
+ http = Net::HTTP.new(uri.host, uri.port)
431
+ http.use_ssl = uri.scheme == "https"
432
+ http.open_timeout = [@open_timeout, time_left].min
433
+ http.read_timeout = [@read_timeout, time_left].min
434
+ http.write_timeout = [@read_timeout, time_left].min
435
+ http.max_retries = 0 # the client retries, and only what's safe to send again
436
+
437
+ request = Net::HTTPGenericRequest.new(verb, !payload.nil?, verb != "HEAD", uri.request_uri, headers)
438
+ request.body = payload unless payload.nil?
439
+ http.start { |connection| connection.request(request) }
440
+ end
441
+
442
+ def prompt_token?(link)
443
+ query = URI.parse(link.to_s).query
444
+ return false if query.nil?
445
+
446
+ URI.decode_www_form(query).any? { |name, value| name == "prompt_token" && !value.to_s.empty? }
447
+ rescue URI::InvalidURIError, ArgumentError
448
+ false
449
+ end
450
+
451
+ def parse_body(response)
452
+ text = response.body
453
+ return nil if response.code.to_i == 204 || text.nil? || text.strip.empty?
454
+
455
+ JSON.parse(text)
456
+ end
457
+
458
+ def backoff(attempt)
459
+ [0.5 * (2**attempt), MAX_RETRY_AFTER].min * (1 + rand * 0.25)
460
+ end
461
+
462
+ def retry_delay(attempt, status, response)
463
+ if RETRY_AFTER_STATUSES.include?(status)
464
+ seconds = Float(response["retry-after"].to_s.strip, exception: false)
465
+ return [seconds, MAX_RETRY_AFTER].min if seconds && seconds >= 0
466
+ end
467
+ backoff(attempt)
468
+ end
469
+
470
+ def build_uri(path_or_url, query)
471
+ text = path_or_url.to_s
472
+ uri =
473
+ if text.start_with?("/")
474
+ parse_http_uri("#{@base_url}#{API_PATH}#{text}") ||
475
+ raise(ArgumentError, "not a valid path: #{text.inspect}")
476
+ else
477
+ link = parse_http_uri(text)
478
+ raise ArgumentError, "expected a path starting with \"/\" or an absolute URL, got #{text.inspect}" unless link
479
+ unless origin(link) == origin(@base_uri)
480
+ raise ArgumentError, "refusing a link on #{origin(link)}: this client only sends requests to #{origin(@base_uri)}"
481
+ end
482
+
483
+ link
484
+ end
485
+
486
+ extra = encode_query(query)
487
+ unless extra.empty?
488
+ uri = uri.dup
489
+ uri.query = [uri.query, extra].compact.reject(&:empty?).join("&")
490
+ end
491
+ uri
492
+ end
493
+
494
+ def parse_http_uri(text)
495
+ uri = URI.parse(text)
496
+ return nil unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
497
+
498
+ uri
499
+ rescue URI::InvalidURIError
500
+ nil
501
+ end
502
+
503
+ def origin(uri)
504
+ "#{uri.scheme.downcase}://#{uri.host.downcase}:#{uri.port}"
505
+ end
506
+
507
+ # Query strings as the v2 API reads them: an array of scalars repeats its bare key
508
+ # (`list_id=1&list_id=2`); a nested Hash brackets its keys (`name_range[from]=A`), and an
509
+ # array inside one brackets too (`currency_filter[list_id][]=1`). nil values are left out,
510
+ # booleans are `true`/`false`, a Time or Date is ISO 8601.
511
+ def encode_query(query)
512
+ raise ArgumentError, "query must be a Hash" unless query.nil? || query.is_a?(Hash)
513
+
514
+ pairs = []
515
+ (query || {}).each { |name, value| query_pairs(name.to_s, value, true, pairs) }
516
+ URI.encode_www_form(pairs)
517
+ end
518
+
519
+ def query_pairs(key, value, top, pairs)
520
+ case value
521
+ when nil
522
+ nil
523
+ when Array
524
+ value.each do |item|
525
+ next if item.nil?
526
+ raise ArgumentError, "query parameter #{key} can't hold an array of arrays" if item.is_a?(Array)
527
+
528
+ if item.is_a?(Hash)
529
+ query_pairs("#{key}[]", item, false, pairs)
530
+ else
531
+ pairs << [top ? key : "#{key}[]", query_scalar(key, item)]
532
+ end
533
+ end
534
+ when Hash
535
+ value.each { |name, inner| query_pairs("#{key}[#{name}]", inner, false, pairs) }
536
+ else
537
+ pairs << [key, query_scalar(key, value)]
538
+ end
539
+ end
540
+
541
+ def query_scalar(key, value)
542
+ case value
543
+ when String, Symbol, Numeric, true, false then value.to_s
544
+ when Time then value.iso8601
545
+ else
546
+ return value.iso8601 if value.respond_to?(:iso8601)
547
+
548
+ raise ArgumentError, "query parameter #{key} must be a String, Numeric, boolean, Array or Hash"
549
+ end
550
+ end
551
+
552
+ def segment(value)
553
+ text = value.to_s
554
+ raise ArgumentError, "expected an id, got #{value.inspect}" if text.empty? || text == "." || text == ".."
555
+
556
+ URI.encode_uri_component(text)
557
+ end
558
+
559
+ def operation_path(operation)
560
+ parts = operation.to_s.sub(%r{\A/+}, "").split("/")
561
+ raise ArgumentError, "expected an operation such as \"customers\"" if parts.empty?
562
+
563
+ parts.map { |part| segment(part) }.join("/")
564
+ end
565
+
566
+ def key_for(idempotency_key)
567
+ key = idempotency_key.nil? ? SecureRandom.uuid : idempotency_key.to_s
568
+ raise ArgumentError, "idempotency_key can't be empty" if key.empty?
569
+
570
+ key
571
+ end
572
+
573
+ def compact(hash)
574
+ hash.compact
575
+ end
576
+ end
577
+ end
578
+
579
+ require_relative "operations"