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 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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Billrb
4
+ VERSION = "0.1.0"
5
+ 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: []