hcbv4 0.1.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.
@@ -0,0 +1,618 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "oauth2"
5
+ require "json"
6
+
7
+ module HCBV4
8
+ class Client
9
+ DEFAULT_BASE_URL = "https://hcb.hackclub.com"
10
+ API_PATH = "/api/v4"
11
+
12
+ attr_reader :oauth_token, :base_url
13
+
14
+ def initialize(oauth_token:, base_url: DEFAULT_BASE_URL)
15
+ @oauth_token = oauth_token
16
+ @base_url = base_url
17
+ end
18
+
19
+ def self.from_credentials(client_id:, client_secret:, access_token:, refresh_token:, expires_at: nil,
20
+ base_url: DEFAULT_BASE_URL)
21
+ oauth_client = OAuth2::Client.new(
22
+ client_id,
23
+ client_secret,
24
+ site: base_url,
25
+ token_url: "/oauth/token"
26
+ )
27
+
28
+ token = OAuth2::AccessToken.new(
29
+ oauth_client,
30
+ access_token,
31
+ refresh_token:,
32
+ expires_at:
33
+ )
34
+
35
+ new(oauth_token: token, base_url:)
36
+ end
37
+
38
+ # ─────────────────────────────────────────────────────────────────────────
39
+ # Users
40
+ # ─────────────────────────────────────────────────────────────────────────
41
+
42
+ # Returns the currently authenticated user.
43
+ # @return [User]
44
+ def me
45
+ User.from_hash(get("/user"))
46
+ end
47
+
48
+ # Returns a user by id. Admin only.
49
+ # @param id [String] user id
50
+ # @return [User]
51
+ def user(id)
52
+ User.from_hash(get("/users/#{id}"))
53
+ end
54
+
55
+ # Returns a user by email address. Admin only.
56
+ # @param email [String]
57
+ # @return [User]
58
+ def user_by_email(email)
59
+ User.from_hash(get("/users/by_email/#{email}"))
60
+ end
61
+
62
+ # Returns profile icons the current user can select from.
63
+ # @return [Array<Hash>] icon objects with :id, :url, :name
64
+ def available_icons
65
+ get("/user/available_icons")
66
+ end
67
+
68
+ # Returns beacon configuration for the current user.
69
+ # @return [Hash]
70
+ def beacon_config
71
+ get("/user/beacon_config")
72
+ end
73
+
74
+ # ─────────────────────────────────────────────────────────────────────────
75
+ # Organizations (Events)
76
+ # ─────────────────────────────────────────────────────────────────────────
77
+
78
+ # Returns all organizations the current user belongs to.
79
+ # @param expand [Array<Symbol>] fields to expand (:balance_cents, :reporting, :account_number, :users)
80
+ # @return [Array<Organization>]
81
+ def organizations(expand: [])
82
+ params = { expand: expand.join(",") }.compact.reject { |_, v| v.respond_to?(:empty?) && v.empty? }
83
+ get("/user/organizations", params).map { |h| Organization.from_hash(h, client: self) }
84
+ end
85
+
86
+ # Returns an organization by id or slug (fetches from API).
87
+ # @param id_or_slug [String]
88
+ # @param expand [Array<Symbol>] fields to expand
89
+ # @return [Organization]
90
+ def organization(id_or_slug, expand: [])
91
+ params = { expand: expand.join(",") }.compact.reject { |_, v| v.respond_to?(:empty?) && v.empty? }
92
+ Organization.from_hash(get("/organizations/#{id_or_slug}", params), client: self)
93
+ end
94
+
95
+ # Stub methods for performing actions without fetching.
96
+ # @!method organization!(id)
97
+ # @!method card_grant!(id)
98
+ # @!method stripe_card!(id)
99
+ # @!method transaction!(id)
100
+ # @!method sponsor!(id)
101
+ # @!method invoice!(id)
102
+ # @!method invitation!(id)
103
+ # @!method receipt!(id)
104
+ %i[Organization CardGrant StripeCard Transaction Sponsor Invoice Invitation Receipt].each do |klass|
105
+ define_method(:"#{klass.to_s.gsub(/([a-z])([A-Z])/, '\1_\2').downcase}!") do |id|
106
+ HCBV4.const_get(klass).of_id(id, client: self)
107
+ end
108
+ end
109
+
110
+ # ─────────────────────────────────────────────────────────────────────────
111
+ # Transactions
112
+ # ─────────────────────────────────────────────────────────────────────────
113
+
114
+ # Returns paginated transactions for an organization.
115
+ # @param organization_id_or_slug [String]
116
+ # @param limit [Integer, nil] max results per page
117
+ # @param after [String, nil] cursor for pagination (transaction id)
118
+ # @param type [String, nil] filter by transaction type
119
+ # @param filters [Hash] additional filters
120
+ # @param expand [Array<Symbol>] fields to expand (:organization)
121
+ # @return [TransactionList]
122
+ def transactions(organization_id_or_slug, limit: nil, after: nil, type: nil, filters: {}, expand: [])
123
+ params = { limit:, after:, type:, filters:, expand: expand.join(",") }.compact.reject do |_, v|
124
+ v.respond_to?(:empty?) && v.empty?
125
+ end
126
+ pagination_context = {
127
+ type: :organization_transactions,
128
+ organization_id: organization_id_or_slug,
129
+ limit:,
130
+ tx_type: type,
131
+ filters:,
132
+ expand:
133
+ }
134
+ TransactionList.from_hash(get("/organizations/#{organization_id_or_slug}/transactions", params), client: self,
135
+ pagination_context:)
136
+ end
137
+
138
+ # Returns a transaction by id.
139
+ # @param id [String]
140
+ # @param event_id [String, nil] organization id for scoping
141
+ # @param expand [Array<Symbol>] fields to expand
142
+ # @return [Transaction]
143
+ def transaction(id, event_id: nil, expand: [])
144
+ params = { event_id:, expand: expand.join(",") }.compact.reject { |_, v| v.respond_to?(:empty?) && v.empty? }
145
+ Transaction.from_hash(get("/transactions/#{id}", params), client: self)
146
+ end
147
+
148
+ # Returns transactions missing receipts across all orgs.
149
+ # @param limit [Integer, nil] max results per page
150
+ # @param after [String, nil] cursor for pagination
151
+ # @return [TransactionList]
152
+ def missing_receipt_transactions(limit: nil, after: nil)
153
+ params = { limit:, after: }.compact
154
+ pagination_context = { type: :missing_receipt, limit: }
155
+ TransactionList.from_hash(get("/user/transactions/missing_receipt", params), client: self, pagination_context:)
156
+ end
157
+
158
+ # ─────────────────────────────────────────────────────────────────────────
159
+ # Card Grants
160
+ # ─────────────────────────────────────────────────────────────────────────
161
+
162
+ # Returns card grants for the current user.
163
+ # @param expand [Array<Symbol>] fields to expand (:user, :organization, :balance_cents, :disbursements)
164
+ # @return [Array<CardGrant>]
165
+ def card_grants(expand: [])
166
+ params = { expand: expand.join(",") }.compact.reject { |_, v| v.respond_to?(:empty?) && v.empty? }
167
+ get("/user/card_grants", params).map { |h| CardGrant.from_hash(h, client: self) }
168
+ end
169
+
170
+ # Returns card grants for a specific organization.
171
+ # @param organization_id [String]
172
+ # @param expand [Array<Symbol>] fields to expand
173
+ # @return [Array<CardGrant>]
174
+ def organization_card_grants(organization_id, expand: [])
175
+ params = { expand: expand.join(",") }.compact.reject { |_, v| v.respond_to?(:empty?) && v.empty? }
176
+ get("/organizations/#{organization_id}/card_grants", params).map { |h| CardGrant.from_hash(h, client: self) }
177
+ end
178
+
179
+ # Returns a card grant by id.
180
+ # @param id [String]
181
+ # @param expand [Array<Symbol>] fields to expand
182
+ # @return [CardGrant]
183
+ def card_grant(id, expand: [])
184
+ params = { expand: expand.join(",") }.compact.reject { |_, v| v.respond_to?(:empty?) && v.empty? }
185
+ CardGrant.from_hash(get("/card_grants/#{id}", params), client: self)
186
+ end
187
+
188
+ # Creates a new card grant (virtual card with spending limits).
189
+ # @param event_id [String] organization id
190
+ # @param amount_cents [Integer] initial funding amount
191
+ # @param email [String] recipient email
192
+ # @param options [Hash] additional options (purpose, merchant_lock, one_time_use, etc.)
193
+ # @return [CardGrant]
194
+ def create_card_grant(event_id:, amount_cents:, email:, **options)
195
+ body = { amount_cents:, email:, **options }
196
+ CardGrant.from_hash(post("/organizations/#{event_id}/card_grants", body), client: self)
197
+ end
198
+
199
+ # ─────────────────────────────────────────────────────────────────────────
200
+ # Stripe Cards
201
+ # ─────────────────────────────────────────────────────────────────────────
202
+
203
+ # Returns stripe cards for the current user.
204
+ # @param expand [Array<Symbol>] fields to expand (:user, :organization, :total_spent_cents, :balance_available)
205
+ # @return [Array<StripeCard>]
206
+ def stripe_cards(expand: [])
207
+ params = { expand: expand.join(",") }.compact.reject { |_, v| v.respond_to?(:empty?) && v.empty? }
208
+ get("/user/cards", params).map { |h| StripeCard.from_hash(h, client: self) }
209
+ end
210
+
211
+ # Returns stripe cards for a specific organization.
212
+ # @param organization_id [String]
213
+ # @param expand [Array<Symbol>] fields to expand
214
+ # @return [Array<StripeCard>]
215
+ def organization_stripe_cards(organization_id, expand: [])
216
+ params = { expand: expand.join(",") }.compact.reject { |_, v| v.respond_to?(:empty?) && v.empty? }
217
+ get("/organizations/#{organization_id}/cards", params).map { |h| StripeCard.from_hash(h, client: self) }
218
+ end
219
+
220
+ # Returns a stripe card by id.
221
+ # @param id [String]
222
+ # @param expand [Array<Symbol>] fields to expand
223
+ # @return [StripeCard]
224
+ def stripe_card(id, expand: [])
225
+ params = { expand: expand.join(",") }.compact.reject { |_, v| v.respond_to?(:empty?) && v.empty? }
226
+ StripeCard.from_hash(get("/cards/#{id}", params), client: self)
227
+ end
228
+
229
+ # Creates a new stripe card (virtual or physical).
230
+ # @param organization_id [String]
231
+ # @param card_type [String] "virtual" or "physical"
232
+ # @param options [Hash] additional options (name, design_id, shipping address, etc.)
233
+ # @return [StripeCard]
234
+ def create_stripe_card(organization_id:, card_type:, **options)
235
+ body = { card: { organization_id:, card_type:, **options } }
236
+ StripeCard.from_hash(post("/cards", body), client: self)
237
+ end
238
+
239
+ # Returns available card designs for physical card personalization.
240
+ # @param event_id [String, nil] organization id to filter designs
241
+ # @return [Array<CardDesign>] available designs
242
+ def card_designs(event_id: nil)
243
+ params = event_id ? { event_id: } : {}
244
+ get("/cards/card_designs", params).map { |h| CardDesign.from_hash(h) }
245
+ end
246
+
247
+ # ─────────────────────────────────────────────────────────────────────────
248
+ # Invoices
249
+ # ─────────────────────────────────────────────────────────────────────────
250
+
251
+ # Returns invoices for an organization.
252
+ # @param event_id [String] organization id
253
+ # @return [Array<Invoice>]
254
+ def invoices(event_id:)
255
+ get("/organizations/#{event_id}/invoices").map { |h| Invoice.from_hash(h, client: self) }
256
+ end
257
+
258
+ # Returns an invoice by id.
259
+ # @param id [String]
260
+ # @return [Invoice]
261
+ def invoice(id)
262
+ Invoice.from_hash(get("/invoices/#{id}"), client: self)
263
+ end
264
+
265
+ # Creates an invoice for a sponsor.
266
+ # @param event_id [String] organization id
267
+ # @param sponsor_id [String]
268
+ # @param due_date [String] ISO date (YYYY-MM-DD)
269
+ # @param item_description [String]
270
+ # @param item_amount [Integer] amount in cents
271
+ # @return [Invoice]
272
+ def create_invoice(event_id:, sponsor_id:, due_date:, item_description:, item_amount:)
273
+ body = { organization_id: event_id, sponsor_id:, invoice: { due_date:, item_description:, item_amount: } }
274
+ Invoice.from_hash(post("/invoices", body), client: self)
275
+ end
276
+
277
+ # ─────────────────────────────────────────────────────────────────────────
278
+ # Sponsors
279
+ # ─────────────────────────────────────────────────────────────────────────
280
+
281
+ # Returns sponsors for an organization.
282
+ # @param event_id [String] organization id
283
+ # @return [Array<Sponsor>]
284
+ def sponsors(event_id:)
285
+ get("/organizations/#{event_id}/sponsors").map { |h| Sponsor.from_hash(h, client: self) }
286
+ end
287
+
288
+ # Returns a sponsor by id.
289
+ # @param id [String]
290
+ # @return [Sponsor]
291
+ def sponsor(id)
292
+ Sponsor.from_hash(get("/sponsors/#{id}"), client: self)
293
+ end
294
+
295
+ # Creates a sponsor for invoicing.
296
+ # @param event_id [String] organization id
297
+ # @param name [String] sponsor/company name
298
+ # @param contact_email [String] billing email
299
+ # @param address [Hash] address fields (address_line1, address_city, address_state, etc.)
300
+ # @return [Sponsor]
301
+ def create_sponsor(event_id:, name:, contact_email:, **address)
302
+ body = { organization_id: event_id, sponsor: { name:, contact_email:, **address } }
303
+ Sponsor.from_hash(post("/sponsors", body), client: self)
304
+ end
305
+
306
+ # ─────────────────────────────────────────────────────────────────────────
307
+ # Disbursements (Transfers)
308
+ # ─────────────────────────────────────────────────────────────────────────
309
+
310
+ # Transfers funds from one organization to another.
311
+ # @param event_id [String] source organization id
312
+ # @param to_organization_id [String] destination organization id
313
+ # @param amount_cents [Integer]
314
+ # @param name [String] transfer description
315
+ # @return [Transfer]
316
+ def create_disbursement(event_id:, to_organization_id:, amount_cents:, name:)
317
+ body = { to_organization_id:, amount_cents:, name: }
318
+ organization = Organization.of_id(event_id, client: self)
319
+ Transfer.from_hash(post("/organizations/#{event_id}/transfers", body), client: self, organization:)
320
+ end
321
+
322
+ # ─────────────────────────────────────────────────────────────────────────
323
+ # ACH Transfers
324
+ # ─────────────────────────────────────────────────────────────────────────
325
+
326
+ # Initiates an ACH bank transfer.
327
+ # @param event_id [String] organization id
328
+ # @param routing_number [String] 9-digit ABA routing number
329
+ # @param account_number [String] bank account number
330
+ # @param recipient_name [String] name on the bank account
331
+ # @param amount_money [String] amount as decimal string (e.g., "100.00")
332
+ # @param payment_for [String] payment description/memo
333
+ # @param options [Hash] additional options
334
+ # @return [Hash]
335
+ def create_ach_transfer(event_id:, routing_number:, account_number:, recipient_name:, amount_money:, payment_for:,
336
+ **options)
337
+ body = {
338
+ ach_transfer: {
339
+ routing_number:, account_number:, recipient_name:, amount_money:, payment_for:, **options
340
+ }
341
+ }
342
+ post("/organizations/#{event_id}/ach_transfers", body)
343
+ end
344
+
345
+ # ─────────────────────────────────────────────────────────────────────────
346
+ # Donations
347
+ # ─────────────────────────────────────────────────────────────────────────
348
+
349
+ # Records an in-person donation (cash, check, etc.).
350
+ # @param event_id [String] organization id
351
+ # @param amount_cents [Integer]
352
+ # @param options [Hash] donor info (name, email, anonymous, tax_deductible, fee_covered)
353
+ # @return [Donation] returns only the donation id
354
+ def create_donation(event_id:, amount_cents:, **options)
355
+ body = { amount_cents:, **options }
356
+ Donation.from_hash(post("/organizations/#{event_id}/donations", body))
357
+ end
358
+
359
+ # ─────────────────────────────────────────────────────────────────────────
360
+ # Receipts
361
+ # ─────────────────────────────────────────────────────────────────────────
362
+
363
+ # Returns receipts, optionally filtered by transaction.
364
+ # @param transaction_id [String, nil] filter to a specific transaction
365
+ # @return [Array<Receipt>]
366
+ def receipts(transaction_id: nil)
367
+ params = transaction_id ? { transaction_id: } : {}
368
+ get("/receipts", params).map { |h| Receipt.from_hash(h, client: self) }
369
+ end
370
+
371
+ # Uploads a receipt image.
372
+ # @param file [File] receipt file
373
+ # @param transaction_id [String, nil] attach to a transaction, or nil for receipt bin
374
+ # @return [Receipt]
375
+ def create_receipt(file:, transaction_id: nil)
376
+ body = { file:, transaction_id: }.compact
377
+ Receipt.from_hash(post("/receipts", body), client: self)
378
+ end
379
+
380
+ # ─────────────────────────────────────────────────────────────────────────
381
+ # Invitations
382
+ # ─────────────────────────────────────────────────────────────────────────
383
+
384
+ # Returns pending invitations for the current user.
385
+ # @return [Array<Invitation>]
386
+ def invitations
387
+ get("/user/invitations").map { |h| Invitation.from_hash(h, client: self) }
388
+ end
389
+
390
+ # Returns an invitation by id.
391
+ # @param id [String]
392
+ # @return [Invitation]
393
+ def invitation(id)
394
+ Invitation.from_hash(get("/user/invitations/#{id}"), client: self)
395
+ end
396
+
397
+ # Invites a user to join an organization.
398
+ # @param event_id [String] organization id
399
+ # @param email [String] invitee email
400
+ # @param role [String, nil] "member" or "manager"
401
+ # @param enable_spending_controls [Boolean, nil] enable spending controls
402
+ # @param initial_control_allowance_amount [Integer, nil] initial allowance in cents
403
+ # @return [Invitation]
404
+ def create_invitation(event_id:, email:, role: nil, enable_spending_controls: nil,
405
+ initial_control_allowance_amount: nil)
406
+ body = { event_id:, email:, role:, enable_spending_controls:, initial_control_allowance_amount: }.compact
407
+ Invitation.from_hash(post("/user/invitations", body), client: self)
408
+ end
409
+
410
+ # ─────────────────────────────────────────────────────────────────────────
411
+ # Stripe Terminal
412
+ # ─────────────────────────────────────────────────────────────────────────
413
+
414
+ # Returns a connection token for Stripe Terminal hardware.
415
+ # @return [Hash] token data
416
+ def terminal_connection_token
417
+ get("/stripe_terminal_connection_token")
418
+ end
419
+
420
+ # ─────────────────────────────────────────────────────────────────────────
421
+ # Raw HTTP methods
422
+ # ─────────────────────────────────────────────────────────────────────────
423
+
424
+ def get(path, params = {})
425
+ request(:get, path, params:)
426
+ end
427
+
428
+ def post(path, body = {})
429
+ request(:post, path, body:)
430
+ end
431
+
432
+ def patch(path, body = {})
433
+ request(:patch, path, body:)
434
+ end
435
+
436
+ def delete(path)
437
+ request(:delete, path)
438
+ end
439
+
440
+ # ─────────────────────────────────────────────────────────────────────────
441
+ # Resource instance methods (called via resource objects)
442
+ # ─────────────────────────────────────────────────────────────────────────
443
+
444
+ # @!visibility private
445
+ def update_card_grant(id, **attributes)
446
+ CardGrant.from_hash(patch("/card_grants/#{id}", attributes), client: self)
447
+ end
448
+
449
+ # @!visibility private
450
+ def topup_card_grant(id, amount_cents:)
451
+ CardGrant.from_hash(post("/card_grants/#{id}/topup", { amount_cents: }), client: self)
452
+ end
453
+
454
+ # @!visibility private
455
+ def withdraw_card_grant(id, amount_cents:)
456
+ CardGrant.from_hash(post("/card_grants/#{id}/withdraw", { amount_cents: }), client: self)
457
+ end
458
+
459
+ # @!visibility private
460
+ def cancel_card_grant(id)
461
+ CardGrant.from_hash(post("/card_grants/#{id}/cancel"), client: self)
462
+ end
463
+
464
+ # @!visibility private
465
+ def activate_card_grant(id)
466
+ CardGrant.from_hash(post("/card_grants/#{id}/activate"), client: self)
467
+ end
468
+
469
+ # @!visibility private
470
+ def update_stripe_card(id, status:, last4: nil)
471
+ params = { status:, last4: }.compact
472
+ StripeCard.from_hash(patch("/cards/#{id}", params), client: self)
473
+ end
474
+
475
+ # @!visibility private
476
+ def cancel_stripe_card(id)
477
+ post("/cards/#{id}/cancel")
478
+ end
479
+
480
+ # @!visibility private
481
+ def stripe_card_ephemeral_keys(id, nonce:, stripe_version: nil)
482
+ params = { nonce:, stripe_version: }.compact
483
+ get("/cards/#{id}/ephemeral_keys", params)
484
+ end
485
+
486
+ # @!visibility private
487
+ def stripe_card_transactions(id, limit: nil, after: nil, missing_receipts: nil)
488
+ params = { limit:, after:, missing_receipts: }.compact
489
+ pagination_context = { type: :stripe_card_transactions, card_id: id, limit:, missing_receipts: }
490
+ TransactionList.from_hash(get("/cards/#{id}/transactions", params), client: self, pagination_context:)
491
+ end
492
+
493
+ # @!visibility private
494
+ def accept_invitation(id)
495
+ Invitation.from_hash(post("/user/invitations/#{id}/accept"), client: self)
496
+ end
497
+
498
+ # @!visibility private
499
+ def reject_invitation(id)
500
+ Invitation.from_hash(post("/user/invitations/#{id}/reject"), client: self)
501
+ end
502
+
503
+ # @!visibility private
504
+ def delete_receipt(id)
505
+ delete("/receipts/#{id}")
506
+ end
507
+
508
+ # @!visibility private
509
+ def update_transaction(id, event_id:, memo:)
510
+ Transaction.from_hash(patch("/organizations/#{event_id}/transactions/#{id}", { memo: }), client: self)
511
+ end
512
+
513
+ # @!visibility private
514
+ def memo_suggestions(event_id:, transaction_id:)
515
+ get("/organizations/#{event_id}/transactions/#{transaction_id}/memo_suggestions")
516
+ end
517
+
518
+ # @!visibility private
519
+ def organization_followers(id_or_slug)
520
+ data = get("/organizations/#{id_or_slug}/followers")
521
+ data["followers"]&.map { |h| User.from_hash(h) } || []
522
+ end
523
+
524
+ # @!visibility private
525
+ def sub_organizations(id_or_slug)
526
+ get("/organizations/#{id_or_slug}/sub_organizations").map { |h| Organization.from_hash(h, client: self) }
527
+ end
528
+
529
+ # @!visibility private
530
+ def create_sub_organization(parent_id_or_slug, name:, email:, cosigner_email: nil, country: nil, scoped_tags: nil)
531
+ body = { name:, email:, cosigner_email:, country:, scoped_tags: }.compact
532
+ Organization.from_hash(post("/organizations/#{parent_id_or_slug}/sub_organizations", body), client: self)
533
+ end
534
+
535
+ # @!visibility private
536
+ def comments(organization_id:, transaction_id:)
537
+ get("/organizations/#{organization_id}/transactions/#{transaction_id}/comments").map { |h| Comment.from_hash(h) }
538
+ end
539
+
540
+ # @!visibility private
541
+ def create_comment(organization_id:, transaction_id:, content:, admin_only: false, file: nil)
542
+ body = { content:, admin_only:, file: }.compact
543
+ Comment.from_hash(post("/organizations/#{organization_id}/transactions/#{transaction_id}/comments", body))
544
+ end
545
+
546
+ private
547
+
548
+ def request(method, path, params: nil, body: nil)
549
+ refresh_token_if_needed!
550
+
551
+ response = connection.public_send(method, "#{API_PATH}#{path}") do |req|
552
+ req.params = params if params
553
+ req.body = body.to_json if body
554
+ req.headers["Authorization"] = "Bearer #{oauth_token.token}"
555
+ end
556
+
557
+ handle_response(response)
558
+ end
559
+
560
+ def refresh_token_if_needed!
561
+ return unless oauth_token.respond_to?(:expired?)
562
+ return unless oauth_token.expired?
563
+
564
+ @oauth_token = oauth_token.refresh!
565
+ end
566
+
567
+ def connection
568
+ @connection ||= Faraday.new(url: base_url) do |f|
569
+ f.request :json
570
+ f.response :json
571
+ f.headers["Content-Type"] = "application/json"
572
+ f.headers["Accept"] = "application/json"
573
+ end
574
+ end
575
+
576
+ def handle_response(response)
577
+ case response.status
578
+ when 200..299
579
+ response.body
580
+ when 400
581
+ raise_api_error(response, BadRequestError)
582
+ when 401
583
+ raise_api_error(response, UnauthorizedError)
584
+ when 403
585
+ raise_api_error(response, ForbiddenError)
586
+ when 404
587
+ raise_api_error(response, NotFoundError)
588
+ when 422
589
+ raise_api_error(response, UnprocessableEntityError)
590
+ when 429
591
+ raise_api_error(response, RateLimitError)
592
+ when 500..599
593
+ raise_api_error(response, ServerError)
594
+ else
595
+ raise_api_error(response, APIError)
596
+ end
597
+ end
598
+
599
+ def raise_api_error(response, error_class)
600
+ body = response.body || {}
601
+ error_code = body["error"]
602
+ messages = Array(body["messages"])
603
+
604
+ error_class = case error_code
605
+ when "invalid_operation" then InvalidOperationError
606
+ when "invalid_user" then InvalidUserError
607
+ else error_class
608
+ end
609
+
610
+ raise error_class.new(
611
+ messages.first || body["error"] || "API error",
612
+ status: response.status,
613
+ error_code:,
614
+ messages:
615
+ )
616
+ end
617
+ end
618
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HCBV4
4
+ class Error < StandardError
5
+ attr_reader :messages
6
+
7
+ def initialize(message = nil, messages: [])
8
+ @messages = messages
9
+ super(message || messages.first)
10
+ end
11
+ end
12
+
13
+ class AuthenticationError < Error; end
14
+ class TokenExpiredError < AuthenticationError; end
15
+ class TokenRevokedError < AuthenticationError; end
16
+ class InvalidScopeError < AuthenticationError; end
17
+
18
+ class APIError < Error
19
+ attr_reader :status, :error_code
20
+
21
+ def initialize(message = nil, status: nil, error_code: nil, messages: [])
22
+ @status = status
23
+ @error_code = error_code
24
+ super(message, messages:)
25
+ end
26
+ end
27
+
28
+ class BadRequestError < APIError; end
29
+ class UnauthorizedError < APIError; end
30
+ class ForbiddenError < APIError; end
31
+ class NotFoundError < APIError; end
32
+ class UnprocessableEntityError < APIError; end
33
+ class RateLimitError < APIError; end
34
+ class ServerError < APIError; end
35
+
36
+ class InvalidOperationError < APIError; end
37
+ class InvalidUserError < APIError; end
38
+ end