billrb 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/CHANGELOG.md +24 -0
- data/LICENSE.txt +21 -0
- data/README.md +214 -0
- data/lib/billrb/client.rb +192 -0
- data/lib/billrb/configuration.rb +69 -0
- data/lib/billrb/errors.rb +56 -0
- data/lib/billrb/list_page.rb +52 -0
- data/lib/billrb/operations.rb +61 -0
- data/lib/billrb/resource.rb +58 -0
- data/lib/billrb/resources/credit_memo.rb +14 -0
- data/lib/billrb/resources/customer.rb +20 -0
- data/lib/billrb/resources/customer_bank_account.rb +38 -0
- data/lib/billrb/resources/invoice.rb +36 -0
- data/lib/billrb/resources/receivable_payment.rb +18 -0
- data/lib/billrb/resources/recurring_invoice.rb +16 -0
- data/lib/billrb/util.rb +25 -0
- data/lib/billrb/version.rb +5 -0
- data/lib/billrb.rb +41 -0
- metadata +78 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ef5dd94f1b7c90b5ad0ebf63d71c6e3361c8ea015d20e43f4eff34fe5e7592ab
|
|
4
|
+
data.tar.gz: d2f1213dae7ab2f5e116d247833f6daa9697da5c2647cb9bb1a3845772aca102
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8218de5263247cade5a381591c554fd35855200b33dd5303995d1002d5b5f0b56aa4bbb0971ea7fbcf77d70c9dec9b9418b9cd94fe0f62e1eae485fb7607cabc
|
|
7
|
+
data.tar.gz: c74ffc5f2eaf9a5d1505c3f134c40a473edbd9748aff52da54ff5c00f7429897e79e0d08638141c2193f1aaf07d8c95555ca6603373dc930eb512dfe0ca1ee5f
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
- Initial architecture: configuration, Faraday-based client with BILL v3
|
|
6
|
+
session authentication (lazy login, automatic re-login on session expiry),
|
|
7
|
+
typed error hierarchy, pagination, and a small resource layer.
|
|
8
|
+
- Automatic retries with exponential backoff for transient failures (429 for
|
|
9
|
+
all methods, 5xx/network for idempotent methods only), honoring `Retry-After`.
|
|
10
|
+
Configurable via `max_retries` / `retry_backoff`.
|
|
11
|
+
- Thread-safe session handling: mutex-guarded login with double-checked
|
|
12
|
+
re-authentication, so a shared client is safe across threads.
|
|
13
|
+
- Nested API objects are wrapped as resources, enabling dot-access at any depth
|
|
14
|
+
(e.g. `invoice.billing_address.city`); `to_h` still returns raw attributes.
|
|
15
|
+
- Pluggable Faraday: `config.adapter` and a `config.faraday` middleware hook.
|
|
16
|
+
- `Configuration#inspect` and `Client#inspect` redact credentials and session id.
|
|
17
|
+
- Credential-gated integration specs (`spec/integration`) that exercise the
|
|
18
|
+
AR resources end-to-end against the BILL sandbox; loaded from a gitignored
|
|
19
|
+
`.env` (see `.env.example`) and skipped when credentials are absent.
|
|
20
|
+
- Tooling: RuboCop (lint) + rufo (format) wired up as pre-commit hooks.
|
|
21
|
+
- Accounts Receivable resources: `Billrb::Customer` (with charge
|
|
22
|
+
authorization), `Billrb::CustomerBankAccount`, `Billrb::Invoice` (with
|
|
23
|
+
email, payment link, and offline payment recording), `Billrb::CreditMemo`,
|
|
24
|
+
`Billrb::RecurringInvoice`, and `Billrb::ReceivablePayment` (charging).
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Carlos Aguilar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# billrb
|
|
2
|
+
|
|
3
|
+
A lightweight Ruby client for the [BILL (bill.com) v3 API](https://developer.bill.com/), focused on the Accounts Receivable modules: customers (including bank accounts and charge authorization), invoices, recurring invoices, credit memos, and received payments.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add this line to your application's Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "billrb"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
And then execute:
|
|
14
|
+
|
|
15
|
+
$ bundle install
|
|
16
|
+
|
|
17
|
+
Or install it yourself as:
|
|
18
|
+
|
|
19
|
+
$ gem install billrb
|
|
20
|
+
|
|
21
|
+
## Configuration
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
Billrb.configure do |config|
|
|
25
|
+
config.username = ENV["BILL_USERNAME"]
|
|
26
|
+
config.password = ENV["BILL_PASSWORD"]
|
|
27
|
+
config.organization_id = ENV["BILL_ORGANIZATION_ID"]
|
|
28
|
+
config.dev_key = ENV["BILL_DEV_KEY"]
|
|
29
|
+
config.environment = :production # defaults to :sandbox
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The client logs in lazily on the first request and transparently re-authenticates when the API session expires (BILL sessions expire after 35 minutes of inactivity).
|
|
34
|
+
|
|
35
|
+
### All configuration options
|
|
36
|
+
|
|
37
|
+
| Option | Default | Description |
|
|
38
|
+
| --- | --- | --- |
|
|
39
|
+
| `username`, `password`, `organization_id`, `dev_key` | — | BILL credentials (required) |
|
|
40
|
+
| `environment` | `:sandbox` | `:sandbox` or `:production` |
|
|
41
|
+
| `timeout`, `open_timeout` | `30`, `5` | Faraday request/connect timeouts (seconds) |
|
|
42
|
+
| `logger` | `nil` | A `Logger`; request lines are logged with headers/bodies excluded |
|
|
43
|
+
| `max_retries` | `2` | Retry attempts for transient failures (see below) |
|
|
44
|
+
| `retry_backoff` | `0.5` | Base seconds for exponential backoff |
|
|
45
|
+
| `adapter` | Faraday default | Faraday adapter, e.g. `:net_http_persistent` |
|
|
46
|
+
| `faraday` | `nil` | A proc given the Faraday builder to add custom middleware |
|
|
47
|
+
|
|
48
|
+
### Resilience
|
|
49
|
+
|
|
50
|
+
Transient failures are retried automatically with exponential backoff, honoring a `Retry-After` header when present. Rate-limit responses (429) are retried for every method since the request was rejected before processing; `5xx` and network errors are retried only for idempotent methods (GET/HEAD/PUT/DELETE), so a `POST` create is never silently repeated. After `max_retries` the mapped error (e.g. `RateLimitError`, `ServerError`) is raised.
|
|
51
|
+
|
|
52
|
+
### Thread safety
|
|
53
|
+
|
|
54
|
+
A client may be shared across threads. Sign-in is mutex-guarded, so concurrent requests trigger at most one login, and an expired session is refreshed only once even when many requests race on it.
|
|
55
|
+
|
|
56
|
+
### Custom Faraday middleware
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
Billrb.configure do |config|
|
|
60
|
+
config.adapter = :net_http_persistent
|
|
61
|
+
config.faraday = ->(builder) { builder.use MyInstrumentationMiddleware }
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
For multi-tenant applications, build clients explicitly instead of using the global configuration:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
client = Billrb::Client.new(
|
|
69
|
+
username: "...", password: "...", organization_id: "...", dev_key: "..."
|
|
70
|
+
)
|
|
71
|
+
Billrb::Customer.list({ max: 50 }, client: client)
|
|
72
|
+
Billrb::Customer.retrieve("customer_id", client: client)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# List with filters and pagination (BILL's native filter syntax)
|
|
79
|
+
page = Billrb::Customer.list(max: 50, filters: "archived:eq:false")
|
|
80
|
+
page.each { |customer| puts customer.name }
|
|
81
|
+
page = page.fetch_next_page if page.next_page?
|
|
82
|
+
|
|
83
|
+
# Or iterate across all pages
|
|
84
|
+
Billrb::Invoice.list(max: 100).auto_paging_each do |invoice|
|
|
85
|
+
puts "#{invoice.invoice_number}: #{invoice.due_date}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# CRUD — params are written snake_case and converted to the API's camelCase
|
|
89
|
+
customer = Billrb::Customer.create(name: "Acme Inc.", email: "billing@acme.test")
|
|
90
|
+
customer = Billrb::Customer.update(customer.id, name: "Acme Incorporated")
|
|
91
|
+
Billrb::Customer.archive(customer.id)
|
|
92
|
+
|
|
93
|
+
invoice = Billrb::Invoice.retrieve("inv_id")
|
|
94
|
+
invoice.due_date # reads "dueDate" from the API response
|
|
95
|
+
invoice.billing_address.city # nested objects are wrapped too
|
|
96
|
+
invoice.line_items.first.amount
|
|
97
|
+
invoice.to_h # raw API attributes (nested values stay hashes)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Invoicing actions
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
# Email the invoice (PDF attached) to the customer
|
|
104
|
+
Billrb::Invoice.send_email(invoice.id, recipient: { to: ["billing@acme.test"] })
|
|
105
|
+
|
|
106
|
+
# Or get a payment link to deliver yourself
|
|
107
|
+
link = Billrb::Invoice.payment_link(invoice.id, customer_id: customer.id, email: "billing@acme.test")
|
|
108
|
+
link.payment_link
|
|
109
|
+
|
|
110
|
+
# Record a payment received outside BILL (cash, check, ...)
|
|
111
|
+
Billrb::Invoice.record_payment(
|
|
112
|
+
amount: 50.0, payment_date: "2026-06-12", payment_type: "CASH",
|
|
113
|
+
invoices: [{ invoice_id: invoice.id, amount: 50.0 }]
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Recurring invoices and credit memos follow the same CRUD shape
|
|
117
|
+
Billrb::RecurringInvoice.create(customer_id: customer.id, ...)
|
|
118
|
+
Billrb::CreditMemo.create(customer_id: customer.id, ...)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Charging customers
|
|
122
|
+
|
|
123
|
+
Charging requires a one-time authorization and a customer bank account:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
Billrb::Customer.authorize_charge(customer.id)
|
|
127
|
+
Billrb::CustomerBankAccount.create(customer.id, routing_number: "...", account_number: "...")
|
|
128
|
+
|
|
129
|
+
payment = Billrb::ReceivablePayment.charge(
|
|
130
|
+
customer_id: customer.id,
|
|
131
|
+
invoices: [{ invoice_id: invoice.id, amount: 100.0 }]
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Received payments are queryable like any other resource
|
|
135
|
+
Billrb::ReceivablePayment.list(max: 50)
|
|
136
|
+
Billrb::ReceivablePayment.retrieve(payment.id)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Errors
|
|
140
|
+
|
|
141
|
+
All errors inherit from `Billrb::Error`:
|
|
142
|
+
|
|
143
|
+
| Error | Raised on |
|
|
144
|
+
| --- | --- |
|
|
145
|
+
| `Billrb::ConfigurationError` | missing/invalid configuration |
|
|
146
|
+
| `Billrb::ConnectionError` | network failures and timeouts |
|
|
147
|
+
| `Billrb::AuthenticationError` | 401 (after one automatic re-login attempt) |
|
|
148
|
+
| `Billrb::BadRequestError` / `ForbiddenError` / `NotFoundError` | 400 / 403 / 404 |
|
|
149
|
+
| `Billrb::RateLimitError` | 429 |
|
|
150
|
+
| `Billrb::ServerError` | 5xx |
|
|
151
|
+
|
|
152
|
+
API errors expose `error.status` and `error.body` (the parsed response) for access to BILL error codes.
|
|
153
|
+
|
|
154
|
+
## Architecture & extending
|
|
155
|
+
|
|
156
|
+
The gem is intentionally small and layered so new BILL modules can be added without touching existing code:
|
|
157
|
+
|
|
158
|
+
- `Billrb::Client` — transport only: session auth, JSON, error mapping. Knows nothing about resources.
|
|
159
|
+
- `Billrb::Resource` — wraps API JSON, exposing camelCase attributes as snake_case readers.
|
|
160
|
+
- `Billrb::Operations` — CRUD mixins (`List`, `Retrieve`, `Create`, `Update`, `Replace`, `Archive`).
|
|
161
|
+
|
|
162
|
+
A new resource is one file declaring its path and supported operations:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
module Billrb
|
|
166
|
+
class CreditMemo < Resource
|
|
167
|
+
self.resource_path = "/credit-memos"
|
|
168
|
+
|
|
169
|
+
extend Operations::List
|
|
170
|
+
extend Operations::Retrieve
|
|
171
|
+
extend Operations::Create
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Development
|
|
177
|
+
|
|
178
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
179
|
+
|
|
180
|
+
### Linting & formatting
|
|
181
|
+
|
|
182
|
+
Formatting is handled by [rufo](https://github.com/ruby-formatter/rufo) and linting by [RuboCop](https://rubocop.org); rufo owns formatting, so RuboCop's layout cops are disabled to keep the two from fighting. They are wired up as [pre-commit](https://pre-commit.com) hooks:
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
pre-commit run --all-files # run on the whole repo
|
|
186
|
+
pre-commit install # optional: run automatically on every commit
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
You can also run them directly: `bundle exec rufo .` and `bundle exec rubocop`.
|
|
190
|
+
|
|
191
|
+
### Integration specs
|
|
192
|
+
|
|
193
|
+
`spec/integration` runs end-to-end against the real BILL sandbox. It needs all four credentials; copy the template and fill it in:
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
cp .env.example .env # .env is gitignored
|
|
197
|
+
bundle exec rspec spec/integration
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Without the credentials these specs are skipped automatically, so `rake spec` stays green for everyone else. In CI they run when the matching repository secrets are configured.
|
|
201
|
+
|
|
202
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
203
|
+
|
|
204
|
+
## Contributing
|
|
205
|
+
|
|
206
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/TECMANIC/billrb. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/TECMANIC/billrb/blob/main/CODE_OF_CONDUCT.md).
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
211
|
+
|
|
212
|
+
## Code of Conduct
|
|
213
|
+
|
|
214
|
+
Everyone interacting in the Billrb project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/TECMANIC/billrb/blob/main/CODE_OF_CONDUCT.md).
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
|
|
5
|
+
module Billrb
|
|
6
|
+
# Thin HTTP layer over the BILL v3 API: session auth, retries, JSON, and
|
|
7
|
+
# error mapping. Knows nothing about individual resources.
|
|
8
|
+
#
|
|
9
|
+
# A client may be shared across threads: sign-in is guarded by a mutex, so
|
|
10
|
+
# concurrent requests trigger at most one login and an expired session is
|
|
11
|
+
# refreshed only once even when many requests race on it.
|
|
12
|
+
class Client
|
|
13
|
+
BASE_PATH = "/connect/v3"
|
|
14
|
+
|
|
15
|
+
# HTTP methods with no side effects, so they are safe to retry on a 5xx or
|
|
16
|
+
# network error. 429 (rate limited) is retried for every method since the
|
|
17
|
+
# request was rejected before processing.
|
|
18
|
+
IDEMPOTENT_METHODS = %i[get head options put delete].freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :config
|
|
21
|
+
|
|
22
|
+
# Accepts an explicit Configuration, inline options
|
|
23
|
+
# (Client.new(dev_key: "...", ...)), or falls back to Billrb.configuration.
|
|
24
|
+
def initialize(config = nil, **options)
|
|
25
|
+
@config = if config
|
|
26
|
+
config
|
|
27
|
+
elsif options.any?
|
|
28
|
+
Configuration.new(**options)
|
|
29
|
+
else
|
|
30
|
+
Billrb.configuration
|
|
31
|
+
end
|
|
32
|
+
@login_mutex = Mutex.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def get(path, params = {})
|
|
36
|
+
request(:get, path, params: params)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def post(path, body = nil)
|
|
40
|
+
request(:post, path, body: body)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def put(path, body = nil)
|
|
44
|
+
request(:put, path, body: body)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def patch(path, body = nil)
|
|
48
|
+
request(:patch, path, body: body)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def delete(path)
|
|
52
|
+
request(:delete, path)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Forces a fresh sign-in. Normally unnecessary — requests sign in lazily.
|
|
56
|
+
def login!
|
|
57
|
+
@login_mutex.synchronize { do_login! }
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def logout!
|
|
62
|
+
@login_mutex.synchronize do
|
|
63
|
+
return false unless @session_id
|
|
64
|
+
|
|
65
|
+
parse!(perform(:post, "/logout"))
|
|
66
|
+
@session_id = nil
|
|
67
|
+
end
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def inspect
|
|
72
|
+
"#<#{self.class.name} environment=#{config.environment.inspect} " \
|
|
73
|
+
"authenticated=#{!@session_id.nil?}>"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def request(method, path, params: nil, body: nil)
|
|
79
|
+
parse!(send_with_retries(method, path, params: params, body: body))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Refreshes an expired session once, then retries transient failures (429
|
|
83
|
+
# always; 5xx and network errors only for idempotent methods) with
|
|
84
|
+
# exponential backoff, honoring a Retry-After header when present.
|
|
85
|
+
def send_with_retries(method, path, **opts)
|
|
86
|
+
attempt = 0
|
|
87
|
+
loop do
|
|
88
|
+
begin
|
|
89
|
+
response = perform_with_reauth(method, path, **opts)
|
|
90
|
+
rescue ConnectionError
|
|
91
|
+
raise unless retry?(method, attempt, nil)
|
|
92
|
+
|
|
93
|
+
pause(backoff(attempt += 1))
|
|
94
|
+
next
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
return response unless retry?(method, attempt, response.status)
|
|
98
|
+
|
|
99
|
+
pause(backoff(attempt += 1, response))
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def perform_with_reauth(method, path, **opts)
|
|
104
|
+
ensure_session!
|
|
105
|
+
stale = @session_id
|
|
106
|
+
response = perform(method, path, **opts)
|
|
107
|
+
if response.status == 401
|
|
108
|
+
reauthenticate!(stale)
|
|
109
|
+
response = perform(method, path, **opts)
|
|
110
|
+
end
|
|
111
|
+
response
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def ensure_session!
|
|
115
|
+
return if @session_id
|
|
116
|
+
|
|
117
|
+
@login_mutex.synchronize { do_login! unless @session_id }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Only sign in again if another thread has not already refreshed the
|
|
121
|
+
# session out from under us.
|
|
122
|
+
def reauthenticate!(stale)
|
|
123
|
+
@login_mutex.synchronize { do_login! if @session_id == stale }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def do_login!
|
|
127
|
+
config.validate!
|
|
128
|
+
response = perform(:post, "/login", body: {
|
|
129
|
+
"devKey" => config.dev_key,
|
|
130
|
+
"username" => config.username,
|
|
131
|
+
"password" => config.password,
|
|
132
|
+
"organizationId" => config.organization_id,
|
|
133
|
+
})
|
|
134
|
+
data = parse!(response)
|
|
135
|
+
@session_id = data.is_a?(Hash) ? data["sessionId"] : nil
|
|
136
|
+
return if @session_id
|
|
137
|
+
|
|
138
|
+
raise AuthenticationError.new("login response did not include a sessionId",
|
|
139
|
+
status: response.status, body: data)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def perform(method, path, params: nil, body: nil)
|
|
143
|
+
connection.run_request(method, BASE_PATH + path, nil, nil) do |req|
|
|
144
|
+
req.headers["sessionId"] = @session_id if @session_id
|
|
145
|
+
req.params.update(params) if params && !params.empty?
|
|
146
|
+
req.body = body if body
|
|
147
|
+
end
|
|
148
|
+
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
|
149
|
+
raise ConnectionError, e.message
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def parse!(response)
|
|
153
|
+
return response.body if response.status.between?(200, 299)
|
|
154
|
+
|
|
155
|
+
raise ApiError.from_response(status: response.status, body: response.body)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# status is nil for a network error.
|
|
159
|
+
def retry?(method, attempt, status)
|
|
160
|
+
return false if attempt >= config.max_retries
|
|
161
|
+
return true if status == 429
|
|
162
|
+
|
|
163
|
+
(status.nil? || (500..599).cover?(status)) && IDEMPOTENT_METHODS.include?(method)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def backoff(attempt, response = nil)
|
|
167
|
+
retry_after = response&.headers&.[]("Retry-After").to_i
|
|
168
|
+
return retry_after if retry_after.positive?
|
|
169
|
+
|
|
170
|
+
config.retry_backoff * (2 ** (attempt - 1))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def pause(seconds)
|
|
174
|
+
sleep(seconds)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def connection # rubocop:disable Metrics/AbcSize -- cohesive Faraday builder
|
|
178
|
+
@connection ||= Faraday.new(url: config.base_url) do |f|
|
|
179
|
+
f.request :json
|
|
180
|
+
f.response :json, content_type: /\bjson$/
|
|
181
|
+
f.options.timeout = config.timeout
|
|
182
|
+
f.options.open_timeout = config.open_timeout
|
|
183
|
+
f.headers["devKey"] = config.dev_key if config.dev_key
|
|
184
|
+
f.headers["Accept"] = "application/json"
|
|
185
|
+
# headers/bodies excluded: they carry credentials and customer data
|
|
186
|
+
f.response :logger, config.logger, headers: false, bodies: false if config.logger
|
|
187
|
+
config.faraday&.call(f)
|
|
188
|
+
f.adapter(*Array(config.adapter || Faraday.default_adapter))
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Billrb
|
|
4
|
+
class Configuration
|
|
5
|
+
ENVIRONMENTS = {
|
|
6
|
+
production: "https://gateway.prod.bill.com",
|
|
7
|
+
sandbox: "https://gateway.stage.bill.com",
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
10
|
+
SECRET_ATTRS = %i[password dev_key].freeze
|
|
11
|
+
|
|
12
|
+
attr_accessor :username, :password, :organization_id, :dev_key,
|
|
13
|
+
:timeout, :open_timeout, :logger,
|
|
14
|
+
:max_retries, :retry_backoff, :adapter, :faraday
|
|
15
|
+
attr_reader :environment
|
|
16
|
+
|
|
17
|
+
def initialize(username: nil, password: nil, organization_id: nil, dev_key: nil,
|
|
18
|
+
environment: :sandbox, timeout: 30, open_timeout: 5, logger: nil,
|
|
19
|
+
max_retries: 2, retry_backoff: 0.5, adapter: nil, faraday: nil)
|
|
20
|
+
self.environment = environment
|
|
21
|
+
@username = username
|
|
22
|
+
@password = password
|
|
23
|
+
@organization_id = organization_id
|
|
24
|
+
@dev_key = dev_key
|
|
25
|
+
@timeout = timeout
|
|
26
|
+
@open_timeout = open_timeout
|
|
27
|
+
@logger = logger
|
|
28
|
+
@max_retries = max_retries
|
|
29
|
+
@retry_backoff = retry_backoff
|
|
30
|
+
@adapter = adapter
|
|
31
|
+
@faraday = faraday
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def environment=(value)
|
|
35
|
+
value = value.to_sym
|
|
36
|
+
unless ENVIRONMENTS.key?(value)
|
|
37
|
+
raise ConfigurationError,
|
|
38
|
+
"unknown environment #{value.inspect} (valid: #{ENVIRONMENTS.keys.join(", ")})"
|
|
39
|
+
end
|
|
40
|
+
@environment = value
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def base_url
|
|
44
|
+
ENVIRONMENTS.fetch(environment)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validate!
|
|
48
|
+
missing = %i[username password organization_id dev_key].select do |key|
|
|
49
|
+
public_send(key).to_s.empty?
|
|
50
|
+
end
|
|
51
|
+
return if missing.empty?
|
|
52
|
+
|
|
53
|
+
raise ConfigurationError, "missing required configuration: #{missing.join(", ")}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Redact credentials so they never leak into logs or error dumps.
|
|
57
|
+
def inspect
|
|
58
|
+
"#<#{self.class.name} environment=#{environment.inspect} " \
|
|
59
|
+
"username=#{username.inspect} organization_id=#{organization_id.inspect} " \
|
|
60
|
+
"password=#{redacted(password)} dev_key=#{redacted(dev_key)}>"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def redacted(value)
|
|
66
|
+
value.nil? ? "nil" : "[REDACTED]"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Billrb
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised when required configuration (credentials, environment) is missing
|
|
7
|
+
# or invalid before a request is attempted.
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised on network-level failures (DNS, refused connection, timeout).
|
|
11
|
+
class ConnectionError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when the API responds with a non-2xx status. Subclasses map common
|
|
14
|
+
# statuses; `status` and `body` carry the raw response for callers that need
|
|
15
|
+
# the BILL error code or details.
|
|
16
|
+
class ApiError < Error
|
|
17
|
+
attr_reader :status, :body
|
|
18
|
+
|
|
19
|
+
def initialize(message, status: nil, body: nil)
|
|
20
|
+
super(message)
|
|
21
|
+
@status = status
|
|
22
|
+
@body = body
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.from_response(status:, body:)
|
|
26
|
+
klass = case status
|
|
27
|
+
when 400 then BadRequestError
|
|
28
|
+
when 401 then AuthenticationError
|
|
29
|
+
when 403 then ForbiddenError
|
|
30
|
+
when 404 then NotFoundError
|
|
31
|
+
when 429 then RateLimitError
|
|
32
|
+
when 500..599 then ServerError
|
|
33
|
+
else ApiError
|
|
34
|
+
end
|
|
35
|
+
klass.new(message_from(body) || "HTTP #{status}", status: status, body: body)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.message_from(body)
|
|
39
|
+
return unless body.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
body["message"] ||
|
|
42
|
+
body.dig("error", "message") ||
|
|
43
|
+
(body["errors"].is_a?(Array) &&
|
|
44
|
+
body["errors"].filter_map { |e| e["message"] if e.is_a?(Hash) }.first) ||
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
private_class_method :message_from
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class BadRequestError < ApiError; end
|
|
51
|
+
class AuthenticationError < ApiError; end
|
|
52
|
+
class ForbiddenError < ApiError; end
|
|
53
|
+
class NotFoundError < ApiError; end
|
|
54
|
+
class RateLimitError < ApiError; end
|
|
55
|
+
class ServerError < ApiError; end
|
|
56
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Billrb
|
|
4
|
+
# One page of a list endpoint response. BILL paginates with `max`/`page`
|
|
5
|
+
# query params and returns `results` plus `nextPage`/`prevPage` tokens.
|
|
6
|
+
class ListPage
|
|
7
|
+
include Enumerable
|
|
8
|
+
|
|
9
|
+
attr_reader :results, :next_page, :prev_page
|
|
10
|
+
|
|
11
|
+
# list_args carries leading positional arguments for nested resources
|
|
12
|
+
# (e.g. the customer id for CustomerBankAccount.list) so pagination can
|
|
13
|
+
# replay the same list call.
|
|
14
|
+
def initialize(resource_class, data, params: {}, client: Billrb.client, list_args: [])
|
|
15
|
+
@resource_class = resource_class
|
|
16
|
+
@client = client
|
|
17
|
+
@params = params
|
|
18
|
+
@list_args = list_args
|
|
19
|
+
data = {} unless data.is_a?(Hash)
|
|
20
|
+
@results = Array(data["results"]).map { |attrs| resource_class.new(attrs) }
|
|
21
|
+
@next_page = data["nextPage"]
|
|
22
|
+
@prev_page = data["prevPage"]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def each(&)
|
|
26
|
+
results.each(&)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def next_page?
|
|
30
|
+
!next_page.nil?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def fetch_next_page
|
|
34
|
+
raise Error, "there is no next page" unless next_page?
|
|
35
|
+
|
|
36
|
+
@resource_class.list(*@list_args, @params.merge(page: next_page), { client: @client })
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Iterates over every result, fetching subsequent pages as needed.
|
|
40
|
+
def auto_paging_each(&block)
|
|
41
|
+
return enum_for(:auto_paging_each) unless block
|
|
42
|
+
|
|
43
|
+
page = self
|
|
44
|
+
loop do
|
|
45
|
+
page.results.each(&block)
|
|
46
|
+
break unless page.next_page?
|
|
47
|
+
|
|
48
|
+
page = page.fetch_next_page
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Billrb
|
|
4
|
+
# CRUD mixins for resource classes. Each resource `extend`s only the
|
|
5
|
+
# operations its endpoint supports, so adding a new BILL module is a new
|
|
6
|
+
# file declaring its path and operations — no existing code changes.
|
|
7
|
+
#
|
|
8
|
+
# Every method takes a trailing options hash; pass `client:` there to use a
|
|
9
|
+
# client other than the default (e.g. Customer.retrieve(id, client: other)).
|
|
10
|
+
module Operations
|
|
11
|
+
def self.client_from(options)
|
|
12
|
+
options[:client] || Billrb.client
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
module List
|
|
16
|
+
def list(params = {}, options = {})
|
|
17
|
+
client = Operations.client_from(options)
|
|
18
|
+
data = client.get(resource_path, Util.camelize_keys(params))
|
|
19
|
+
ListPage.new(self, data, params: params, client: client)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
module Retrieve
|
|
24
|
+
def retrieve(id, options = {})
|
|
25
|
+
new(Operations.client_from(options).get("#{resource_path}/#{id}"))
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module Create
|
|
30
|
+
def create(params, options = {})
|
|
31
|
+
new(Operations.client_from(options).post(resource_path, Util.camelize_keys(params)))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
module Update
|
|
36
|
+
def update(id, params, options = {})
|
|
37
|
+
new(Operations.client_from(options).patch("#{resource_path}/#{id}",
|
|
38
|
+
Util.camelize_keys(params)))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# PUT — full replacement of the resource, including its line items
|
|
43
|
+
# (omitted line item ids are removed), unlike PATCH-based Update.
|
|
44
|
+
module Replace
|
|
45
|
+
def replace(id, params, options = {})
|
|
46
|
+
new(Operations.client_from(options).put("#{resource_path}/#{id}",
|
|
47
|
+
Util.camelize_keys(params)))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
module Archive
|
|
52
|
+
def archive(id, options = {})
|
|
53
|
+
new(Operations.client_from(options).post("#{resource_path}/#{id}/archive"))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def restore(id, options = {})
|
|
57
|
+
new(Operations.client_from(options).post("#{resource_path}/#{id}/restore"))
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Billrb
|
|
4
|
+
# Base class for API resources. Wraps the raw JSON attributes (camelCase
|
|
5
|
+
# string keys, as returned by the API) and exposes them as snake_case
|
|
6
|
+
# readers: resource.payment_term_id reads attributes["paymentTermId"].
|
|
7
|
+
class Resource
|
|
8
|
+
class << self
|
|
9
|
+
attr_accessor :resource_path
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
attr_reader :attributes
|
|
13
|
+
|
|
14
|
+
def initialize(attributes = {})
|
|
15
|
+
@attributes = attributes.is_a?(Hash) ? attributes : {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def [](key)
|
|
19
|
+
wrap(attributes[Util.camelize(key)])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def id
|
|
23
|
+
attributes["id"]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Raw attributes exactly as returned by the API (nested values stay hashes).
|
|
27
|
+
def to_h
|
|
28
|
+
attributes
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def method_missing(name, *args)
|
|
32
|
+
key = Util.camelize(name)
|
|
33
|
+
return wrap(attributes[key]) if args.empty? && attributes.key?(key)
|
|
34
|
+
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def respond_to_missing?(name, include_private = false)
|
|
39
|
+
attributes.key?(Util.camelize(name)) || super
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def inspect
|
|
43
|
+
"#<#{self.class.name} #{attributes.inspect}>"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Expose nested objects as Resources so dot-access works at any depth
|
|
49
|
+
# (e.g. invoice.billing_address.city), while to_h stays raw.
|
|
50
|
+
def wrap(value)
|
|
51
|
+
case value
|
|
52
|
+
when Hash then Resource.new(value)
|
|
53
|
+
when Array then value.map { |item| wrap(item) }
|
|
54
|
+
else value
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Billrb
|
|
4
|
+
class CreditMemo < Resource
|
|
5
|
+
self.resource_path = "/credit-memos"
|
|
6
|
+
|
|
7
|
+
extend Operations::List
|
|
8
|
+
extend Operations::Retrieve
|
|
9
|
+
extend Operations::Create
|
|
10
|
+
extend Operations::Update
|
|
11
|
+
extend Operations::Replace
|
|
12
|
+
extend Operations::Archive
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Billrb
|
|
4
|
+
class Customer < Resource
|
|
5
|
+
self.resource_path = "/customers"
|
|
6
|
+
|
|
7
|
+
extend Operations::List
|
|
8
|
+
extend Operations::Retrieve
|
|
9
|
+
extend Operations::Create
|
|
10
|
+
extend Operations::Update
|
|
11
|
+
extend Operations::Archive
|
|
12
|
+
|
|
13
|
+
# POST /v3/customers/{id}/charge-authorization — required before charging
|
|
14
|
+
# a customer with ReceivablePayment.charge.
|
|
15
|
+
def self.authorize_charge(id, params = {}, options = {})
|
|
16
|
+
new(Operations.client_from(options).post("#{resource_path}/#{id}/charge-authorization",
|
|
17
|
+
Util.camelize_keys(params)))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Billrb
|
|
4
|
+
# Bank account on file for a customer, required for charging them.
|
|
5
|
+
# Nested under a customer, so every operation takes the customer id first.
|
|
6
|
+
class CustomerBankAccount < Resource
|
|
7
|
+
def self.resource_path(customer_id)
|
|
8
|
+
"/customers/#{customer_id}/bank-accounts"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.list(customer_id, params = {}, options = {})
|
|
12
|
+
client = Operations.client_from(options)
|
|
13
|
+
data = client.get(resource_path(customer_id), Util.camelize_keys(params))
|
|
14
|
+
ListPage.new(self, data, params: params, client: client, list_args: [customer_id])
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.retrieve(customer_id, id, options = {})
|
|
18
|
+
new(Operations.client_from(options).get("#{resource_path(customer_id)}/#{id}"))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.create(customer_id, params, options = {})
|
|
22
|
+
new(Operations.client_from(options).post(resource_path(customer_id),
|
|
23
|
+
Util.camelize_keys(params)))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Only `nickname` and `ownerType` can change; for anything else, create a
|
|
27
|
+
# new bank account.
|
|
28
|
+
def self.update(customer_id, id, params, options = {})
|
|
29
|
+
new(Operations.client_from(options).patch("#{resource_path(customer_id)}/#{id}",
|
|
30
|
+
Util.camelize_keys(params)))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Archived customer bank accounts cannot be restored.
|
|
34
|
+
def self.archive(customer_id, id, options = {})
|
|
35
|
+
new(Operations.client_from(options).post("#{resource_path(customer_id)}/#{id}/archive"))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Billrb
|
|
4
|
+
class Invoice < Resource
|
|
5
|
+
self.resource_path = "/invoices"
|
|
6
|
+
|
|
7
|
+
extend Operations::List
|
|
8
|
+
extend Operations::Retrieve
|
|
9
|
+
extend Operations::Create
|
|
10
|
+
extend Operations::Update
|
|
11
|
+
extend Operations::Replace
|
|
12
|
+
extend Operations::Archive
|
|
13
|
+
|
|
14
|
+
# POST /v3/invoices/{id}/email — emails the invoice (with PDF attached)
|
|
15
|
+
# to the customer, e.g. send_email(id, recipient: { to: ["a@b.test"] }).
|
|
16
|
+
def self.send_email(id, params = {}, options = {})
|
|
17
|
+
Resource.new(Operations.client_from(options).post("#{resource_path}/#{id}/email",
|
|
18
|
+
Util.camelize_keys(params)))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# POST /v3/invoices/{id}/payment-link — returns a payment link the
|
|
22
|
+
# customer can use to pay the invoice.
|
|
23
|
+
def self.payment_link(id, params = {}, options = {})
|
|
24
|
+
Resource.new(Operations.client_from(options).post("#{resource_path}/#{id}/payment-link",
|
|
25
|
+
Util.camelize_keys(params)))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# POST /v3/invoices/record-payment — records a payment received outside
|
|
29
|
+
# BILL (cash, check, ...) and applies it to one or more invoices.
|
|
30
|
+
def self.record_payment(params, options = {})
|
|
31
|
+
ReceivablePayment.new(Operations.client_from(options).post(
|
|
32
|
+
"#{resource_path}/record-payment", Util.camelize_keys(params),
|
|
33
|
+
))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Billrb
|
|
4
|
+
# A payment received from a customer. Creating one charges the customer for
|
|
5
|
+
# one or more invoices — the customer must have `authorizedToCharge` set
|
|
6
|
+
# (Customer.authorize_charge) and a bank account (CustomerBankAccount).
|
|
7
|
+
class ReceivablePayment < Resource
|
|
8
|
+
self.resource_path = "/receivable-payments"
|
|
9
|
+
|
|
10
|
+
extend Operations::List
|
|
11
|
+
extend Operations::Retrieve
|
|
12
|
+
extend Operations::Create
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
alias charge create
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Billrb
|
|
4
|
+
# Template for creating identical invoices on a schedule. Modifying a
|
|
5
|
+
# recurring invoice changes all future invoices generated from it.
|
|
6
|
+
class RecurringInvoice < Resource
|
|
7
|
+
self.resource_path = "/recurring-invoices"
|
|
8
|
+
|
|
9
|
+
extend Operations::List
|
|
10
|
+
extend Operations::Retrieve
|
|
11
|
+
extend Operations::Create
|
|
12
|
+
extend Operations::Update
|
|
13
|
+
extend Operations::Replace
|
|
14
|
+
extend Operations::Archive
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/billrb/util.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Billrb
|
|
4
|
+
module Util
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# "organization_id" -> "organizationId". Keys without underscores pass
|
|
8
|
+
# through unchanged, so callers may also use the API's native camelCase.
|
|
9
|
+
def camelize(key)
|
|
10
|
+
parts = key.to_s.split("_")
|
|
11
|
+
parts.first.to_s + parts.drop(1).map(&:capitalize).join
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def camelize_keys(value)
|
|
15
|
+
case value
|
|
16
|
+
when Hash
|
|
17
|
+
value.to_h { |k, v| [camelize(k), camelize_keys(v)] }
|
|
18
|
+
when Array
|
|
19
|
+
value.map { |v| camelize_keys(v) }
|
|
20
|
+
else
|
|
21
|
+
value
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/billrb.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "billrb/version"
|
|
4
|
+
require_relative "billrb/util"
|
|
5
|
+
require_relative "billrb/errors"
|
|
6
|
+
require_relative "billrb/configuration"
|
|
7
|
+
require_relative "billrb/client"
|
|
8
|
+
require_relative "billrb/list_page"
|
|
9
|
+
require_relative "billrb/resource"
|
|
10
|
+
require_relative "billrb/operations"
|
|
11
|
+
require_relative "billrb/resources/customer"
|
|
12
|
+
require_relative "billrb/resources/customer_bank_account"
|
|
13
|
+
require_relative "billrb/resources/invoice"
|
|
14
|
+
require_relative "billrb/resources/credit_memo"
|
|
15
|
+
require_relative "billrb/resources/recurring_invoice"
|
|
16
|
+
require_relative "billrb/resources/receivable_payment"
|
|
17
|
+
|
|
18
|
+
module Billrb
|
|
19
|
+
class << self
|
|
20
|
+
def configuration
|
|
21
|
+
@configuration ||= Configuration.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def configure
|
|
25
|
+
yield(configuration)
|
|
26
|
+
@client = nil
|
|
27
|
+
configuration
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Default client built from the module-level configuration. For
|
|
31
|
+
# multi-tenant apps, build clients directly: Billrb::Client.new(dev_key: ...)
|
|
32
|
+
def client
|
|
33
|
+
@client ||= Client.new(configuration)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def reset!
|
|
37
|
+
@configuration = nil
|
|
38
|
+
@client = nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: billrb
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Carlos Aguilar
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: faraday
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2.0'
|
|
26
|
+
description: Lightweight, extensible Ruby client for the BILL (bill.com) v3 REST API.
|
|
27
|
+
Covers Accounts Receivable resources such as customers and invoices, with a small
|
|
28
|
+
resource layer that makes new endpoints easy to add.
|
|
29
|
+
email:
|
|
30
|
+
- carlos.aguilar@tecmanic.com
|
|
31
|
+
executables: []
|
|
32
|
+
extensions: []
|
|
33
|
+
extra_rdoc_files: []
|
|
34
|
+
files:
|
|
35
|
+
- CHANGELOG.md
|
|
36
|
+
- LICENSE.txt
|
|
37
|
+
- README.md
|
|
38
|
+
- lib/billrb.rb
|
|
39
|
+
- lib/billrb/client.rb
|
|
40
|
+
- lib/billrb/configuration.rb
|
|
41
|
+
- lib/billrb/errors.rb
|
|
42
|
+
- lib/billrb/list_page.rb
|
|
43
|
+
- lib/billrb/operations.rb
|
|
44
|
+
- lib/billrb/resource.rb
|
|
45
|
+
- lib/billrb/resources/credit_memo.rb
|
|
46
|
+
- lib/billrb/resources/customer.rb
|
|
47
|
+
- lib/billrb/resources/customer_bank_account.rb
|
|
48
|
+
- lib/billrb/resources/invoice.rb
|
|
49
|
+
- lib/billrb/resources/receivable_payment.rb
|
|
50
|
+
- lib/billrb/resources/recurring_invoice.rb
|
|
51
|
+
- lib/billrb/util.rb
|
|
52
|
+
- lib/billrb/version.rb
|
|
53
|
+
homepage: https://github.com/TECMANIC/billrb
|
|
54
|
+
licenses:
|
|
55
|
+
- MIT
|
|
56
|
+
metadata:
|
|
57
|
+
homepage_uri: https://github.com/TECMANIC/billrb
|
|
58
|
+
source_code_uri: https://github.com/TECMANIC/billrb
|
|
59
|
+
changelog_uri: https://github.com/TECMANIC/billrb/blob/main/CHANGELOG.md
|
|
60
|
+
rubygems_mfa_required: 'true'
|
|
61
|
+
rdoc_options: []
|
|
62
|
+
require_paths:
|
|
63
|
+
- lib
|
|
64
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.1'
|
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '0'
|
|
74
|
+
requirements: []
|
|
75
|
+
rubygems_version: 3.6.7
|
|
76
|
+
specification_version: 4
|
|
77
|
+
summary: Ruby client for the BILL (bill.com) v3 API, focused on Accounts Receivable.
|
|
78
|
+
test_files: []
|