xero-kiwi 0.5.1 → 0.6.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: 9e67980bd7d7be1131ee07b55847cd4633a527eec4159103ba43551903f0870c
4
- data.tar.gz: f06a30a5311f02be7d8695344a7f8eb65e76f4b0abab1743f23b9701f7655600
3
+ metadata.gz: c3c9271654c527275c19a5738effb1f5d334844a14898eaf467fc6671fbc308a
4
+ data.tar.gz: 0645a88a9ccd58130524e24d9981f3999b74ddf8ee98af9600a73f45cda4d29e
5
5
  SHA512:
6
- metadata.gz: bf15925c5971526baeb6c3773de4f454ab917509200794b05957b27eabdfb4691e757a01f9defca50357cf0ce8ba7aa504b8e29973685c1428b365b28d80c5bf
7
- data.tar.gz: 854a1cc894da5a8706e084f1b2e328a3529470f785e4cc22c27d80080ec7c2780cf3db40eb3f919fd9afd359539a879af82d241b6a2061f3673fa80ead876357
6
+ metadata.gz: 74bbf0e97f3207d0aaaa16c85a82fe5c1f8572ef10c3c5d802ed2b051538ad002c9aae5df62da3f9ffd7aa76e64d4446a33cfcb98ac5d9eec3edc2f6a143d1bb
7
+ data.tar.gz: 32141ab2ffa3f40865a432711e1e6147c99006325e30f633f035790ab1ffefd085021eda40d5f2e22621eda084fb34174691fe6c65c405497ab7452106c9aa69
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.0] - 2026-09-27
4
+
5
+ ### Fixed
6
+
7
+ - **`Accounting::Allocation#amount` always returned nil.** It was mapped to Xero's `"Amount"`, which is the key for the allocation *request* body. Every response — and allocations only ever reach this gem embedded in a CreditNote, Prepayment or Overpayment response — carries `"AppliedAmount"`. The allocated value is now modelled as **`applied_amount`**, and `#amount` is an alias of it so callers who reached for the obvious name get the value they meant. Nothing that depended on a real value is affected, because there was never one to depend on.
8
+
9
+ This mattered more than a missing field usually would: `allocations.map(&:amount)` returned an array of nils rather than raising, so an importer would have written zeroes into every allocated amount with nothing failing anywhere. Reported by a consumer who checked stored payloads before trusting the typed path.
10
+
11
+ Four spec files had fabricated `"Amount"` payloads and so agreed with the bug instead of catching it; all now use the real response shape.
12
+
13
+ ### Breaking
14
+
15
+ - `Allocation#to_h` now carries `:applied_amount` instead of `:amount`, and `:amount` is no longer a declared attribute (it is a plain reader, so one value maps to one key). Anyone reading `to_h[:amount]` was reading nil.
16
+
17
+ ### Documentation
18
+
19
+ - The README now documents the release process. It covered running specs but said nothing about cutting a release, so the sequencing lived only in one person's head. It leads with the rule and the reason — `rake release` tags whatever is checked out, so a version bump riding along in a pull request gets orphaned the moment that PR is squash-merged, which is what happened to v0.5.2 — and records the thing that is not guessable from the repo: `docs/` ships inside the gem, so a documentation-only fix still needs a patch release to reach anyone.
20
+
21
+ ## [0.5.2] - 2026-09-27
22
+
23
+ ### Documentation
24
+
25
+ - Documented that `Resource#raw` returns nil on nested objects (line items, addresses, contact persons, payment terms) even with `retain_raw: true`, and that the enclosing resource's hash is the access path — `org.raw["Addresses"]` rather than `org.addresses.map(&:raw)`. The behaviour was deliberate and explained in a comment on `from_response`, but the `#raw` docstring never mentioned it and the failure is a silent nil, so a consumer mapped over a nested collection and was one step from storing `[null]`.
26
+ - Added a written contract for `raw` in `docs/client.md`: it is the parsed JSON with only top-level keys stringified, kiwi never normalises it (absent keys stay absent rather than becoming `[]`), unmodelled keys are preserved, and the freeze is shallow — the hash is frozen, the structures inside it are not. Specs pin all four, plus the nested-nil behaviour. The previous "is frozen" example overstated the guarantee and has been replaced.
27
+ - `llms.txt` is now generated alongside `llms-full.txt` from a single manifest in `tasks/llms.rb`, rather than hand-maintained. It had not been touched since the initial commit and was missing four releases of material — no `querying.md`, no `throttling.md`, no `tracking-category.md`, and no mention of `Page`, `RateLimit`, the throttle limiters or the tracking classes. `rake llms:check` now also fails when a doc exists under `docs/` that the manifest does not list, which is the half that was missing: the freshness check could not notice a doc that was never indexed in the first place.
28
+
3
29
  ## [0.5.1] - 2026-09-26
4
30
 
5
31
  ### Documentation
data/README.md CHANGED
@@ -113,6 +113,42 @@ default `redis://127.0.0.1:6379/15`.
113
113
  docker compose down # when you're done
114
114
  ```
115
115
 
116
+ ### Releasing
117
+
118
+ **Merge first, then bump.** `rake release` tags whatever is checked out, so a
119
+ version bump that rides along inside a pull request gets orphaned the moment
120
+ that PR is squash-merged — the squash creates a new commit and the tag is left
121
+ pointing at the pre-merge branch commit, unreachable from `main`. Bumping after
122
+ the merge makes that impossible regardless of merge strategy.
123
+
124
+ So: land the work through a PR as normal, then
125
+
126
+ ```sh
127
+ git checkout main && git pull
128
+ ```
129
+
130
+ 1. Bump `VERSION` in [lib/xero_kiwi/version.rb](lib/xero_kiwi/version.rb).
131
+ 2. Move the `[Unreleased]` entries in [CHANGELOG.md](CHANGELOG.md) under a
132
+ dated version heading.
133
+ 3. `bundle install` to refresh the lockfile's `xero-kiwi (x.y.z)`.
134
+ 4. `bundle exec rake` — specs, RuboCop, and the `llms:check` freshness guard.
135
+ 5. Commit to `main`. A commit-msg hook requires the `crimesjohnson:` prefix
136
+ there, so: `crimesjohnson: release x.y.z with <the headline>`.
137
+
138
+ ```sh
139
+ bundle exec rake release # builds, tags, pushes the tag, publishes
140
+ ```
141
+
142
+ The gemspec sets `rubygems_mfa_required`, so this prompts for an OTP and can't
143
+ run unattended. Finally, cut the GitHub release from the changelog section:
144
+
145
+ ```sh
146
+ gh release create vx.y.z --title "vx.y.z — <headline>" --notes-file <(…) --verify-tag --latest
147
+ ```
148
+
149
+ Note that `docs/` ships inside the gem, so a documentation-only fix still needs
150
+ a patch release to reach anyone — it won't arrive through GitHub alone.
151
+
116
152
  ## Contributing
117
153
 
118
154
  Bug reports and pull requests are welcome on GitHub at
data/Rakefile CHANGED
@@ -9,76 +9,35 @@ require "rubocop/rake_task"
9
9
 
10
10
  RuboCop::RakeTask.new
11
11
 
12
- # Files included in llms-full.txt, in reading order. Keep README first, then
13
- # the docs in the same order the README's table of contents lists them.
14
- LLMS_SOURCE_FILES = %w[
15
- README.md
16
- docs/getting-started.md
17
- docs/client.md
18
- docs/oauth.md
19
- docs/tokens.md
20
- docs/connections.md
21
- docs/accounting/contact.md
22
- docs/accounting/contact-group.md
23
- docs/accounting/organisation.md
24
- docs/accounting/user.md
25
- docs/accounting/credit-note.md
26
- docs/accounting/invoice.md
27
- docs/accounting/payment.md
28
- docs/accounting/overpayment.md
29
- docs/accounting/prepayment.md
30
- docs/accounting/branding-theme.md
31
- docs/accounting/tracking-category.md
32
- docs/accounting/address.md
33
- docs/accounting/phone.md
34
- docs/accounting/external-link.md
35
- docs/accounting/payment-terms.md
36
- docs/errors.md
37
- docs/retries-and-rate-limits.md
38
- docs/throttling.md
39
- docs/querying.md
40
- ].freeze
41
-
42
- LLMS_FULL_PATH = "llms-full.txt"
43
-
44
- # Builds the llms-full.txt content from LLMS_SOURCE_FILES. Pure function:
45
- # returns a String, doesn't touch the filesystem. Both `llms:build` and
46
- # `llms:check` use this so the two tasks can never disagree about the
47
- # expected output.
48
- def build_llms_full
49
- out = String.new(encoding: "UTF-8")
50
- out << "# Xero Kiwi — full documentation\n\n"
51
- 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"
52
- out << "For the curated index version, see llms.txt in the same directory.\n\n"
53
- out << "Source: https://github.com/douglasgreyling/xero-kiwi\n\n"
54
- LLMS_SOURCE_FILES.each { |path| append_file_block(out, path) }
55
- out
56
- end
57
-
58
- def append_file_block(out, path)
59
- separator = "=" * 80
60
- out << "\n" << separator << "\n"
61
- out << "FILE: #{path}\n"
62
- out << separator << "\n\n"
63
- out << File.read(path, encoding: "UTF-8") << "\n"
64
- end
12
+ require_relative "tasks/llms"
65
13
 
66
14
  namespace :llms do
67
- desc "Regenerate llms-full.txt from README and docs/"
15
+ desc "Regenerate llms.txt and llms-full.txt from README and docs/"
68
16
  task :build do
69
- File.write(LLMS_FULL_PATH, build_llms_full)
70
- puts "Wrote #{LLMS_FULL_PATH} (#{File.size(LLMS_FULL_PATH)} bytes, #{LLMS_SOURCE_FILES.size} source files)"
17
+ Llms.outputs.each do |path, content|
18
+ File.write(path, content)
19
+ puts "Wrote #{path} (#{File.size(path)} bytes)"
20
+ end
71
21
  end
72
22
 
73
- desc "Verify llms-full.txt is up to date with README and docs/"
23
+ desc "Verify llms.txt and llms-full.txt are up to date, and that every doc is indexed"
74
24
  task :check do
75
- expected = build_llms_full
76
- actual = File.exist?(LLMS_FULL_PATH) ? File.read(LLMS_FULL_PATH, encoding: "UTF-8") : ""
25
+ missing = Llms.unindexed_docs
26
+ unless missing.empty?
27
+ warn "✗ These docs are not listed in Llms::DOCS, so they are missing from llms.txt and llms-full.txt:"
28
+ missing.each { |path| warn " #{path}" }
29
+ warn " Add them to tasks/llms.rb and run `bundle exec rake llms:build`."
30
+ exit 1
31
+ end
32
+
33
+ stale = Llms.outputs.reject do |path, expected|
34
+ File.exist?(path) && File.read(path, encoding: "UTF-8") == expected
35
+ end
77
36
 
78
- if expected == actual
79
- puts "✓ #{LLMS_FULL_PATH} is up to date"
37
+ if stale.empty?
38
+ puts "✓ llms.txt and llms-full.txt are up to date"
80
39
  else
81
- warn "✗ #{LLMS_FULL_PATH} is out of date with README/docs."
40
+ warn "✗ Out of date with README/docs: #{stale.keys.join(", ")}"
82
41
  warn " Run `bundle exec rake llms:build` and commit the result."
83
42
  exit 1
84
43
  end
@@ -86,7 +45,7 @@ namespace :llms do
86
45
  end
87
46
 
88
47
  # Top-level convenience alias.
89
- desc "Regenerate llms-full.txt (alias for llms:build)"
48
+ desc "Regenerate the LLM docs bundles (alias for llms:build)"
90
49
  task llms: "llms:build"
91
50
 
92
51
  task default: %i[spec rubocop llms:check]
@@ -61,7 +61,7 @@ fields Xero returns:
61
61
  | `reference` | `String` | Additional reference number (ACCRECCREDIT only). |
62
62
  | `sent_to_contact` | `Boolean` | Whether the credit note has been sent to the contact. |
63
63
  | `remaining_credit` | `Numeric` | The remaining credit balance. |
64
- | `allocations` | `Array<XeroKiwi::Accounting::Allocation>` | Allocations to invoices. Each allocation has an `invoice` reference. |
64
+ | `allocations` | `Array<XeroKiwi::Accounting::Allocation>` | Allocations to invoices. Each has `allocation_id`, `applied_amount`, `date`, `is_deleted`, and an `invoice` reference. |
65
65
  | `branding_theme_id` | `String` | The branding theme ID applied to the credit note. |
66
66
  | `has_attachments` | `Boolean` | Whether the credit note has attachments. |
67
67
 
@@ -57,7 +57,7 @@ fields Xero returns:
57
57
  | `currency_code` | `String` | The currency code (e.g. `"NZD"`). |
58
58
  | `currency_rate` | `String` | The currency rate (1.0 for base currency). |
59
59
  | `remaining_credit` | `String` | The remaining credit balance. |
60
- | `allocations` | `Array<XeroKiwi::Accounting::Allocation>` | Allocations to invoices. Each allocation has an `invoice` reference. |
60
+ | `allocations` | `Array<XeroKiwi::Accounting::Allocation>` | Allocations to invoices. Each has `allocation_id`, `applied_amount`, `date`, `is_deleted`, and an `invoice` reference. |
61
61
  | `payments` | `Array<XeroKiwi::Accounting::Payment>` | Payment records (references). See [Payments](payment.md). |
62
62
  | `has_attachments` | `Boolean` | Whether the overpayment has attachments. |
63
63
  | `reference` | `String` | Reference for the overpayment. |
@@ -58,7 +58,7 @@ fields Xero returns:
58
58
  | `currency_rate` | `String` | The currency rate (1.0 for base currency). |
59
59
  | `invoice_number` | `String` | The invoice number (for receive prepayments only). |
60
60
  | `remaining_credit` | `String` | The remaining credit balance. |
61
- | `allocations` | `Array<XeroKiwi::Accounting::Allocation>` | Allocations to invoices. Each allocation has an `invoice` reference. |
61
+ | `allocations` | `Array<XeroKiwi::Accounting::Allocation>` | Allocations to invoices. Each has `allocation_id`, `applied_amount`, `date`, `is_deleted`, and an `invoice` reference. |
62
62
  | `payments` | `Array<XeroKiwi::Accounting::Payment>` | Payment records (references). See [Payments](payment.md). |
63
63
  | `has_attachments` | `Boolean` | Whether the prepayment has attachments. |
64
64
  | `fully_paid_on_date` | `Time` | When the prepayment was fully allocated, parsed as UTC. |
data/docs/client.md CHANGED
@@ -68,6 +68,18 @@ Five things to know:
68
68
  the envelope before building a resource, so there's nothing left of it by
69
69
  the time `raw` is populated. If you need the envelope back, you're
70
70
  rebuilding it yourself.
71
+ - **Nested objects return `nil`, even with `retain_raw: true`.** Line
72
+ items, addresses, contact persons and payment terms do not carry their
73
+ own payload. Reach them through the enclosing resource's hash:
74
+
75
+ ```ruby
76
+ org.addresses.map(&:raw) # => [nil] ← not what you want
77
+ org.raw["Addresses"] # => [{"AddressType" => "POBOX", …}]
78
+ ```
79
+
80
+ This is deliberate — see [why nested objects don't carry
81
+ raw](#why-nested-objects-dont-carry-raw) — but it fails quietly, so it's
82
+ worth knowing before you map over a collection and store the result.
71
83
  - **It's the JSON representation.** Kiwi sends
72
84
  `Accept: application/json`. Xero also serves XML, which nests
73
85
  differently — XML has no arrays, so a single child parses to a Hash and
@@ -78,14 +90,46 @@ Five things to know:
78
90
  the modelled attributes — different keys, different nesting. If you store
79
91
  `to_h` where you meant to store the payload, readers fail by returning
80
92
  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
93
  - **It costs memory.** Every resource holds its source hash alongside the
86
94
  hydrated attributes, which roughly doubles the footprint of a large page.
87
95
  That's why it's off by default.
88
96
 
97
+ ### What `raw` guarantees
98
+
99
+ Depend on these; they won't change without a major version.
100
+
101
+ - It is the parsed JSON for that resource, with **only the top-level keys
102
+ stringified**. Everything nested is exactly what `JSON.parse` produced.
103
+ - **Kiwi does not normalise it.** No key renaming, no defaulting, no
104
+ filling in of absent keys. If Xero omits `Addresses`, `raw` has no
105
+ `"Addresses"` key — it does not become `[]`. If Xero sends `[]`, you get
106
+ `[]`. `raw.fetch("Addresses", [])` and `raw["Addresses"] || []` are both
107
+ safe and mean the same thing here.
108
+ - Keys the gem doesn't model are preserved. That's the point of it.
109
+ - The top-level hash is **frozen; nested structures are not**. `raw` itself
110
+ can't be mutated, but `raw["Addresses"]` is an ordinary mutable Array.
111
+ Dup before mutating if that matters to you.
112
+
113
+ The one thing deliberately *not* guaranteed is the presence of `raw` on
114
+ nested objects, per the bullet above.
115
+
116
+ ### Why nested objects don't carry raw
117
+
118
+ Two reasons, both worth knowing if you're tempted to ask for it.
119
+
120
+ It would cost real memory on the workload `retain_raw` exists for. A page
121
+ of 1,000 invoices with five line items each retains about 3.4 MB with
122
+ top-level raw; giving every nested object its own hash adds about 1.6 MB
123
+ on top, roughly half again, and that's on a flag that has already doubled
124
+ your footprint.
125
+
126
+ More importantly it couldn't be done consistently. Several attributes —
127
+ `payment_terms` on Contact and Organisation, `invoice_addresses` on
128
+ Invoice — hydrate through custom lambdas that construct their objects
129
+ directly, so they'd stay `nil` while their siblings worked. "Nested
130
+ objects never carry raw" is a rule you can hold in your head. "Nested
131
+ objects carry raw except these four" is not.
132
+
89
133
  ### Migrating from an XML-based client
90
134
 
91
135
  If you're replacing a Xero client that sent `Accept: text/xml` — HTTParty
@@ -5,17 +5,31 @@ module XeroKiwi
5
5
  # Represents an allocation of a credit note, prepayment, or overpayment
6
6
  # against an invoice.
7
7
  #
8
+ # Xero names the allocated value differently depending on direction: the
9
+ # allocation *request* body takes "Amount", while every *response* — and
10
+ # allocations only ever reach this gem embedded in a CreditNote,
11
+ # Prepayment or Overpayment response — carries "AppliedAmount". This
12
+ # class was originally modelled off the request shape, so `amount`
13
+ # silently returned nil for every allocation the gem could actually
14
+ # produce. `#amount` is now an alias of `#applied_amount` so callers who
15
+ # reached for the obvious name get the value they meant.
16
+ #
8
17
  # See: https://developer.xero.com/documentation/api/accounting/overpayments
9
18
  class Allocation
10
19
  include Resource
11
20
 
12
21
  identity :allocation_id
13
22
 
14
- attribute :allocation_id, xero: "AllocationID", type: :guid
15
- attribute :amount, xero: "Amount", type: :decimal
16
- attribute :date, xero: "Date", type: :date
17
- attribute :invoice, xero: "Invoice", type: :object, of: Invoice, reference: true
18
- attribute :is_deleted, xero: "IsDeleted", type: :bool
23
+ attribute :allocation_id, xero: "AllocationID", type: :guid
24
+ attribute :applied_amount, xero: "AppliedAmount", type: :decimal
25
+ attribute :date, xero: "Date", type: :date
26
+ attribute :invoice, xero: "Invoice", type: :object, of: Invoice, reference: true
27
+ attribute :is_deleted, xero: "IsDeleted", type: :bool
28
+
29
+ # Kept for callers written against the pre-0.6.0 attribute name. It
30
+ # could only ever have returned nil, so nothing that relied on a real
31
+ # value is affected.
32
+ def amount = applied_amount
19
33
  end
20
34
  end
21
35
  end
@@ -139,11 +139,23 @@ module XeroKiwi
139
139
  # arrived, or nil unless the client was built with `retain_raw: true`.
140
140
  # Use it to reach fields the gem doesn't model.
141
141
  #
142
+ # Returns nil on NESTED objects even when retain_raw is on — line
143
+ # items, addresses, contact persons, payment terms. Reach those
144
+ # through the enclosing resource instead:
145
+ #
146
+ # org.addresses.map(&:raw) # => [nil]
147
+ # org.raw["Addresses"] # => [{"AddressType" => "POBOX", ...}]
148
+ #
142
149
  # Scope is the item, NOT the enclosing envelope: `contact.raw` has no
143
150
  # "Contacts" key, and `organisation.raw` no "Organisations" key. Kiwi
144
151
  # unwraps the envelope before a resource is built, so there is nothing
145
152
  # left of it by the time this is populated.
146
153
  #
154
+ # Kiwi never normalises what it stores here: absent keys stay absent
155
+ # rather than becoming [] or nil entries, and keys the gem doesn't
156
+ # model are preserved. The hash itself is frozen; the structures
157
+ # nested inside it are not.
158
+ #
147
159
  # It is also the JSON representation specifically. Kiwi sends
148
160
  # `Accept: application/json`; Xero's XML representation nests
149
161
  # differently — no arrays, so one child is a Hash and several are an
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module XeroKiwi
4
- VERSION = "0.5.1"
4
+ VERSION = "0.6.0"
5
5
  end