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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +26 -4
  4. data/Rakefile +3 -3
  5. data/docker-compose.yml +17 -0
  6. data/docs/accounting/branding-theme.md +1 -1
  7. data/docs/accounting/organisation.md +1 -1
  8. data/docs/accounting/user.md +1 -1
  9. data/docs/client.md +3 -3
  10. data/docs/connections.md +1 -1
  11. data/docs/errors.md +10 -10
  12. data/docs/getting-started.md +2 -2
  13. data/docs/oauth.md +2 -2
  14. data/docs/plans/attribute-dsl-refactor.md +301 -0
  15. data/docs/retries-and-rate-limits.md +17 -15
  16. data/docs/throttling.md +183 -0
  17. data/docs/tokens.md +8 -8
  18. data/lib/xero_kiwi/accounting/address.rb +12 -43
  19. data/lib/xero_kiwi/accounting/allocation.rb +7 -52
  20. data/lib/xero_kiwi/accounting/branding_theme.rb +9 -63
  21. data/lib/xero_kiwi/accounting/contact.rb +48 -134
  22. data/lib/xero_kiwi/accounting/contact_group.rb +7 -42
  23. data/lib/xero_kiwi/accounting/contact_person.rb +5 -31
  24. data/lib/xero_kiwi/accounting/credit_note.rb +27 -100
  25. data/lib/xero_kiwi/accounting/external_link.rb +3 -26
  26. data/lib/xero_kiwi/accounting/hydrator.rb +85 -0
  27. data/lib/xero_kiwi/accounting/invoice.rb +40 -127
  28. data/lib/xero_kiwi/accounting/line_item.rb +15 -51
  29. data/lib/xero_kiwi/accounting/organisation.rb +40 -117
  30. data/lib/xero_kiwi/accounting/overpayment.rb +23 -92
  31. data/lib/xero_kiwi/accounting/payment.rb +23 -92
  32. data/lib/xero_kiwi/accounting/payment_terms.rb +13 -22
  33. data/lib/xero_kiwi/accounting/phone.rb +5 -30
  34. data/lib/xero_kiwi/accounting/prepayment.rb +24 -94
  35. data/lib/xero_kiwi/accounting/resource.rb +153 -0
  36. data/lib/xero_kiwi/accounting/tracking_category.rb +5 -30
  37. data/lib/xero_kiwi/accounting/user.rb +10 -65
  38. data/lib/xero_kiwi/client.rb +8 -3
  39. data/lib/xero_kiwi/throttle/middleware.rb +30 -0
  40. data/lib/xero_kiwi/throttle/null_limiter.rb +17 -0
  41. data/lib/xero_kiwi/throttle/redis_token_bucket.rb +170 -0
  42. data/lib/xero_kiwi/throttle.rb +35 -0
  43. data/lib/xero_kiwi/version.rb +1 -1
  44. data/lib/xero_kiwi.rb +3 -0
  45. data/llms-full.txt +74 -50
  46. metadata +24 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f6a32ee3719881fa7bd110e3bd1abb532e76dfdcddeb93e580199deacf1fe35
4
- data.tar.gz: 3b7ea0828116a3f20ce4557d17f85540859d977db14e1db40cd7113321ecaaa8
3
+ metadata.gz: 4deb1de10e85de555f19546d2d2243135b2f95fff462e45a2cac72e8622a9e31
4
+ data.tar.gz: 46dfc6f3e6eeeda92546e95b3161aea9802fb401e823d0f2ca67d91bab3555d7
5
5
  SHA512:
6
- metadata.gz: 7182cd4adc572b4fe80ac9a43a3ed5f7075404d5eb3c25ae26e843f3a9b600d6891e5feaafcac202b0cd57258b20b63e72d318766080cf3b42a65c74b055c9d8
7
- data.tar.gz: fb570096febd7f79e7d5643c87a81b7d5839156d576a430a267b1ca44b35fa0ab5c37723e63b8470b679e30bb88a5264c5134ca91b4caea072b7cb05c4372cd5
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. XeroKiwi handles
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
- XeroKiwi requires Ruby 3.4.1 or newer.
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 XeroKiwi handles 429s and transient failures, customising the retry policy |
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
- XeroKiwi is in early development. The API surface for the features documented above
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 << "# XeroKiwi — full documentation\n\n"
48
- out << "This file is the complete documentation for the XeroKiwi 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"
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
@@ -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)/`). XeroKiwi parses both .NET JSON and ISO 8601
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
- XeroKiwi handles both transparently — all `Time` attributes are parsed to UTC
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.
@@ -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)/`). XeroKiwi parses both .NET JSON and ISO 8601
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 XeroKiwi exceptions.
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
- XeroKiwi uses Faraday under the hood, so you can swap the HTTP adapter for
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 XeroKiwi's defaults, so you only need to specify
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
- XeroKiwi force-appends a `Z` before parsing so you always get a UTC `Time` back —
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 XeroKiwi raises a typed exception. This page walks through
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 XeroKiwi raised")
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 XeroKiwi raises** descends from `XeroKiwi::Error`. If you only want
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 XeroKiwi
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, XeroKiwi will already have tried to
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. XeroKiwi retries 502/503/504 automatically
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 XeroKiwi
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 XeroKiwi failure"
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.** XeroKiwi already retries the
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 XeroKiwi is already doing.
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
@@ -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 XeroKiwi to your `Gemfile`:
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
- XeroKiwi is built around a small set of objects, each with one job:
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 XeroKiwi will raise `XeroKiwi::OAuth::CodeExchangeError`.
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.** XeroKiwi gives you `generate_state` and
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.