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
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "savon"
|
|
4
|
+
|
|
5
|
+
module Spree
|
|
6
|
+
module Kashflow
|
|
7
|
+
##
|
|
8
|
+
# Stateless SOAP client for the KashFlow API. Only this class may reference Savon —
|
|
9
|
+
# every public method returns plain Ruby (Integer, Array<Hash>, TrueClass) so no SOAP
|
|
10
|
+
# object crosses the gem boundary. KashFlow has no session token: credentials are sent
|
|
11
|
+
# on every call, so nothing is cached between calls.
|
|
12
|
+
#
|
|
13
|
+
class Client
|
|
14
|
+
# @return [String] the WSDL endpoint KashFlow publishes for the SOAP API
|
|
15
|
+
WSDL = "https://securedwebapp.com/api/service.asmx?WSDL"
|
|
16
|
+
|
|
17
|
+
# @return [Regexp] matches SOAP faults caused by bad credentials
|
|
18
|
+
AUTH_FAULT = /invalid.*(username|password)|not authori[sz]ed/i
|
|
19
|
+
|
|
20
|
+
# @return [String] the in-band `Status` value KashFlow returns on success.
|
|
21
|
+
#
|
|
22
|
+
# The WSDL declares `Status` as a bare `s:string` on every `*Response`
|
|
23
|
+
# element (`minOccurs="0"`), with no `<s:enumeration>` and no
|
|
24
|
+
# `<wsdl:documentation>` naming the success value — so this constant is
|
|
25
|
+
# KashFlow's documented convention (`"OK"`), not something the schema
|
|
26
|
+
# pins down. Comparison is case-insensitive, and an *absent* `Status`
|
|
27
|
+
# (legal, since `minOccurs="0"`) is treated as success; only a present
|
|
28
|
+
# `Status` that is not `"OK"` is a business-level rejection.
|
|
29
|
+
SUCCESS_STATUS = "OK"
|
|
30
|
+
|
|
31
|
+
##
|
|
32
|
+
# @param username [String] the KashFlow API username
|
|
33
|
+
# @param password [String] the KashFlow API password
|
|
34
|
+
#
|
|
35
|
+
def initialize(username:, password:)
|
|
36
|
+
@username = username
|
|
37
|
+
@password = password
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
##
|
|
41
|
+
# Verifies the configured credentials against the KashFlow API. Does not rescue —
|
|
42
|
+
# the caller decides how to handle a raised error.
|
|
43
|
+
#
|
|
44
|
+
# @return [TrueClass, FalseClass] true when KashFlow accepts the credentials
|
|
45
|
+
# @raise [Spree::Kashflow::Error] when KashFlow rejects the credentials or the
|
|
46
|
+
# request otherwise fails
|
|
47
|
+
#
|
|
48
|
+
def verify_credentials
|
|
49
|
+
call(:get_currencies)
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
##
|
|
54
|
+
# @return [Array<Hash>] currencies as `{code:, id:}` hashes
|
|
55
|
+
# @raise [Spree::Kashflow::Error] when the request fails
|
|
56
|
+
#
|
|
57
|
+
def currencies
|
|
58
|
+
rows = extract_rows(call(:get_currencies), "GetCurrenciesResponse", "GetCurrenciesResult", "Currencies")
|
|
59
|
+
rows.map { |row| {code: row["CurrencyCode"], id: row["CurrencyId"].to_i} }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
##
|
|
63
|
+
# @return [Array<Hash>] nominal codes as `{id:, name:}` hashes
|
|
64
|
+
# @raise [Spree::Kashflow::Error] when the request fails
|
|
65
|
+
#
|
|
66
|
+
def nominal_codes
|
|
67
|
+
rows = extract_rows(call(:get_nominal_codes), "GetNominalCodesResponse", "GetNominalCodesResult", "NominalCode")
|
|
68
|
+
rows.map { |row| {id: row["id"].to_i, name: row["Name"]} }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
##
|
|
72
|
+
# @return [Array<Hash>] bank accounts as `{id:, name:}` hashes
|
|
73
|
+
# @raise [Spree::Kashflow::Error] when the request fails
|
|
74
|
+
#
|
|
75
|
+
def bank_accounts
|
|
76
|
+
rows = extract_rows(call(:get_bank_accounts), "GetBankAccountsResponse", "GetBankAccountsResult", "BankAccount")
|
|
77
|
+
rows.map { |row| {id: row["AccountID"].to_i, name: row["AccountName"]} }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
##
|
|
81
|
+
# @return [Array<Hash>] invoice payment methods as `{id:, name:}` hashes
|
|
82
|
+
# @raise [Spree::Kashflow::Error] when the request fails
|
|
83
|
+
#
|
|
84
|
+
def payment_methods
|
|
85
|
+
rows = extract_rows(call(:get_inv_pay_methods), "GetInvPayMethodsResponse", "GetInvPayMethodsResult", "PaymentMethod")
|
|
86
|
+
rows.map { |row| {id: row["MethodID"].to_i, name: row["MethodName"]} }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
##
|
|
90
|
+
# Creates or updates a customer in KashFlow.
|
|
91
|
+
#
|
|
92
|
+
# @param payload [Hash] a KashFlow `Customer` structure
|
|
93
|
+
# @return [Integer] the KashFlow customer id
|
|
94
|
+
# @raise [Spree::Kashflow::ApiError] when the request fails, or when
|
|
95
|
+
# KashFlow returns no usable customer id
|
|
96
|
+
#
|
|
97
|
+
def upsert_customer(payload)
|
|
98
|
+
response = call(:insert_customer, {"custr" => payload})
|
|
99
|
+
result = response.dig("InsertCustomerResponse", "InsertCustomerResult")
|
|
100
|
+
assert_identifier!(result, "customer id")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
##
|
|
104
|
+
# Creates an invoice in KashFlow.
|
|
105
|
+
#
|
|
106
|
+
# @param payload [Hash] a KashFlow `Invoice_TypeDefined` structure
|
|
107
|
+
# @return [Integer] the invoice number KashFlow assigned
|
|
108
|
+
# @raise [Spree::Kashflow::ApiError] when the request fails, or when
|
|
109
|
+
# KashFlow returns no usable invoice number
|
|
110
|
+
#
|
|
111
|
+
def create_invoice(payload)
|
|
112
|
+
response = call(:insert_invoice_type_defined, {"Inv_TD" => payload})
|
|
113
|
+
result = response.dig("InsertInvoice_TypeDefinedResponse", "InsertInvoice_TypeDefinedResult")
|
|
114
|
+
assert_identifier!(result, "invoice number")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
##
|
|
118
|
+
# Records a payment against an invoice in KashFlow.
|
|
119
|
+
#
|
|
120
|
+
# The WSDL declares `InsertInvoicePaymentResult` as an `s:int` — the id of
|
|
121
|
+
# the payment KashFlow created. A `0` is therefore a rejection reported
|
|
122
|
+
# without a `Status`, and returning `true` regardless would leave the
|
|
123
|
+
# invoice permanently unpaid while the caller recorded a successful sync.
|
|
124
|
+
#
|
|
125
|
+
# @param payload [Hash] a KashFlow `Payment` structure
|
|
126
|
+
# @return [TrueClass] true when the payment was recorded
|
|
127
|
+
# @raise [Spree::Kashflow::ApiError] when the request fails, or when
|
|
128
|
+
# KashFlow returns no payment id
|
|
129
|
+
#
|
|
130
|
+
def record_invoice_payment(payload)
|
|
131
|
+
response = call(:insert_invoice_payment, {"InvoicePayment" => payload})
|
|
132
|
+
result = response.dig("InsertInvoicePaymentResponse", "InsertInvoicePaymentResult")
|
|
133
|
+
assert_identifier!(result, "payment id")
|
|
134
|
+
true
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
##
|
|
138
|
+
# Applies a credit note to an invoice in KashFlow.
|
|
139
|
+
#
|
|
140
|
+
# The WSDL declares `applyCreditNoteToInvoiceResult` as an `s:boolean`.
|
|
141
|
+
# A `false` is a refusal to link, and swallowing it produces exactly the
|
|
142
|
+
# orphan credit note {SyncRefundJob}'s precondition guard exists to
|
|
143
|
+
# prevent — a credit note sitting in KashFlow attached to nothing.
|
|
144
|
+
#
|
|
145
|
+
# @param credit_note_number [Integer] the KashFlow credit note id
|
|
146
|
+
# @param invoice_number [Integer] the KashFlow invoice id
|
|
147
|
+
# @return [TrueClass] true when the credit note was applied
|
|
148
|
+
# @raise [Spree::Kashflow::ApiError] when the request fails, or when
|
|
149
|
+
# KashFlow refuses to link the credit note
|
|
150
|
+
#
|
|
151
|
+
def apply_credit_note(credit_note_number:, invoice_number:)
|
|
152
|
+
response = call(:apply_credit_note_to_invoice, {"InvoiceID" => invoice_number, "CreditNoteID" => credit_note_number})
|
|
153
|
+
result = response.dig("applyCreditNoteToInvoiceResponse", "applyCreditNoteToInvoiceResult")
|
|
154
|
+
assert_accepted!(result, "did not apply credit note #{credit_note_number} to invoice #{invoice_number}")
|
|
155
|
+
true
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
##
|
|
161
|
+
# @return [Savon::Client] a Savon client bound to the KashFlow WSDL
|
|
162
|
+
#
|
|
163
|
+
def savon_client
|
|
164
|
+
@savon_client ||= Savon.client(
|
|
165
|
+
wsdl: WSDL,
|
|
166
|
+
log: false,
|
|
167
|
+
convert_response_tags_to: ->(tag) { tag }
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
##
|
|
172
|
+
# Invokes a KashFlow SOAP operation, merging credentials into every request, and
|
|
173
|
+
# maps transport errors, SOAP faults and in-band `Status` rejections onto this
|
|
174
|
+
# gem's error hierarchy.
|
|
175
|
+
#
|
|
176
|
+
# @param operation [Symbol] the Savon operation name
|
|
177
|
+
# @param message [Hash] the operation's message body, excluding credentials
|
|
178
|
+
# @return [Hash] the parsed response body
|
|
179
|
+
# @raise [Spree::Kashflow::AuthenticationError] when KashFlow rejects the credentials
|
|
180
|
+
# @raise [Spree::Kashflow::ApiError] when KashFlow refuses the operation
|
|
181
|
+
# @raise [Spree::Kashflow::TransportError] when the KashFlow service could not be reached
|
|
182
|
+
#
|
|
183
|
+
def call(operation, message = {})
|
|
184
|
+
credentials = {"UserName" => @username, "Password" => @password}
|
|
185
|
+
response = savon_client.call(operation, message: credentials.merge(message))
|
|
186
|
+
body = response.body
|
|
187
|
+
assert_status!(body)
|
|
188
|
+
body
|
|
189
|
+
rescue Savon::SOAPFault => e
|
|
190
|
+
fault_message = e.to_hash.dig(:fault, :faultstring) || e.message
|
|
191
|
+
raise_business_error(fault_message)
|
|
192
|
+
rescue Savon::HTTPError, HTTPI::SSLError, OpenSSL::SSL::SSLError, SocketError,
|
|
193
|
+
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT, Errno::EHOSTUNREACH,
|
|
194
|
+
Net::OpenTimeout, Net::ReadTimeout => e
|
|
195
|
+
raise TransportError, e.message
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
##
|
|
199
|
+
# KashFlow reports business-level rejections *in band*: the SOAP call
|
|
200
|
+
# succeeds at HTTP 200 with an empty result element and a `Status` /
|
|
201
|
+
# `StatusDetail` pair beside it. Left uninspected, a rejection reads as a
|
|
202
|
+
# `nil` result and coerces to `0` — a value the caller would then record
|
|
203
|
+
# as a real KashFlow identifier. See {SUCCESS_STATUS} for why the success
|
|
204
|
+
# value is a convention rather than a schema-declared enumeration.
|
|
205
|
+
#
|
|
206
|
+
# @param body [Hash] the parsed response body
|
|
207
|
+
# @return [void]
|
|
208
|
+
# @raise [Spree::Kashflow::AuthenticationError] when the rejection is a
|
|
209
|
+
# credentials problem
|
|
210
|
+
# @raise [Spree::Kashflow::ApiError] for any other non-success `Status`
|
|
211
|
+
#
|
|
212
|
+
def assert_status!(body)
|
|
213
|
+
envelope = body.values.detect { |value| value.is_a?(Hash) }
|
|
214
|
+
return if envelope.nil?
|
|
215
|
+
|
|
216
|
+
status = envelope["Status"]
|
|
217
|
+
return if status.nil?
|
|
218
|
+
return if status.to_s.strip.casecmp(SUCCESS_STATUS).zero?
|
|
219
|
+
|
|
220
|
+
detail = envelope["StatusDetail"]
|
|
221
|
+
raise_business_error([status, detail].compact_blank.join(": "))
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
##
|
|
225
|
+
# @param message [String] the message KashFlow refused the request with
|
|
226
|
+
# @return [void]
|
|
227
|
+
# @raise [Spree::Kashflow::AuthenticationError] when the message names a
|
|
228
|
+
# credentials problem
|
|
229
|
+
# @raise [Spree::Kashflow::ApiError] otherwise
|
|
230
|
+
#
|
|
231
|
+
def raise_business_error(message)
|
|
232
|
+
if message.match?(AUTH_FAULT)
|
|
233
|
+
raise AuthenticationError, message
|
|
234
|
+
else
|
|
235
|
+
raise ApiError, message
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
##
|
|
240
|
+
# KashFlow returns `0` (or nothing at all) where it means "refused", and
|
|
241
|
+
# a `0` recorded as an identifier is worse than a raised error: it looks
|
|
242
|
+
# like a successful sync, satisfies the caller's idempotency check, and
|
|
243
|
+
# blocks every retry.
|
|
244
|
+
#
|
|
245
|
+
# @param result [Object, nil] the raw result element
|
|
246
|
+
# @param label [String] what the identifier is, for the error message
|
|
247
|
+
# @return [Integer] the identifier
|
|
248
|
+
# @raise [Spree::Kashflow::ApiError] when the result is nil or zero
|
|
249
|
+
#
|
|
250
|
+
def assert_identifier!(result, label)
|
|
251
|
+
identifier = result.to_i
|
|
252
|
+
return identifier unless result.nil? || identifier.zero?
|
|
253
|
+
|
|
254
|
+
raise ApiError, "KashFlow returned no #{label} (result: #{result.inspect})"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
##
|
|
258
|
+
# The counterpart of {#assert_identifier!} for the operations the WSDL types
|
|
259
|
+
# as `s:boolean` rather than `s:int`. Savon hands the element back as the
|
|
260
|
+
# string `"true"` / `"false"`, so only an explicit `"true"` is acceptance;
|
|
261
|
+
# `false`, `nil` and an absent element are all rejections.
|
|
262
|
+
#
|
|
263
|
+
# @param result [Object, nil] the raw result element
|
|
264
|
+
# @param message [String] what KashFlow refused, for the error message
|
|
265
|
+
# @return [void]
|
|
266
|
+
# @raise [Spree::Kashflow::ApiError] when the result is not true
|
|
267
|
+
#
|
|
268
|
+
def assert_accepted!(result, message)
|
|
269
|
+
return if result.to_s.strip.casecmp("true").zero?
|
|
270
|
+
|
|
271
|
+
raise ApiError, "KashFlow #{message} (result: #{result.inspect})"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
##
|
|
275
|
+
# Normalises a KashFlow list response into an array of row hashes.
|
|
276
|
+
#
|
|
277
|
+
# @param body [Hash] the parsed response body
|
|
278
|
+
# @param response_key [String] the top-level response element name
|
|
279
|
+
# @param result_key [String] the result element name nested under the response
|
|
280
|
+
# @param collection_key [String] the element name repeated for each row
|
|
281
|
+
# @return [Array<Hash>] the rows, or an empty array when the response carries none
|
|
282
|
+
#
|
|
283
|
+
def extract_rows(body, response_key, result_key, collection_key)
|
|
284
|
+
result = body.dig(response_key, result_key)
|
|
285
|
+
return [] if result.nil?
|
|
286
|
+
|
|
287
|
+
rows = result[collection_key]
|
|
288
|
+
case rows
|
|
289
|
+
when nil then []
|
|
290
|
+
when Array then rows
|
|
291
|
+
else [rows]
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Kashflow
|
|
5
|
+
##
|
|
6
|
+
# Rails engine for the KashFlow integration.
|
|
7
|
+
#
|
|
8
|
+
# Loads the gem's +app/+ tree into the host application and applies the
|
|
9
|
+
# gem's single decorator on each reload.
|
|
10
|
+
#
|
|
11
|
+
class Engine < ::Rails::Engine
|
|
12
|
+
require "spree/core"
|
|
13
|
+
isolate_namespace Spree
|
|
14
|
+
|
|
15
|
+
# Deliberately not "spree-kashflow": engine_name generates route helper
|
|
16
|
+
# prefixes and must be a valid Ruby identifier, so it cannot contain a dash.
|
|
17
|
+
engine_name "spree_kashflow"
|
|
18
|
+
|
|
19
|
+
config.generators do |g|
|
|
20
|
+
g.test_framework :rspec
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
##
|
|
24
|
+
# Loads the gem's decorators. Called on every reload in development.
|
|
25
|
+
#
|
|
26
|
+
# @return [void]
|
|
27
|
+
#
|
|
28
|
+
def self.activate
|
|
29
|
+
# Three levels up from lib/spree/kashflow/ to reach the gem root.
|
|
30
|
+
Dir.glob(File.join(File.dirname(__FILE__), "../../../app/**/*_decorator*.rb")).sort.each do |c|
|
|
31
|
+
Rails.configuration.cache_classes ? require(c) : load(c)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
config.to_prepare(&method(:activate).to_proc)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Kashflow
|
|
5
|
+
##
|
|
6
|
+
# Base class for every error this gem raises.
|
|
7
|
+
#
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
##
|
|
11
|
+
# Raised when KashFlow rejects the configured credentials. Not retryable.
|
|
12
|
+
#
|
|
13
|
+
class AuthenticationError < Error; end
|
|
14
|
+
|
|
15
|
+
##
|
|
16
|
+
# Raised when KashFlow accepts the request but refuses the operation.
|
|
17
|
+
#
|
|
18
|
+
class ApiError < Error; end
|
|
19
|
+
|
|
20
|
+
##
|
|
21
|
+
# Raised when the KashFlow service could not be reached. Retryable.
|
|
22
|
+
#
|
|
23
|
+
class TransportError < Error; end
|
|
24
|
+
|
|
25
|
+
##
|
|
26
|
+
# Raised by {Spree::Kashflow::InvoicePayload#to_h} when the assembled invoice
|
|
27
|
+
# lines fail to reconcile against either the line's own net total or the
|
|
28
|
+
# order's total. Nothing is posted to KashFlow when this is raised.
|
|
29
|
+
#
|
|
30
|
+
class TotalMismatchError < Error; end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Kashflow
|
|
5
|
+
##
|
|
6
|
+
# The Spree metafield keys ({Spree::Metafields#set_metafield} /
|
|
7
|
+
# {Spree::Metafields#get_metafield} "namespace.key" strings) this gem reads and
|
|
8
|
+
# writes. Centralised here so call sites never repeat the string literal, and so
|
|
9
|
+
# a later task (or the README) has one place to look up the whole table.
|
|
10
|
+
#
|
|
11
|
+
# `Spree::Metafields#set_metafield` auto-creates its backing
|
|
12
|
+
# `Spree::MetafieldDefinition` the first time a `"namespace.key"` string is used
|
|
13
|
+
# (`Spree::Metafields#resolve_metafield_definition_id_from_string`,
|
|
14
|
+
# spree_core-5.6.1 `app/models/concerns/spree/metafields.rb:222`) — confirmed
|
|
15
|
+
# empirically against the dummy app (see Task 7's report). No definition needs to
|
|
16
|
+
# be pre-seeded, but the auto-created definition takes `display_on`'s default of
|
|
17
|
+
# `"both"`, which would make a KashFlow invoice number — and raw KashFlow fault
|
|
18
|
+
# text in {ORDER_SYNC_ERROR} — storefront-visible by accident. Every write this
|
|
19
|
+
# gem makes therefore goes through {.write}, which seeds the definition as
|
|
20
|
+
# `"back_end"` first. These are internal accounting-sync markers; nothing here is
|
|
21
|
+
# ever meant for a customer.
|
|
22
|
+
#
|
|
23
|
+
module Metafields
|
|
24
|
+
# @return [String] order metafield; the KashFlow invoice number. Written
|
|
25
|
+
# immediately after `InsertInvoice_TypeDefined` returns, before any
|
|
26
|
+
# follow-on call, so a later failure can never cause the invoice to be
|
|
27
|
+
# posted twice. Its presence is the per-step guard {SyncOrderJob} uses to
|
|
28
|
+
# skip re-posting the invoice, and it is the identifier
|
|
29
|
+
# {CreditNotePayload} carries as `CustomerReference` when a refund is
|
|
30
|
+
# posted as a credit note.
|
|
31
|
+
ORDER_INVOICE_NUMBER = "kashflow.invoice_number"
|
|
32
|
+
|
|
33
|
+
# @return [String] order metafield; the timestamp at which the order's
|
|
34
|
+
# payment was recorded against the KashFlow invoice. Written immediately
|
|
35
|
+
# after `InsertInvoicePayment` succeeds, and checked separately from
|
|
36
|
+
# {ORDER_INVOICE_NUMBER} so a retry resumes at the payment step instead of
|
|
37
|
+
# re-posting the invoice or skipping the job wholesale.
|
|
38
|
+
ORDER_PAYMENT_RECORDED_AT = "kashflow.payment_recorded_at"
|
|
39
|
+
|
|
40
|
+
# @return [String] order metafield; the KashFlow customer id returned by
|
|
41
|
+
# `Client#upsert_customer`.
|
|
42
|
+
ORDER_CUSTOMER_CODE = "kashflow.customer_code"
|
|
43
|
+
|
|
44
|
+
# @return [String] order metafield; the timestamp of the last successful sync.
|
|
45
|
+
ORDER_SYNCED_AT = "kashflow.synced_at"
|
|
46
|
+
|
|
47
|
+
# @return [String] order metafield; the message of the last sync failure.
|
|
48
|
+
# Cleared (destroyed) on the next successful sync.
|
|
49
|
+
ORDER_SYNC_ERROR = "kashflow.sync_error"
|
|
50
|
+
|
|
51
|
+
# @return [String] refund metafield; the KashFlow credit note (invoice) number.
|
|
52
|
+
# Written immediately after the credit note is posted, before the link
|
|
53
|
+
# call, so a failing link can never cause a second credit note. Its
|
|
54
|
+
# presence is the per-step guard {SyncRefundJob} uses to skip re-posting.
|
|
55
|
+
REFUND_CREDIT_NOTE_NUMBER = "kashflow.credit_note_number"
|
|
56
|
+
|
|
57
|
+
# @return [String] refund metafield; the timestamp at which the credit note
|
|
58
|
+
# was linked to the original invoice via `applyCreditNoteToInvoice`.
|
|
59
|
+
# Checked separately from {REFUND_CREDIT_NOTE_NUMBER} so a retry re-runs
|
|
60
|
+
# only the link step. Re-applying a link is unverified against a live
|
|
61
|
+
# account, so this marker exists to avoid attempting it twice.
|
|
62
|
+
REFUND_CREDIT_NOTE_LINKED_AT = "kashflow.credit_note_linked_at"
|
|
63
|
+
|
|
64
|
+
# @return [String] the `display_on` value every definition this gem owns
|
|
65
|
+
# is seeded with: admin-only, never rendered on the storefront.
|
|
66
|
+
DISPLAY_ON = "back_end"
|
|
67
|
+
|
|
68
|
+
##
|
|
69
|
+
# Writes one of this gem's metafields, seeding its backing
|
|
70
|
+
# `Spree::MetafieldDefinition` as {DISPLAY_ON} first so Spree's auto-create
|
|
71
|
+
# path never gets to default it to `"both"`.
|
|
72
|
+
#
|
|
73
|
+
# Idempotent and cheap: after the first write per key the seed is a single
|
|
74
|
+
# indexed lookup. Deliberately not a boot-time initializer or a migration —
|
|
75
|
+
# the gem ships neither, and a definition seeded at boot would need the
|
|
76
|
+
# database up before the app could load.
|
|
77
|
+
#
|
|
78
|
+
# @param record [#set_metafield] the {Spree::Order} or {Spree::Refund} to write to
|
|
79
|
+
# @param key [String] one of this module's `"namespace.key"` constants
|
|
80
|
+
# @param value [Object, nil] the value to store; `nil` clears the metafield
|
|
81
|
+
# @return [void]
|
|
82
|
+
#
|
|
83
|
+
# @example
|
|
84
|
+
# Spree::Kashflow::Metafields.write(order, Spree::Kashflow::Metafields::ORDER_INVOICE_NUMBER, 4471)
|
|
85
|
+
#
|
|
86
|
+
def self.write(record, key, value)
|
|
87
|
+
ensure_definition!(record, key)
|
|
88
|
+
record.set_metafield(key, value)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
##
|
|
92
|
+
# @param record [ActiveRecord::Base] the resource the definition belongs to
|
|
93
|
+
# @param key [String] a `"namespace.key"` string
|
|
94
|
+
# @return [Spree::MetafieldDefinition] the seeded (or already-present) definition
|
|
95
|
+
#
|
|
96
|
+
def self.ensure_definition!(record, key)
|
|
97
|
+
namespace, definition_key = key.split(".", 2)
|
|
98
|
+
|
|
99
|
+
Spree::MetafieldDefinition.find_or_create_by!(
|
|
100
|
+
namespace: namespace,
|
|
101
|
+
key: definition_key,
|
|
102
|
+
resource_type: record.class.name
|
|
103
|
+
) { |definition| definition.display_on = DISPLAY_ON }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Bundler auto-requires a gem by its *name*, so `gem "spree-kashflow"` in a host
|
|
4
|
+
# Gemfile issues `require "spree-kashflow"`. The real entry point is
|
|
5
|
+
# `spree/kashflow` (matching the Spree::Kashflow namespace), so this shim keeps
|
|
6
|
+
# the default `Bundler.require` working.
|
|
7
|
+
require "spree/kashflow"
|
metadata
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: spree-kashflow
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Aypex
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: savon
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.17'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2.17'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: spree
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 5.6.0
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 5.6.0
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: spree_extension
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
description: Pushes completed Spree orders to KashFlow as invoices and refunds as
|
|
55
|
+
credit notes over the KashFlow SOAP API, configured per store through Spree's Integrations
|
|
56
|
+
framework.
|
|
57
|
+
email: hello@aypex.io
|
|
58
|
+
executables: []
|
|
59
|
+
extensions: []
|
|
60
|
+
extra_rdoc_files: []
|
|
61
|
+
files:
|
|
62
|
+
- CHANGELOG.md
|
|
63
|
+
- LICENSE
|
|
64
|
+
- README.md
|
|
65
|
+
- Rakefile
|
|
66
|
+
- app/assets/images/integration_icons/kashflow-logo.png
|
|
67
|
+
- app/jobs/spree/kashflow/sync_order_job.rb
|
|
68
|
+
- app/jobs/spree/kashflow/sync_refund_job.rb
|
|
69
|
+
- app/models/spree/integrations/kashflow.rb
|
|
70
|
+
- app/models/spree/refund_decorator.rb
|
|
71
|
+
- app/presenters/spree/kashflow/credit_note_payload.rb
|
|
72
|
+
- app/presenters/spree/kashflow/customer_payload.rb
|
|
73
|
+
- app/presenters/spree/kashflow/invoice_payload.rb
|
|
74
|
+
- app/subscribers/spree/kashflow/order_completed_subscriber.rb
|
|
75
|
+
- app/subscribers/spree/kashflow/reimbursement_subscriber.rb
|
|
76
|
+
- app/views/spree/admin/integrations/forms/_kashflow.html.erb
|
|
77
|
+
- config/initializers/spree.rb
|
|
78
|
+
- config/locales/en.yml
|
|
79
|
+
- config/routes.rb
|
|
80
|
+
- lib/spree-kashflow.rb
|
|
81
|
+
- lib/spree/kashflow.rb
|
|
82
|
+
- lib/spree/kashflow/client.rb
|
|
83
|
+
- lib/spree/kashflow/engine.rb
|
|
84
|
+
- lib/spree/kashflow/errors.rb
|
|
85
|
+
- lib/spree/kashflow/metafields.rb
|
|
86
|
+
- lib/spree/kashflow/version.rb
|
|
87
|
+
homepage: https://github.com/aypex-io/spree-kashflow
|
|
88
|
+
licenses:
|
|
89
|
+
- MIT
|
|
90
|
+
metadata:
|
|
91
|
+
source_code_uri: https://github.com/aypex-io/spree-kashflow
|
|
92
|
+
bug_tracker_uri: https://github.com/aypex-io/spree-kashflow/issues
|
|
93
|
+
changelog_uri: https://github.com/aypex-io/spree-kashflow/blob/main/CHANGELOG.md
|
|
94
|
+
rubygems_mfa_required: 'true'
|
|
95
|
+
rdoc_options: []
|
|
96
|
+
require_paths:
|
|
97
|
+
- lib
|
|
98
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '3.3'
|
|
103
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
104
|
+
requirements:
|
|
105
|
+
- - ">="
|
|
106
|
+
- !ruby/object:Gem::Version
|
|
107
|
+
version: '0'
|
|
108
|
+
requirements: []
|
|
109
|
+
rubygems_version: 4.0.16
|
|
110
|
+
specification_version: 4
|
|
111
|
+
summary: KashFlow accounting integration for Spree
|
|
112
|
+
test_files: []
|