spree-kashflow 0.1.1 → 0.1.3
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 +4 -4
- data/CHANGELOG.md +44 -0
- data/lib/spree/kashflow/client.rb +110 -4
- data/lib/spree/kashflow/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bf3ea17173c688a2ae5facaa72f582c02b2bb56b3d013d4d64e7467104b200b6
|
|
4
|
+
data.tar.gz: 14b81280691c79a6d6501cb4ae1e173a1e094be9c504bf90bcfb7de9cb40bc93
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4909476c53475670965c33e28bf2ec279e7551bbac94a7a43ddd3777d72577569eb84ec7fc9d216f23afa79c7e2989efef18a3deb8757fea76c20fdc04c51d2e
|
|
7
|
+
data.tar.gz: 289bc0e640aab7cea52cb96c2b019a00dd11e9bc010035b45056d947990152317755863978b5d16e071b5196a19ffc24429d9544c1fd9de7dfd34244a300f19e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.3
|
|
4
|
+
|
|
5
|
+
- **Fix: `upsert_customer` only ever inserted.** KashFlow publishes no
|
|
6
|
+
`InsertOrUpdateCustomer`, and the client called `InsertCustomer`
|
|
7
|
+
unconditionally with no lookup. 0.1.1 made customer codes stable per customer
|
|
8
|
+
— which is what keeps one KashFlow customer per Spree customer rather than one
|
|
9
|
+
per order — and that turned the missing lookup into a hard failure: the insert
|
|
10
|
+
succeeded exactly once, and the same customer's *second* order was rejected
|
|
11
|
+
with `Customer Code is not unique`, posting no invoice. `upsert_customer` now
|
|
12
|
+
looks the code up with `GetCustomer` and either `UpdateCustomer`s the existing
|
|
13
|
+
record or `InsertCustomer`s a new one, so it is idempotent across repeated
|
|
14
|
+
syncs.
|
|
15
|
+
- `CustomerID` is prepended to the update payload rather than merged onto the
|
|
16
|
+
end, because it is the first element of the WSDL's `Customer` sequence and an
|
|
17
|
+
ASMX endpoint enforcing that sequence drops or mis-binds an out-of-order
|
|
18
|
+
element rather than raising — an update whose id was dropped would write to
|
|
19
|
+
the wrong customer, or to none, and still return successfully.
|
|
20
|
+
- How KashFlow signals "no such customer" on `GetCustomer` is undocumented, so
|
|
21
|
+
both plausible shapes are treated as absent: an empty result, and an in-band
|
|
22
|
+
business rejection. Only `ApiError` is swallowed; an authentication or
|
|
23
|
+
transport failure keeps propagating rather than being read as "absent" and
|
|
24
|
+
turned back into a duplicate insert.
|
|
25
|
+
|
|
26
|
+
## 0.1.2
|
|
27
|
+
|
|
28
|
+
- **Fix: every order sync still failed against a live KashFlow account**, now on
|
|
29
|
+
the invoice rather than the customer. KashFlow types every date field in the
|
|
30
|
+
WSDL as `s:dateTime`, and its .NET `XmlSerializer` rejected the whole envelope
|
|
31
|
+
with `The string '2026-08-18 10:53:48 UTC' is not a valid AllXsd value` —
|
|
32
|
+
no invoice created, and the fault naming only a character offset. Gyoku
|
|
33
|
+
type-switches with `Module#===`, so `ActiveSupport::TimeWithZone` (a
|
|
34
|
+
delegator, not a `Time` subclass) fell through to `to_s`; plain `Time` did
|
|
35
|
+
too, and `Date` emitted an xsd `date` rather than a `dateTime`. Since
|
|
36
|
+
`Time.current` and Active Record datetime attributes all return
|
|
37
|
+
`TimeWithZone`, this was the default path, affecting `InvoiceDate`, `DueDate`
|
|
38
|
+
and `PayDate` on orders and `InvoiceDate` / `DueDate` on refunds.
|
|
39
|
+
`Client#call` now normalises every temporal value in an outgoing message to
|
|
40
|
+
UTC xsd `dateTime`, so the wire format is owned by the one class that touches
|
|
41
|
+
the wire and any date field added later is correct by default.
|
|
42
|
+
- Assert the *serialised* SOAP body for temporal values. The suite doubled the
|
|
43
|
+
client and asserted the payload Hash, where a `TimeWithZone` looks correct
|
|
44
|
+
right up until it reaches the wire — which is why 0.1.1 shipped with this
|
|
45
|
+
defect behind a green build.
|
|
46
|
+
|
|
3
47
|
## 0.1.1
|
|
4
48
|
|
|
5
49
|
- **Fix: every order sync failed against a live KashFlow account.** The customer
|
|
@@ -87,14 +87,28 @@ module Spree
|
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
##
|
|
90
|
-
# Creates or updates a customer in KashFlow
|
|
90
|
+
# Creates or updates a customer in KashFlow, keyed on the customer `Code`.
|
|
91
91
|
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
92
|
+
# KashFlow publishes no `InsertOrUpdateCustomer`, so an upsert is a lookup
|
|
93
|
+
# followed by an `UpdateCustomer` or an `InsertCustomer`. The lookup is not
|
|
94
|
+
# optional: customer codes are stable per customer (that is what keeps one
|
|
95
|
+
# KashFlow customer per Spree customer rather than one per order), so an
|
|
96
|
+
# unconditional insert succeeds exactly once and every subsequent order for
|
|
97
|
+
# that customer is rejected with "Customer Code is not unique" — with no
|
|
98
|
+
# invoice posted.
|
|
99
|
+
#
|
|
100
|
+
# @param payload [Hash] a KashFlow `Customer` structure, whose `"Code"`
|
|
101
|
+
# identifies the customer
|
|
102
|
+
# @return [Integer] the KashFlow customer id, existing or newly assigned
|
|
94
103
|
# @raise [Spree::Kashflow::ApiError] when the request fails, or when
|
|
95
104
|
# KashFlow returns no usable customer id
|
|
105
|
+
# @raise [Spree::Kashflow::AuthenticationError] when KashFlow rejects the
|
|
106
|
+
# credentials
|
|
96
107
|
#
|
|
97
108
|
def upsert_customer(payload)
|
|
109
|
+
existing_id = customer_id_for_code(payload["Code"])
|
|
110
|
+
return update_customer(existing_id, payload) if existing_id
|
|
111
|
+
|
|
98
112
|
response = call(:insert_customer, {"custr" => payload})
|
|
99
113
|
result = response.dig("InsertCustomerResponse", "InsertCustomerResult")
|
|
100
114
|
assert_identifier!(result, "customer id")
|
|
@@ -182,7 +196,8 @@ module Spree
|
|
|
182
196
|
#
|
|
183
197
|
def call(operation, message = {})
|
|
184
198
|
credentials = {"UserName" => @username, "Password" => @password}
|
|
185
|
-
|
|
199
|
+
body = coerce_temporal(credentials.merge(message))
|
|
200
|
+
response = savon_client.call(operation, message: body)
|
|
186
201
|
body = response.body
|
|
187
202
|
assert_status!(body)
|
|
188
203
|
body
|
|
@@ -195,6 +210,97 @@ module Spree
|
|
|
195
210
|
raise TransportError, e.message
|
|
196
211
|
end
|
|
197
212
|
|
|
213
|
+
##
|
|
214
|
+
# Looks a customer up by its KashFlow `Code`.
|
|
215
|
+
#
|
|
216
|
+
# How KashFlow signals "no such customer" here is undocumented, and no
|
|
217
|
+
# spec that stubs the SOAP layer can settle it, so both shapes it can
|
|
218
|
+
# plausibly take are treated as absent: an empty `GetCustomerResult`, and
|
|
219
|
+
# an in-band business rejection. Only {ApiError} is swallowed — an
|
|
220
|
+
# authentication or transport failure must keep propagating, since
|
|
221
|
+
# reading either as "absent" would turn it into a duplicate insert and
|
|
222
|
+
# put the caller back on the collision this method exists to avoid.
|
|
223
|
+
#
|
|
224
|
+
# @param code [String, nil] the KashFlow customer code
|
|
225
|
+
# @return [Integer, nil] the customer id, or nil when no customer holds
|
|
226
|
+
# that code
|
|
227
|
+
#
|
|
228
|
+
def customer_id_for_code(code)
|
|
229
|
+
return nil if code.to_s.strip.empty?
|
|
230
|
+
|
|
231
|
+
result = call(:get_customer, {"CustomerCode" => code})
|
|
232
|
+
.dig("GetCustomerResponse", "GetCustomerResult")
|
|
233
|
+
return nil unless result.is_a?(Hash)
|
|
234
|
+
|
|
235
|
+
identifier = result["CustomerID"].to_i
|
|
236
|
+
identifier.zero? ? nil : identifier
|
|
237
|
+
rescue ApiError
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
##
|
|
242
|
+
# Updates an existing KashFlow customer in place.
|
|
243
|
+
#
|
|
244
|
+
# `CustomerID` is prepended rather than merged onto the end because it is
|
|
245
|
+
# the first element of the WSDL's `Customer` sequence, and an ASMX
|
|
246
|
+
# endpoint enforcing that sequence drops or mis-binds an out-of-order
|
|
247
|
+
# element rather than raising — an update whose id was dropped would write
|
|
248
|
+
# to the wrong customer, or to none, and still return successfully.
|
|
249
|
+
#
|
|
250
|
+
# `UpdateCustomerResult` is a `s:string`, not the customer id, so the id
|
|
251
|
+
# is carried over from the lookup instead of being read off the response.
|
|
252
|
+
#
|
|
253
|
+
# @param identifier [Integer] the KashFlow customer id
|
|
254
|
+
# @param payload [Hash] a KashFlow `Customer` structure
|
|
255
|
+
# @return [Integer] the customer id that was updated
|
|
256
|
+
#
|
|
257
|
+
def update_customer(identifier, payload)
|
|
258
|
+
call(:update_customer, {"custr" => {"CustomerID" => identifier}.merge(payload)})
|
|
259
|
+
identifier
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
##
|
|
263
|
+
# Rewrites every temporal value in an outgoing message to xsd `dateTime`.
|
|
264
|
+
#
|
|
265
|
+
# KashFlow types every date field in the WSDL as `s:dateTime`, and its
|
|
266
|
+
# .NET `XmlSerializer` rejects the *entire* envelope with a
|
|
267
|
+
# `FormatException` ("is not a valid AllXsd value") when one of them is
|
|
268
|
+
# not valid xsd — the invoice is never created, and the fault names only
|
|
269
|
+
# a character offset, not the field.
|
|
270
|
+
#
|
|
271
|
+
# Gyoku cannot be relied on to do this. It type-switches with
|
|
272
|
+
# `case/when`, i.e. `Module#===`, so:
|
|
273
|
+
# - `ActiveSupport::TimeWithZone` is a *delegator*, not a `Time`
|
|
274
|
+
# subclass, and misses the branch entirely -> `to_s`
|
|
275
|
+
# ("2026-08-18 10:53:48 UTC").
|
|
276
|
+
# - plain `Time` is likewise emitted via `to_s` by Gyoku 1.4.
|
|
277
|
+
# - `Date` serialises as `2026-08-18`, an xsd `date`, not the `dateTime`
|
|
278
|
+
# the schema asks for.
|
|
279
|
+
#
|
|
280
|
+
# `Time.current` and every Active Record datetime attribute return
|
|
281
|
+
# `TimeWithZone`, so the broken path was the default one, which is why
|
|
282
|
+
# every caller was affected. Normalising here rather than at each call
|
|
283
|
+
# site keeps the wire format owned by the one class that touches the
|
|
284
|
+
# wire, and means a date field added to any future payload is correct
|
|
285
|
+
# without the author having to know this.
|
|
286
|
+
#
|
|
287
|
+
# Values are converted to UTC first so the instant is preserved and the
|
|
288
|
+
# emitted form is unambiguous (`...Z`) regardless of the app's zone.
|
|
289
|
+
#
|
|
290
|
+
# @param value [Object] any message value; Hashes and Arrays are walked
|
|
291
|
+
# @return [Object] the value with temporals replaced by xsd strings
|
|
292
|
+
#
|
|
293
|
+
def coerce_temporal(value)
|
|
294
|
+
case value
|
|
295
|
+
when Hash then value.transform_values { |element| coerce_temporal(element) }
|
|
296
|
+
when Array then value.map { |element| coerce_temporal(element) }
|
|
297
|
+
when ActiveSupport::TimeWithZone, Time then value.utc.xmlschema
|
|
298
|
+
when DateTime then value.to_time.utc.xmlschema
|
|
299
|
+
when Date then value.to_time(:utc).xmlschema
|
|
300
|
+
else value
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
198
304
|
##
|
|
199
305
|
# KashFlow reports business-level rejections *in band*: the SOAP call
|
|
200
306
|
# succeeds at HTTP 200 with an empty result element and a `Status` /
|