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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b2346845707634b7353b64e8d2a982136ead6009e47f7b1ea5b94d1e905b5455
4
- data.tar.gz: 2a238745c13d9cd5a6cf0fde8e2854b2a7bc9c12c3ad35df25e396ea0d8a7159
3
+ metadata.gz: bf3ea17173c688a2ae5facaa72f582c02b2bb56b3d013d4d64e7467104b200b6
4
+ data.tar.gz: 14b81280691c79a6d6501cb4ae1e173a1e094be9c504bf90bcfb7de9cb40bc93
5
5
  SHA512:
6
- metadata.gz: ff74935654982f15a9094b50b36a26d1507705b33955444df4d9490c7511d51008c6c01cd92c42ff036475f5eb7d5ad475e820aa3ddee699fe9c35d895681956
7
- data.tar.gz: ecca24ec4c22aef839778a214b0a62e206588836810cb012ac762151e08ef9aed538a5e0c5b8c5a8c90ca21151658003b644691437ac19e4a8244d68a8b46438
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
- # @param payload [Hash] a KashFlow `Customer` structure
93
- # @return [Integer] the KashFlow customer id
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
- response = savon_client.call(operation, message: credentials.merge(message))
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` /
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Spree
4
4
  module Kashflow
5
- VERSION = "0.1.1"
5
+ VERSION = "0.1.3"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree-kashflow
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aypex