spree-kashflow 0.1.0 → 0.1.2

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: ff9334bd3e09ee1cdb9965f3eddc61af9d887161dd95901c9b54bc6feaf15cdc
4
- data.tar.gz: 7e4576dd85f13c15326591811c0d05a6efa17de61a44e1b82b6a62fca8dd0717
3
+ metadata.gz: d20212552f51bc299bb20645fc8f5dbca833b2bdb63a1678d752b29a15f00a17
4
+ data.tar.gz: fb65959bc02ff1ac1b1eb7b38257ca03b9fba95386efb49b40baf91378b7c0bb
5
5
  SHA512:
6
- metadata.gz: d55939c427bc7154665e69801126b67a35cc95f01024f9303f9d380377b3b290db422ce3e67cd802c98465a65db9b85bdb47f01c015e84340062bdcebe60856a
7
- data.tar.gz: b785bbb7a59d6a80686fe13b3cab2f37b83e4d7d7d97d87369c13c09a469852a76604a51d431f5a6e433953ce1e1242feb68f67ffecc10397ee5dff8576f28e7
6
+ metadata.gz: a257c8a10a8576b39a5b58613568cc1faf751f3fa28caa505df9d190404be40d12e0e52272a48c3f2f141cc7e2b0c540b59bf32bb2501d7c2dd60b01b31ecf9b
7
+ data.tar.gz: 19f304c825178155659864ba3b064507ade831b27632d385befd8d27a60f84ccd74471b0eef8ce94cea0f1bd1391ce900fc034af404377e578d87e292527a276
data/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2
4
+
5
+ - **Fix: every order sync still failed against a live KashFlow account**, now on
6
+ the invoice rather than the customer. KashFlow types every date field in the
7
+ WSDL as `s:dateTime`, and its .NET `XmlSerializer` rejected the whole envelope
8
+ with `The string '2026-08-18 10:53:48 UTC' is not a valid AllXsd value` —
9
+ no invoice created, and the fault naming only a character offset. Gyoku
10
+ type-switches with `Module#===`, so `ActiveSupport::TimeWithZone` (a
11
+ delegator, not a `Time` subclass) fell through to `to_s`; plain `Time` did
12
+ too, and `Date` emitted an xsd `date` rather than a `dateTime`. Since
13
+ `Time.current` and Active Record datetime attributes all return
14
+ `TimeWithZone`, this was the default path, affecting `InvoiceDate`, `DueDate`
15
+ and `PayDate` on orders and `InvoiceDate` / `DueDate` on refunds.
16
+ `Client#call` now normalises every temporal value in an outgoing message to
17
+ UTC xsd `dateTime`, so the wire format is owned by the one class that touches
18
+ the wire and any date field added later is correct by default.
19
+ - Assert the *serialised* SOAP body for temporal values. The suite doubled the
20
+ client and asserted the payload Hash, where a `TimeWithZone` looks correct
21
+ right up until it reaches the wire — which is why 0.1.1 shipped with this
22
+ defect behind a green build.
23
+
24
+ ## 0.1.1
25
+
26
+ - **Fix: every order sync failed against a live KashFlow account.** The customer
27
+ `Code` was set to the order's email address, which KashFlow rejects — "the
28
+ customer code specified is invalid, please re-enter without any special
29
+ characters" — on the customer upsert, before an invoice was ever attempted.
30
+ Codes are now uppercase-alphanumeric and derived from the customer rather than
31
+ the order: `SPU<user id>` for a registered user, `SPG<email digest>` for a
32
+ guest. Both are stable across that customer's orders, which is what keeps
33
+ `InsertCustomer` an update rather than a duplicate.
34
+ - Require `digest` explicitly rather than relying on a transitive dependency to
35
+ load it.
36
+ - Replace the placeholder integration logo with the IRIS KashFlow brand asset.
37
+
3
38
  ## 0.1.0
4
39
 
5
40
  - Initial release.
@@ -23,6 +23,42 @@ module Spree
23
23
  # column, so this gem reads it out of `order.metadata` under this key.
24
24
  VAT_NUMBER_METADATA_KEY = "vat_number"
25
25
 
26
+ ##
27
+ # KashFlow rejects a customer `Code` that carries special characters:
28
+ #
29
+ # NO: The customer code specified is invalid, please re-enter without
30
+ # any special characters.
31
+ #
32
+ # v0.1.0 sent `order.email` here, so EVERY sync failed on the customer
33
+ # upsert before an invoice was ever attempted — caught on the first live
34
+ # order (tkf-prd order 12, 2026-08-18). The WSDL types `Code` as a bare
35
+ # `s:string` with no facets, so the rule is server-side and undiscoverable
36
+ # from the schema; these codes are therefore deliberately conservative —
37
+ # uppercase alphanumerics only, comfortably short.
38
+ #
39
+ # `InsertCustomer` upserts BY `Code`, so the code identifies the CUSTOMER,
40
+ # not the order. Anything per-order would create a fresh KashFlow customer
41
+ # on every purchase instead of updating the existing one.
42
+
43
+ # @return [String] format for a registered user's code, keyed on the Spree
44
+ # user id so it survives the customer changing their email address.
45
+ USER_CODE_FORMAT = "SPU%d"
46
+
47
+ # @return [String] format for a guest's code. Guests have no stable id, so
48
+ # the normalised email is hashed — same email in, same code out, which is
49
+ # what keeps the upsert an update rather than a duplicate.
50
+ GUEST_CODE_FORMAT = "SPG%s"
51
+
52
+ # @return [Integer] hex characters of the email digest kept in a guest code.
53
+ # 12 hex chars is 48 bits: collision-safe at any plausible customer count
54
+ # while leaving the total code short.
55
+ GUEST_CODE_DIGEST_LENGTH = 12
56
+
57
+ # @return [Integer] the longest code this presenter will emit. KashFlow does
58
+ # not publish the limit in the WSDL; this is a conservative ceiling that
59
+ # both formats stay well inside.
60
+ CODE_MAX_LENGTH = 20
61
+
26
62
  ##
27
63
  # @param order [Spree::Order] a completed order with a billing address
28
64
  #
@@ -43,7 +79,7 @@ module Spree
43
79
  #
44
80
  def to_h
45
81
  payload = {
46
- "Code" => order.email,
82
+ "Code" => customer_code,
47
83
  "Name" => customer_name,
48
84
  "Email" => order.email,
49
85
  "Address1" => bill_address&.address1,
@@ -66,6 +102,41 @@ module Spree
66
102
  # @return [Spree::Order]
67
103
  attr_reader :order
68
104
 
105
+ ##
106
+ # The KashFlow customer reference. See {USER_CODE_FORMAT} for why this is
107
+ # keyed on the customer rather than the order, and why it is alphanumeric.
108
+ #
109
+ # The order-number fallback is unreachable for a completed order — Spree
110
+ # validates email presence — but it exists so a blank email cannot collapse
111
+ # every such order onto one shared code, which would merge unrelated
112
+ # customers into a single KashFlow record.
113
+ #
114
+ # @return [String] an uppercase-alphanumeric customer code
115
+ #
116
+ def customer_code
117
+ return format(USER_CODE_FORMAT, order.user_id) if order.user_id.present?
118
+ return order.number if normalized_email.blank?
119
+
120
+ format(GUEST_CODE_FORMAT, email_digest)
121
+ end
122
+
123
+ ##
124
+ # @return [String] the order email, lowercased and stripped, so that the
125
+ # same guest is not split across several KashFlow customers by casing or
126
+ # stray whitespace
127
+ #
128
+ def normalized_email
129
+ order.email.to_s.strip.downcase
130
+ end
131
+
132
+ ##
133
+ # @return [String] the leading {GUEST_CODE_DIGEST_LENGTH} hex characters of
134
+ # the normalised email's SHA-256, uppercased
135
+ #
136
+ def email_digest
137
+ Digest::SHA256.hexdigest(normalized_email)[0, GUEST_CODE_DIGEST_LENGTH].upcase
138
+ end
139
+
69
140
  ##
70
141
  # Nullable on purpose. A digital-only order can complete with no billing
71
142
  # address at all, and a `NoMethodError` here would escape the sync job's
@@ -182,7 +182,8 @@ module Spree
182
182
  #
183
183
  def call(operation, message = {})
184
184
  credentials = {"UserName" => @username, "Password" => @password}
185
- response = savon_client.call(operation, message: credentials.merge(message))
185
+ body = coerce_temporal(credentials.merge(message))
186
+ response = savon_client.call(operation, message: body)
186
187
  body = response.body
187
188
  assert_status!(body)
188
189
  body
@@ -195,6 +196,48 @@ module Spree
195
196
  raise TransportError, e.message
196
197
  end
197
198
 
199
+ ##
200
+ # Rewrites every temporal value in an outgoing message to xsd `dateTime`.
201
+ #
202
+ # KashFlow types every date field in the WSDL as `s:dateTime`, and its
203
+ # .NET `XmlSerializer` rejects the *entire* envelope with a
204
+ # `FormatException` ("is not a valid AllXsd value") when one of them is
205
+ # not valid xsd — the invoice is never created, and the fault names only
206
+ # a character offset, not the field.
207
+ #
208
+ # Gyoku cannot be relied on to do this. It type-switches with
209
+ # `case/when`, i.e. `Module#===`, so:
210
+ # - `ActiveSupport::TimeWithZone` is a *delegator*, not a `Time`
211
+ # subclass, and misses the branch entirely -> `to_s`
212
+ # ("2026-08-18 10:53:48 UTC").
213
+ # - plain `Time` is likewise emitted via `to_s` by Gyoku 1.4.
214
+ # - `Date` serialises as `2026-08-18`, an xsd `date`, not the `dateTime`
215
+ # the schema asks for.
216
+ #
217
+ # `Time.current` and every Active Record datetime attribute return
218
+ # `TimeWithZone`, so the broken path was the default one, which is why
219
+ # every caller was affected. Normalising here rather than at each call
220
+ # site keeps the wire format owned by the one class that touches the
221
+ # wire, and means a date field added to any future payload is correct
222
+ # without the author having to know this.
223
+ #
224
+ # Values are converted to UTC first so the instant is preserved and the
225
+ # emitted form is unambiguous (`...Z`) regardless of the app's zone.
226
+ #
227
+ # @param value [Object] any message value; Hashes and Arrays are walked
228
+ # @return [Object] the value with temporals replaced by xsd strings
229
+ #
230
+ def coerce_temporal(value)
231
+ case value
232
+ when Hash then value.transform_values { |element| coerce_temporal(element) }
233
+ when Array then value.map { |element| coerce_temporal(element) }
234
+ when ActiveSupport::TimeWithZone, Time then value.utc.xmlschema
235
+ when DateTime then value.to_time.utc.xmlschema
236
+ when Date then value.to_time(:utc).xmlschema
237
+ else value
238
+ end
239
+ end
240
+
198
241
  ##
199
242
  # KashFlow reports business-level rejections *in band*: the SOAP call
200
243
  # 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.0"
5
+ VERSION = "0.1.2"
6
6
  end
7
7
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "spree/core"
4
5
  require "savon"
5
6
  require "spree/kashflow/version"
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.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aypex