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 +7 -0
- data/README.md +579 -0
- data/Rakefile +12 -0
- data/lib/hcbv4/client.rb +618 -0
- data/lib/hcbv4/errors.rb +38 -0
- data/lib/hcbv4/models/ach_transfer.rb +48 -0
- data/lib/hcbv4/models/base_transaction_detail.rb +37 -0
- data/lib/hcbv4/models/card_charge.rb +25 -0
- data/lib/hcbv4/models/card_design.rb +21 -0
- data/lib/hcbv4/models/card_grant.rb +100 -0
- data/lib/hcbv4/models/check.rb +72 -0
- data/lib/hcbv4/models/comment.rb +19 -0
- data/lib/hcbv4/models/donation.rb +11 -0
- data/lib/hcbv4/models/donation_transaction.rb +77 -0
- data/lib/hcbv4/models/expense_payout.rb +25 -0
- data/lib/hcbv4/models/invitation.rb +47 -0
- data/lib/hcbv4/models/invoice.rb +30 -0
- data/lib/hcbv4/models/invoice_transaction.rb +47 -0
- data/lib/hcbv4/models/merchant.rb +16 -0
- data/lib/hcbv4/models/organization.rb +164 -0
- data/lib/hcbv4/models/organization_user.rb +33 -0
- data/lib/hcbv4/models/receipt.rb +30 -0
- data/lib/hcbv4/models/resource.rb +23 -0
- data/lib/hcbv4/models/sponsor.rb +43 -0
- data/lib/hcbv4/models/stripe_card.rb +134 -0
- data/lib/hcbv4/models/tag.rb +12 -0
- data/lib/hcbv4/models/transaction.rb +129 -0
- data/lib/hcbv4/models/transaction_list.rb +100 -0
- data/lib/hcbv4/models/transaction_type.rb +11 -0
- data/lib/hcbv4/models/transfer.rb +67 -0
- data/lib/hcbv4/models/user.rb +76 -0
- data/lib/hcbv4/models.rb +37 -0
- data/lib/hcbv4/version.rb +5 -0
- data/lib/hcbv4.rb +10 -0
- data/sig/hcbv4.rbs +4 -0
- metadata +77 -0
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
|