spree-kashflow 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ff9334bd3e09ee1cdb9965f3eddc61af9d887161dd95901c9b54bc6feaf15cdc
4
+ data.tar.gz: 7e4576dd85f13c15326591811c0d05a6efa17de61a44e1b82b6a62fca8dd0717
5
+ SHA512:
6
+ metadata.gz: d55939c427bc7154665e69801126b67a35cc95f01024f9303f9d380377b3b290db422ce3e67cd802c98465a65db9b85bdb47f01c015e84340062bdcebe60856a
7
+ data.tar.gz: b785bbb7a59d6a80686fe13b3cab2f37b83e4d7d7d97d87369c13c09a469852a76604a51d431f5a6e433953ce1e1242feb68f67ffecc10397ee5dff8576f28e7
data/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release.
6
+ - Pushes completed orders to KashFlow as invoices over the SOAP API, with the
7
+ customer upserted and the payment recorded.
8
+ - Pushes refunds as credit notes (negative invoices) linked to the original
9
+ invoice.
10
+ - Configured per store through Spree's Integrations framework; nominal codes and
11
+ bank account are chosen from the connected KashFlow account.
12
+ - Refuses to post an invoice whose assembled total does not match the order
13
+ total, recording the failure instead.
14
+ - Requires Spree >= 5.6. No migrations.
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2026 Aypex
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,257 @@
1
+ # Spree::Kashflow
2
+
3
+ A [Spree](https://github.com/spree/spree) extension that posts completed orders to
4
+ [KashFlow](https://www.kashflow.com/) as invoices, and refunds as credit notes, over
5
+ KashFlow's SOAP API.
6
+
7
+ ## Why SOAP, not REST?
8
+
9
+ KashFlow has two APIs. The REST API's own docs carry a production disclaimer —
10
+ ["you should not be using it in a production environment"](https://api.kashflow.com/) —
11
+ that has stood, unrevised, since October 2018. The SOAP API is the one KashFlow
12
+ actually supports for production integrations, so that's what this gem uses.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ bundle add spree-kashflow
18
+ ```
19
+
20
+ Requires Spree >= 5.6 and Ruby >= 3.3. There are no migrations and no generator to
21
+ run — installing the gem and restarting the app is the whole install.
22
+
23
+ ## Configuration
24
+
25
+ In the Spree admin, go to **Configuration → Integrations → New Integration** and
26
+ choose **KashFlow** (listed under the "Accounting" group).
27
+
28
+ Enter your KashFlow **API username** and **API password**. Two things trip people up
29
+ here:
30
+
31
+ - The **SOAP API must be explicitly enabled** on the KashFlow account before these
32
+ credentials will work — it's not on by default.
33
+ - The KashFlow **API password is often not the same as the login password**. Check
34
+ KashFlow's API settings for the credentials it issues specifically for API access.
35
+
36
+ Four more fields decide where money lands in the ledger:
37
+
38
+ | Field | Purpose |
39
+ |---|---|
40
+ | Sales nominal code | Ledger account line items post to |
41
+ | Shipping nominal code | Ledger account shipping charges post to |
42
+ | Bank account | Account payments are recorded against |
43
+ | Payment method | KashFlow payment method used when recording a payment |
44
+
45
+ All four are required — there are no defaults, because a wrong nominal code
46
+ silently posts revenue to the wrong place.
47
+
48
+ **The first save always fails, and that's expected.** All six fields render from
49
+ the start, but the four dropdowns are populated by live lookups against the
50
+ connected account, which can only run once credentials are *persisted*. Since
51
+ all four are also validated as present and greater than zero, the first submit
52
+ cannot pass. The flow is:
53
+
54
+ 1. Enter the API username and password. Leave the four dropdowns alone — they
55
+ are empty.
56
+ 2. Save. The form comes back with validation errors on the four numeric fields.
57
+ This is the expected outcome, not a misconfiguration.
58
+ 3. The dropdowns are now populated from the connected account. Pick all four.
59
+ 4. Save again. This one succeeds.
60
+
61
+ If the dropdowns are still empty after step 2, the credentials were rejected —
62
+ re-check the two gotchas above.
63
+
64
+ No further setup is needed for metafields: this gem uses Spree's metafields
65
+ framework to track sync state and seeds its own `MetafieldDefinition` records on
66
+ first write, as **back-end only**. Nothing needs to be seeded by hand, and
67
+ nothing this gem stores (invoice numbers, customer codes, raw KashFlow error
68
+ text) is ever exposed on the storefront.
69
+
70
+ ## Before you go live
71
+
72
+ **No payload in this gem has been verified against a live KashFlow account.**
73
+ Every field name, sequence order and value shape is inferred from the vendored
74
+ WSDL (`docs/kashflow-service.wsdl`) and from KashFlow's documented conventions;
75
+ the test suite asserts the SOAP request bodies this gem *sends*, but nothing has
76
+ been posted to a real account and read back. Verifying an invoice, a payment and
77
+ a credit note end-to-end against a live (or sandbox) KashFlow account is a
78
+ release gate, not an optional extra.
79
+
80
+ Check these, in this order:
81
+
82
+ 1. **The `"OK"` status token — check this first.** `Client::SUCCESS_STATUS` is
83
+ `"OK"`. The WSDL declares `Status` as a bare `s:string` with no
84
+ `<s:enumeration>` and no documentation naming the success value, so `"OK"` is
85
+ KashFlow's documented *convention*, not something the schema pins down. If the
86
+ real token differs, **every successful post raises `ApiError`** — the
87
+ comparison inverts completely. Because `ApiError` is now discarded rather than
88
+ retried, that failure mode is quiet: no duplicate invoices, but every single
89
+ order fails with a `kashflow.sync_error` and nothing reaches KashFlow. Post one
90
+ invoice and read the raw `Status` back before anything else.
91
+ 2. **An invoice** — that the lines, VAT and totals render as expected, and that
92
+ `Sort` orders them the way you want.
93
+ 3. **A payment** — that `InsertInvoicePayment` returns a non-zero payment id and
94
+ the invoice shows as paid.
95
+ 4. **A credit note** — that `applyCreditNoteToInvoice` returns `true` and the
96
+ credit note appears linked to the original invoice, not floating on its own.
97
+
98
+ ### Wiring the customer's VAT number
99
+
100
+ Spree has no dedicated column for a customer's VAT number, and this gem does not
101
+ invent one. `Spree::Kashflow::CustomerPayload` reads it from
102
+ `order.metadata["vat_number"]` — but **nothing in Spree or this gem writes that
103
+ key**. If your store needs `VATNumber` sent to KashFlow (for EC-zone B2B
104
+ customers, for example), your host application must populate
105
+ `order.metadata["vat_number"]` before the order completes. Left unset, the
106
+ customer payload simply omits `VATNumber` — no error, just silently absent.
107
+
108
+ ## Behaviour
109
+
110
+ A sync is triggered by:
111
+
112
+ - **Order completion** — the `order.completed` event enqueues `SyncOrderJob`,
113
+ which upserts the customer, posts the invoice, and (if the order is paid)
114
+ records the payment.
115
+ - **A reimbursement** — the `reimbursement.reimbursed` event enqueues
116
+ `SyncRefundJob` for each refund it produced.
117
+ - **Any refund** — a decorator on `Spree::Refund` enqueues `SyncRefundJob` on
118
+ every refund's creation, independent of the reimbursement flow. This covers
119
+ an admin issuing an ad-hoc refund directly against a payment, which Spree
120
+ doesn't route through a reimbursement event.
121
+
122
+ ### Resume semantics
123
+
124
+ Each job is a small **resumable state machine**, not an all-or-nothing unit. Both
125
+ make two KashFlow calls that must be tracked separately, and each call is
126
+ followed *immediately* by the metafield that records it:
127
+
128
+ | Job | Step 1 | Marker | Step 2 | Marker |
129
+ |---|---|---|---|---|
130
+ | `SyncOrderJob` | post the invoice | `kashflow.invoice_number` | record the payment | `kashflow.payment_recorded_at` |
131
+ | `SyncRefundJob` | post the credit note | `kashflow.credit_note_number` | link it to the invoice | `kashflow.credit_note_linked_at` |
132
+
133
+ Each step is guarded on its **own** marker. A rerun skips whatever has already
134
+ succeeded and resumes at the first unfinished step, so the same order or refund
135
+ firing more than one trigger — or a job retrying — never double-books, and never
136
+ strands a half-finished sync either.
137
+
138
+ The ordering is the point. **Creating a ledger document must be at-most-once;
139
+ applying a payment or a link is naturally re-attemptable.** So the invoice number
140
+ is written the instant `InsertInvoice_TypeDefined` returns, before the payment is
141
+ even attempted. A duplicate invoice overstates revenue and VAT, is structurally
142
+ valid so nothing in KashFlow flags it, and remains a permanent artefact even
143
+ after being credit-noted. A permanently-unpaid invoice recognises revenue
144
+ correctly, surfaces in aged debtors at month-end, and is a one-click fix. If
145
+ exactly one of those has to be possible, it is the second.
146
+
147
+ A whole-job guard would undo this, which is why there isn't one: keyed on the
148
+ invoice number, it would see the key the failed run just wrote, no-op, and strand
149
+ the unpaid invoice for good.
150
+
151
+ One accepted consequence: an order can carry **both** an invoice number and a
152
+ `kashflow.sync_error`. That combination is accurate rather than contradictory —
153
+ the invoice genuinely exists in KashFlow, and only the payment failed. It also
154
+ keeps `SyncRefundJob`'s precondition guard (which refuses to credit-note an order
155
+ with no invoice number) working for such an order.
156
+
157
+ A business rejection from KashFlow (`Spree::Kashflow::ApiError`) is **discarded,
158
+ not retried**. A nominal code that doesn't exist or a `PayAccount` that isn't a
159
+ bank account does not become valid on the 25th attempt, and the
160
+ `kashflow.sync_error` metafield is already the operator-visible record. Only
161
+ transport failures are retried.
162
+
163
+ A sync **never blocks checkout**. Both jobs run asynchronously via Active Job,
164
+ after the order has already completed or the refund has already been created.
165
+ Nothing in the checkout or refund path waits on KashFlow.
166
+
167
+ ## The correctness guard
168
+
169
+ Before posting anything, this gem assembles the invoice's line items and checks
170
+ that they reconcile — both individually (`Rate * Quantity` against each line's
171
+ net total) and in aggregate (the assembled invoice total against
172
+ `order.total`). If they don't match, **nothing is posted**. Instead, the gem
173
+ raises `Spree::Kashflow::TotalMismatchError`, and the failing job records the
174
+ error message on the order's `kashflow.sync_error` metafield.
175
+
176
+ This is deliberate, not a limitation to work around. A failed sync is a
177
+ queryable flag your team can find and fix. A wrong invoice is a discrepancy
178
+ someone finds at year end, after it's been sitting in KashFlow's ledger for
179
+ months. Refusing to post is the safer failure mode.
180
+
181
+ One consequence: **orders with exclusive tax are refused, not mis-booked.** The
182
+ guard's arithmetic only accounts for VAT baked into the price
183
+ (`included_tax_total`); an order carrying `additional_tax_total` (tax added on
184
+ top of the price rather than included in it) will never reconcile, and will
185
+ always be refused with a sync error rather than posted with wrong figures.
186
+
187
+ ### Finding failed syncs
188
+
189
+ Query orders (or refunds) carrying a `kashflow.sync_error` metafield:
190
+
191
+ ```ruby
192
+ Spree::Order.with_metafield_key("kashflow.sync_error")
193
+ ```
194
+
195
+ The metafield's value is the error message from the failed attempt. It is
196
+ cleared automatically on the next successful sync.
197
+
198
+ ## Known limitations
199
+
200
+ - **Partial refunds are approximated, not apportioned.** Spree carries no
201
+ information tying a partial refund back to specific line items or their
202
+ individual VAT rates. A full refund mirrors the original invoice's lines
203
+ exactly, negated. A partial refund instead posts as a **single negative
204
+ line**, at the order's *blended* VAT rate (`order.included_tax_total /
205
+ (order.total - order.included_tax_total)`), rather than apportioned across
206
+ the lines it actually came from. Apportioning without that information would
207
+ mean inventing numbers in an accounting ledger, which this gem avoids.
208
+ - **`Sort` on invoice lines is a placeholder.** Lines are sent with a 1-based
209
+ index in `Sort` (line items first, then shipping). This is KashFlow's line
210
+ ordering field, but its effect on the rendered invoice has not been
211
+ confirmed against a live KashFlow account.
212
+ - **`ExchangeRate` is hardcoded to `1`.** Before posting, this gem confirms the
213
+ order's currency is *enabled* on the connected KashFlow account — but an
214
+ enabled currency isn't necessarily at parity with the account's base
215
+ currency. No currency conversion is performed. Multi-currency accounts where
216
+ the order currency differs from KashFlow's base currency will post at the
217
+ wrong effective rate. KashFlow's `GetCurrencies` already returns the real
218
+ rate and `Client#currencies` currently discards it, so this is the **top item
219
+ for 0.2.0**.
220
+ - **`ValuesInCurrency` is not set, and is unverified.** KashFlow's invoice
221
+ schema carries a `ValuesInCurrency` flag governing whether the amounts sent
222
+ are in the order's currency or the account's base currency. This gem leaves it
223
+ unset and has not confirmed which KashFlow assumes by default. Combined with
224
+ the `ExchangeRate` limitation above, treat multi-currency as unsupported for
225
+ now.
226
+ - **Orders that complete *unpaid* post an invoice that stays unpaid.** The only
227
+ sync trigger is order completion; there is no `payment.completed` trigger. An
228
+ order paid at checkout posts its payment along with the invoice, but an order
229
+ completed on bank transfer, on account, or through any pay-later method
230
+ (including Aypex's own `spree-bank_payments`) posts an invoice with `Paid` of
231
+ `0` — and *nothing subsequently marks it paid in KashFlow* when the money
232
+ actually arrives. Those payments have to be reconciled in KashFlow by hand
233
+ until 0.2.0 adds a payment trigger.
234
+ - **Sequential partial refunds are not capped in aggregate.**
235
+ `CreditNotePayload` decides full-versus-partial by comparing a *single*
236
+ refund's amount against the order total, and nothing sums the credit notes
237
+ already posted for an order. Several partial refunds against one order can
238
+ therefore credit more than the order was worth. KashFlow may refuse the
239
+ over-credit itself, but this gem does not prevent the attempt.
240
+ - **A fresh Savon client per call.** `Client` builds a new Savon client — and
241
+ therefore refetches the 457&nbsp;KB WSDL — on every call. Rendering the admin
242
+ configuration form makes four lookups, so four fetches. Functionally correct
243
+ but wasteful; caching is a 0.2.0 item.
244
+
245
+ ## Development
246
+
247
+ ```bash
248
+ bundle install
249
+ bundle exec rake test_app
250
+ bundle exec rspec
251
+ bundle exec standardrb
252
+ ```
253
+
254
+ ## Licence
255
+
256
+ The gem is available as open source under the terms of the
257
+ [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+ Bundler::GemHelper.install_tasks
5
+
6
+ require "rspec/core/rake_task"
7
+ require "spree/testing_support/extension_rake"
8
+
9
+ RSpec::Core::RakeTask.new
10
+
11
+ task default: :spec
12
+
13
+ desc "Generates a dummy app for testing"
14
+ task :test_app do
15
+ # Must be the require path, not the gem name -- spree_core's common:test_app
16
+ # does a literal `require ENV['LIB_NAME']` and templates the same string into
17
+ # the generated dummy app.
18
+ ENV["LIB_NAME"] = "spree/kashflow"
19
+ ENV["DB"] ||= "postgres"
20
+ Rake::Task["extension:test_app"].execute(install_admin: true)
21
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Kashflow
5
+ ##
6
+ # Posts a completed order to KashFlow as an invoice.
7
+ #
8
+ # A resumable state machine, not an all-or-nothing job. Posting the invoice
9
+ # and recording its payment are two separate KashFlow calls, each with its own
10
+ # metafield marker written the instant it succeeds
11
+ # ({Metafields::ORDER_INVOICE_NUMBER} and
12
+ # {Metafields::ORDER_PAYMENT_RECORDED_AT}), and each guarded independently in
13
+ # {#sync}. A rerun therefore resumes at the first unfinished step rather than
14
+ # replaying the whole job or skipping it wholesale — which is what makes it
15
+ # safe both to retry and to enqueue from more than one trigger (a subscriber
16
+ # and a decorator both firing for the same event, for instance) without
17
+ # double-booking. Takes an id, never an {Spree::Order}, so ActiveJob's
18
+ # argument serialisation never has to round-trip a whole AR object.
19
+ #
20
+ class SyncOrderJob < Spree::BaseJob
21
+ # Bad credentials will never succeed on retry, so retrying only fills the
22
+ # queue; the failure is written to {Metafields::ORDER_SYNC_ERROR} before the
23
+ # job discards (see the `rescue` in {#perform}, which runs before ActiveJob's
24
+ # `discard_on` handler).
25
+ discard_on Spree::Kashflow::AuthenticationError
26
+
27
+ # An {Spree::Kashflow::ApiError} is a business rejection — a nominal code
28
+ # that doesn't exist, a disabled currency, a `PayAccount` that isn't a real
29
+ # bank account. None of those change by being asked again, and without a
30
+ # policy here the error would inherit the backend default (25 attempts on
31
+ # Sidekiq). The {Metafields::ORDER_SYNC_ERROR} metafield written by
32
+ # {#perform}'s `rescue` — which runs before this handler — is already the
33
+ # operator-visible record, so the job discards rather than churning.
34
+ discard_on Spree::Kashflow::ApiError
35
+
36
+ # KashFlow being briefly unreachable is exactly the case retrying recovers
37
+ # from automatically.
38
+ retry_on Spree::Kashflow::TransportError, wait: :polynomially_longer, attempts: 5
39
+
40
+ ##
41
+ # @param order_id [Integer, String] the {Spree::Order} id
42
+ # @return [void]
43
+ # @raise [Spree::Kashflow::Error] any subclass raised while posting; the
44
+ # message is written to {Metafields::ORDER_SYNC_ERROR} first
45
+ #
46
+ def perform(order_id)
47
+ order = Spree::Order.find_by(id: order_id)
48
+ return if order.nil?
49
+
50
+ integration = Spree::Integrations::Kashflow.active.find_by(store: order.store)
51
+ return if integration.nil?
52
+
53
+ return if invoice_posted?(order) && payment_settled?(order)
54
+
55
+ sync(order, integration)
56
+ rescue Spree::Kashflow::Error => e
57
+ Metafields.write(order, Metafields::ORDER_SYNC_ERROR, e.message)
58
+ raise
59
+ end
60
+
61
+ private
62
+
63
+ ##
64
+ # Each KashFlow call is followed immediately by the metafield that records
65
+ # it, and each is skipped independently when its metafield is already
66
+ # present.
67
+ #
68
+ # Creating an invoice must be at-most-once; recording a payment against an
69
+ # invoice that already exists is naturally re-attemptable. So
70
+ # {Metafields::ORDER_INVOICE_NUMBER} is written the instant
71
+ # `create_invoice` returns, before `record_payment` is even attempted. A
72
+ # duplicate invoice overstates revenue and VAT, is structurally valid so no
73
+ # KashFlow report flags it, and remains a permanent artefact even after
74
+ # being credit-noted. A permanently-unpaid invoice recognises revenue
75
+ # correctly, surfaces in aged debtors at month-end, and is a one-click fix.
76
+ #
77
+ # Guarding per step rather than at the top of {#perform} is the other half
78
+ # of that: a whole-job guard keyed on the invoice number would see the key a
79
+ # retry just wrote and no-op, stranding the unpaid invoice for good.
80
+ #
81
+ # A consequence, accepted deliberately: an order can carry both an invoice
82
+ # number and a {Metafields::ORDER_SYNC_ERROR}. That is accurate — the
83
+ # invoice genuinely exists in KashFlow — and it keeps {SyncRefundJob}'s
84
+ # precondition guard working for an order whose payment never posted.
85
+ #
86
+ # @param order [Spree::Order]
87
+ # @param integration [Spree::Integrations::Kashflow]
88
+ # @return [void]
89
+ #
90
+ def sync(order, integration)
91
+ client = integration.client
92
+ assert_currency_enabled!(order, client)
93
+
94
+ customer_id = client.upsert_customer(CustomerPayload.new(order).to_h)
95
+ Metafields.write(order, Metafields::ORDER_CUSTOMER_CODE, customer_id)
96
+
97
+ unless invoice_posted?(order)
98
+ invoice_number = client.create_invoice(invoice_envelope(order, integration, customer_id))
99
+ Metafields.write(order, Metafields::ORDER_INVOICE_NUMBER, invoice_number)
100
+ end
101
+
102
+ unless payment_settled?(order)
103
+ record_payment(order, integration, client, invoice_number(order))
104
+ Metafields.write(order, Metafields::ORDER_PAYMENT_RECORDED_AT, Time.current)
105
+ end
106
+
107
+ Metafields.write(order, Metafields::ORDER_SYNCED_AT, Time.current)
108
+ Metafields.write(order, Metafields::ORDER_SYNC_ERROR, nil)
109
+ end
110
+
111
+ ##
112
+ # @param order [Spree::Order]
113
+ # @return [TrueClass, FalseClass] whether the invoice has already been
114
+ # posted to KashFlow for this order
115
+ #
116
+ def invoice_posted?(order)
117
+ order.has_metafield?(Metafields::ORDER_INVOICE_NUMBER)
118
+ end
119
+
120
+ ##
121
+ # An unpaid order has no payment step to complete, so it counts as settled.
122
+ #
123
+ # @param order [Spree::Order]
124
+ # @return [TrueClass, FalseClass] whether the payment step is done or
125
+ # inapplicable
126
+ #
127
+ def payment_settled?(order)
128
+ !order.paid? || order.has_metafield?(Metafields::ORDER_PAYMENT_RECORDED_AT)
129
+ end
130
+
131
+ ##
132
+ # @param order [Spree::Order]
133
+ # @return [Integer, nil] the KashFlow invoice number recorded on the order
134
+ #
135
+ def invoice_number(order)
136
+ order.get_metafield(Metafields::ORDER_INVOICE_NUMBER)&.value&.to_i
137
+ end
138
+
139
+ ##
140
+ # Booking a USD order as GBP is worse than not booking it, so this is checked
141
+ # before anything is posted.
142
+ #
143
+ # @param order [Spree::Order]
144
+ # @param client [Spree::Kashflow::Client]
145
+ # @return [void]
146
+ # @raise [Spree::Kashflow::ApiError] when the order's currency is not among
147
+ # the currencies enabled on the KashFlow account
148
+ #
149
+ def assert_currency_enabled!(order, client)
150
+ enabled = client.currencies.map { |currency| currency[:code] }
151
+ return if enabled.include?(order.currency)
152
+
153
+ raise Spree::Kashflow::ApiError, "Currency #{order.currency.inspect} is not enabled in KashFlow (enabled: #{enabled.join(", ")})"
154
+ end
155
+
156
+ ##
157
+ # Wraps {InvoicePayload#to_h}'s four fields in the envelope the WSDL `Invoice`
158
+ # complex type also declares `minOccurs="1"`, which Task 5 deliberately left
159
+ # to this job because they depend on things Task 5's mapper doesn't have: the
160
+ # customer id from the upsert call, and this job's own idempotency/payment
161
+ # state. Keys are emitted in `Invoice_TypeDefined`'s WSDL `<s:sequence>`
162
+ # order — Savon serialises a Hash body in insertion order, and a .NET ASMX
163
+ # endpoint enforcing that sequence drops or mis-binds an out-of-order
164
+ # element rather than raising.
165
+ #
166
+ # `Lines` is nested under an explicit `"InvoiceLine"` key because the WSDL
167
+ # types it as `ArrayOfInvoiceLine`, whose single member is an unbounded
168
+ # `InvoiceLine` element. Handing Gyoku a bare Array instead repeats
169
+ # `<Lines>` as a sibling per line rather than wrapping them, which an ASMX
170
+ # `XmlSerializer` skips as unknown children — posting an invoice with
171
+ # header totals and no lines, and still returning an invoice number.
172
+ #
173
+ # Defaults for the fields with no natural source:
174
+ # - `InvoiceDBID`, `InvoiceNumber`: unknown before KashFlow assigns them on
175
+ # insert, so `0`.
176
+ # - `DueDate`: same as `InvoiceDate` — the order is already completed (and
177
+ # frequently already paid), so there are no payment terms to express.
178
+ # - `SuppressTotal`: `0` — show the total on the rendered invoice.
179
+ # - `ProjectID`: `0` — no KashFlow project is associated with these orders.
180
+ # - `ExchangeRate`: `1` — {#assert_currency_enabled!} has already refused to
181
+ # post an order whose currency isn't one KashFlow itself is configured
182
+ # for, so no conversion applies.
183
+ # - `CustomerReference`: the Spree order number, so an accountant
184
+ # reconciling a discrepancy in KashFlow can find the order it came from.
185
+ # - `CISRCNetAmount`, `CISRCVatAmount`, `IsCISReverseCharge`: UK Construction
186
+ # Industry Scheme reverse-charge fields. Structurally required by the
187
+ # schema but not applicable to this integration (a supplements retailer,
188
+ # not a CIS contractor), so `0` / `false` rather than `nil` — `nil` would
189
+ # depend on Gyoku emitting `xsi:nil="true"` and the ASMX deserialiser
190
+ # accepting it, an assumption no spec here can exercise since every spec
191
+ # stubs the SOAP layer. `0` is arithmetically neutral and unambiguous on
192
+ # the wire; revisit if a sandbox call ever shows KashFlow prefers null.
193
+ #
194
+ # @param order [Spree::Order]
195
+ # @param integration [Spree::Integrations::Kashflow]
196
+ # @param customer_id [Integer] the id returned by `Client#upsert_customer`
197
+ # @return [Hash{String => Object}] a KashFlow `Invoice` structure, envelope
198
+ # fields and {InvoicePayload#to_h}'s fields together, in WSDL sequence order
199
+ #
200
+ def invoice_envelope(order, integration, customer_id)
201
+ payload = InvoicePayload.new(order, integration: integration).to_h
202
+ invoice_date = order.completed_at || Time.current
203
+
204
+ {
205
+ "InvoiceDBID" => 0,
206
+ "InvoiceNumber" => 0,
207
+ "InvoiceDate" => invoice_date,
208
+ "DueDate" => invoice_date,
209
+ "CustomerID" => customer_id,
210
+ "Paid" => order.paid? ? 1 : 0,
211
+ "CustomerReference" => order.number,
212
+ "SuppressTotal" => 0,
213
+ "ProjectID" => 0,
214
+ "CurrencyCode" => payload["CurrencyCode"],
215
+ "ExchangeRate" => BigDecimal(1),
216
+ "Lines" => {"InvoiceLine" => payload["Lines"]},
217
+ "NetAmount" => payload["NetAmount"],
218
+ "VATAmount" => payload["VATAmount"],
219
+ "AmountPaid" => order.paid? ? order.total : BigDecimal(0),
220
+ "CISRCNetAmount" => 0,
221
+ "CISRCVatAmount" => 0,
222
+ "IsCISReverseCharge" => false
223
+ }
224
+ end
225
+
226
+ ##
227
+ # @param order [Spree::Order]
228
+ # @param integration [Spree::Integrations::Kashflow]
229
+ # @param client [Spree::Kashflow::Client]
230
+ # @param invoice_number [Integer]
231
+ # @return [void]
232
+ #
233
+ def record_payment(order, integration, client, invoice_number)
234
+ client.record_invoice_payment(
235
+ "PayID" => 0,
236
+ "PayInvoice" => invoice_number,
237
+ "PayDate" => order.completed_at || Time.current,
238
+ "PayMethod" => integration.preferred_payment_method_id,
239
+ "PayAccount" => integration.preferred_bank_account_id,
240
+ "PayAmount" => order.total
241
+ )
242
+ end
243
+ end
244
+ end
245
+ end