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 +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE +22 -0
- data/README.md +257 -0
- data/Rakefile +21 -0
- data/app/assets/images/integration_icons/kashflow-logo.png +0 -0
- data/app/jobs/spree/kashflow/sync_order_job.rb +245 -0
- data/app/jobs/spree/kashflow/sync_refund_job.rb +169 -0
- data/app/models/spree/integrations/kashflow.rb +112 -0
- data/app/models/spree/refund_decorator.rb +31 -0
- data/app/presenters/spree/kashflow/credit_note_payload.rb +180 -0
- data/app/presenters/spree/kashflow/customer_payload.rb +138 -0
- data/app/presenters/spree/kashflow/invoice_payload.rb +286 -0
- data/app/subscribers/spree/kashflow/order_completed_subscriber.rb +24 -0
- data/app/subscribers/spree/kashflow/reimbursement_subscriber.rb +25 -0
- data/app/views/spree/admin/integrations/forms/_kashflow.html.erb +18 -0
- data/config/initializers/spree.rb +17 -0
- data/config/locales/en.yml +12 -0
- data/config/routes.rb +4 -0
- data/lib/spree/kashflow/client.rb +296 -0
- data/lib/spree/kashflow/engine.rb +38 -0
- data/lib/spree/kashflow/errors.rb +32 -0
- data/lib/spree/kashflow/metafields.rb +107 -0
- data/lib/spree/kashflow/version.rb +7 -0
- data/lib/spree/kashflow.rb +9 -0
- data/lib/spree-kashflow.rb +7 -0
- metadata +112 -0
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 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
|
|
Binary file
|
|
@@ -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
|