xero-kiwi 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 353faef8c28dbf7801d5c9e8ebd152e0db172ede1aa69378144ac47cfa603436
4
- data.tar.gz: b7decf2d114e9cbfba300c0274d6d9a3604c8d464ce080ac46b4a6df1e23d548
3
+ metadata.gz: 9e67980bd7d7be1131ee07b55847cd4633a527eec4159103ba43551903f0870c
4
+ data.tar.gz: f06a30a5311f02be7d8695344a7f8eb65e76f4b0abab1743f23b9701f7655600
5
5
  SHA512:
6
- metadata.gz: ff17d3c14fb35e1d6b3334935cda29c4fba9441ab012692dabc5518bb116a6615007e62a2e3c5c06a9c490f0b56d1fc16f8f42f9ff4b59d389088d68f7b2e3b9
7
- data.tar.gz: ed0c6a55bcf9ed6d013f02ad76a3486a8d66f59961b959d85d7ed3e2a9f53c85cf43edb5e6f454f8c5fa556ad73983086105860983ca16c3db23633d39b3e31e
6
+ metadata.gz: bf15925c5971526baeb6c3773de4f454ab917509200794b05957b27eabdfb4691e757a01f9defca50357cf0ce8ba7aa504b8e29973685c1428b365b28d80c5bf
7
+ data.tar.gz: 854a1cc894da5a8706e084f1b2e328a3529470f785e4cc22c27d80080ec7c2780cf3db40eb3f919fd9afd359539a879af82d241b6a2061f3673fa80ead876357
data/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.1] - 2026-09-26
4
+
5
+ ### Documentation
6
+
7
+ - Pinned down the scope of `Resource#raw`, which was ambiguous enough to mislead a real consumer. It holds the resource's own item hash — `contact.raw` has no `"Contacts"` key — and it is the JSON representation specifically. Xero's XML representation nests differently (no arrays, so one child parses to a Hash and several to an Array), so `raw` cannot reproduce an XML-derived shape. `docs/client.md` gains a short migration section covering what that means for anyone replacing a client that sent `Accept: text/xml` and has stored payloads. Specs added for both the envelope scope and the PascalCase/snake_case split. The migration section sorts readers into the two piles that behave differently: ones that dig the XML-only structure and must change, and ones that already normalise (`[value].flatten.compact`) where only the writer changes and stored rows stay readable.
8
+
9
+ ## [0.5.0] - 2026-09-26
10
+
11
+ The sync-support release: everything needed to drive a full-tenant sync through kiwi rather than around it.
12
+
13
+ ### Added
14
+
15
+ - **`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.
16
+ - **`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.
17
+ - **`start_page:`** on both `each_<resource>` and `each_<resource>_page` (default 1), so a resumed run picks up where the last one stopped.
18
+ - **`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.
19
+ - **`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.
20
+ - **`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.
21
+ - **`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.
22
+ - **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`.
23
+ - `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.
24
+
25
+ ### Breaking
26
+
27
+ - **`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.
28
+
29
+ ### Fixed
30
+
31
+ - 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.
32
+
33
+ ### Changed
34
+
35
+ - 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.
36
+ - 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.
37
+
3
38
  ## [0.4.0] - 2026-04-20
4
39
 
5
40
  ### 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,98 @@ 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.
63
+
64
+ Five things to know:
65
+
66
+ - **It's the item, not the envelope.** `contact.raw` has no `"Contacts"`
67
+ key and `organisation.raw` has no `"Organisations"` key — kiwi unwraps
68
+ the envelope before building a resource, so there's nothing left of it by
69
+ the time `raw` is populated. If you need the envelope back, you're
70
+ rebuilding it yourself.
71
+ - **It's the JSON representation.** Kiwi sends
72
+ `Accept: application/json`. Xero also serves XML, which nests
73
+ differently — XML has no arrays, so a single child parses to a Hash and
74
+ several to an Array, where JSON is always an Array. `raw` cannot
75
+ reproduce an XML-derived shape. See [migrating from an XML
76
+ client](#migrating-from-an-xml-based-client) below.
77
+ - **`#raw` is not `#to_h`.** `to_h` is a snake_case projection rebuilt from
78
+ the modelled attributes — different keys, different nesting. If you store
79
+ `to_h` where you meant to store the payload, readers fail by returning
80
+ nil rather than raising.
81
+ - **It's per resource, not per nested object.** Line items, addresses and
82
+ contact persons have no `#raw` of their own. They don't need one: the
83
+ resource's hash already holds every nested payload, so
84
+ `contact.raw["ContactPersons"]` gets there.
85
+ - **It costs memory.** Every resource holds its source hash alongside the
86
+ hydrated attributes, which roughly doubles the footprint of a large page.
87
+ That's why it's off by default.
88
+
89
+ ### Migrating from an XML-based client
90
+
91
+ If you're replacing a Xero client that sent `Accept: text/xml` — HTTParty
92
+ and similar default to it — any payloads you already have stored are
93
+ XML-shaped, and `raw` will not match them. The differences are structural,
94
+ not cosmetic:
95
+
96
+ | | XML (`text/xml`) | JSON (`application/json`) |
97
+ |---|---|---|
98
+ | Organisation body | `{"Organisations" => {"Organisation" => {…}}}` | `{"Organisations" => [{…}]}` |
99
+ | One address | `{"Address" => {…}}` | `[{…}]` |
100
+ | Several addresses | `{"Address" => [{…}, {…}]}` | `[{…}, {…}]` |
101
+
102
+ The single-child collapse is the one that catches people: under XML a
103
+ contact with one person parses to a Hash and a contact with two parses to
104
+ an Array, from the same endpoint. Code written against that has a
105
+ normalising step somewhere, whether or not its author knew why.
106
+
107
+ No client setting reproduces these shapes — they're artefacts of an XML
108
+ parse kiwi doesn't do. But the remedy isn't the same everywhere, and it's
109
+ worth sorting your readers into two piles before planning the work.
110
+
111
+ **Readers that dig the XML-only structure have to change.** Something like
112
+ `dig("Organisations", "Organisation", "Addresses", "Address")` returns nil
113
+ against anything kiwi produces, whether you store `raw` or `to_h`.
114
+ Promote the fields those readers need to real columns and use `raw` for
115
+ the backfill — it's the true payload, so it carries everything required to
116
+ populate them.
117
+
118
+ **Readers that already normalise usually survive untouched.** A reader
119
+ doing `[value].flatten.compact` handles the Hash case, the Array case and
120
+ nil identically, so a JSON array flows straight through. If the keys it
121
+ reads are the same in both representations, only the *writer* changes:
122
+ stop unwrapping, store the array. Existing rows stay readable, and you
123
+ skip a migration entirely.
124
+
125
+ Type coercion is usually fine in that second pile too. XML gives you
126
+ strings — `"false"` rather than `false` — and if the reader hands that to
127
+ something like ActiveModel's boolean cast, both the old string and the new
128
+ real boolean land on the same value.
129
+
130
+ One piece of luck worth knowing about: the two piles tend to fail
131
+ differently. A `dig` that misses returns nil and writes a blank record
132
+ quietly. Code that assumed a Hash, such as `Array#to_h` on what is now a
133
+ list, raises `TypeError` on the first record with data in it. The noisy
134
+ failures are the ones you can trust to find themselves — budget your
135
+ review time for the silent ones.
44
136
 
45
137
  ## What the client gives you
46
138
 
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