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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e0cb83ef3bb6adbedbad8e6671d7bacea19c0602c8f36672651484a8216e28b6
4
+ data.tar.gz: e5ea1d71c14fae2aaaad3966b27fb6ad3fcabf87b1871d09410584354e470ef0
5
+ SHA512:
6
+ metadata.gz: 8409cb00d02bf2fad991c697157bdcaf3bca1f873beac81b1c96adc39b0bf4497f2071cc3a576da59f5696b6df51a4552b2ce112bb4e1c4273985d0324752844
7
+ data.tar.gz: d6268de4958175d1f6d9307c96fe131f018353c67fdb08701b83fd7b33260db23858e7795994c6afcbdf6195369a854308ca33523c4e01a1acfbee4bc5b9a2fc
data/README.md ADDED
@@ -0,0 +1,579 @@
1
+ # hcbv4
2
+
3
+ an unofficial Ruby client for the [HCB](https://hackclub.com/hcb/) v4 API.
4
+
5
+ this gem handles OAuth2 token refresh, cursor-based pagination, and wraps API responses in immutable `Data` objects with chainable methods.
6
+
7
+ ## installation
8
+
9
+ ```ruby
10
+ gem "hcbv4"
11
+ ```
12
+
13
+ requires Ruby 3.2+.
14
+
15
+ ## authentication
16
+
17
+ the HCB API uses OAuth2 for authentication. you'll need to get an admin to register an OAuth application to get a `client_id` and `client_secret`, then implement the standard OAuth2 authorization code flow to obtain tokens.
18
+
19
+ ### creating a client from credentials
20
+
21
+ once you have tokens from your OAuth flow:
22
+
23
+ ```ruby
24
+ client = HCBV4::Client.from_credentials(
25
+ client_id: ENV["HCB_CLIENT_ID"],
26
+ client_secret: ENV["HCB_CLIENT_SECRET"],
27
+ access_token: "...",
28
+ refresh_token: "...",
29
+ expires_at: 1734567890 # unix timestamp, optional
30
+ )
31
+ ```
32
+
33
+ the client automatically refreshes expired tokens before each request. after any API call, you can grab the (possibly refreshed) tokens to persist them:
34
+
35
+ ```ruby
36
+ token = client.oauth_token
37
+ save_to_database(
38
+ access_token: token.token,
39
+ refresh_token: token.refresh_token,
40
+ expires_at: token.expires_at
41
+ )
42
+ ```
43
+
44
+ ### using a pre-built token
45
+
46
+ if you're managing the `OAuth2::AccessToken` lifecycle yourself:
47
+
48
+ ```ruby
49
+ client = HCBV4::Client.new(oauth_token: your_oauth2_token)
50
+ ```
51
+
52
+ ### custom base URL
53
+
54
+ you'll probably want to test your integrations far away from real dollars!
55
+
56
+ ```ruby
57
+ client = HCBV4::Client.from_credentials(
58
+ # ...credentials...
59
+ base_url: "http://localhost:300"
60
+ )
61
+ ```
62
+
63
+ ## basic usage
64
+
65
+ ```ruby
66
+ # get the authenticated user
67
+ me = client.me
68
+ puts "logged in as #{me.name} (#{me.email})"
69
+
70
+ # list organizations the user belongs to
71
+ orgs = client.organizations(expand: [:balance_cents, :users])
72
+ orgs.each do |org|
73
+ puts "#{org.name}: $#{org.balance_cents / 100.0}"
74
+ org.users&.each { |u| puts " - #{u.name} (#{u.role})" }
75
+ end
76
+
77
+ # fetch a specific organization by ID or slug
78
+ org = client.organization("hq", expand: [:balance_cents, :account_number])
79
+ puts "balance: $#{org.balance_cents / 100.0}"
80
+ puts "account: #{org.routing_number} / #{org.account_number}"
81
+ ```
82
+
83
+ ## pagination
84
+
85
+ endpoints that return lists use cursor-based pagination. the gem returns a `TransactionList` that includes pagination state:
86
+
87
+ ```ruby
88
+ # fetch the first page
89
+ txs = client.transactions("my-org", limit: 50)
90
+
91
+ # iterate through all pages
92
+ loop do
93
+ txs.each do |tx|
94
+ puts "#{tx.date}: #{tx.memo} (#{tx.amount_cents})"
95
+ end
96
+
97
+ break unless txs.has_more?
98
+ txs = txs.next_page
99
+ end
100
+ ```
101
+
102
+ the pagination context (filters, expand options, etc.) is preserved across pages, so `next_page` returns consistent results.
103
+
104
+ ## stub resources
105
+
106
+ sometimes you already have a resource ID and want to call methods on it without fetching the full object first. this is common when handling webhooks or building UIs where the ID comes from user input.
107
+
108
+ the bang methods create a "stub" resource with just the ID and client attached:
109
+
110
+ ```ruby
111
+ # instead of this (makes an API call):
112
+ org = client.organization("org_xxx")
113
+ org.create_card_grant(amount_cents: 5000, email: "nora@hackclub.com")
114
+
115
+ # you can do this (no fetch, just action):
116
+ org = client.organization!("org_xxx")
117
+ org.create_card_grant(amount_cents: 5000, email: "nora@hackclub.com")
118
+ ```
119
+
120
+ the stub has `nil` for all attributes except `id`, but action methods work fine because they only need the ID. if you need the actual data later, call `reload!`:
121
+
122
+ ```ruby
123
+ org = client.organization!("org_xxx")
124
+ org.name # => nil
125
+ org = org.reload!
126
+ org.name # => "My Hackathon"
127
+ ```
128
+
129
+ available stub methods: `organization!`, `card_grant!`, `stripe_card!`, `transaction!`, `sponsor!`, `invoice!`, `invitation!`, `receipt!`.
130
+
131
+ ## expand parameters
132
+
133
+ many endpoints accept an `expand:` parameter to include related data in a single request. without expansion, related fields are `nil`:
134
+
135
+ ```ruby
136
+ # without expand - users not included
137
+ org = client.organization("my-org")
138
+ org.users # => nil
139
+
140
+ # with expand - users embedded in response
141
+ org = client.organization("my-org", expand: [:users, :balance_cents])
142
+ org.users # => [#<HCBV4::OrganizationUser ...>, ...]
143
+ org.balance_cents # => xxx4500
144
+ ```
145
+
146
+ common expand options by endpoint:
147
+
148
+ | endpoint | expand options |
149
+ |----------|----------------|
150
+ | `organizations` | `:balance_cents`, `:reporting`, `:account_number`, `:users` |
151
+ | `card_grants` | `:user`, `:organization`, `:balance_cents`, `:disbursements` |
152
+ | `stripe_cards` | `:user`, `:organization`, `:total_spent_cents`, `:balance_available` |
153
+ | `transactions` | `:organization` |
154
+
155
+ ## API reference
156
+
157
+ ### users
158
+
159
+ ```ruby
160
+ client.me # current authenticated user
161
+ client.user("usr_xxx") # user by ID (admin only)
162
+ client.user_by_email("x@y.com") # user by email (admin only)
163
+ ```
164
+
165
+ ### organizations
166
+
167
+ organizations (also called "events" in some API contexts) are the core unit. they hold funds, issue cards, and track transactions.
168
+
169
+ ```ruby
170
+ client.organizations(expand: [...]) # all orgs for current user
171
+ client.organization("org_or_slug", expand: [...]) # single org by ID or slug
172
+
173
+ # from an organization object:
174
+ org.transactions(limit: 100, type: "card_charge")
175
+ org.card_grants(expand: [:balance_cents])
176
+ org.stripe_cards(expand: [:total_spent_cents])
177
+ org.invoices
178
+ org.sponsors
179
+ org.followers
180
+ org.sub_organizations
181
+
182
+ # creating resources under an org:
183
+ org.create_card_grant(
184
+ amount_cents: 10000,
185
+ email: "recipient@example.com",
186
+ purpose: "travel expenses",
187
+ merchant_lock: true,
188
+ allowed_merchants: ["uber", "lyft"]
189
+ )
190
+
191
+ org.create_stripe_card(card_type: "virtual")
192
+ org.create_stripe_card(
193
+ card_type: "physical",
194
+ design_id: "des_xxx",
195
+ shipping_name: "Jane Doe",
196
+ shipping_address_line1: "xxx Main St",
197
+ shipping_address_city: "San Francisco",
198
+ shipping_address_state: "CA",
199
+ shipping_address_postal_code: "94102",
200
+ shipping_address_country: "US"
201
+ )
202
+
203
+ org.create_sponsor(
204
+ name: "Acme Corp",
205
+ contact_email: "billing@acme.com",
206
+ address_line1: "456 Corporate Blvd",
207
+ address_city: "New York",
208
+ address_state: "NY",
209
+ address_postal_code: "10001"
210
+ )
211
+
212
+ org.create_invoice(
213
+ sponsor_id: "sp_xxx",
214
+ due_date: "2025-12-01",
215
+ item_description: "Gold sponsorship",
216
+ item_amount: 500000 # $5,000 in cents
217
+ )
218
+
219
+ # or, directly on the sponsor:
220
+
221
+ sponsor.create_invoice(
222
+ due_date: "2025-12-01",
223
+ item_description: "Gold sponsorship",
224
+ item_amount: 500000 # $5,000 in cents
225
+ )
226
+
227
+ org.create_disbursement(
228
+ to_organization_id: "hq",
229
+ amount_cents: 50000,
230
+ name: "i just want them to have some walkin' around money!"
231
+ )
232
+
233
+ org.create_ach_transfer(
234
+ routing_number: "xxx456789",
235
+ account_number: "987654321",
236
+ recipient_name: "Acme Corp",
237
+ amount_money: "150.00", # string with decimal for some reason? thanks engr, very cool
238
+ payment_for: "widgets. lots of widgets."
239
+ )
240
+
241
+ org.create_invitation(
242
+ email: "newmember@example.com",
243
+ role: "manager", # or "member"
244
+ enable_spending_controls: true,
245
+ initial_control_allowance_amount: 50000
246
+ )
247
+
248
+ org.create_sub_organization(
249
+ name: "hackathon-travel-team",
250
+ email: "travel-lead@example.com"
251
+ )
252
+ ```
253
+
254
+ ### transactions
255
+
256
+ transactions represent money moving in or out of an organization. each transaction has a `type` indicating what kind it is, and a corresponding detail object.
257
+
258
+ ```ruby
259
+ client.transactions("org_id", limit: 100, type: "card_charge")
260
+ client.transaction("txn_xxx", expand: [:organization])
261
+ client.missing_receipt_transactions(limit: 50) # across all orgs
262
+
263
+ tx = client.transaction("txn_xxx")
264
+
265
+ # type detection
266
+ tx.type # => :card_charge, :donation, :transfer, :ach_transfer, :check, :invoice, :expense_payout, :check_deposit
267
+ tx.details # => the type-specific object
268
+
269
+ # for card charges, access merchant info:
270
+ if tx.type == :card_charge
271
+ charge = tx.card_charge
272
+ puts "#{charge.merchant.name} - #{charge.merchant.city}, #{charge.merchant.state}"
273
+ end
274
+
275
+ # status checks
276
+ tx.pending?
277
+ tx.declined?
278
+ tx.missing_receipt?
279
+ tx.has_custom_memo?
280
+
281
+ # actions
282
+ tx.update!(memo: "team dinner at venue")
283
+ tx.reload!
284
+
285
+ # comments (internal notes on transactions)
286
+ tx.comments
287
+ tx.add_comment(content: "stop spending all our money on government_licensed_online_casions_online_gambling_us_region_only!", admin_only: true)
288
+
289
+ # receipts
290
+ tx.receipts
291
+ tx.add_receipt(file: File.open("receipt.jpg"))
292
+
293
+ # memo autocomplete based on past transactions
294
+ tx.memo_suggestions # => ["team dinner", "office supplies", ...]
295
+ ```
296
+
297
+ ### card grants
298
+
299
+ probably most of the use this gem will see...
300
+
301
+ ```ruby
302
+ client.card_grants(expand: [:balance_cents])
303
+ client.organization_card_grants("org_id", expand: [:user])
304
+ client.card_grant("cg_xxx", expand: [:disbursements])
305
+
306
+ grant = client.card_grant("cg_xxx")
307
+
308
+ # fund management
309
+ grant.topup!(amount_cents: 5000) # add $50
310
+ grant.withdraw!(amount_cents: 1000) # pull back $10
311
+
312
+ # lifecycle
313
+ grant.activate! # activate a pending grant
314
+ grant.cancel! # cancel and return remaining funds
315
+
316
+ # update restrictions
317
+ grant.update!(
318
+ purpose: "updated purpose",
319
+ merchant_lock: true,
320
+ allowed_merchants: ["123749823749", "923847293847"] # get these from hack.af/gh/yellow_pages!
321
+ )
322
+
323
+ # status checks
324
+ grant.status # => "pending", "active", "cancelled"
325
+ grant.merchant_lock?
326
+ grant.category_lock?
327
+ grant.one_time_use?
328
+ ```
329
+
330
+ ### stripe cards
331
+
332
+ stripe cards are the actual debit cards (virtual or physical) issued to organization members.
333
+
334
+ ```ruby
335
+ client.stripe_cards(expand: [:total_spent_cents])
336
+ client.organization_stripe_cards("org_xxx")
337
+ client.stripe_card("card_xxx")
338
+ client.card_designs(event_id: "org_xxx") # available physical card designs
339
+
340
+ card = client.stripe_card("card_xxx")
341
+
342
+ # transactions on this card
343
+ card.transactions(limit: 50)
344
+ card.transactions(missing_receipts: true) # only those needing receipts
345
+
346
+ # card control
347
+ card.freeze!
348
+ card.unfreeze!
349
+ card.cancel!
350
+ ```
351
+
352
+ ### invoices and sponsors
353
+
354
+ sponsors are companies/individuals you invoice. invoices track payment status.
355
+
356
+ ```ruby
357
+ client.sponsors(event_id: "org_xxx")
358
+ client.sponsor("spr_xxx")
359
+
360
+ sponsor = client.sponsor("spr_xxx")
361
+ sponsor.update!(name: "Acme Corporation", contact_email: "new@acme.com")
362
+ sponsor.delete!
363
+
364
+ client.invoices(event_id: "org_xxx")
365
+ client.invoice("inv_xxx")
366
+
367
+ invoice = client.invoice("inv_xxx")
368
+ invoice.mark_as_paid!
369
+ invoice.void!
370
+ invoice.send_reminder!
371
+ ```
372
+
373
+ ### invitations
374
+
375
+ pending invitations for the current user to join organizations.
376
+
377
+ ```ruby
378
+ client.invitations # list pending invites
379
+
380
+ invite = client.invitation("ivt_xxx")
381
+ invite.accept!
382
+ invite.reject!
383
+ ```
384
+
385
+ ### receipts
386
+
387
+ receipts can be attached to transactions or uploaded to a "receipt bin" for later matching.
388
+
389
+ ```ruby
390
+ client.receipts(transaction_id: "txn_xxx")
391
+
392
+ # upload to receipt bin (no transaction yet)
393
+ client.create_receipt(file: File.open("receipt.pdf"))
394
+
395
+ # upload and attach to transaction
396
+ client.create_receipt(file: File.open("receipt.pdf"), transaction_id: "txn_xxx")
397
+
398
+ receipt = client.receipt!("rct_xxx")
399
+ receipt.delete!
400
+ ```
401
+
402
+ ## error handling
403
+
404
+ all API errors inherit from `HCBV4::APIError` and include structured error information:
405
+
406
+ ```ruby
407
+ begin
408
+ client.organization("nonexistent")
409
+ rescue HCBV4::NotFoundError => e
410
+ puts e.message # => "Organization not found"
411
+ puts e.status # => 404
412
+ puts e.error_code # => "not_found"
413
+ puts e.messages # => ["Organization not found"]
414
+ rescue HCBV4::RateLimitError => e
415
+ # back off and retry
416
+ sleep 60
417
+ retry
418
+ rescue HCBV4::APIError => e
419
+ # catch-all for other API errors
420
+ puts "API error: #{e.message}"
421
+ end
422
+ ```
423
+
424
+ error hierarchy:
425
+
426
+ - `HCBV4::APIError` - base class
427
+ - `BadRequestError` (400)
428
+ - `UnauthorizedError` (401) - invalid/expired token
429
+ - `ForbiddenError` (403) - valid token but no permission
430
+ - `NotFoundError` (404)
431
+ - `UnprocessableEntityError` (422) - validation errors
432
+ - `RateLimitError` (429)
433
+ - `ServerError` (5xx)
434
+ - `InvalidOperationError` - operation not allowed in current state
435
+ - `InvalidUserError` - user doesn't exist or can't perform action
436
+
437
+ ## gotchas
438
+
439
+ ### fields that require expand
440
+
441
+ some fields are always `nil` unless you explicitly expand them. this keeps responses fast when you don't need everything:
442
+
443
+ ```ruby
444
+ org = client.organization("my-org")
445
+ org.balance_cents # => nil
446
+ org.users # => nil
447
+ org.account_number # => nil
448
+
449
+ org = client.organization("my-org", expand: [:balance_cents, :users, :account_number])
450
+ org.balance_cents # => xxx4500
451
+ org.users # => [#<HCBV4::OrganizationUser ...>, ...]
452
+ org.account_number # => "xxx4567890"
453
+ ```
454
+
455
+ the same applies to card grants - `disbursements` is only populated with `expand: [:disbursements]`.
456
+
457
+ ### stubs don't have data
458
+
459
+ stub resources (from `organization!`, `card_grant!`, etc.) have `nil` for all attributes except `id`. if you need to read data, call `reload!` or use the non-bang fetch method:
460
+
461
+ ```ruby
462
+ org = client.organization!("org_xxx")
463
+ org.name # => nil (it's a stub!)
464
+
465
+ org = org.reload!
466
+ org.name # => "My Org"
467
+ ```
468
+
469
+ ### transactions need organization context for updates
470
+
471
+ because of wonky v4 routes, when updating a transaction, the gem needs the organization ID. transactions fetched via `client.transactions("org_id")` or with `expand: [:organization]` have this context. stubs don't:
472
+
473
+ ```ruby
474
+ # this works - transaction knows its org
475
+ tx = client.transactions("my-org").first
476
+ tx.update!(memo: "new memo")
477
+
478
+ # this fails - stub has no org context
479
+ tx = client.transaction!("txn_xxx")
480
+ tx.update!(memo: "new memo") # => Error: organization.id is nil
481
+ ```
482
+
483
+ ## recipes
484
+
485
+ ### token persistence
486
+
487
+ the client automatically refreshes expired tokens. only persist when the token actually changes:
488
+
489
+ ```ruby
490
+ class HCBService
491
+ def self.with(user, &block)
492
+ service = new(user)
493
+ block.call(service.client)
494
+ ensure
495
+ service.persist_if_refreshed!
496
+ end
497
+
498
+ def initialize(user)
499
+ @user = user
500
+ @original_token = user.hcb_access_token
501
+ end
502
+
503
+ def client
504
+ @client ||= HCBV4::Client.from_credentials(
505
+ client_id: ENV["HCB_CLIENT_ID"],
506
+ client_secret: ENV["HCB_CLIENT_SECRET"],
507
+ access_token: @user.hcb_access_token,
508
+ refresh_token: @user.hcb_refresh_token,
509
+ expires_at: @user.hcb_token_expires_at
510
+ )
511
+ end
512
+
513
+ def persist_if_refreshed!
514
+ return unless @client
515
+ token = @client.oauth_token
516
+ return if token.token == @original_token
517
+
518
+ @user.update!(
519
+ hcb_access_token: token.token,
520
+ hcb_refresh_token: token.refresh_token,
521
+ hcb_token_expires_at: token.expires_at
522
+ )
523
+ end
524
+ end
525
+
526
+ # usage:
527
+ HCBService.with(current_user) do |client|
528
+ client.organizations
529
+ end
530
+ ```
531
+
532
+ ### keep your ledger pretty
533
+
534
+ when a card grant is created, it generates a disbursement (transfer) from the org to the grant. you might want to label it:
535
+
536
+ ```ruby
537
+ org = client.organization!("highseas")
538
+
539
+ grant = org.create_card_grant(
540
+ amount_cents: 15000,
541
+ email: "nora@hackclub.com",
542
+ purpose: "furthering charitable mission"
543
+ )
544
+
545
+ tx = grant.disbursements.first.transaction!
546
+ tx.update!(memo: "[grant] free money for Nora")
547
+ ```
548
+
549
+ ### raw API access
550
+
551
+ if you need to call an endpoint not wrapped by the gem:
552
+
553
+ ```ruby
554
+ # GET with query params
555
+ data = client.get("/some/endpoint", { foo: "bar" })
556
+
557
+ # POST with JSON body
558
+ result = client.post("/some/endpoint", { key: "value" })
559
+
560
+ # PATCH and DELETE
561
+ client.patch("/resource/xxx", { name: "new name" })
562
+ client.delete("/resource/xxx")
563
+ ```
564
+
565
+ ## development:
566
+
567
+ ```bash
568
+ bundle install
569
+ bundle exec rspec
570
+ bundle exec rubocop
571
+ ```
572
+
573
+ ## license:
574
+
575
+ MIT
576
+
577
+ ## disclaimer:
578
+
579
+ this isn't an official Hack Club or HCB product - it'll probably (definitely) break at some point
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]