xero-kiwi 0.9.0 → 0.10.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: b000115e3c885aaad2274ddd3b75f507af96e5e779b016d290559dedfcb96ab8
4
- data.tar.gz: c86cc3db68b995cf2620f45165157759e4ec9e4bac78ca12a2e9493562ea5856
3
+ metadata.gz: 122c7e012d619aabdf473185bf0e39e1e04204940d2c6d6b96888d1b6f73b76c
4
+ data.tar.gz: 2bbb94621f3e417275b1ea326a2e54e50f03083c01991863e2f91a16af5945ea
5
5
  SHA512:
6
- metadata.gz: 267a35b68a93afcccef4ce0c161f739c627b048461dbc52972b5405df85b4d2b9b3ca3dfb12d135e20ba57c363bb20605a65d25a51fb108708c0b14a2b8d8341
7
- data.tar.gz: f8b9e15d6b786fa9090f61bcc093c50a2bf9654f6ad4cf35bb8b6139dc3224e375907075e82bea1c1997be0c88df406c4559018c5cc89c62d398e4130dc26b71
6
+ metadata.gz: 0266fde7e2c101e0533f9e3202ba2871f0a17c170e27ff82dd380448028f608da61f697c3ca08d2a965245125c0987f0a9ddb54ccc01e0a89f47d437fd5aa62d
7
+ data.tar.gz: 93e24cd65f1ebf4f7dec4a97fe17d352fb7f6d2ed95f22e6466f403f9997859d16d51569614736c9a1ae77351f94b306dd8304b8f368f465d7c040f2eebb4514
data/CHANGELOG.md CHANGED
@@ -1,5 +1,91 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.10.0] - 2026-09-29
4
+
5
+ ### Breaking
6
+
7
+ - **Money attributes now return `BigDecimal` instead of `Float`.** Xero sends money as a JSON number, so Ruby parsed it into a Float and the `:decimal` type declaration passed it straight through, doing nothing. Floats cannot represent most decimal fractions, so arithmetic between two money fields drifts while each one still prints correctly:
8
+
9
+ ```ruby
10
+ 17228.67 + 2584.3 # => 19812.969999999998, where Xero's Total is 19812.97
11
+ ```
12
+
13
+ Measured against the recorded `invoices/list` response, `sub_total + total_tax == total` failed on **3 of 55 invoices** as Floats and on **none** as BigDecimals. Every individual value round-trips exactly — all 111 distinct money literals in that recording — so the failure only appears once you do arithmetic, which is what makes it quiet.
14
+
15
+ Applies to all 36 `:decimal` attributes across `Invoice`, `CreditNote`, `Prepayment`, `Overpayment`, `Payment`, `Allocation` and `LineItem`. Floats and Integers convert via `#to_d`, which uses the shortest decimal that round-trips, so BigDecimal holds the number Xero wrote rather than the binary approximation of it. Numeric strings are parsed with `BigDecimal()` rather than `String#to_d`, because `to_d` answers `0.0` for unparseable input and a silent zero in a money field is the failure this gem has shipped twice. Unparseable input reads as `nil`, as it does for `:date`.
16
+
17
+ Upgrading: comparison is not a concern — `BigDecimal("19812.97")` equals the Float `19812.97`, and a `decimal`/`numeric` column takes it unchanged. A consumer verified that last point on real data, passing five money fields straight into decimal columns with no conversion and getting identical rows.
18
+
19
+ Two things do change. `to_s` gives `"0.1981297e5"` rather than `"19812.97"` (use `to_s("F")`), and `to_json` gives that same scientific-notation **string** where a Float gave a JSON number — a visible change in shape if you write a **typed** money attribute into a `jsonb` column. **`raw` is unaffected**: it holds Xero's parsed payload untouched, so money in there is still a Float and still serialises as a JSON number. `#inspect` renders decimals in plain form, so debugging output is unaffected.
20
+
21
+ **Check for `.to_f` on money you persist.** It silently undoes this change, and a `decimal(19,4)` column holds more precision than a Float can carry — `BigDecimal("1234567890123.4567").to_f` round-trips to `1234567890123.456`. Out of reach for GBP or ZAR at ordinary invoice sizes, not for IDR or VND. A `.to_f` left over from an XML-era client, where the value arrived as a String, is where to look.
22
+
23
+ - **`""` now reads as `nil` on `:decimal` attributes too.** 0.8.0 normalised empty strings on `:string`, `:enum` and `:guid` and explicitly left `:decimal` alone as wanting its own decision. This is that decision. The alternative was `BigDecimal("")` raising, or a silent zero — the same shape as both allocation regressions.
24
+
25
+ - **Removed `Invoice#sales_tax_calculation_type_code`.** It read the payload key `"SalesTaxCalculationTypeCode"`, which appears in none of this gem's recorded responses, nowhere in Xero's 911K OpenAPI spec, and in none of a consumer's 368 recorded interactions across both the XML and JSON eras. Xero has no invoice-level sales-tax-calculation field under any name; its US sales tax fields are on the line item. The reader has returned `nil` since the first commit.
26
+
27
+ Removing it cannot break working code, because there has never been a value to depend on. It is listed as Breaking because the method disappears: a caller reading it goes from a silent `nil` to a `NoMethodError`, which is the same trade the empty-string change made in 0.8.0. If Xero ever does send the key, `retain_raw: true` exposes it through `invoice.raw`.
28
+
29
+ Recording why it went, so nobody re-adds it from the same plausible-sounding guess: it was never verified against a payload or a schema, and it was documented as "US auto sales tax calculation type", which made a permanent `nil` read as an answer about the organisation rather than a gap in the gem.
30
+
31
+ ### Fixed
32
+
33
+ - **`invoice.credit_notes`, `invoice.prepayments` and `invoice.overpayments` returned objects with the applied amount missing.** Xero nests **allocation stubs** under an invoice, not whole documents: a stub carries `AppliedAmount`, the amount applied to *that* invoice. Nothing modelled that key, so the nearest-looking reader was `total` — the document's own total, and a different number on **19 of the 24 stubs** in the recorded response. On one, `total` was `10983.65` where `applied_amount` was `857.35`.
34
+
35
+ All three resources now model `applied_amount`. It is `nil` on a document fetched in its own right, where Xero sends no such key, and populated on every stub in the recording. The only route to it before was `invoice.raw["CreditNotes"]` with `retain_raw: true`, since nested objects carry no `#raw`.
36
+
37
+ Found by the coverage task below rather than by comparison or by reading Xero's docs — the key is in no doc table, because the docs describe the XML representation and this shape only exists in JSON.
38
+
39
+ - **`LineItem#account_id` was reading a key Xero does not send.** It mapped to `"AccountId"`; Xero sends `"AccountID"`, so the attribute was `nil` on every line item ever returned. The string `AccountId` appears **zero times** in Xero's 911K OpenAPI spec, and a consumer's recorded JSON response carries `AccountID` on all 18 of its populated line items.
40
+
41
+ No recording in this repo could have caught it: list endpoints omit line items, so `LineItem` has never had a real payload here. It was found by comparing the classes against Xero's published spec, which is now `rake xero:schema`.
42
+
43
+ ### Added
44
+
45
+ - **`XeroKiwi::OAuth#client_credentials_token`** — Xero's non-tenanted `client_credentials` grant, authenticating the application rather than a user.
46
+
47
+ ```ruby
48
+ token = oauth.client_credentials_token(scopes: "app.connections")
49
+ client = XeroKiwi::Client.new(access_token: token.access_token)
50
+ client.connections
51
+ ```
52
+
53
+ It is for the calls that belong to the app rather than to a connection — `connections` and `delete_connection`, both already non-tenanted — in the case where **no user token is available**: tearing down an organisation, or reassigning which user owns a sync. Without it, a consumer managing connections has to hand-roll the same POST, which is what prompted this: one had ported everything off its legacy client except this single grant, and was keeping the old client alive to serve it.
54
+
55
+ `redirect_uri` is not required. `scopes:` takes a String or an Array, joined the way `authorization_url` joins them, and is omitted from the request entirely when nil so Xero assigns the app's own scopes.
56
+
57
+ **The token cannot be refreshed.** Xero issues none for this grant, so `token.refreshable?` is false and a `Client` holding one raises `AuthenticationError` on expiry rather than attempting a renewal that cannot succeed — `Client#can_refresh?` already required a refresh token, so no change was needed there. Renewal is calling this again, which costs one request and needs no stored state.
58
+
59
+ Failures raise `XeroKiwi::OAuth::ClientCredentialsError`, which subclasses `AuthenticationError` so existing rescues still catch it.
60
+
61
+ **`Client#connections` takes an optional `tenant_id:`**, which that grant requires. A user token identifies a user, so Xero can answer "which tenants can you see" unaided; an app token identifies nobody and answers `400 Xero-User-Id and/or Xero-Tenant-Id header must be supplied.` Measured by a consumer against live Xero — same token, same request, one header apart. It takes a String or a `XeroKiwi::Connection`; omitting it sends no header, which is what a user token wants and is unchanged. `delete_connection` needs no tenant header and takes none.
62
+
63
+ So "app tokens are untenanted" holds at the *token* level and not at the request level. The first version of these docs showed `client.connections` on a client-credentials token, which is the call that 400s.
64
+
65
+ - `Accounting::Organisation` now models `tax_number_name`, which names what the organisation's locale calls its tax number (`"VAT Number"` on the recorded tenant). Present and populated in the recording, previously reachable only through `raw`.
66
+
67
+ - Fields Xero's spec documents and the recordings confirm on every record, all previously reachable only through `raw`: `Invoice#is_discounted`, `Invoice#has_errors`, `CreditNote#has_errors`, `CreditNote#invoice_addresses`, `Contact#has_validation_errors`, `Payment#has_validation_errors`, `ContactGroup#has_validation_errors`, and `Prepayment#branding_theme_id`.
68
+
69
+ - **`attribute` accepts several candidate keys**, as `xero: %w[TrackingCategoryOption TrackingOptionName]`. The first one present in the payload wins. It is for a field with no recorded payload to settle which key Xero sends: a wrong single key reads `nil` forever and is indistinguishable from a field the tenant never fills, which is how two regressions shipped. `Contact#tracking_option_name` uses it — Xero's spec calls that field `TrackingCategoryOption`, kiwi called it `TrackingOptionName`, and neither spelling appears in any recording because no tenant to hand has a contact-level tracking default.
70
+
71
+ - **`rake xero:schema`** compares the resource classes against Xero's published OpenAPI spec, cached for a day. It is the other half of `xero:coverage`: a recording shows what one tenant populated, the spec shows what an endpoint can return at all. Each has caught what the other missed — the spec found the `AccountID` bug that no payload here could, and the recordings hold `User#GlobalUserID`, which the spec omits entirely and whose absence emptied a consumer's memberships table.
72
+
73
+ It reports rather than fails, for the same reason `xero:coverage` does, and because the spec is not authoritative on its own: it documents `CreditNote#DueDate`, which Xero sends on none of the 18 recorded credit notes, and omits the four `TrackingOption` booleans that a live capture proved real. Treat a difference as a question; a payload settles it where one exists.
74
+
75
+ - **`rake xero:coverage`** compares every recorded response against the resource classes that model it, and reports keys Xero sends that nothing reads, attributes nil in every recording, declared types that disagree with what arrived, and classes no recording exercises. Every silent bug this gem has shipped would have appeared in one of those four lists, with the disproving payload already committed. It reports rather than fails: gating it would need an allowlist of legitimately-absent keys, and an allowlist becomes a list nobody reads.
76
+
77
+ It also names the blind spot, and distinguishes *no cassette here* from *unverified*, which are not the same thing. `LineItem`, `Tracking`, `TrackingCategory` and `TrackingOption` have no cassette in this repo but have each been checked against a real response elsewhere — the task now says so, and says which. `Tracking` was the last class resting on Xero's spec alone, and a live `GET /Invoices/{id}` on a line item carrying two tracking categories settled it: four keys sent, four modelled, none nil. Only `ExternalLink` is genuinely unverified — no payload anywhere has carried a populated instance.
78
+
79
+ The walk also descends into attributes that hydrate through a custom lambda, which it previously skipped. `PaymentTerms` and `PaymentTerm` had read as exercised by nothing while a recorded contact carried both, and the type check immediately showed `PaymentTerm#day` arriving as an Integer against a `:string` declaration. Those attributes now declare `of:` purely so the audit can find the class; `Hydrator` ignores it when `hydrate:` is set.
80
+
81
+ ### Documentation
82
+
83
+ - Money attribute types were documented three different ways for the same field — `String` on Overpayment and Prepayment, `Numeric` on CreditNote and Payment, `String/Numeric` on Invoice — and three examples showed `total # => "100.00"`, a quoted string it never was. All 32 rows now say `BigDecimal`, generated from the attribute declarations so they cannot drift apart again.
84
+
85
+ - Each of Credit Note, Prepayment and Overpayment gained a **Nested under an invoice** section covering the stub shape, with the figures taken from the recorded response rather than invented.
86
+
87
+ - `docs/client.md` documents money as a fourth thing to check when migrating from an XML client, alongside the existing nesting, key-name and type axes.
88
+
3
89
  ## [0.9.0] - 2026-09-29
4
90
 
5
91
  ### Added
data/README.md CHANGED
@@ -117,6 +117,48 @@ default `redis://127.0.0.1:6379/15`.
117
117
  docker compose down # when you're done
118
118
  ```
119
119
 
120
+ ### Checking a resource against real payloads
121
+
122
+ ```sh
123
+ bundle exec rake xero:coverage
124
+ ```
125
+
126
+ Compares every recorded response in `spec/fixtures/vcr_cassettes` against
127
+ the resource classes that model it, and reports four things: keys Xero sends
128
+ that nothing reads, attributes that were nil in every recording, declared
129
+ types that disagree with what arrived, and classes no recording exercises at
130
+ all.
131
+
132
+ **Run it whenever you add or change a resource.** Every silent bug this gem
133
+ has shipped would have appeared in one of those four lists, and in each case
134
+ the disproving payload was already committed — the recordings were never the
135
+ gap, reading them was.
136
+
137
+ It reports rather than fails, and deliberately. Gating it would need an
138
+ allowlist of the legitimately-absent keys, and an allowlist becomes a list
139
+ nobody reads — which is the failure this is meant to prevent. Read the
140
+ output instead.
141
+
142
+ ```sh
143
+ bundle exec rake xero:schema
144
+ ```
145
+
146
+ The other half: compares the resource classes against **Xero's published
147
+ OpenAPI spec**, downloaded and cached for a day. A recording shows only what
148
+ one tenant happened to populate; the spec lists what an endpoint can return
149
+ at all.
150
+
151
+ Run both. Neither is sufficient, and each has caught what the other missed:
152
+
153
+ | | Found by |
154
+ |---|---|
155
+ | `LineItem` read `AccountId`, Xero sends `AccountID` | **spec** — no recording here carries a line item |
156
+ | `User#global_user_id` | **recordings** — absent from the spec entirely |
157
+ | `CreditNote#due_date` would be a gap | **neither** — the spec documents it, Xero sends it on none |
158
+
159
+ Treat a difference as a question rather than a defect. A payload settles it
160
+ where one exists.
161
+
120
162
  ### Releasing
121
163
 
122
164
  **Merge first, then bump.** `rake release` tags whatever is checked out, so a
data/Rakefile CHANGED
@@ -10,6 +10,88 @@ require "rubocop/rake_task"
10
10
  RuboCop::RakeTask.new
11
11
 
12
12
  require_relative "tasks/llms"
13
+ require_relative "tasks/coverage"
14
+ require_relative "tasks/schema"
15
+
16
+ namespace :xero do
17
+ desc "Compare every recorded Xero response against the resources that model it"
18
+ task :coverage do
19
+ $LOAD_PATH.unshift File.expand_path("lib", __dir__)
20
+ require "xero_kiwi"
21
+
22
+ report = Coverage.survey
23
+
24
+ puts "Keys Xero sends that nothing models"
25
+ puts "-" * 70
26
+ Coverage.unmodelled(report).sort_by { |klass, _| klass.name }.each do |klass, rows|
27
+ puts "\n #{Coverage.short(klass)} (#{report[:seen][klass]} recorded)"
28
+ rows.each do |key, populated, total|
29
+ state = populated.positive? ? "populated #{populated}/#{total}" : "always empty"
30
+ puts format(" %-30<key>s %<state>s", key: key, state: state)
31
+ end
32
+ end
33
+
34
+ puts "\n\nAttributes nil in every recording"
35
+ puts "-" * 70
36
+ puts " A wrong `xero:` key is indistinguishable from a field this tenant never fills."
37
+ Coverage.unpopulated(report).sort_by { |klass, _| klass.name }.each do |klass, names|
38
+ puts "\n #{Coverage.short(klass)} (#{report[:seen][klass]} recorded)"
39
+ names.each do |name|
40
+ puts format(" %-30<name>s -> %<key>s", name: name, key: klass.attributes[name][:xero].inspect)
41
+ end
42
+ end
43
+
44
+ puts "\n\nDeclared type against what Xero sends"
45
+ puts "-" * 70
46
+ Coverage.mistyped(report).sort_by { |klass, _| klass.name }.each do |klass, rows|
47
+ puts "\n #{Coverage.short(klass)}"
48
+ rows.each do |name, type, counts|
49
+ actual = counts.map { |cls, n| "#{cls} x#{n}" }.join(", ")
50
+ puts format(" %-26<name>s declared %-9<type>s got %<actual>s",
51
+ name: name, type: type.inspect, actual: actual)
52
+ end
53
+ end
54
+
55
+ puts "\n\nClasses no cassette here exercises"
56
+ puts "-" * 70
57
+ Coverage.unexercised(report).sort_by(&:name).each do |klass|
58
+ short = Coverage.short(klass)
59
+ note = Coverage::CONFIRMED_ELSEWHERE[short]
60
+ puts note ? " #{short} — confirmed elsewhere: #{note}" : " #{short} — unverified"
61
+ end
62
+ puts
63
+ end
64
+
65
+ desc "Compare the resource classes against Xero's published OpenAPI spec"
66
+ task :schema do
67
+ $LOAD_PATH.unshift File.expand_path("lib", __dir__)
68
+ require "xero_kiwi"
69
+
70
+ puts "Fields Xero documents that we do not read"
71
+ puts "-" * 70
72
+ Schema.unmodelled.each do |klass, keys|
73
+ puts "\n #{Coverage.short(klass)}"
74
+ keys.each { |key| puts " #{key}" }
75
+ end
76
+
77
+ puts "\n\nKeys we read that Xero does not document"
78
+ puts "-" * 70
79
+ puts " Either the spec omits the field or we invented the key. Only a"
80
+ puts " payload can say which — check `rake xero:coverage` for one."
81
+ Schema.undocumented.each do |klass, keys|
82
+ puts "\n #{Coverage.short(klass)}"
83
+ keys.each { |key| puts " #{key}" }
84
+ end
85
+
86
+ unmatched = Schema.unmatched
87
+ unless unmatched.empty?
88
+ puts "\n\nClasses with no matching schema in the spec"
89
+ puts "-" * 70
90
+ unmatched.each { |klass| puts " #{Coverage.short(klass)}" }
91
+ end
92
+ puts
93
+ end
94
+ end
13
95
 
14
96
  namespace :llms do
15
97
  desc "Regenerate llms.txt and llms-full.txt from README and docs/"
@@ -50,6 +50,7 @@ fields Xero returns:
50
50
  | `contact_group_id` | `String` | The unique Xero identifier for the group. |
51
51
  | `name` | `String` | The display name (e.g. "VIP Customers"). |
52
52
  | `status` | `String` | `"ACTIVE"` (only active groups are returned by Xero). |
53
+ | `has_validation_errors` | `Boolean` | Whether Xero flagged validation errors on the group. |
53
54
  | `contacts` | `Array<XeroKiwi::Accounting::Contact>` | The contacts in the group (references — each has `reference?` returning `true`). Only present when fetching a single group. See [Contacts](contact.md). |
54
55
 
55
56
  ## Predicates
@@ -87,6 +87,7 @@ are always returned on list and single-contact responses:
87
87
  | `batch_payments` | `Hash` | Batch payment details (raw). |
88
88
  | `discount` | `Float` | Default discount rate. |
89
89
  | `balances` | `Hash` | Outstanding and overdue AR/AP balances (raw). |
90
+ | `has_validation_errors` | `Boolean` | Whether Xero flagged validation errors on the contact. |
90
91
  | `has_attachments` | `Boolean` | Whether the contact has attachments. |
91
92
 
92
93
  ## The ContactPerson object
@@ -50,22 +50,48 @@ fields Xero returns:
50
50
  | `status` | `String` | e.g. `"DRAFT"`, `"SUBMITTED"`, `"AUTHORISED"`, `"PAID"`, `"VOIDED"`. |
51
51
  | `line_amount_types` | `String` | `"Inclusive"`, `"Exclusive"`, or `"NoTax"`. |
52
52
  | `line_items` | `Array<XeroKiwi::Accounting::LineItem>` | The line items. See [Prepayments — LineItem](prepayment.md#the-lineitem-object). |
53
- | `sub_total` | `Numeric` | The subtotal excluding taxes. |
54
- | `total_tax` | `Numeric` | The total tax amount. |
55
- | `total` | `Numeric` | The total (subtotal + total tax). |
56
- | `cis_deduction` | `Numeric` | CIS deduction (UK Construction Industry Scheme only). |
53
+ | `sub_total` | `BigDecimal` | The subtotal excluding taxes. |
54
+ | `total_tax` | `BigDecimal` | The total tax amount. |
55
+ | `total` | `BigDecimal` | The total (subtotal + total tax). |
56
+ | `applied_amount` | `BigDecimal` | Only set when this credit note is nested under an invoice, where it is the amount applied to *that* invoice — not the credit note total. `nil` on a credit note fetched in its own right. See [Nested under an invoice](#nested-under-an-invoice). |
57
+ | `cis_deduction` | `BigDecimal` | CIS deduction (UK Construction Industry Scheme only). |
57
58
  | `updated_date_utc` | `Time` | When the credit note was last modified, parsed as UTC. |
58
59
  | `currency_code` | `String` | The currency code (e.g. `"NZD"`). |
59
- | `currency_rate` | `Numeric` | The currency rate (1.0 for base currency). |
60
+ | `currency_rate` | `BigDecimal` | The currency rate (1.0 for base currency). |
60
61
  | `fully_paid_on_date` | `Time` | When the credit note was fully allocated, parsed as UTC. |
61
62
  | `reference` | `String` | Additional reference number (ACCRECCREDIT only). |
62
63
  | `sent_to_contact` | `Boolean` | Whether the credit note has been sent to the contact. |
63
- | `remaining_credit` | `Numeric` | The remaining credit balance. |
64
+ | `remaining_credit` | `BigDecimal` | The remaining credit balance. |
64
65
  | `allocations` | `Array<XeroKiwi::Accounting::Allocation>` | Allocations to invoices. Each has `allocation_id`, the allocated value (as both `amount` and `applied_amount` — see below), `date`, `is_deleted`, and an `invoice` reference. |
65
66
  | `payments` | `Array<XeroKiwi::Accounting::Payment>` | Payment records (references), as on prepayments and overpayments. See [Payments](payment.md). |
66
67
  | `branding_theme_id` | `String` | The branding theme ID applied to the credit note. |
68
+ | `has_errors` | `Boolean` | Whether Xero flagged validation errors on the credit note. |
69
+ | `invoice_addresses` | `Array<Hash>` | Invoice addresses (US auto sales tax only). Empty array when absent. |
67
70
  | `has_attachments` | `Boolean` | Whether the credit note has attachments. |
68
71
 
72
+ ## Nested under an invoice
73
+
74
+ `invoice.credit_notes` does not return whole credit notes. Xero nests an
75
+ **allocation stub** there — a partial credit note describing how much of it
76
+ was applied to *that* invoice:
77
+
78
+ ```ruby
79
+ invoice = client.invoices(tenant_id).first
80
+ stub = invoice.credit_notes.find { |s| s.applied_amount != s.total }
81
+
82
+ stub.applied_amount # => BigDecimal("857.35") applied to this invoice
83
+ stub.total # => BigDecimal("10983.65") the credit note's own total
84
+ stub.credit_note_id # => "4bbdbaaf-…" fetch it in full with this
85
+ ```
86
+
87
+ `applied_amount` and `total` answer different questions, and they differ on
88
+ 8 of the 11 stubs in this gem's recorded response. Reaching for `total`
89
+ to ask "how much was applied to this invoice" overstates it.
90
+
91
+ A stub carries only `credit_note_id`, `applied_amount`, `total` and `date`.
92
+ Everything else is `nil` or empty, including `line_items` — fetch the
93
+ credit note by its ID when you need the rest of it.
94
+
69
95
  ## Predicates
70
96
 
71
97
  ```ruby
@@ -31,7 +31,7 @@ items. Fetch an individual invoice for full details including line items.
31
31
 
32
32
  ```ruby
33
33
  inv = client.invoice(tenant_id, "243216c5-369e-4056-ac67-05388f86dc81")
34
- inv.total # => "2025.00"
34
+ inv.total # => BigDecimal("2025.00")
35
35
  inv.invoice_number # => "OIT00546"
36
36
  ```
37
37
 
@@ -54,31 +54,32 @@ cover both the list and single-invoice responses:
54
54
  | `status` | `String` | e.g. `"DRAFT"`, `"SUBMITTED"`, `"AUTHORISED"`, `"PAID"`, `"VOIDED"`, `"DELETED"`. |
55
55
  | `line_amount_types` | `String` | `"Inclusive"`, `"Exclusive"`, or `"NoTax"`. |
56
56
  | `line_items` | `Array<XeroKiwi::Accounting::LineItem>` | The line items (empty on list, populated on single). See [Prepayments — LineItem](prepayment.md#the-lineitem-object). |
57
- | `sub_total` | `String/Numeric` | The subtotal excluding taxes. |
58
- | `total_tax` | `String/Numeric` | The total tax amount. |
59
- | `total` | `String/Numeric` | The total (subtotal + total tax). |
60
- | `total_discount` | `String/Numeric` | Total discounts on line items. |
57
+ | `sub_total` | `BigDecimal` | The subtotal excluding taxes. |
58
+ | `total_tax` | `BigDecimal` | The total tax amount. |
59
+ | `total` | `BigDecimal` | The total (subtotal + total tax). |
60
+ | `total_discount` | `BigDecimal` | Total discounts on line items. |
61
61
  | `updated_date_utc` | `Time` | When the invoice was last modified, parsed as UTC. |
62
62
  | `currency_code` | `String` | The currency code (e.g. `"NZD"`). |
63
- | `currency_rate` | `Numeric` | The currency rate (1.0 for base currency). |
63
+ | `currency_rate` | `BigDecimal` | The currency rate (1.0 for base currency). |
64
64
  | `reference` | `String` | Additional reference number (ACCREC only). |
65
65
  | `branding_theme_id` | `String` | The branding theme ID. |
66
66
  | `url` | `String` | URL link to a source document. |
67
67
  | `sent_to_contact` | `Boolean` | Whether the invoice displays as "sent" in Xero. |
68
68
  | `expected_payment_date` | `Time` | Expected payment date (ACCREC only). |
69
69
  | `planned_payment_date` | `Time` | Planned payment date (ACCPAY only). |
70
+ | `is_discounted` | `Boolean` | Whether any line item carries a discount. |
71
+ | `has_errors` | `Boolean` | Whether Xero flagged validation errors on the invoice. |
70
72
  | `has_attachments` | `Boolean` | Whether the invoice has attachments. |
71
73
  | `repeating_invoice_id` | `String` | The repeating invoice template ID, if applicable. |
72
74
  | `payments` | `Array<XeroKiwi::Accounting::Payment>` | Payment records (references). See [Payments](payment.md). |
73
75
  | `credit_notes` | `Array<Hash>` | Credit notes applied (raw hashes). |
74
76
  | `prepayments` | `Array<Hash>` | Prepayments applied (raw hashes). |
75
77
  | `overpayments` | `Array<Hash>` | Overpayments applied (raw hashes). |
76
- | `amount_due` | `String/Numeric` | Amount remaining to be paid. |
77
- | `amount_paid` | `String/Numeric` | Sum of payments received. |
78
- | `amount_credited` | `String/Numeric` | Sum of credit notes, overpayments, and prepayments applied. |
79
- | `cis_deduction` | `Numeric` | CIS deduction (UK Construction Industry Scheme only). |
78
+ | `amount_due` | `BigDecimal` | Amount remaining to be paid. |
79
+ | `amount_paid` | `BigDecimal` | Sum of payments received. |
80
+ | `amount_credited` | `BigDecimal` | Sum of credit notes, overpayments, and prepayments applied. |
81
+ | `cis_deduction` | `BigDecimal` | CIS deduction (UK Construction Industry Scheme only). |
80
82
  | `fully_paid_on_date` | `Time` | When the invoice was fully paid, parsed as UTC. |
81
- | `sales_tax_calculation_type_code` | `String` | US auto sales tax calculation type. |
82
83
  | `invoice_addresses` | `Array<Hash>` | Invoice addresses (US auto sales tax only). |
83
84
 
84
85
  ## Predicates
@@ -44,6 +44,7 @@ Xero returns:
44
44
  | `registration_number` | `String` | The company registration number. |
45
45
  | `employer_identification_number` | `String` | The EIN (US organisations). |
46
46
  | `tax_number` | `String` | The tax/VAT number. |
47
+ | `tax_number_name` | `String` | What the organisation's locale calls that number, e.g. `"VAT Number"`. |
47
48
  | `financial_year_end_day` | `Integer` | Day of month the financial year ends (1–31). |
48
49
  | `financial_year_end_month` | `Integer` | Month the financial year ends (1–12). |
49
50
  | `sales_tax_basis` | `String` | e.g. `"Payments"`, `"CASH"`, `"ACCRUALS"`. |
@@ -29,7 +29,7 @@ overpayments = client.overpayments(connection)
29
29
 
30
30
  ```ruby
31
31
  op = client.overpayment(tenant_id, "aea95d78-ea48-456b-9b08-6bc012600072")
32
- op.total # => "100.00"
32
+ op.total # => BigDecimal("100.00")
33
33
  ```
34
34
 
35
35
  `client.overpayment` hits `GET /api.xro/2.0/Overpayments/{OverpaymentID}` and
@@ -50,18 +50,42 @@ fields Xero returns:
50
50
  | `status` | `String` | e.g. `"AUTHORISED"`, `"PAID"`, `"VOIDED"`. |
51
51
  | `line_amount_types` | `String` | `"Inclusive"`, `"Exclusive"`, or `"NoTax"`. |
52
52
  | `line_items` | `Array<XeroKiwi::Accounting::LineItem>` | The line items. See [Prepayments — LineItem](prepayment.md#the-lineitem-object). |
53
- | `sub_total` | `String` | The subtotal excluding taxes. |
54
- | `total_tax` | `String` | The total tax amount. |
55
- | `total` | `String` | The total (subtotal + total tax). |
53
+ | `sub_total` | `BigDecimal` | The subtotal excluding taxes. |
54
+ | `total_tax` | `BigDecimal` | The total tax amount. |
55
+ | `total` | `BigDecimal` | The total (subtotal + total tax). |
56
+ | `applied_amount` | `BigDecimal` | Only set when this overpayment is nested under an invoice, where it is the amount applied to *that* invoice — not the overpayment total. `nil` on an overpayment fetched in its own right. See [Nested under an invoice](#nested-under-an-invoice). |
56
57
  | `updated_date_utc` | `Time` | When the overpayment was last modified, parsed as UTC. |
57
58
  | `currency_code` | `String` | The currency code (e.g. `"NZD"`). |
58
- | `currency_rate` | `String` | The currency rate (1.0 for base currency). |
59
- | `remaining_credit` | `String` | The remaining credit balance. |
59
+ | `currency_rate` | `BigDecimal` | The currency rate (1.0 for base currency). |
60
+ | `remaining_credit` | `BigDecimal` | The remaining credit balance. |
60
61
  | `allocations` | `Array<XeroKiwi::Accounting::Allocation>` | Allocations to invoices. Each has `allocation_id`, the allocated value (as both `amount` and `applied_amount` — see below), `date`, `is_deleted`, and an `invoice` reference. |
61
62
  | `payments` | `Array<XeroKiwi::Accounting::Payment>` | Payment records (references). See [Payments](payment.md). |
62
63
  | `has_attachments` | `Boolean` | Whether the overpayment has attachments. |
63
64
  | `reference` | `String` | Reference for the overpayment. |
64
65
 
66
+ ## Nested under an invoice
67
+
68
+ `invoice.overpayments` does not return whole overpayments. Xero nests an
69
+ **allocation stub** there — a partial overpayment describing how much of it
70
+ was applied to *that* invoice:
71
+
72
+ ```ruby
73
+ invoice = client.invoices(tenant_id).first
74
+ stub = invoice.overpayments.find { |s| s.applied_amount != s.total }
75
+
76
+ stub.applied_amount # => BigDecimal("1366.2") applied to this invoice
77
+ stub.total # => BigDecimal("1367.0") the overpayment's own total
78
+ stub.overpayment_id # => "c678f3f6-…" fetch it in full with this
79
+ ```
80
+
81
+ `applied_amount` and `total` answer different questions, and they differ on
82
+ 4 of the 4 stubs in this gem's recorded response. Reaching for `total`
83
+ to ask "how much was applied to this invoice" overstates it.
84
+
85
+ A stub carries only `overpayment_id`, `applied_amount`, `total` and `date`.
86
+ Everything else is `nil` or empty, including `line_items` — fetch the
87
+ overpayment by its ID when you need the rest of it.
88
+
65
89
  ## Predicates
66
90
 
67
91
  ```ruby
@@ -44,10 +44,11 @@ fields Xero returns:
44
44
  |-----------|------|------------|
45
45
  | `payment_id` | `String` | The unique Xero identifier. |
46
46
  | `date` | `Time` | The date the payment was made, parsed as UTC. |
47
- | `currency_rate` | `Numeric` | Exchange rate (1.0 for base currency). |
48
- | `amount` | `Numeric` | The payment amount in the invoice's currency. |
49
- | `bank_amount` | `Numeric` | The payment amount in the account's currency. |
47
+ | `currency_rate` | `BigDecimal` | Exchange rate (1.0 for base currency). |
48
+ | `amount` | `BigDecimal` | The payment amount in the invoice's currency. |
49
+ | `bank_amount` | `BigDecimal` | The payment amount in the account's currency. |
50
50
  | `reference` | `String` | An optional description for the payment. |
51
+ | `has_validation_errors` | `Boolean` | Whether Xero flagged validation errors on the payment. |
51
52
  | `is_reconciled` | `Boolean` | Whether the payment has been reconciled. |
52
53
  | `status` | `String` | e.g. `"AUTHORISED"`, `"DELETED"`. |
53
54
  | `payment_type` | `String` | e.g. `"ACCRECPAYMENT"`, `"ACCPAYPAYMENT"`. See Xero docs for all types. |
@@ -29,7 +29,7 @@ prepayments = client.prepayments(connection)
29
29
 
30
30
  ```ruby
31
31
  prepayment = client.prepayment(tenant_id, "aea95d78-ea48-456b-9b08-6bc012600072")
32
- prepayment.total # => "100.00"
32
+ prepayment.total # => BigDecimal("100.00")
33
33
  ```
34
34
 
35
35
  `client.prepayment` hits `GET /api.xro/2.0/Prepayments/{PrepaymentID}` and
@@ -50,17 +50,19 @@ fields Xero returns:
50
50
  | `status` | `String` | e.g. `"AUTHORISED"`, `"PAID"`, `"VOIDED"`. |
51
51
  | `line_amount_types` | `String` | `"Inclusive"`, `"Exclusive"`, or `"NoTax"`. |
52
52
  | `line_items` | `Array<XeroKiwi::Accounting::LineItem>` | The line items. See [LineItem](#the-lineitem-object). |
53
- | `sub_total` | `String` | The subtotal excluding taxes. |
54
- | `total_tax` | `String` | The total tax amount. |
55
- | `total` | `String` | The total (subtotal + total tax). |
53
+ | `sub_total` | `BigDecimal` | The subtotal excluding taxes. |
54
+ | `total_tax` | `BigDecimal` | The total tax amount. |
55
+ | `total` | `BigDecimal` | The total (subtotal + total tax). |
56
+ | `applied_amount` | `BigDecimal` | Only set when this prepayment is nested under an invoice, where it is the amount applied to *that* invoice — not the prepayment total. `nil` on a prepayment fetched in its own right. See [Nested under an invoice](#nested-under-an-invoice). |
56
57
  | `updated_date_utc` | `Time` | When the prepayment was last modified, parsed as UTC. |
57
58
  | `currency_code` | `String` | The currency code (e.g. `"NZD"`). |
58
- | `currency_rate` | `String` | The currency rate (1.0 for base currency). |
59
+ | `currency_rate` | `BigDecimal` | The currency rate (1.0 for base currency). |
59
60
  | `invoice_number` | `String` | The invoice number (for receive prepayments only). |
60
61
  | `reference` | `String` | The prepayment reference, as on credit notes and overpayments. Nil when Xero sends it empty. |
61
- | `remaining_credit` | `String` | The remaining credit balance. |
62
+ | `remaining_credit` | `BigDecimal` | The remaining credit balance. |
62
63
  | `allocations` | `Array<XeroKiwi::Accounting::Allocation>` | Allocations to invoices. Each has `allocation_id`, the allocated value (as both `amount` and `applied_amount` — see below), `date`, `is_deleted`, and an `invoice` reference. |
63
64
  | `payments` | `Array<XeroKiwi::Accounting::Payment>` | Payment records (references). See [Payments](payment.md). |
65
+ | `branding_theme_id` | `String` | The branding theme applied, as on credit notes and invoices. |
64
66
  | `has_attachments` | `Boolean` | Whether the prepayment has attachments. |
65
67
  | `fully_paid_on_date` | `Time` | When the prepayment was fully allocated, parsed as UTC. |
66
68
 
@@ -72,14 +74,38 @@ documents (prepayments, invoices, etc.):
72
74
  | Attribute | Type | What it is |
73
75
  |-----------|------|------------|
74
76
  | `description` | `String` | Line item description. |
75
- | `quantity` | `Float` | Quantity. |
76
- | `unit_amount` | `Float` | Unit amount. |
77
+ | `quantity` | `BigDecimal` | Quantity. |
78
+ | `unit_amount` | `BigDecimal` | Unit amount. |
77
79
  | `account_code` | `String` | The account code. |
80
+ | `account_id` | `String` | The account ID. Xero sends this as `AccountID`; kiwi read `AccountId` until 0.10.0 and so returned `nil`. |
78
81
  | `tax_type` | `String` | The tax type override. |
79
- | `tax_amount` | `Float` | The calculated tax amount. |
80
- | `line_amount` | `Float` | The line total. |
82
+ | `tax_amount` | `BigDecimal` | The calculated tax amount. |
83
+ | `line_amount` | `BigDecimal` | The line total. |
81
84
  | `tracking` | `Array<Hash>` | Tracking categories (raw, max 2 per line). |
82
85
 
86
+ ## Nested under an invoice
87
+
88
+ `invoice.prepayments` does not return whole prepayments. Xero nests an
89
+ **allocation stub** there — a partial prepayment describing how much of it
90
+ was applied to *that* invoice:
91
+
92
+ ```ruby
93
+ invoice = client.invoices(tenant_id).first
94
+ stub = invoice.prepayments.find { |s| s.applied_amount != s.total }
95
+
96
+ stub.applied_amount # => BigDecimal("999.99") applied to this invoice
97
+ stub.total # => BigDecimal("1784.87") the prepayment's own total
98
+ stub.prepayment_id # => "4a27d582-…" fetch it in full with this
99
+ ```
100
+
101
+ `applied_amount` and `total` answer different questions, and they differ on
102
+ 7 of the 9 stubs in this gem's recorded response. Reaching for `total`
103
+ to ask "how much was applied to this invoice" overstates it.
104
+
105
+ A stub carries only `prepayment_id`, `applied_amount`, `total` and `date`.
106
+ Everything else is `nil` or empty, including `line_items` — fetch the
107
+ prepayment by its ID when you need the rest of it.
108
+
83
109
  ## Predicates
84
110
 
85
111
  ```ruby
data/docs/client.md CHANGED
@@ -215,6 +215,58 @@ This is the third axis on which the two representations differ, after
215
215
  nesting and key names. Treat "it's the same field, so it's the same value"
216
216
  as the assumption to check rather than the one to rely on.
217
217
 
218
+ **Money is a `BigDecimal`, not a String and not a Float.** An XML client
219
+ hands you `"19812.97"`; Xero's JSON sends the number `19812.97`, which Ruby
220
+ parses as a Float. Kiwi converts every money field to `BigDecimal` on the
221
+ way in, because Floats cannot represent most decimal fractions and so
222
+ arithmetic between two of them drifts:
223
+
224
+ ```ruby
225
+ 17228.67 + 2584.3 # => 19812.969999999998 ← Float
226
+ invoice.sub_total + invoice.total_tax == invoice.total # => true
227
+ ```
228
+
229
+ That identity failed on 3 of the 55 invoices in this gem's recorded
230
+ response as Floats, and on none of them as BigDecimals. Each individual
231
+ value was correct in both — it is only arithmetic between fields that goes
232
+ wrong, which is what makes it quiet.
233
+
234
+ For migration this mostly helps. Comparison is not a concern —
235
+ `BigDecimal("19812.97")` equals the Float `19812.97`, equals
236
+ `BigDecimal("19812.97")`, and `BigDecimal("100")` equals `100` — and a
237
+ `decimal`/`numeric` column takes it unchanged. Two things do change:
238
+
239
+ - **`to_s` gives `"0.1981297e5"`**, not `"19812.97"`. Use `to_s("F")` for a
240
+ plain decimal string.
241
+ - **`to_json` gives the string `"0.1981297e5"`**, where a Float gave the
242
+ number `19812.97`. Writing a **typed** money attribute into a `jsonb`
243
+ column or an API response changes the stored shape — from a JSON number
244
+ to a JSON string in scientific notation. Call `to_s("F")` or `to_f` on
245
+ the way in, depending on which you want.
246
+
247
+ **`raw` is not affected.** It holds Xero's parsed payload untouched, so a
248
+ money value in there is still a Float and still serialises as a JSON
249
+ number. A consumer whose `jsonb` columns are fed from `raw` sees no
250
+ change at all — that separation is the whole point of `raw`.
251
+
252
+ `is_a?(Float)` is also now false, though `is_a?(Numeric)` still holds.
253
+
254
+ **Check for `.to_f` on money you persist.** Converting back to Float
255
+ immediately undoes this change, and does so silently: a `decimal(19,4)`
256
+ column holds more precision than a Float can carry, so the round trip loses
257
+ digits rather than raising.
258
+
259
+ ```ruby
260
+ BigDecimal("1234567890123.4567").round(4) # => 1234567890123.4567
261
+ BigDecimal("1234567890123.4567").to_f.to_d.round(4) # => 1234567890123.456
262
+ BigDecimal("999999999999999.9999").to_f.to_d.round(4) # => 1000000000000000.0
263
+ ```
264
+
265
+ Out of reach for currencies like GBP or ZAR at ordinary invoice sizes, not
266
+ for IDR or VND. A `.to_f` left over from an XML-era client — where the value
267
+ arrived as a String and had to be converted — is the likely place to find
268
+ one.
269
+
218
270
  One piece of luck worth knowing about: the two piles tend to fail
219
271
  differently. A `dig` that misses returns nil and writes a blank record
220
272
  quietly. Code that assumed a Hash, such as `Array#to_h` on what is now a
@@ -226,7 +278,7 @@ review time for the silent ones.
226
278
 
227
279
  | Method | Returns | Purpose |
228
280
  |--------|---------|---------|
229
- | `client.connections` | `Array<XeroKiwi::Connection>` | Fetch the tenants this token is authorised against. See [Connections](connections.md). |
281
+ | `client.connections(tenant_id: nil)` | `Array<XeroKiwi::Connection>` | Fetch the tenants this token is authorised against. `tenant_id:` is required on a client-credentials token and unused on a user token. See [Connections](connections.md). |
230
282
  | `client.contacts(tenant_id_or_connection)` | `Array<XeroKiwi::Accounting::Contact>` | Fetch the contacts for a tenant. See [Contacts](accounting/contact.md). |
231
283
  | `client.contact(tenant_id_or_connection, contact_id)` | `XeroKiwi::Accounting::Contact` | Fetch a single contact by ID. See [Contacts](accounting/contact.md). |
232
284
  | `client.contact_groups(tenant_id_or_connection)` | `Array<XeroKiwi::Accounting::ContactGroup>` | Fetch the contact groups for a tenant. See [Contact Groups](accounting/contact-group.md). |
data/docs/connections.md CHANGED
@@ -26,6 +26,21 @@ if the user authorised your app but didn't pick any tenants (rare, but
26
26
  possible). The endpoint doesn't take any filtering parameters — Xero returns
27
27
  everything the token has access to.
28
28
 
29
+ ### On a client-credentials token, pass `tenant_id:`
30
+
31
+ ```ruby
32
+ client.connections(tenant_id: org_tenant_id)
33
+ ```
34
+
35
+ Everything above assumes a **user** token, where no argument is needed. An
36
+ app token from the [`client_credentials` grant](oauth.md#the-client-credentials-grant-no-user)
37
+ identifies no user, so Xero can't work out whose connections to list and
38
+ answers `400 Xero-User-Id and/or Xero-Tenant-Id header must be supplied.`
39
+
40
+ `tenant_id:` takes a String or a `XeroKiwi::Connection`, and sets the
41
+ `Xero-Tenant-Id` header. Omitting it sends no header, which is what a user
42
+ token wants. `delete_connection` needs no tenant header on either.
43
+
29
44
  ## The Connection object
30
45
 
31
46
  Each `XeroKiwi::Connection` is an immutable value object exposing the fields Xero