xero-kiwi 0.1.1 → 0.2.1
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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +26 -4
- data/Rakefile +3 -3
- data/docker-compose.yml +17 -0
- data/docs/accounting/branding-theme.md +1 -1
- data/docs/accounting/organisation.md +1 -1
- data/docs/accounting/user.md +1 -1
- data/docs/client.md +3 -3
- data/docs/connections.md +1 -1
- data/docs/errors.md +10 -10
- data/docs/getting-started.md +2 -2
- data/docs/oauth.md +2 -2
- data/docs/plans/attribute-dsl-refactor.md +301 -0
- data/docs/retries-and-rate-limits.md +17 -15
- data/docs/throttling.md +183 -0
- data/docs/tokens.md +8 -8
- data/lib/xero_kiwi/accounting/address.rb +12 -43
- data/lib/xero_kiwi/accounting/allocation.rb +7 -52
- data/lib/xero_kiwi/accounting/branding_theme.rb +9 -63
- data/lib/xero_kiwi/accounting/contact.rb +48 -134
- data/lib/xero_kiwi/accounting/contact_group.rb +7 -42
- data/lib/xero_kiwi/accounting/contact_person.rb +5 -31
- data/lib/xero_kiwi/accounting/credit_note.rb +27 -100
- data/lib/xero_kiwi/accounting/external_link.rb +3 -26
- data/lib/xero_kiwi/accounting/hydrator.rb +85 -0
- data/lib/xero_kiwi/accounting/invoice.rb +40 -127
- data/lib/xero_kiwi/accounting/line_item.rb +15 -51
- data/lib/xero_kiwi/accounting/organisation.rb +40 -117
- data/lib/xero_kiwi/accounting/overpayment.rb +23 -92
- data/lib/xero_kiwi/accounting/payment.rb +23 -92
- data/lib/xero_kiwi/accounting/payment_terms.rb +13 -22
- data/lib/xero_kiwi/accounting/phone.rb +5 -30
- data/lib/xero_kiwi/accounting/prepayment.rb +24 -94
- data/lib/xero_kiwi/accounting/resource.rb +153 -0
- data/lib/xero_kiwi/accounting/tracking_category.rb +5 -30
- data/lib/xero_kiwi/accounting/user.rb +10 -65
- data/lib/xero_kiwi/client.rb +8 -3
- data/lib/xero_kiwi/throttle/middleware.rb +30 -0
- data/lib/xero_kiwi/throttle/null_limiter.rb +17 -0
- data/lib/xero_kiwi/throttle/redis_token_bucket.rb +170 -0
- data/lib/xero_kiwi/throttle.rb +35 -0
- data/lib/xero_kiwi/version.rb +1 -1
- data/lib/xero_kiwi.rb +3 -0
- data/llms-full.txt +74 -50
- metadata +24 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4deb1de10e85de555f19546d2d2243135b2f95fff462e45a2cac72e8622a9e31
|
|
4
|
+
data.tar.gz: 46dfc6f3e6eeeda92546e95b3161aea9802fb401e823d0f2ca67d91bab3555d7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 781f5c1cfde039f7746984185d218d2fa9e07ceb66cb660d7227291f7e45210f97f590a97cb0d24dfa7c265843c98a37b3d4fd857b41d6cd6ab7e53d1db8d646
|
|
7
|
+
data.tar.gz: 727152993b2424d6513c44947db8b75cd4b1a8734b000e390644c59154ba72804cd48cf16b36e0df299df250b1b6a5eebeace82aba0319adc924a30125f5bdc6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.1] - 2026-04-17
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- Internal refactor of the accounting resource classes. Each resource now declares its fields through a shared `attribute` DSL (`lib/xero_kiwi/accounting/resource.rb`) rather than an `ATTRIBUTES` constant + hand-written `initialize`. Hydration logic (including the `/Date(ms)/` and ISO 8601 parsing previously duplicated across nine files) lives in a single `XeroKiwi::Accounting::Hydrator` module. The mixin also provides default `==` / `eql?` / `hash` (via an `identity :xxx_id` declaration for resources with a server-side primary key, structural `to_h`-based otherwise) and an ActiveRecord-style `inspect` that shows every attribute inline — nested objects collapse to a one-line reference and collections to a `[N items]` summary. No public API changes — constructor signatures and return types are preserved.
|
|
8
|
+
|
|
9
|
+
## [0.2.0] - 2026-04-15
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Optional proactive rate-limit throttling via a Redis-backed token bucket, keyed per tenant. Pass `throttle:` to `XeroKiwi::Client.new` to coordinate rate limits across processes (e.g. multiple Sidekiq workers hitting the same Xero tenant). Supports per-minute and per-day limits; per-minute waits are bounded by `max_wait`, per-day exhaustion raises `XeroKiwi::Throttle::DailyLimitExhausted`. Composes with the existing reactive retry layer — neither replaces the other. See `docs/throttling.md`.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- `redis` is now a runtime dependency (used only if you opt into throttling).
|
|
18
|
+
|
|
3
19
|
## [0.1.1] - 2026-04-15
|
|
4
20
|
|
|
5
21
|
- Add `lib/xero-kiwi.rb` shim so `gem "xero-kiwi"` in a Gemfile auto-requires the gem without needing `require: "xero_kiwi"`.
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Xero Kiwi
|
|
2
2
|
|
|
3
|
-
A Ruby wrapper for the [Xero](https://www.xero.com) Accounting API.
|
|
3
|
+
A Ruby wrapper for the [Xero](https://www.xero.com) Accounting API. Xero Kiwi handles
|
|
4
4
|
the unglamorous parts of integrating with Xero — OAuth2, token refresh, rate
|
|
5
5
|
limiting, retries — so the rest of your code can focus on the actual business
|
|
6
6
|
problem.
|
|
@@ -33,7 +33,7 @@ gem "xero-kiwi"
|
|
|
33
33
|
|
|
34
34
|
Then run `bundle install`.
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
Xero Kiwi requires Ruby 3.4.1 or newer.
|
|
37
37
|
|
|
38
38
|
## Quick start
|
|
39
39
|
|
|
@@ -70,14 +70,36 @@ below.
|
|
|
70
70
|
| [Tokens](docs/tokens.md) | The `XeroKiwi::Token` value object, automatic refresh, revocation, persistence callbacks |
|
|
71
71
|
| [OAuth](docs/oauth.md) | Authorization URL building, code exchange, PKCE, ID token verification, full Rails-style example |
|
|
72
72
|
| [Errors](docs/errors.md) | The error hierarchy, what to catch and when |
|
|
73
|
-
| [Retries and rate limits](docs/retries-and-rate-limits.md) | How
|
|
73
|
+
| [Retries and rate limits](docs/retries-and-rate-limits.md) | How Xero Kiwi handles 429s and transient failures, customising the retry policy |
|
|
74
|
+
| [Throttling](docs/throttling.md) | Redis-backed token bucket for proactive rate-limit coordination across multiple workers |
|
|
74
75
|
|
|
75
76
|
## Status
|
|
76
77
|
|
|
77
|
-
|
|
78
|
+
Xero Kiwi is in early development. The API surface for the features documented above
|
|
78
79
|
is stable, but expect new resource methods to be added over time. Breaking
|
|
79
80
|
changes will be called out in the [changelog](CHANGELOG.md).
|
|
80
81
|
|
|
82
|
+
## Development
|
|
83
|
+
|
|
84
|
+
The gem runs natively via Bundler — nothing is containerised for everyday
|
|
85
|
+
development. A `docker-compose.yml` exists for *external services* the specs
|
|
86
|
+
need, currently just Redis (used by the throttle limiter's Lua-backed specs).
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
bundle install
|
|
90
|
+
docker compose up -d redis # optional; only needed for :redis-tagged specs
|
|
91
|
+
bundle exec rspec
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Without Redis running, specs tagged `:redis` are filtered out automatically
|
|
95
|
+
(see [spec/spec_helper.rb](spec/spec_helper.rb)) so the suite still passes.
|
|
96
|
+
Override the test Redis URL with `TEST_REDIS_URL=...` if you don't want the
|
|
97
|
+
default `redis://127.0.0.1:6379/15`.
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
docker compose down # when you're done
|
|
101
|
+
```
|
|
102
|
+
|
|
81
103
|
## Contributing
|
|
82
104
|
|
|
83
105
|
Bug reports and pull requests are welcome on GitHub at
|
data/Rakefile
CHANGED
|
@@ -44,8 +44,8 @@ LLMS_FULL_PATH = "llms-full.txt"
|
|
|
44
44
|
# expected output.
|
|
45
45
|
def build_llms_full
|
|
46
46
|
out = +""
|
|
47
|
-
out << "#
|
|
48
|
-
out << "This file is the complete documentation for the
|
|
47
|
+
out << "# Xero Kiwi — full documentation\n\n"
|
|
48
|
+
out << "This file is the complete documentation for the Xero Kiwi gem (a Ruby wrapper for the Xero Accounting API), assembled into a single document for LLM consumption. It contains the README and every doc in the docs/ folder, in reading order.\n\n"
|
|
49
49
|
out << "For the curated index version, see llms.txt in the same directory.\n\n"
|
|
50
50
|
out << "Source: https://github.com/douglasgreyling/xero-kiwi\n\n"
|
|
51
51
|
LLMS_SOURCE_FILES.each { |path| append_file_block(out, path) }
|
|
@@ -57,7 +57,7 @@ def append_file_block(out, path)
|
|
|
57
57
|
out << "\n" << separator << "\n"
|
|
58
58
|
out << "FILE: #{path}\n"
|
|
59
59
|
out << separator << "\n\n"
|
|
60
|
-
out << File.read(path) << "\n"
|
|
60
|
+
out << File.read(path, encoding: "UTF-8") << "\n"
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
namespace :llms do
|
data/docker-compose.yml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
services:
|
|
2
|
+
redis:
|
|
3
|
+
image: redis:7-alpine
|
|
4
|
+
container_name: xero-kiwi-redis
|
|
5
|
+
ports:
|
|
6
|
+
- "6379:6379"
|
|
7
|
+
volumes:
|
|
8
|
+
- redis-data:/data
|
|
9
|
+
healthcheck:
|
|
10
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
11
|
+
interval: 5s
|
|
12
|
+
timeout: 2s
|
|
13
|
+
retries: 5
|
|
14
|
+
restart: unless-stopped
|
|
15
|
+
|
|
16
|
+
volumes:
|
|
17
|
+
redis-data:
|
|
@@ -58,7 +58,7 @@ sets.
|
|
|
58
58
|
## Date parsing
|
|
59
59
|
|
|
60
60
|
The `created_date_utc` field uses Xero's .NET JSON timestamp format
|
|
61
|
-
(`/Date(946684800000+0000)/`).
|
|
61
|
+
(`/Date(946684800000+0000)/`). Xero Kiwi parses both .NET JSON and ISO 8601
|
|
62
62
|
formats transparently — the attribute is always a UTC `Time` object.
|
|
63
63
|
|
|
64
64
|
## Error behaviour
|
|
@@ -82,7 +82,7 @@ API. Connections use ISO 8601 strings (e.g. `"2019-07-09T23:40:30.1833130"`),
|
|
|
82
82
|
but the Accounting API (including Organisation) uses the legacy .NET JSON
|
|
83
83
|
format: `/Date(1574275974000)/`.
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
Xero Kiwi handles both transparently — all `Time` attributes are parsed to UTC
|
|
86
86
|
`Time` objects regardless of which format Xero sends. You don't need to think
|
|
87
87
|
about this unless you're looking at raw cassette data or debugging timestamp
|
|
88
88
|
issues.
|
data/docs/accounting/user.md
CHANGED
|
@@ -74,7 +74,7 @@ Two users are `==` if they share the same `user_id`. `#hash` is consistent with
|
|
|
74
74
|
## Date parsing
|
|
75
75
|
|
|
76
76
|
The `updated_date_utc` field uses Xero's .NET JSON timestamp format
|
|
77
|
-
(`/Date(1516230549137+0000)/`).
|
|
77
|
+
(`/Date(1516230549137+0000)/`). Xero Kiwi parses both .NET JSON and ISO 8601
|
|
78
78
|
formats transparently — the attribute is always a UTC `Time` object.
|
|
79
79
|
|
|
80
80
|
## Error behaviour
|
data/docs/client.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
`XeroKiwi::Client` is the entry point for talking to Xero's accounting API. You
|
|
4
4
|
construct one with credentials and call resource methods on it. The client
|
|
5
5
|
holds the OAuth token state, knows how to refresh it, and translates HTTP
|
|
6
|
-
errors into
|
|
6
|
+
errors into Xero Kiwi exceptions.
|
|
7
7
|
|
|
8
8
|
## Constructing a client
|
|
9
9
|
|
|
@@ -97,7 +97,7 @@ skipped: a 401 raises immediately and you handle it in your own code.
|
|
|
97
97
|
|
|
98
98
|
## Custom adapters
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
Xero Kiwi uses Faraday under the hood, so you can swap the HTTP adapter for
|
|
101
101
|
testing or for connection pooling:
|
|
102
102
|
|
|
103
103
|
```ruby
|
|
@@ -123,7 +123,7 @@ test adapter swallows refresh requests too.
|
|
|
123
123
|
|
|
124
124
|
## Customising the retry policy
|
|
125
125
|
|
|
126
|
-
`retry_options:` is merged into
|
|
126
|
+
`retry_options:` is merged into Xero Kiwi's defaults, so you only need to specify
|
|
127
127
|
overrides:
|
|
128
128
|
|
|
129
129
|
```ruby
|
data/docs/connections.md
CHANGED
|
@@ -74,7 +74,7 @@ old_connections - new_connections # diff by id
|
|
|
74
74
|
|
|
75
75
|
Xero serialises dates in C# DateTime format and frequently omits the timezone
|
|
76
76
|
marker on values that are documented as UTC (e.g. `"2019-07-09T23:40:30.1833130"`).
|
|
77
|
-
|
|
77
|
+
Xero Kiwi force-appends a `Z` before parsing so you always get a UTC `Time` back —
|
|
78
78
|
without this, `Time.parse` would silently fall back to local time and you'd
|
|
79
79
|
get the wrong instant.
|
|
80
80
|
|
data/docs/errors.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Errors
|
|
2
2
|
|
|
3
|
-
Every failure path in
|
|
3
|
+
Every failure path in Xero Kiwi raises a typed exception. This page walks through
|
|
4
4
|
the hierarchy, explains what each class means, and tells you what to catch
|
|
5
5
|
in common situations.
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ in common situations.
|
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
StandardError
|
|
11
|
-
└─ XeroKiwi::Error (root — catch this for "anything
|
|
11
|
+
└─ XeroKiwi::Error (root — catch this for "anything Xero Kiwi raised")
|
|
12
12
|
├─ XeroKiwi::APIError (root for HTTP responses; carries status + body)
|
|
13
13
|
│ ├─ XeroKiwi::AuthenticationError (401)
|
|
14
14
|
│ │ ├─ XeroKiwi::TokenRefreshError (refresh round-trip failed)
|
|
@@ -22,7 +22,7 @@ StandardError
|
|
|
22
22
|
|
|
23
23
|
A few things worth noticing about the shape:
|
|
24
24
|
|
|
25
|
-
- **Everything
|
|
25
|
+
- **Everything Xero Kiwi raises** descends from `XeroKiwi::Error`. If you only want
|
|
26
26
|
one rescue clause for "the Xero integration broke," catch this.
|
|
27
27
|
- **HTTP responses** descend from `XeroKiwi::APIError`, which carries `status`
|
|
28
28
|
and `body` attributes you can inspect.
|
|
@@ -38,7 +38,7 @@ A few things worth noticing about the shape:
|
|
|
38
38
|
### `XeroKiwi::Error`
|
|
39
39
|
|
|
40
40
|
The root class. Inherits from `StandardError`. You almost never raise this
|
|
41
|
-
directly — it exists as a catch-all for code that wants to rescue "any
|
|
41
|
+
directly — it exists as a catch-all for code that wants to rescue "any Xero Kiwi
|
|
42
42
|
problem" without enumerating every subclass.
|
|
43
43
|
|
|
44
44
|
### `XeroKiwi::APIError`
|
|
@@ -62,7 +62,7 @@ The access token was rejected. The most common causes:
|
|
|
62
62
|
- Token has the wrong scopes for this endpoint.
|
|
63
63
|
- The wrong tenant ID was passed in the `Xero-Tenant-Id` header.
|
|
64
64
|
|
|
65
|
-
If your client has refresh capability,
|
|
65
|
+
If your client has refresh capability, Xero Kiwi will already have tried to
|
|
66
66
|
refresh and retry exactly once before this raises. Seeing `AuthenticationError`
|
|
67
67
|
on a refresh-capable client means **the second 401 also failed** — refresh
|
|
68
68
|
won't fix it, and you need to either re-authorise or surface the error.
|
|
@@ -115,9 +115,9 @@ Inspect `error.body` to surface it.
|
|
|
115
115
|
|
|
116
116
|
### `XeroKiwi::ServerError` (HTTP 5xx)
|
|
117
117
|
|
|
118
|
-
A 5xx response that wasn't retried.
|
|
118
|
+
A 5xx response that wasn't retried. Xero Kiwi retries 502/503/504 automatically
|
|
119
119
|
(see [retries and rate limits](retries-and-rate-limits.md)) so by the time
|
|
120
|
-
you see this, the retries are exhausted or the status was 500 (which
|
|
120
|
+
you see this, the retries are exhausted or the status was 500 (which Xero Kiwi
|
|
121
121
|
deliberately doesn't retry, since 500s are usually persistent bugs in the
|
|
122
122
|
request rather than transient infrastructure issues).
|
|
123
123
|
|
|
@@ -164,7 +164,7 @@ The error message has a brief description of which check failed (e.g.
|
|
|
164
164
|
|
|
165
165
|
## What to catch when
|
|
166
166
|
|
|
167
|
-
### "Any
|
|
167
|
+
### "Any Xero Kiwi failure"
|
|
168
168
|
|
|
169
169
|
```ruby
|
|
170
170
|
begin
|
|
@@ -256,12 +256,12 @@ Order matters — `TokenRefreshError` is more specific than
|
|
|
256
256
|
|
|
257
257
|
## Things the error system deliberately does NOT do
|
|
258
258
|
|
|
259
|
-
- **No "this error is retryable" predicate.**
|
|
259
|
+
- **No "this error is retryable" predicate.** Xero Kiwi already retries the
|
|
260
260
|
cases that *should* be retried at the HTTP level. By the time an exception
|
|
261
261
|
reaches your code, the retries are exhausted and the situation is
|
|
262
262
|
application-level. Adding `error.retryable?` would create a tempting
|
|
263
263
|
foot-gun where callers retry inside their own code, doubling up on the
|
|
264
|
-
retries
|
|
264
|
+
retries Xero Kiwi is already doing.
|
|
265
265
|
- **No automatic Sentry / Bugsnag integration.** Errors raise normally;
|
|
266
266
|
configure your own observability layer to catch them at the boundary.
|
|
267
267
|
- **No `error.code` enum.** The HTTP `status` is the enum. Inspecting it
|
data/docs/getting-started.md
CHANGED
|
@@ -16,7 +16,7 @@ Before you can talk to Xero from Ruby, you need:
|
|
|
16
16
|
|
|
17
17
|
## Installing the gem
|
|
18
18
|
|
|
19
|
-
Add
|
|
19
|
+
Add Xero Kiwi to your `Gemfile`:
|
|
20
20
|
|
|
21
21
|
```ruby
|
|
22
22
|
gem "xero-kiwi"
|
|
@@ -30,7 +30,7 @@ bundle install
|
|
|
30
30
|
|
|
31
31
|
## The mental model
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
Xero Kiwi is built around a small set of objects, each with one job:
|
|
34
34
|
|
|
35
35
|
| Object | What it does |
|
|
36
36
|
|--------|--------------|
|
data/docs/oauth.md
CHANGED
|
@@ -198,7 +198,7 @@ token = oauth.exchange_code(
|
|
|
198
198
|
|
|
199
199
|
If you pass a `pkce:` to `authorization_url` but **forget** the
|
|
200
200
|
`code_verifier:` on `exchange_code`, Xero will reject the exchange with
|
|
201
|
-
`invalid_grant` and
|
|
201
|
+
`invalid_grant` and Xero Kiwi will raise `XeroKiwi::OAuth::CodeExchangeError`.
|
|
202
202
|
|
|
203
203
|
## Step 2: handling the callback
|
|
204
204
|
|
|
@@ -489,7 +489,7 @@ end
|
|
|
489
489
|
|
|
490
490
|
## Things OAuth deliberately does NOT do
|
|
491
491
|
|
|
492
|
-
- **No session storage.**
|
|
492
|
+
- **No session storage.** Xero Kiwi gives you `generate_state` and
|
|
493
493
|
`generate_pkce` as helpers but never touches your session/cookies/Redis.
|
|
494
494
|
Where you stash the values is your problem — and that's a feature,
|
|
495
495
|
because every framework is different.
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# Attribute DSL Refactor
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
Every accounting resource class in kiwi repeats the same pattern: an
|
|
6
|
+
`ATTRIBUTES` name-map constant, an `attr_reader(*ATTRIBUTES.keys)` call, and a
|
|
7
|
+
hand-written `initialize` that duplicates hydration logic across nine files.
|
|
8
|
+
`parse_time` alone is copy-pasted into every resource. Nested-object and
|
|
9
|
+
collection hydration (`Contact.new(attrs, reference: true)`,
|
|
10
|
+
`(attrs["LineItems"] || []).map { … }`) follows an identical shape in every
|
|
11
|
+
class but is written by hand each time.
|
|
12
|
+
|
|
13
|
+
This is cheap today but will bite us soon. The upcoming query work
|
|
14
|
+
(filtering/sorting/pagination) needs field-type metadata per attribute — adding
|
|
15
|
+
a second `FIELDS` constant next to `ATTRIBUTES` would double the duplication.
|
|
16
|
+
Writer support down the line will need serialisation metadata too. Both extend
|
|
17
|
+
cleanly from a single attribute declaration.
|
|
18
|
+
|
|
19
|
+
This plan extracts a small `attribute` DSL and migrates every accounting
|
|
20
|
+
resource to it. No behaviour change, no public API change — constructors keep
|
|
21
|
+
the same signature, existing specs keep passing. It's pure preparation for the
|
|
22
|
+
0.3.0 querying work (tracked separately) and for writer support later.
|
|
23
|
+
|
|
24
|
+
## Decisions locked in
|
|
25
|
+
|
|
26
|
+
- **Reader-only.** No serialisation/writer concerns in this refactor. DSL
|
|
27
|
+
naming (`attribute`, not `reader` or `field`) leaves room for it later.
|
|
28
|
+
- **No query metadata yet.** The `query: true` flag and `query_fields` map
|
|
29
|
+
come with the querying plan — not now. One concern at a time.
|
|
30
|
+
- **Public API unchanged.** `Invoice.new(hash, reference: …)` keeps working
|
|
31
|
+
identically. `to_h`, `attr_reader`s, `==`, `hash`, `inspect`, resource-specific
|
|
32
|
+
helper methods (`accounts_receivable?`, etc.) all stay.
|
|
33
|
+
- **Every accounting class migrates** — including nested value types
|
|
34
|
+
(`Address`, `Phone`, `LineItem`, `ContactPerson`, `ExternalLink`,
|
|
35
|
+
`PaymentTerm`, any others). Partial migration would leave two patterns
|
|
36
|
+
coexisting, which is worse than either alone.
|
|
37
|
+
- **Version: flagged, not decided.** Internal refactor with no public API
|
|
38
|
+
change — defensibly a patch (`0.2.1`). Could also bundle under `0.3.0` since
|
|
39
|
+
that's the version the querying work will ship under and this is its
|
|
40
|
+
foundation. Confirm before bumping.
|
|
41
|
+
|
|
42
|
+
## Design
|
|
43
|
+
|
|
44
|
+
### 1. `XeroKiwi::Accounting::Resource` mixin
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
# lib/xero_kiwi/accounting/resource.rb
|
|
48
|
+
module XeroKiwi
|
|
49
|
+
module Accounting
|
|
50
|
+
module Resource
|
|
51
|
+
def self.included(base) = base.extend(ClassMethods)
|
|
52
|
+
|
|
53
|
+
module ClassMethods
|
|
54
|
+
def payload_key(key) = @payload_key = key
|
|
55
|
+
|
|
56
|
+
def attribute(name, xero:, type: :string, of: nil, hydrate: nil)
|
|
57
|
+
attributes[name] = { xero: xero, type: type, of: of, hydrate: hydrate }
|
|
58
|
+
attr_reader name
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def attributes = (@attributes ||= {})
|
|
62
|
+
|
|
63
|
+
def from_response(payload)
|
|
64
|
+
return [] if payload.nil?
|
|
65
|
+
|
|
66
|
+
items = payload[@payload_key]
|
|
67
|
+
return [] if items.nil?
|
|
68
|
+
|
|
69
|
+
items.map { |attrs| new(attrs) }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def initialize(attrs, reference: false)
|
|
74
|
+
attrs = attrs.transform_keys(&:to_s)
|
|
75
|
+
@is_reference = reference
|
|
76
|
+
|
|
77
|
+
self.class.attributes.each do |name, spec|
|
|
78
|
+
value = Hydrator.call(attrs[spec[:xero]], spec)
|
|
79
|
+
instance_variable_set("@#{name}", value)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def reference? = @is_reference
|
|
84
|
+
|
|
85
|
+
def to_h
|
|
86
|
+
self.class.attributes.keys.to_h { |k| [k, public_send(k)] }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 2. `XeroKiwi::Accounting::Hydrator`
|
|
94
|
+
|
|
95
|
+
Shared hydration dispatch and the single home for `parse_time`.
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
# lib/xero_kiwi/accounting/hydrator.rb
|
|
99
|
+
module XeroKiwi
|
|
100
|
+
module Accounting
|
|
101
|
+
module Hydrator
|
|
102
|
+
module_function
|
|
103
|
+
|
|
104
|
+
def call(raw, spec)
|
|
105
|
+
return spec[:hydrate].call(raw) if spec[:hydrate]
|
|
106
|
+
return [] if spec[:type] == :collection && raw.nil?
|
|
107
|
+
return nil if raw.nil?
|
|
108
|
+
|
|
109
|
+
case spec[:type]
|
|
110
|
+
when :string, :enum, :guid, :bool, :decimal
|
|
111
|
+
raw
|
|
112
|
+
when :date
|
|
113
|
+
parse_time(raw)
|
|
114
|
+
when :object
|
|
115
|
+
spec[:of].new(raw, reference: true)
|
|
116
|
+
when :collection
|
|
117
|
+
raw.map { |item| spec[:of].new(item) }
|
|
118
|
+
else
|
|
119
|
+
raise ArgumentError, "unknown attribute type: #{spec[:type]}"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def parse_time(value)
|
|
124
|
+
return nil if value.nil?
|
|
125
|
+
|
|
126
|
+
str = value.to_s.strip
|
|
127
|
+
return nil if str.empty?
|
|
128
|
+
|
|
129
|
+
if (match = str.match(%r{\A/Date\((\d+)([+-]\d{4})?\)/\z}))
|
|
130
|
+
Time.at(match[1].to_i / 1000.0).utc
|
|
131
|
+
else
|
|
132
|
+
str = "#{str}Z" unless str.match?(/[Zz]\z|[+-]\d{2}:?\d{2}\z/)
|
|
133
|
+
Time.iso8601(str)
|
|
134
|
+
end
|
|
135
|
+
rescue ArgumentError
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### 3. Supported attribute types
|
|
144
|
+
|
|
145
|
+
| Type | Hydrates to | Notes |
|
|
146
|
+
|---------------|-------------------------------------------------|--------------------------------------------|
|
|
147
|
+
| `:string` | value as-is | default |
|
|
148
|
+
| `:enum` | value as-is | semantic marker for future validation |
|
|
149
|
+
| `:guid` | value as-is | semantic marker for future query typing |
|
|
150
|
+
| `:bool` | value as-is | |
|
|
151
|
+
| `:decimal` | value as-is | Xero returns these as numbers or strings |
|
|
152
|
+
| `:date` | `Time` via `Hydrator.parse_time` | handles `/Date(ms)/` and ISO8601 |
|
|
153
|
+
| `:object` | `spec[:of].new(raw, reference: true)` | requires `of:` |
|
|
154
|
+
| `:collection` | `raw.map { spec[:of].new(item) }`, `nil` → `[]` | requires `of:` |
|
|
155
|
+
|
|
156
|
+
Escape hatch: `hydrate: ->(raw) { … }` for one-off fields the built-in types
|
|
157
|
+
can't express. Runs before dispatch.
|
|
158
|
+
|
|
159
|
+
### 4. Before / after (example)
|
|
160
|
+
|
|
161
|
+
Invoice before — 40+ lines of `initialize`, `parse_time` method, `ATTRIBUTES`
|
|
162
|
+
constant duplicated:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
ATTRIBUTES = { invoice_id: "InvoiceID", … }.freeze
|
|
166
|
+
attr_reader(*ATTRIBUTES.keys)
|
|
167
|
+
|
|
168
|
+
def initialize(attrs, reference: false)
|
|
169
|
+
attrs = attrs.transform_keys(&:to_s)
|
|
170
|
+
@is_reference = reference
|
|
171
|
+
@invoice_id = attrs["InvoiceID"]
|
|
172
|
+
@invoice_number = attrs["InvoiceNumber"]
|
|
173
|
+
@contact = attrs["Contact"] ? Contact.new(attrs["Contact"], reference: true) : nil
|
|
174
|
+
@date = parse_time(attrs["Date"])
|
|
175
|
+
@line_items = (attrs["LineItems"] || []).map { |li| LineItem.new(li) }
|
|
176
|
+
# … 30 more lines …
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
def parse_time(value)
|
|
182
|
+
# … 15 lines, duplicated in 9 files …
|
|
183
|
+
end
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
After — one declaration per field, no `initialize`, no `parse_time`:
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
include Accounting::Resource
|
|
190
|
+
|
|
191
|
+
payload_key "Invoices"
|
|
192
|
+
|
|
193
|
+
attribute :invoice_id, xero: "InvoiceID", type: :guid
|
|
194
|
+
attribute :invoice_number, xero: "InvoiceNumber", type: :string
|
|
195
|
+
attribute :contact, xero: "Contact", type: :object, of: Contact
|
|
196
|
+
attribute :date, xero: "Date", type: :date
|
|
197
|
+
attribute :line_items, xero: "LineItems", type: :collection, of: LineItem
|
|
198
|
+
# …
|
|
199
|
+
|
|
200
|
+
def accounts_receivable? = type == "ACCREC"
|
|
201
|
+
def accounts_payable? = type == "ACCPAY"
|
|
202
|
+
|
|
203
|
+
def ==(other) = other.is_a?(Invoice) && other.invoice_id == invoice_id
|
|
204
|
+
alias eql? ==
|
|
205
|
+
def hash = [self.class, invoice_id].hash
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### 5. Edge cases to preserve
|
|
209
|
+
|
|
210
|
+
- **Raw pass-through fields** that are currently kept as plain hashes/arrays
|
|
211
|
+
(e.g. `Invoice#invoice_addresses`, `Contact#bank_account_details`) — use
|
|
212
|
+
`type: :string` (misnomer but harmless) or the `hydrate:` escape hatch with
|
|
213
|
+
an identity lambda. Audit each during migration.
|
|
214
|
+
- **`reference: true` semantics** — nested `:object` attributes always hydrate
|
|
215
|
+
with `reference: true`; nested `:collection` attributes hydrate without it
|
|
216
|
+
(full objects). This matches today's behaviour per-file. If any class
|
|
217
|
+
currently deviates, that deviation gets a `hydrate:` escape hatch.
|
|
218
|
+
- **Resource-specific helpers** (`accounts_receivable?`, `reference?`,
|
|
219
|
+
`inspect`, `==`, `hash`) stay inline on each class.
|
|
220
|
+
- **Classes without list endpoints** (nested value types — `Address`, `Phone`,
|
|
221
|
+
etc.) use the DSL but don't call `payload_key`. `from_response` on them isn't
|
|
222
|
+
called; the method existing but unused is harmless.
|
|
223
|
+
|
|
224
|
+
## Files
|
|
225
|
+
|
|
226
|
+
### Create
|
|
227
|
+
|
|
228
|
+
- `lib/xero_kiwi/accounting/resource.rb` — the DSL mixin.
|
|
229
|
+
- `lib/xero_kiwi/accounting/hydrator.rb` — shared hydration + `parse_time`.
|
|
230
|
+
- `spec/xero_kiwi/accounting/resource_spec.rb` — DSL unit tests.
|
|
231
|
+
- `spec/xero_kiwi/accounting/hydrator_spec.rb` — hydrator unit tests.
|
|
232
|
+
|
|
233
|
+
### Modify
|
|
234
|
+
|
|
235
|
+
- `lib/xero_kiwi.rb` — `require` the new files before any accounting class.
|
|
236
|
+
- Every `lib/xero_kiwi/accounting/*.rb` — migrate to `attribute` DSL, drop
|
|
237
|
+
`ATTRIBUTES`, drop hand-written `initialize`, drop private `parse_time`.
|
|
238
|
+
Concrete list: `contact.rb`, `contact_group.rb`, `contact_person.rb`,
|
|
239
|
+
`invoice.rb`, `credit_note.rb`, `prepayment.rb`, `overpayment.rb`,
|
|
240
|
+
`payment.rb`, `user.rb`, `branding_theme.rb`, `organisation.rb`, `address.rb`,
|
|
241
|
+
`phone.rb`, `external_link.rb`, `payment_term.rb`, `line_item.rb`, and any
|
|
242
|
+
other value classes in `lib/xero_kiwi/accounting/`.
|
|
243
|
+
- `lib/xero_kiwi/version.rb` — bump (0.2.1 or 0.3.0 — confirm).
|
|
244
|
+
- `CHANGELOG.md` — **Changed** entry describing the internal refactor.
|
|
245
|
+
- `Gemfile.lock` — rebuild after version bump.
|
|
246
|
+
|
|
247
|
+
## Testing strategy
|
|
248
|
+
|
|
249
|
+
- **Hydrator unit specs:** each type dispatches correctly. `/Date(ms)/` parses.
|
|
250
|
+
ISO8601 with and without timezone parses. Empty/invalid strings return
|
|
251
|
+
`nil`. `:object` hydrates nested reference. `:collection` with `nil` →
|
|
252
|
+
`[]`, populated → `map`ped. `:collection` with `nil` + `hydrate:` runs the
|
|
253
|
+
lambda. Unknown type raises.
|
|
254
|
+
- **Resource unit specs:** declare a throwaway class inside the spec with a
|
|
255
|
+
couple of attributes of each type, hydrate a fixture hash, assert every
|
|
256
|
+
reader returns the expected value, `to_h` returns a keyed hash, `payload_key`
|
|
257
|
+
+ `from_response` parse an envelope.
|
|
258
|
+
- **Existing accounting resource specs carry the real load.** They already
|
|
259
|
+
call `Class.new(fixture_hash)` and assert every reader — they should pass
|
|
260
|
+
unchanged. A regression in the DSL surfaces as real-resource spec failures,
|
|
261
|
+
which is exactly what we want.
|
|
262
|
+
- **Client integration specs** — untouched, must still pass green.
|
|
263
|
+
|
|
264
|
+
## Verification
|
|
265
|
+
|
|
266
|
+
```sh
|
|
267
|
+
bundle install
|
|
268
|
+
bundle exec rspec # full suite green
|
|
269
|
+
bundle exec rspec spec/xero_kiwi/accounting # DSL + hydrator + every resource
|
|
270
|
+
bundle exec rspec spec/xero_kiwi/client_spec.rb # list methods unaffected
|
|
271
|
+
bundle exec rubocop # style still clean
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Smoke check in IRB against a recorded fixture or live tenant:
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
require "xero_kiwi"
|
|
278
|
+
|
|
279
|
+
client = XeroKiwi::Client.new(access_token: ENV.fetch("XERO_TOKEN"))
|
|
280
|
+
invoices = client.invoices(tenant)
|
|
281
|
+
inv = invoices.first
|
|
282
|
+
|
|
283
|
+
inv.invoice_id # string
|
|
284
|
+
inv.date # Time
|
|
285
|
+
inv.contact # Accounting::Contact, reference? == true
|
|
286
|
+
inv.line_items # Array<Accounting::LineItem>
|
|
287
|
+
inv.to_h.keys # every attribute name
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## Follow-up
|
|
291
|
+
|
|
292
|
+
Once this lands, the 0.3.0 querying plan picks up by:
|
|
293
|
+
|
|
294
|
+
1. Adding a `query: true` option to `attribute`.
|
|
295
|
+
2. Auto-populating `query_fields` on the class from attributes flagged
|
|
296
|
+
queryable.
|
|
297
|
+
3. Building `Query::Filter` / `Query::Order` compilers against that map.
|
|
298
|
+
4. Adding the `Page` return type, `each_*` helpers, and `modified_since`
|
|
299
|
+
support on the client.
|
|
300
|
+
|
|
301
|
+
None of which requires re-touching the resource files.
|