xero-kiwi 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 353faef8c28dbf7801d5c9e8ebd152e0db172ede1aa69378144ac47cfa603436
4
- data.tar.gz: b7decf2d114e9cbfba300c0274d6d9a3604c8d464ce080ac46b4a6df1e23d548
3
+ metadata.gz: b980ba3e8f03590d2ca887887d8509268bf87df52b882eef4730a9ce1512250c
4
+ data.tar.gz: bdd4952e2b7d1fd3af201f2446d17d5cd7f2bfe86e58ee9002f4115cc5624240
5
5
  SHA512:
6
- metadata.gz: ff17d3c14fb35e1d6b3334935cda29c4fba9441ab012692dabc5518bb116a6615007e62a2e3c5c06a9c490f0b56d1fc16f8f42f9ff4b59d389088d68f7b2e3b9
7
- data.tar.gz: ed0c6a55bcf9ed6d013f02ad76a3486a8d66f59961b959d85d7ed3e2a9f53c85cf43edb5e6f454f8c5fa556ad73983086105860983ca16c3db23633d39b3e31e
6
+ metadata.gz: e7086fef7948e63b5d9437a164a241f819e5a96beead2eb2701989613e8b95f643d805be209a63b98ab3dcd278c7e81f46165de054f1fd865318abb82a13e0e9
7
+ data.tar.gz: 94c08345afefa1b130634ab9d643052d14a24ba5200ae60a7b324344706d71465e9cecb6ce30e183876dafdf7bde0fcdbbaea487869ea99073bb388f2a7f8ab2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] - 2026-09-26
4
+
5
+ The sync-support release: everything needed to drive a full-tenant sync through kiwi rather than around it.
6
+
7
+ ### Added
8
+
9
+ - **`page_size:`** on every list method, plus a client-level default (`Client.new(page_size: 1_000)`) that per-call overrides beat. Maps to Xero's `pageSize`. Unset means the parameter is omitted and Xero applies its own default of 100, so existing callers are unaffected. Worth setting for any full sync — at 100 per page a 50,000-invoice tenant costs ~500 API calls against a 5,000/day limit; at 1,000 it costs ~50.
10
+ - **`each_<resource>_page`** for all ten list resources, yielding a whole `XeroKiwi::Page` rather than its items — so the page number reaches the caller, which is what a resumable sync needs to record.
11
+ - **`start_page:`** on both `each_<resource>` and `each_<resource>_page` (default 1), so a resumed run picks up where the last one stopped.
12
+ - **`include_archived:`** on `contacts` / `each_contact` / `each_contact_page`. A distinct query parameter, not a `where` filter: filtering on `contact_status` returns *only* archived contacts, where this returns both kinds in one pass.
13
+ - **`Resource#raw`** — Xero's untouched response hash, behind `Client.new(retain_raw: true)`. Off by default because retaining it roughly doubles the memory of a large page. Populated on resources built directly from a response, not on nested objects; the top-level hash already holds every nested payload, so `contact.raw["ContactPersons"]` gets there. Note `#raw` is not `#to_h` — `to_h` is a snake_case projection with different keys and different nesting.
14
+ - **`client.rate_limit(tenant_id)`** returning a `XeroKiwi::RateLimit`. Blends what Xero's headers last reported for that tenant with what the configured throttle bucket holds, and reports **the stricter of the two** — a configured limit is a ceiling you chose, a reported limit is reality. Exposes `day_remaining`, `minute_remaining`, `day_below?`, `minute_below?`, `day_source`, `minute_source`, `reported`, `configured` and `known?`. Headers are captured on every response including errors, since a 429 is when they matter most. With nothing known, `day_below?` returns false — not knowing isn't a reason to halt a sync.
15
+ - **`RedisTokenBucket#remaining(key)`** — current token counts without spending one, via a separate read-only Lua script. `#remaining` is an optional part of the limiter contract; `Client` checks `respond_to?` first, so a custom limiter written against 0.2.0 keeps working.
16
+ - **Tracking categories**: `client.tracking_categories`, `client.tracking_category`, `each_tracking_category` and `each_tracking_category_page`, with a new `Accounting::TrackingOption` for the nested `Options` array. See `docs/accounting/tracking-category.md`.
17
+ - `XeroKiwi::Page#reported_page_size` — the page size Xero actually stated, or nil when the response carried no `pagination` envelope. `page_size` keeps its existing fallback to the item count.
18
+
19
+ ### Breaking
20
+
21
+ - **`Accounting::TrackingCategory` is now the `/TrackingCategories` endpoint resource** (`tracking_category_id`, `name`, `status`, `options`). The flattened category-and-chosen-option pair nested on line items and contacts — which this class used to model — is now **`Accounting::Tracking`**, matching Xero's own field name for it. Update any reference to `XeroKiwi::Accounting::TrackingCategory` that came from `line_item.tracking` or a contact's tracking collections. The two shapes share a name and one ID field and nothing else, which is why they're now separate classes.
22
+
23
+ ### Fixed
24
+
25
+ - Page walks no longer fetch a redundant empty page. `build_page` falls back to the item count when Xero returns no `pagination` envelope, which made the walker's short-page check compare a number to itself (`items.size < items.size`) and never fire, leaving `empty?` as the only way to stop. The walk now measures against Xero's stated page size when there is one and the largest page seen so far otherwise — deliberately **not** against the requested `page_size`, since Xero clamps a request above an endpoint's maximum and a walk that asked for 2,000 where the cap is 1,000 would have seen page 1 as short and truncated the sync.
26
+
27
+ ### Changed
28
+
29
+ - Widened the `jwt` runtime constraint to `>= 2.7, < 4.0` and `redis` to `>= 5.0, < 7.0`. Both majors (`jwt` 3.x, `redis` 6.x) pass the full suite, including the Lua-backed throttle specs against a real Redis. Widening rather than bumping means a host app on `redis` 5 (Sidekiq, Rails cache) isn't forced to move in lockstep with this gem.
30
+ - Dropped the unused `mock_redis` development dependency. The throttle's bucket maths runs as a server-side Lua script, so the specs have always used a real Redis — `mock_redis` was only ever named in the comments explaining why it couldn't be used, and its `redis (~> 5)` runtime pin blocked resolving `redis` 6.
31
+
3
32
  ## [0.4.0] - 2026-04-20
4
33
 
5
34
  ### Added
data/README.md CHANGED
@@ -23,9 +23,16 @@ problem.
23
23
  themes, and nested objects like addresses, phones, external links, and payment
24
24
  terms — all wrapped in proper value objects.
25
25
  - **Querying**: every list endpoint accepts `where` / `order` / `page` /
26
- `modified_since`, with a typed hash DSL (`where: { status: "AUTHORISED" }`)
27
- and raw-string escape hatches. Lazy `each_<resource>` helpers walk every
28
- page for whole-tenant scans and incremental syncs.
26
+ `page_size` / `modified_since`, with a typed hash DSL
27
+ (`where: { status: "AUTHORISED" }`) and raw-string escape hatches. Lazy
28
+ `each_<resource>` helpers walk every page for whole-tenant scans and
29
+ incremental syncs.
30
+ - **Resumable syncs**: `each_<resource>_page` yields whole pages so you can
31
+ record the page number alongside the rows, and `start_page:` picks the walk
32
+ back up where it stopped.
33
+ - **Quota introspection**: `client.rate_limit(tenant_id)` reports how much of
34
+ a tenant's daily and per-minute allowance is left, taking the stricter of
35
+ what Xero reported and what your own throttle bucket holds.
29
36
 
30
37
  ## Installation
31
38
 
@@ -71,12 +78,13 @@ below.
71
78
  | [Overpayments](docs/accounting/overpayment.md) | Listing and fetching overpayments, the `XeroKiwi::Accounting::Overpayment` resource |
72
79
  | [Prepayments](docs/accounting/prepayment.md) | Listing and fetching prepayments, the `XeroKiwi::Accounting::Prepayment` resource, LineItem |
73
80
  | [Branding Themes](docs/accounting/branding-theme.md) | Listing and fetching branding themes, the `XeroKiwi::Accounting::BrandingTheme` resource |
81
+ | [Tracking Categories](docs/accounting/tracking-category.md) | Listing and fetching tracking categories, the `TrackingCategory` / `TrackingOption` / `Tracking` resources |
74
82
  | [Tokens](docs/tokens.md) | The `XeroKiwi::Token` value object, automatic refresh, revocation, persistence callbacks |
75
83
  | [OAuth](docs/oauth.md) | Authorization URL building, code exchange, PKCE, ID token verification, full Rails-style example |
76
84
  | [Errors](docs/errors.md) | The error hierarchy, what to catch and when |
77
85
  | [Retries and rate limits](docs/retries-and-rate-limits.md) | How Xero Kiwi handles 429s and transient failures, customising the retry policy |
78
86
  | [Throttling](docs/throttling.md) | Redis-backed token bucket for proactive rate-limit coordination across multiple workers |
79
- | [Querying](docs/querying.md) | `where` / `order` / `page` / `modified_since` on list endpoints, the `Page` return type, and `each_*` lazy pagination helpers |
87
+ | [Querying](docs/querying.md) | `where` / `order` / `page` / `page_size` / `include_archived` / `modified_since` on list endpoints, the `Page` return type, `each_*` and `each_*_page` helpers, and `start_page:` resumability |
80
88
 
81
89
  ## Status
82
90
 
data/Rakefile CHANGED
@@ -28,12 +28,14 @@ LLMS_SOURCE_FILES = %w[
28
28
  docs/accounting/overpayment.md
29
29
  docs/accounting/prepayment.md
30
30
  docs/accounting/branding-theme.md
31
+ docs/accounting/tracking-category.md
31
32
  docs/accounting/address.md
32
33
  docs/accounting/phone.md
33
34
  docs/accounting/external-link.md
34
35
  docs/accounting/payment-terms.md
35
36
  docs/errors.md
36
37
  docs/retries-and-rate-limits.md
38
+ docs/throttling.md
37
39
  docs/querying.md
38
40
  ].freeze
39
41
 
@@ -0,0 +1,150 @@
1
+ # Tracking Categories
2
+
3
+ Xero **tracking categories** let an organisation tag transactions along its
4
+ own dimensions — Region, Department, Cost Centre. Each category holds a set
5
+ of options (Eastside, Westside), and a line item picks at most one option
6
+ per category. An organisation can have at most two active categories.
7
+
8
+ You need a `tenant_id` from a [connection](../connections.md) before you can
9
+ fetch tracking categories.
10
+
11
+ > See: [Xero docs — Tracking Categories](https://developer.xero.com/documentation/api/accounting/trackingcategories)
12
+
13
+ ## Two shapes, two classes
14
+
15
+ Xero uses the phrase "tracking category" for two different things, and kiwi
16
+ models them as two separate classes. Getting these mixed up is the most
17
+ common confusion here, so it's worth being explicit.
18
+
19
+ **`XeroKiwi::Accounting::TrackingCategory`** — the *definition*, returned by
20
+ `GET /TrackingCategories`. The category plus every option available under it.
21
+
22
+ ```json
23
+ { "TrackingCategoryID": "abc", "Name": "Region", "Status": "ACTIVE",
24
+ "Options": [ { "TrackingOptionID": "def", "Name": "Eastside", "Status": "ACTIVE" } ] }
25
+ ```
26
+
27
+ **`XeroKiwi::Accounting::Tracking`** — the *assignment*, nested on a line
28
+ item (Xero's own field name for it is `Tracking`) or a contact. One category
29
+ and the single option chosen for it, flattened.
30
+
31
+ ```json
32
+ { "TrackingCategoryID": "abc", "TrackingOptionID": "def",
33
+ "Name": "Region", "Option": "Eastside" }
34
+ ```
35
+
36
+ They share a name and one ID field and nothing else. If you're reading
37
+ `invoice.line_items.first.tracking`, you have `Tracking` objects. If you
38
+ called `client.tracking_categories`, you have `TrackingCategory` objects.
39
+
40
+ ## Listing tracking categories
41
+
42
+ ```ruby
43
+ client = XeroKiwi::Client.new(access_token: "ya29...")
44
+
45
+ categories = client.tracking_categories(tenant_id)
46
+
47
+ categories.first.name # => "Region"
48
+ categories.first.options.map(&:name) # => ["Eastside", "Westside"]
49
+ ```
50
+
51
+ `client.tracking_categories` hits `GET /api.xro/2.0/TrackingCategories` with
52
+ the `Xero-Tenant-Id` header set, and returns a `XeroKiwi::Page` of
53
+ `TrackingCategory` objects.
54
+
55
+ This endpoint isn't paged — an organisation has at most two active
56
+ categories. `each_tracking_category` exists so all ten list resources behave
57
+ consistently, but there's no reason to reach for it over the plain call.
58
+
59
+ ## Fetching a single category
60
+
61
+ ```ruby
62
+ category = client.tracking_category(tenant_id, "e2f2f732-e92a-4f3a-9c4d-ee4da0182a13")
63
+ category.name # => "Region"
64
+ ```
65
+
66
+ Hits `GET /api.xro/2.0/TrackingCategories/{TrackingCategoryID}` and returns a
67
+ single `TrackingCategory`, or `nil` if the response is empty.
68
+
69
+ ## The TrackingCategory object
70
+
71
+ | Attribute | Type | What it is |
72
+ |-----------|------|------------|
73
+ | `tracking_category_id` | `String` | The unique Xero identifier for the category. |
74
+ | `name` | `String` | The category's display name (e.g. "Region"). |
75
+ | `status` | `String` | `"ACTIVE"` or `"ARCHIVED"`. |
76
+ | `options` | `Array<TrackingOption>` | The options available under this category. Empty array when absent. |
77
+
78
+ `#active?` is a shorthand for `status == "ACTIVE"`.
79
+
80
+ ## The TrackingOption object
81
+
82
+ | Attribute | Type | What it is |
83
+ |-----------|------|------------|
84
+ | `tracking_option_id` | `String` | The unique Xero identifier for the option. |
85
+ | `name` | `String` | The option's display name (e.g. "Eastside"). |
86
+ | `status` | `String` | `"ACTIVE"` or `"DELETED"`. |
87
+
88
+ `#active?` is a shorthand for `status == "ACTIVE"`.
89
+
90
+ ## The Tracking object
91
+
92
+ The assignment shape, nested on line items and contacts.
93
+
94
+ | Attribute | Type | What it is |
95
+ |-----------|------|------------|
96
+ | `tracking_category_id` | `String` | Which category this assignment is for. |
97
+ | `tracking_option_id` | `String` | Which option was chosen. |
98
+ | `name` | `String` | The category's name, denormalised by Xero. |
99
+ | `option` | `String` | The chosen option's name, denormalised by Xero. |
100
+
101
+ ## Querying
102
+
103
+ `name` and `status` are queryable on both `TrackingCategory` and
104
+ `TrackingOption`, as is the identity field.
105
+
106
+ ```ruby
107
+ client.tracking_categories(tenant_id, where: { status: "ACTIVE" })
108
+ ```
109
+
110
+ ## Equality and hashing
111
+
112
+ Two `TrackingCategory` objects are `==` if they share the same
113
+ `tracking_category_id`; two `TrackingOption` objects if they share the same
114
+ `tracking_option_id`. `Tracking` has no server-side primary key of its own,
115
+ so it falls back to structural equality — every attribute must match.
116
+
117
+ ## Error behaviour
118
+
119
+ | HTTP status | Exception | What it usually means |
120
+ |-------------|-----------|------------------------|
121
+ | 200 | (none — returns categories) | Success |
122
+ | 401 | `XeroKiwi::AuthenticationError` | Access token is invalid or expired |
123
+ | 403 | `XeroKiwi::ClientError` | The token doesn't have the `accounting.settings` scope |
124
+ | 404 | `XeroKiwi::ClientError` | The tracking category ID doesn't exist in this organisation |
125
+
126
+ ## Common patterns
127
+
128
+ ### Building a lookup from option ID to names
129
+
130
+ Line items carry only IDs and denormalised names; if you need the canonical
131
+ option list, fetch the definitions once per sync and index them.
132
+
133
+ ```ruby
134
+ options = client.tracking_categories(tenant_id).flat_map do |category|
135
+ category.options.map { |option| [option.tracking_option_id, [category.name, option.name]] }
136
+ end.to_h
137
+
138
+ invoice.line_items.each do |line|
139
+ line.tracking.each do |assignment|
140
+ category_name, option_name = options.fetch(assignment.tracking_option_id)
141
+ puts "#{category_name}: #{option_name}"
142
+ end
143
+ end
144
+ ```
145
+
146
+ ### Ignoring archived categories
147
+
148
+ ```ruby
149
+ active = client.tracking_categories(tenant_id).select(&:active?)
150
+ ```
data/docs/client.md CHANGED
@@ -41,6 +41,40 @@ you. See [Tokens](tokens.md) for the full refresh story.
41
41
  | `adapter:` | `Symbol` / Faraday adapter | No | `Faraday.default_adapter` | The Faraday adapter to use. Override to swap in `:net_http_persistent`, `:typhoeus`, or a test adapter. |
42
42
  | `user_agent:` | `String` | No | `"XeroKiwi/<version>"` | Sent as the `User-Agent` header on every request. |
43
43
  | `retry_options:` | `Hash` | No | See [retries and rate limits](retries-and-rate-limits.md) | Overrides for the `faraday-retry` configuration. Merged into the defaults. |
44
+ | `throttle:` | Limiter | No | `XeroKiwi.default_throttle`, else none | Proactive per-tenant rate limiting. See [Throttling](throttling.md). |
45
+ | `page_size:` | `Integer` | No | `nil` (Xero's own default of 100) | Default `pageSize` for every list call. Per-call `page_size:` overrides it. See [Querying](querying.md). |
46
+ | `retain_raw:` | `Boolean` | No | `false` | Keep Xero's untouched response hash on each resource, readable via `#raw`. See below. |
47
+
48
+ ### `retain_raw:` and `#raw`
49
+
50
+ By default a resource hydrates the fields kiwi models and discards the
51
+ original payload. Turn `retain_raw` on and each resource built directly
52
+ from a response keeps that payload:
53
+
54
+ ```ruby
55
+ client = XeroKiwi::Client.new(access_token: token, retain_raw: true)
56
+ contact = client.contact(tenant, contact_id)
57
+
58
+ contact.raw
59
+ # => {"ContactID" => "…", "ContactPersons" => [...], "SomeNewField" => "…"}
60
+ ```
61
+
62
+ Use it to reach fields kiwi doesn't model yet, or to store what Xero sent
63
+ verbatim.
64
+
65
+ Three things to know:
66
+
67
+ - **`#raw` is not `#to_h`.** `to_h` is a snake_case projection rebuilt from
68
+ the modelled attributes — different keys, different nesting. If you store
69
+ `to_h` where you meant to store the payload, readers fail by returning
70
+ nil rather than raising.
71
+ - **It's top-level only.** Nested objects (line items, addresses, contact
72
+ persons) have no `#raw` of their own. They don't need one: the top-level
73
+ hash already holds every nested payload, so `contact.raw["ContactPersons"]`
74
+ gets there.
75
+ - **It costs memory.** Every resource holds its source hash alongside the
76
+ hydrated attributes, which roughly doubles the footprint of a large page.
77
+ That's why it's off by default.
44
78
 
45
79
  ## What the client gives you
46
80
 
data/docs/querying.md CHANGED
@@ -130,6 +130,28 @@ client.invoices(tenant, page: 2).page_size # => 100
130
130
  client.invoices(tenant, page: 2).item_count # total-on-this-page
131
131
  ```
132
132
 
133
+ ## `page_size:` — how many per page
134
+
135
+ Maps to Xero's `pageSize` query param. Xero's own default is 100 and its
136
+ maximum is 1,000 on most paged endpoints.
137
+
138
+ Set it once on the client and every call inherits it; override per call
139
+ where you need something different.
140
+
141
+ ```ruby
142
+ client = XeroKiwi::Client.new(access_token: token, page_size: 1_000)
143
+
144
+ client.invoices(tenant) # pageSize=1000
145
+ client.invoices(tenant, page_size: 100) # this call only
146
+ ```
147
+
148
+ Leave it unset and kiwi omits the parameter entirely, so Xero applies its
149
+ own default.
150
+
151
+ This is worth setting for any full-tenant sync. At 100 per page a
152
+ 50,000-invoice tenant costs ~500 API calls; at 1,000 it costs ~50, against
153
+ a daily limit of 5,000.
154
+
133
155
  ### Walking every page — `each_<resource>`
134
156
 
135
157
  For incremental syncs or whole-tenant scans, use the `each_*` helpers.
@@ -150,7 +172,50 @@ client.each_invoice(tenant, order: { date: :desc })
150
172
 
151
173
  Available for every listable resource: `each_user`, `each_contact`,
152
174
  `each_contact_group`, `each_invoice`, `each_credit_note`, `each_payment`,
153
- `each_prepayment`, `each_overpayment`, `each_branding_theme`.
175
+ `each_prepayment`, `each_overpayment`, `each_branding_theme`,
176
+ `each_tracking_category`.
177
+
178
+ ### Walking pages instead of items — `each_<resource>_page`
179
+
180
+ Same walk, but each yield is a whole `Page` rather than one item. Use it
181
+ when you need the page number — which is what makes a sync resumable.
182
+
183
+ ```ruby
184
+ client.each_invoice_page(tenant, page_size: 1_000) do |page|
185
+ Invoice.upsert_all(page.map(&:to_h))
186
+ cursor.update!(invoices: page.page) # same transaction as the upsert
187
+ end
188
+ ```
189
+
190
+ Recording the page number in the same transaction that stores the rows
191
+ matters: a marker written when the response lands, before the rows are
192
+ saved, can survive a crash that the rows don't — and the next run then
193
+ skips a page it never actually imported.
194
+
195
+ ### Resuming — `start_page:`
196
+
197
+ Both `each_*` and `each_*_page` accept `start_page:` (default 1), so a
198
+ resumed run picks up where the last one stopped.
199
+
200
+ ```ruby
201
+ client.each_invoice_page(tenant, start_page: cursor.invoices + 1) do |page|
202
+ …
203
+ end
204
+ ```
205
+
206
+ ### How the walk knows when to stop
207
+
208
+ It stops on an empty page, or on a page shorter than a full one. "Full" is
209
+ measured against Xero's stated page size when the response carries a
210
+ `pagination` envelope, and otherwise against the largest page seen so far
211
+ in that walk.
212
+
213
+ It is deliberately **not** measured against the `page_size:` you asked
214
+ for. Xero clamps a request above an endpoint's maximum, so a walk that
215
+ asked for 2,000 where the cap is 1,000 would see its very first page as
216
+ short and stop after one page — silently truncating the sync. The cost of
217
+ measuring instead of assuming is one extra request when the whole result
218
+ fits in a single page and no envelope came back.
154
219
 
155
220
  ## `modified_since:` — incremental sync
156
221
 
@@ -168,6 +233,25 @@ exception, no special flag. An empty page after `modified_since:` is
168
233
  indistinguishable from a filter that matched nothing (intentional — the
169
234
  caller can treat them identically).
170
235
 
236
+ ## `include_archived:` — archived contacts
237
+
238
+ Contacts only. Maps to Xero's `includeArchived` query param, which returns
239
+ archived contacts **alongside** active ones in the same pass.
240
+
241
+ ```ruby
242
+ client.contacts(tenant, include_archived: true)
243
+ client.each_contact(tenant, include_archived: true) { |contact| … }
244
+ ```
245
+
246
+ This is not the same as filtering on `contact_status`. A
247
+ `where: { contact_status: "ARCHIVED" }` returns *only* archived contacts;
248
+ `include_archived: true` returns both kinds together, which is what you
249
+ want when mirroring a tenant's full contact list.
250
+
251
+ It matters beyond contacts themselves: Xero keeps serving archived
252
+ contacts as members of contact groups, so without this you can't tell
253
+ which group members are archived locally.
254
+
171
255
  ## Combining everything
172
256
 
173
257
  Mix and match freely:
@@ -178,6 +262,7 @@ client.invoices(
178
262
  where: { status: "AUTHORISED", contact: { contact_id: "abc-123" } },
179
263
  order: { date: :desc },
180
264
  page: 1,
265
+ page_size: 1_000,
181
266
  modified_since: last_sync_at
182
267
  )
183
268
  ```
@@ -20,6 +20,54 @@ Xero enforces three separate rate limits, all returned with HTTP 429:
20
20
  Plus a `Retry-After` header on every 429 telling you how many seconds to
21
21
  wait before trying again.
22
22
 
23
+ ## Checking how much quota is left
24
+
25
+ `client.rate_limit(tenant_id)` answers "how many calls can I still make
26
+ for this tenant today?" — useful when a long job should stop early and
27
+ leave headroom for everything else on the same tenant.
28
+
29
+ ```ruby
30
+ invoice_ids.each do |id|
31
+ break if client.rate_limit(tenant).day_below?(1_000)
32
+
33
+ urls << client.online_invoice_url(tenant, id)
34
+ end
35
+ ```
36
+
37
+ It blends the only two sources that know anything, and always reports
38
+ **the stricter of the two**:
39
+
40
+ | Source | Where it comes from | What it sees |
41
+ |--------|--------------------|--------------|
42
+ | `reported` | Xero's `X-DayLimit-Remaining` / `X-MinLimit-Remaining` headers on the last call to that tenant | Every consumer of the tenant's quota, including other applications. Nil until a request has been made. |
43
+ | `configured` | The throttle limiter's own bucket | Only calls made through this limiter — but it encodes the headroom you deliberately configured. Nil when throttling is off. |
44
+
45
+ So if Xero reports 3,000 calls left but a bucket configured at 4,900/day
46
+ holds 500 tokens, the answer is 500 — the configured limit is a ceiling
47
+ you chose and the sync should not spend past it. If another app has burned
48
+ the tenant's quota so Xero reports 50 while the bucket still shows 2,000,
49
+ the answer is 50 — that one is reality.
50
+
51
+ ```ruby
52
+ rl = client.rate_limit(tenant)
53
+
54
+ rl.day_remaining # => 500 the binding number
55
+ rl.minute_remaining # => 55
56
+ rl.day_below?(1_000) # => true
57
+ rl.day_source # => :configured which one is binding
58
+ rl.reported # => #<struct day=3000, minute=58, app_minute=9800>
59
+ rl.configured # => #<struct day=500, minute=55>
60
+ rl.known? # => true
61
+ ```
62
+
63
+ The headers are captured on every response, including error responses — a
64
+ 429 is exactly when they matter most.
65
+
66
+ When neither source has a reading, `day_remaining` is nil and
67
+ `day_below?` returns **false**. Not knowing how much quota is left isn't a
68
+ reason to halt a sync, and halting would break the common case where the
69
+ first call is what populates the figures.
70
+
23
71
  ## What Xero Kiwi does automatically
24
72
 
25
73
  Xero Kiwi sets up a `faraday-retry` middleware that handles transient failures
data/docs/throttling.md CHANGED
@@ -192,9 +192,28 @@ Fail-open is deliberate: a misbehaving Redis shouldn't stop your app talking
192
192
  to Xero. The reactive retry layer still protects you from actually hitting
193
193
  the limits.
194
194
 
195
+ ## Asking how much is left
196
+
197
+ `RedisTokenBucket#remaining(tenant_id)` reports the current token counts
198
+ without spending one:
199
+
200
+ ```ruby
201
+ bucket.remaining("tenant-abc")
202
+ # => { minute: 55, day: 4_312 }
203
+ ```
204
+
205
+ It runs the same refill arithmetic as `acquire` in a separate read-only
206
+ Lua script, so polling it can't starve the bucket you're polling. Like
207
+ `acquire`, it fails open and returns `nil` if Redis is unreachable, and
208
+ `day` is `nil` when no `per_day` limit is configured.
209
+
210
+ Most callers won't use this directly — `client.rate_limit(tenant_id)`
211
+ combines it with what Xero itself reported and hands back whichever is
212
+ stricter. See [retries and rate limits](retries-and-rate-limits.md).
213
+
195
214
  ## Writing a custom limiter
196
215
 
197
- The limiter contract is one method:
216
+ The limiter contract is one required method:
198
217
 
199
218
  ```ruby
200
219
  class MyLimiter
@@ -203,6 +222,13 @@ class MyLimiter
203
222
  # XeroKiwi::Throttle::Timeout / DailyLimitExhausted if you want the
204
223
  # same exception shapes.
205
224
  end
225
+
226
+ # Optional. Implement it and client.rate_limit(tenant_id) will factor
227
+ # your bucket into its answer; leave it out and kiwi falls back to
228
+ # Xero's reported headers alone.
229
+ def remaining(tenant_id)
230
+ { minute: …, day: … }
231
+ end
206
232
  end
207
233
  ```
208
234
 
@@ -11,7 +11,7 @@ module XeroKiwi
11
11
  payload_key "Contacts"
12
12
  identity :contact_id
13
13
 
14
- # Some classes referenced below (TrackingCategory, ContactGroup,
14
+ # Some classes referenced below (Tracking, ContactGroup,
15
15
  # BrandingTheme) haven't been loaded at the time Contact's class body
16
16
  # runs, so the lookup is deferred with a String reference.
17
17
 
@@ -40,8 +40,8 @@ module XeroKiwi
40
40
  attribute :merged_to_contact_id, xero: "MergedToContactID", type: :guid
41
41
  attribute :sales_default_account_code, xero: "SalesDefaultAccountCode"
42
42
  attribute :purchases_default_account_code, xero: "PurchasesDefaultAccountCode"
43
- attribute :sales_tracking_categories, xero: "SalesTrackingCategories", type: :collection, of: "TrackingCategory"
44
- attribute :purchases_tracking_categories, xero: "PurchasesTrackingCategories", type: :collection, of: "TrackingCategory"
43
+ attribute :sales_tracking_categories, xero: "SalesTrackingCategories", type: :collection, of: "Tracking"
44
+ attribute :purchases_tracking_categories, xero: "PurchasesTrackingCategories", type: :collection, of: "Tracking"
45
45
  attribute :sales_default_line_amount_type, xero: "SalesDefaultLineAmountType"
46
46
  attribute :purchases_default_line_amount_type, xero: "PurchasesDefaultLineAmountType"
47
47
  attribute :tracking_category_name, xero: "TrackingCategoryName"
@@ -21,7 +21,7 @@ module XeroKiwi
21
21
  attribute :line_amount, xero: "LineAmount", type: :decimal
22
22
  attribute :discount_rate, xero: "DiscountRate", type: :decimal
23
23
  attribute :discount_amount, xero: "DiscountAmount", type: :decimal
24
- attribute :tracking, xero: "Tracking", type: :collection, of: TrackingCategory
24
+ attribute :tracking, xero: "Tracking", type: :collection, of: Tracking
25
25
  attribute :item, xero: "Item"
26
26
  end
27
27
  end
@@ -45,14 +45,15 @@ module XeroKiwi
45
45
  attribute :payment_terms, xero: "PaymentTerms", hydrate: ->(raw) { PaymentTerms.from_hash(raw) }
46
46
 
47
47
  # Xero's /Organisation endpoint returns a one-element "Organisations"
48
- # array — we unwrap it to a single object.
49
- def self.from_response(payload)
48
+ # array — we unwrap it to a single object. `opts` stays positional to
49
+ # match Resource.from_response; see the note there.
50
+ def self.from_response(payload, opts = {})
50
51
  return nil if payload.nil?
51
52
 
52
53
  items = payload["Organisations"]
53
54
  return nil if items.nil? || items.empty?
54
55
 
55
- new(items.first)
56
+ new(items.first, retain_raw: opts[:retain_raw])
56
57
  end
57
58
 
58
59
  def demo_company? = is_demo_company == true
@@ -99,13 +99,24 @@ module XeroKiwi
99
99
 
100
100
  public
101
101
 
102
- def from_response(payload)
102
+ # `opts[:retain_raw]` is threaded down from Client#initialize. It
103
+ # applies to the resources built here and not to their nested
104
+ # objects — the top-level `raw` hash already holds every nested
105
+ # payload verbatim, so `contact.raw["ContactPersons"]` gets there
106
+ # without pushing the flag through Hydrator and every build_object
107
+ # call.
108
+ #
109
+ # `opts` is positional for the same reason it is on #initialize: a
110
+ # bare string-keyed payload (`from_response("Users" => [])`) would
111
+ # otherwise be swallowed as keyword arguments in Ruby 3, leaving
112
+ # `payload` unset.
113
+ def from_response(payload, opts = {})
103
114
  return [] if payload.nil?
104
115
 
105
116
  items = payload[payload_key]
106
117
  return [] if items.nil?
107
118
 
108
- items.map { |attrs| new(attrs) }
119
+ items.map { |attrs| new(attrs, retain_raw: opts[:retain_raw]) }
109
120
  end
110
121
  end
111
122
 
@@ -116,6 +127,7 @@ module XeroKiwi
116
127
  def initialize(attrs, opts = {})
117
128
  attrs = attrs.transform_keys(&:to_s)
118
129
  @is_reference = opts[:reference] == true
130
+ @raw = opts[:retain_raw] ? attrs.freeze : nil
119
131
 
120
132
  self.class.attributes.each do |name, spec|
121
133
  value = Hydrator.call(attrs[spec[:xero]], spec)
@@ -123,6 +135,14 @@ module XeroKiwi
123
135
  end
124
136
  end
125
137
 
138
+ # Xero's response hash for this resource, exactly as it arrived, or nil
139
+ # unless the client was built with `retain_raw: true`. Use it to reach
140
+ # fields the gem doesn't model, or to store the payload verbatim.
141
+ #
142
+ # Note `to_h` is NOT this — it's a snake_case projection rebuilt from
143
+ # the modelled attributes, with different keys and different nesting.
144
+ attr_reader :raw
145
+
126
146
  def reference?
127
147
  @is_reference
128
148
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XeroKiwi
4
+ module Accounting
5
+ # A tracking assignment — one category and the single option chosen for
6
+ # it — as nested on a line item (`Tracking`) or a contact. This is not
7
+ # the same shape as the /TrackingCategories endpoint returns; see
8
+ # Accounting::TrackingCategory for that.
9
+ #
10
+ # See: https://developer.xero.com/documentation/api/accounting/invoices
11
+ class Tracking
12
+ include Resource
13
+
14
+ attribute :tracking_category_id, xero: "TrackingCategoryID", type: :guid
15
+ attribute :tracking_option_id, xero: "TrackingOptionID", type: :guid
16
+ attribute :name, xero: "Name"
17
+ attribute :option, xero: "Option"
18
+ end
19
+ end
20
+ end
@@ -2,16 +2,25 @@
2
2
 
3
3
  module XeroKiwi
4
4
  module Accounting
5
- # Represents a tracking category assignment on a line item or contact.
5
+ # A tracking category *definition* as returned by /TrackingCategories —
6
+ # the category itself plus the options available under it.
6
7
  #
7
- # See: https://developer.xero.com/documentation/api/accounting/invoices
8
+ # Not to be confused with Accounting::Tracking, which is the flattened
9
+ # category-and-chosen-option pair nested on line items and contacts.
10
+ #
11
+ # See: https://developer.xero.com/documentation/api/accounting/trackingcategories
8
12
  class TrackingCategory
9
13
  include Resource
10
14
 
15
+ payload_key "TrackingCategories"
16
+ identity :tracking_category_id
17
+
11
18
  attribute :tracking_category_id, xero: "TrackingCategoryID", type: :guid
12
- attribute :tracking_option_id, xero: "TrackingOptionID", type: :guid
13
- attribute :name, xero: "Name"
14
- attribute :option, xero: "Option"
19
+ attribute :name, xero: "Name", query: true
20
+ attribute :status, xero: "Status", type: :enum, query: true
21
+ attribute :options, xero: "Options", type: :collection, of: TrackingOption
22
+
23
+ def active? = status == "ACTIVE"
15
24
  end
16
25
  end
17
26
  end