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,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Kashflow
|
|
5
|
+
##
|
|
6
|
+
# Posts a refund to KashFlow as a credit note, linked to the original invoice
|
|
7
|
+
# via `applyCreditNoteToInvoice`.
|
|
8
|
+
#
|
|
9
|
+
# A resumable state machine, the same way as {SyncOrderJob}: posting the
|
|
10
|
+
# credit note and linking it to the original invoice are two separate calls,
|
|
11
|
+
# each with its own marker written the instant it succeeds
|
|
12
|
+
# ({Metafields::REFUND_CREDIT_NOTE_NUMBER} and
|
|
13
|
+
# {Metafields::REFUND_CREDIT_NOTE_LINKED_AT}), and each guarded independently
|
|
14
|
+
# in {#sync}. A rerun resumes at the first unfinished step.
|
|
15
|
+
#
|
|
16
|
+
class SyncRefundJob < Spree::BaseJob
|
|
17
|
+
# See {SyncOrderJob}: bad credentials will never succeed on retry.
|
|
18
|
+
discard_on Spree::Kashflow::AuthenticationError
|
|
19
|
+
|
|
20
|
+
# See {SyncOrderJob}: a business rejection does not become acceptance on the
|
|
21
|
+
# 25th attempt, and {Metafields::ORDER_SYNC_ERROR} already records it.
|
|
22
|
+
discard_on Spree::Kashflow::ApiError
|
|
23
|
+
|
|
24
|
+
# See {SyncOrderJob}: KashFlow being briefly unreachable is retry-safe.
|
|
25
|
+
retry_on Spree::Kashflow::TransportError, wait: :polynomially_longer, attempts: 5
|
|
26
|
+
|
|
27
|
+
##
|
|
28
|
+
# @param refund_id [Integer, String] the {Spree::Refund} id
|
|
29
|
+
# @return [void]
|
|
30
|
+
# @raise [Spree::Kashflow::Error] any subclass raised while posting; the
|
|
31
|
+
# message is written to the order's {Metafields::ORDER_SYNC_ERROR} first
|
|
32
|
+
#
|
|
33
|
+
def perform(refund_id)
|
|
34
|
+
refund = Spree::Refund.find_by(id: refund_id)
|
|
35
|
+
return if refund.nil?
|
|
36
|
+
|
|
37
|
+
order = refund.order
|
|
38
|
+
integration = Spree::Integrations::Kashflow.active.find_by(store: order.store)
|
|
39
|
+
return if integration.nil?
|
|
40
|
+
|
|
41
|
+
return if credit_note_posted?(refund) && credit_note_linked?(refund)
|
|
42
|
+
|
|
43
|
+
sync(refund, order, integration)
|
|
44
|
+
rescue Spree::Kashflow::Error => e
|
|
45
|
+
Metafields.write(order, Metafields::ORDER_SYNC_ERROR, e.message)
|
|
46
|
+
raise
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
##
|
|
52
|
+
# A refund on an order that never synced would post a credit note with no
|
|
53
|
+
# `CustomerReference` for KashFlow to link it to
|
|
54
|
+
# ({CreditNotePayload#to_h} returns `nil` there) — an orphan
|
|
55
|
+
# `applyCreditNoteToInvoice` cannot resolve. Refused up front rather than
|
|
56
|
+
# posted and left dangling.
|
|
57
|
+
#
|
|
58
|
+
# @param refund [Spree::Refund]
|
|
59
|
+
# @param order [Spree::Order]
|
|
60
|
+
# @param integration [Spree::Integrations::Kashflow]
|
|
61
|
+
# @return [void]
|
|
62
|
+
# @raise [Spree::Kashflow::ApiError] when the order has no KashFlow invoice
|
|
63
|
+
# number yet
|
|
64
|
+
#
|
|
65
|
+
def sync(refund, order, integration)
|
|
66
|
+
unless order.has_metafield?(CreditNotePayload::INVOICE_NUMBER_METAFIELD_KEY)
|
|
67
|
+
raise Spree::Kashflow::ApiError,
|
|
68
|
+
"Order #{order.number.inspect} has no KashFlow invoice number; refusing to post an orphan credit note for refund #{refund.id}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
client = integration.client
|
|
72
|
+
customer_id = client.upsert_customer(CustomerPayload.new(order).to_h)
|
|
73
|
+
|
|
74
|
+
# Written the instant `create_invoice` returns, before the link call is
|
|
75
|
+
# attempted. A credit note is a ledger document: creating it must be
|
|
76
|
+
# at-most-once, because a duplicate permanently overstates the credit and
|
|
77
|
+
# nothing in KashFlow flags it. Linking one that already exists is a
|
|
78
|
+
# separate, re-attemptable step. Writing this marker last — so that a
|
|
79
|
+
# failed link left the refund "unposted" — is precisely what made a retry
|
|
80
|
+
# post a *second* credit note, manufacturing the orphan the guard above
|
|
81
|
+
# exists to prevent rather than avoiding it.
|
|
82
|
+
unless credit_note_posted?(refund)
|
|
83
|
+
posted_number = client.create_invoice(credit_note_envelope(refund, order, integration, customer_id))
|
|
84
|
+
Metafields.write(refund, Metafields::REFUND_CREDIT_NOTE_NUMBER, posted_number)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Guarded separately, and by its own marker rather than by the credit note
|
|
88
|
+
# number: re-applying a link is unverified against a live account, so it
|
|
89
|
+
# is never attempted twice.
|
|
90
|
+
return if credit_note_linked?(refund)
|
|
91
|
+
|
|
92
|
+
invoice_number = order.get_metafield(CreditNotePayload::INVOICE_NUMBER_METAFIELD_KEY).value.to_i
|
|
93
|
+
client.apply_credit_note(credit_note_number: credit_note_number(refund), invoice_number: invoice_number)
|
|
94
|
+
Metafields.write(refund, Metafields::REFUND_CREDIT_NOTE_LINKED_AT, Time.current)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
##
|
|
98
|
+
# @param refund [Spree::Refund]
|
|
99
|
+
# @return [TrueClass, FalseClass] whether the credit note has already been
|
|
100
|
+
# posted to KashFlow for this refund
|
|
101
|
+
#
|
|
102
|
+
def credit_note_posted?(refund)
|
|
103
|
+
refund.has_metafield?(Metafields::REFUND_CREDIT_NOTE_NUMBER)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
##
|
|
107
|
+
# @param refund [Spree::Refund]
|
|
108
|
+
# @return [TrueClass, FalseClass] whether the credit note has already been
|
|
109
|
+
# linked to the original invoice
|
|
110
|
+
#
|
|
111
|
+
def credit_note_linked?(refund)
|
|
112
|
+
refund.has_metafield?(Metafields::REFUND_CREDIT_NOTE_LINKED_AT)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
##
|
|
116
|
+
# @param refund [Spree::Refund]
|
|
117
|
+
# @return [Integer, nil] the KashFlow credit note number recorded on the refund
|
|
118
|
+
#
|
|
119
|
+
def credit_note_number(refund)
|
|
120
|
+
refund.get_metafield(Metafields::REFUND_CREDIT_NOTE_NUMBER)&.value&.to_i
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
##
|
|
124
|
+
# Wraps {CreditNotePayload#to_h}'s fields in the same WSDL `Invoice` envelope
|
|
125
|
+
# {SyncOrderJob#invoice_envelope} builds — a credit note is posted through
|
|
126
|
+
# the same `InsertInvoice_TypeDefined` operation as a real invoice, so it
|
|
127
|
+
# needs the same `minOccurs="1"` fields. `Paid`/`AmountPaid` are `0`: a
|
|
128
|
+
# credit note is not itself a payment, it is linked to the original invoice
|
|
129
|
+
# separately via `applyCreditNoteToInvoice`. See
|
|
130
|
+
# {SyncOrderJob#invoice_envelope} for the rationale behind the
|
|
131
|
+
# `ArrayOfInvoiceLine` wrapper around `Lines` and the CIS reverse-charge
|
|
132
|
+
# trio (UK Construction Industry Scheme fields, structurally required but
|
|
133
|
+
# not applicable to this integration).
|
|
134
|
+
#
|
|
135
|
+
# @param refund [Spree::Refund]
|
|
136
|
+
# @param order [Spree::Order]
|
|
137
|
+
# @param integration [Spree::Integrations::Kashflow]
|
|
138
|
+
# @param customer_id [Integer] the id returned by `Client#upsert_customer`
|
|
139
|
+
# @return [Hash{String => Object}] a KashFlow `Invoice` structure, in WSDL
|
|
140
|
+
# sequence order
|
|
141
|
+
#
|
|
142
|
+
def credit_note_envelope(refund, order, integration, customer_id)
|
|
143
|
+
payload = CreditNotePayload.new(refund, integration: integration).to_h
|
|
144
|
+
credit_note_date = refund.created_at || Time.current
|
|
145
|
+
|
|
146
|
+
{
|
|
147
|
+
"InvoiceDBID" => 0,
|
|
148
|
+
"InvoiceNumber" => 0,
|
|
149
|
+
"InvoiceDate" => credit_note_date,
|
|
150
|
+
"DueDate" => credit_note_date,
|
|
151
|
+
"CustomerID" => customer_id,
|
|
152
|
+
"Paid" => 0,
|
|
153
|
+
"CustomerReference" => payload["CustomerReference"],
|
|
154
|
+
"SuppressTotal" => 0,
|
|
155
|
+
"ProjectID" => 0,
|
|
156
|
+
"CurrencyCode" => payload["CurrencyCode"],
|
|
157
|
+
"ExchangeRate" => BigDecimal(1),
|
|
158
|
+
"Lines" => {"InvoiceLine" => payload["Lines"]},
|
|
159
|
+
"NetAmount" => payload["NetAmount"],
|
|
160
|
+
"VATAmount" => payload["VATAmount"],
|
|
161
|
+
"AmountPaid" => BigDecimal(0),
|
|
162
|
+
"CISRCNetAmount" => 0,
|
|
163
|
+
"CISRCVatAmount" => 0,
|
|
164
|
+
"IsCISReverseCharge" => false
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Integrations
|
|
5
|
+
##
|
|
6
|
+
# Per-store KashFlow credentials and posting configuration.
|
|
7
|
+
#
|
|
8
|
+
# The four numeric preferences decide which ledger accounts money lands in and deliberately have no
|
|
9
|
+
# defaults: a wrong nominal code silently posts revenue to the wrong place, so the integration cannot
|
|
10
|
+
# be saved until an operator chooses them.
|
|
11
|
+
#
|
|
12
|
+
class Kashflow < Spree::Integration
|
|
13
|
+
preference :username, :string
|
|
14
|
+
preference :password, :password
|
|
15
|
+
preference :sales_nominal_code, :integer
|
|
16
|
+
preference :shipping_nominal_code, :integer
|
|
17
|
+
preference :bank_account_id, :integer
|
|
18
|
+
preference :payment_method_id, :integer
|
|
19
|
+
|
|
20
|
+
validates :preferred_username, :preferred_password, presence: true
|
|
21
|
+
validates :preferred_sales_nominal_code,
|
|
22
|
+
:preferred_shipping_nominal_code,
|
|
23
|
+
:preferred_bank_account_id,
|
|
24
|
+
:preferred_payment_method_id,
|
|
25
|
+
presence: true,
|
|
26
|
+
numericality: {only_integer: true, greater_than: 0}
|
|
27
|
+
|
|
28
|
+
##
|
|
29
|
+
# @return [String] the admin group this integration is listed under
|
|
30
|
+
#
|
|
31
|
+
def self.integration_group
|
|
32
|
+
"Accounting"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
##
|
|
36
|
+
# @return [String] path to the bundled logo, relative to the asset root
|
|
37
|
+
#
|
|
38
|
+
def self.icon_path
|
|
39
|
+
"integration_icons/kashflow-logo.png"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
##
|
|
43
|
+
# @return [String] the name shown in the admin
|
|
44
|
+
#
|
|
45
|
+
def self.integration_name
|
|
46
|
+
Spree.t("admin.integrations.kashflow.brand_name")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
##
|
|
50
|
+
# Verifies the stored credentials against the KashFlow API.
|
|
51
|
+
#
|
|
52
|
+
# @return [TrueClass, FalseClass] true when KashFlow accepts the credentials
|
|
53
|
+
#
|
|
54
|
+
def can_connect?
|
|
55
|
+
client.verify_credentials
|
|
56
|
+
rescue Spree::Kashflow::Error => e
|
|
57
|
+
self.connection_error_message = e.message
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
##
|
|
62
|
+
# @return [Spree::Kashflow::Client] a client bound to this integration's credentials
|
|
63
|
+
#
|
|
64
|
+
def client
|
|
65
|
+
Spree::Kashflow::Client.new(username: preferred_username, password: preferred_password)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
##
|
|
69
|
+
# @return [Array<Array(String, Integer)>] nominal code `[name, id]` pairs for
|
|
70
|
+
# `options_for_select`; empty when credentials are absent or the lookup fails
|
|
71
|
+
#
|
|
72
|
+
def nominal_code_options
|
|
73
|
+
@nominal_code_options ||= options_from { client.nominal_codes }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
##
|
|
77
|
+
# @return [Array<Array(String, Integer)>] bank account `[name, id]` pairs for
|
|
78
|
+
# `options_for_select`; empty when credentials are absent or the lookup fails
|
|
79
|
+
#
|
|
80
|
+
def bank_account_options
|
|
81
|
+
@bank_account_options ||= options_from { client.bank_accounts }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
##
|
|
85
|
+
# @return [Array<Array(String, Integer)>] invoice payment method `[name, id]` pairs for
|
|
86
|
+
# `options_for_select`; empty when credentials are absent or the lookup fails
|
|
87
|
+
#
|
|
88
|
+
def payment_method_options
|
|
89
|
+
@payment_method_options ||= options_from { client.payment_methods }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
##
|
|
95
|
+
# Fetches rows from KashFlow and maps them to `options_for_select` pairs. Degrades to an
|
|
96
|
+
# empty array whenever the dropdown can't be trusted: credentials not yet entered (the
|
|
97
|
+
# client is never built in that case) or the connected account rejecting or failing the
|
|
98
|
+
# request — an admin opening this form must always see it render, not a 500.
|
|
99
|
+
#
|
|
100
|
+
# @yieldreturn [Array<Hash>] rows as `{id:, name:}` hashes
|
|
101
|
+
# @return [Array<Array(String, Integer)>]
|
|
102
|
+
#
|
|
103
|
+
def options_from
|
|
104
|
+
return [] if preferred_username.blank? || preferred_password.blank?
|
|
105
|
+
|
|
106
|
+
yield.map { |row| [row[:name], row[:id]] }
|
|
107
|
+
rescue Spree::Kashflow::Error
|
|
108
|
+
[]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
##
|
|
5
|
+
# The only decorator in this gem, and it must not grow.
|
|
6
|
+
#
|
|
7
|
+
# Spree 5.6 publishes no `refund.created` event, and the returns flow
|
|
8
|
+
# (`reimbursement.reimbursed`, handled by
|
|
9
|
+
# {Spree::Kashflow::ReimbursementSubscriber}) does not cover an admin issuing
|
|
10
|
+
# an ad-hoc refund directly against a payment — the most common manual path.
|
|
11
|
+
# Left unmirrored, that path would reintroduce the ledger drift KashFlow
|
|
12
|
+
# credit notes exist to prevent, so this decorator enqueues
|
|
13
|
+
# {Spree::Kashflow::SyncRefundJob} on every refund. It does nothing else:
|
|
14
|
+
# no client, no payload building, no integration lookup — the job already
|
|
15
|
+
# no-ops when no active integration exists.
|
|
16
|
+
#
|
|
17
|
+
# If Spree ever adds a `refund.created` event, delete this decorator in
|
|
18
|
+
# favour of a `Spree::Kashflow::RefundCreatedSubscriber`.
|
|
19
|
+
#
|
|
20
|
+
module RefundDecorator
|
|
21
|
+
##
|
|
22
|
+
# @param base [Class] {Spree::Refund}, the class this module is prepended to
|
|
23
|
+
# @return [void]
|
|
24
|
+
#
|
|
25
|
+
def self.prepended(base)
|
|
26
|
+
base.after_create_commit -> { Spree::Kashflow::SyncRefundJob.perform_later(id) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
::Spree::Refund.prepend self
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Kashflow
|
|
5
|
+
##
|
|
6
|
+
# Maps a Spree refund onto the shape KashFlow's `Invoice` complex type expects.
|
|
7
|
+
# KashFlow has no `InsertCreditNote` operation: a credit note is an invoice with
|
|
8
|
+
# negative values, posted through the same `InsertInvoice_TypeDefined` path and
|
|
9
|
+
# then linked to the invoice it credits via `applyCreditNoteToInvoice` (Task 7's
|
|
10
|
+
# job, not this class's — this class only carries the original invoice number
|
|
11
|
+
# through `CustomerReference` so that call can be made).
|
|
12
|
+
#
|
|
13
|
+
# A refund equal to the order's total produces a full negative mirror of the
|
|
14
|
+
# invoice's lines, built by delegating to {Spree::Kashflow::InvoicePayload#to_h}
|
|
15
|
+
# so the credit note inherits that class's reconciliation guard rather than
|
|
16
|
+
# bypassing it. A partial refund produces a single negative line for the
|
|
17
|
+
# refunded amount: Spree carries no information tying a partial refund back to
|
|
18
|
+
# specific line items or VAT rates, so no apportionment across the original
|
|
19
|
+
# lines is attempted. Instead, VAT on that single line is approximated at the
|
|
20
|
+
# order's blended rate (`order.included_tax_total / (order.total -
|
|
21
|
+
# order.included_tax_total)`) — a deliberate simplification, not a derivation
|
|
22
|
+
# from the refunded item(s), since Spree does not expose which items a partial
|
|
23
|
+
# refund was for.
|
|
24
|
+
#
|
|
25
|
+
# Pure object: reads the refund and its associations, performs no network
|
|
26
|
+
# calls, and writes nothing back to Spree.
|
|
27
|
+
#
|
|
28
|
+
class CreditNotePayload
|
|
29
|
+
# @return [String] the order metafield key this gem reads the original
|
|
30
|
+
# KashFlow invoice number from
|
|
31
|
+
INVOICE_NUMBER_METAFIELD_KEY = Spree::Kashflow::Metafields::ORDER_INVOICE_NUMBER
|
|
32
|
+
|
|
33
|
+
##
|
|
34
|
+
# @param refund [Spree::Refund] the refund to post as a KashFlow credit note
|
|
35
|
+
# @param integration [Spree::Integrations::Kashflow] the integration whose
|
|
36
|
+
# nominal codes decide which ledger accounts a partial-refund line posts to
|
|
37
|
+
#
|
|
38
|
+
def initialize(refund, integration:)
|
|
39
|
+
@refund = refund
|
|
40
|
+
@integration = integration
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# @return [Hash{String => Object}] a KashFlow `Invoice` structure with
|
|
45
|
+
# negative values, ready to hand to KashFlow's invoice-insert operation
|
|
46
|
+
# @raise [Spree::Kashflow::TotalMismatchError] when the refund is full and
|
|
47
|
+
# the underlying invoice's lines fail to reconcile against the order's
|
|
48
|
+
# total (see {Spree::Kashflow::InvoicePayload#to_h})
|
|
49
|
+
#
|
|
50
|
+
def to_h
|
|
51
|
+
full_refund? ? full_refund_to_h : partial_refund_to_h
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# @return [Spree::Refund]
|
|
57
|
+
attr_reader :refund
|
|
58
|
+
|
|
59
|
+
# @return [Spree::Integrations::Kashflow]
|
|
60
|
+
attr_reader :integration
|
|
61
|
+
|
|
62
|
+
# @return [Spree::Order]
|
|
63
|
+
def order
|
|
64
|
+
refund.order
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
##
|
|
68
|
+
# @return [Boolean] whether the refund amount equals the order's total
|
|
69
|
+
#
|
|
70
|
+
def full_refund?
|
|
71
|
+
refund.amount == order.total
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
##
|
|
75
|
+
# A full negative mirror of {Spree::Kashflow::InvoicePayload#to_h}'s output.
|
|
76
|
+
# Deliberately calls `#to_h`, not `#lines` — `#lines` bypasses that class's
|
|
77
|
+
# correctness guard, and a credit note has no arithmetic of its own to
|
|
78
|
+
# re-verify the guard against, so it inherits the guard by going through the
|
|
79
|
+
# reconciled method rather than reimplementing reconciliation here.
|
|
80
|
+
#
|
|
81
|
+
# @return [Hash{String => Object}]
|
|
82
|
+
#
|
|
83
|
+
def full_refund_to_h
|
|
84
|
+
invoice = InvoicePayload.new(order, integration: integration).to_h
|
|
85
|
+
|
|
86
|
+
{
|
|
87
|
+
"CurrencyCode" => invoice["CurrencyCode"],
|
|
88
|
+
"CustomerReference" => invoice_number,
|
|
89
|
+
"Lines" => invoice["Lines"].map { |line| negate_line(line) },
|
|
90
|
+
"NetAmount" => -invoice["NetAmount"],
|
|
91
|
+
"VATAmount" => -invoice["VATAmount"]
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
##
|
|
96
|
+
# @param line [Hash{String => Object}] a KashFlow `InvoiceLine` structure
|
|
97
|
+
# @return [Hash{String => Object}] the same line with `Rate` and `VatAmount`
|
|
98
|
+
# negated; `Quantity` is left positive since KashFlow expresses a credit
|
|
99
|
+
# through negative values, not a negative quantity
|
|
100
|
+
#
|
|
101
|
+
def negate_line(line)
|
|
102
|
+
line.merge("Rate" => -line["Rate"], "VatAmount" => -line["VatAmount"])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
##
|
|
106
|
+
# A single negative line for the refunded amount, since Spree carries no
|
|
107
|
+
# information tying a partial refund to specific line items.
|
|
108
|
+
#
|
|
109
|
+
# @return [Hash{String => Object}]
|
|
110
|
+
#
|
|
111
|
+
def partial_refund_to_h
|
|
112
|
+
line = partial_refund_line
|
|
113
|
+
|
|
114
|
+
{
|
|
115
|
+
"CurrencyCode" => order.currency,
|
|
116
|
+
"CustomerReference" => invoice_number,
|
|
117
|
+
"Lines" => [line],
|
|
118
|
+
"NetAmount" => line["Rate"],
|
|
119
|
+
"VATAmount" => line["VatAmount"]
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
##
|
|
124
|
+
# @return [Hash{String => Object}] the single `InvoiceLine` for a partial
|
|
125
|
+
# refund, in WSDL `<s:sequence>` order
|
|
126
|
+
#
|
|
127
|
+
def partial_refund_line
|
|
128
|
+
net_total = (refund.amount / (1 + blended_vat_rate)).round(2)
|
|
129
|
+
vat_total = refund.amount - net_total
|
|
130
|
+
|
|
131
|
+
{
|
|
132
|
+
"Quantity" => BigDecimal(1),
|
|
133
|
+
"Description" => "Refund: #{refund.reason.name}",
|
|
134
|
+
"Rate" => -net_total,
|
|
135
|
+
"ChargeType" => integration.preferred_sales_nominal_code,
|
|
136
|
+
"VatRate" => (blended_vat_rate * 100).round(2),
|
|
137
|
+
"VatAmount" => -vat_total,
|
|
138
|
+
# KashFlow does not sync against the Spree catalogue, so there is no
|
|
139
|
+
# KashFlow product to reference.
|
|
140
|
+
"ProductID" => 0,
|
|
141
|
+
"Sort" => 1,
|
|
142
|
+
# No KashFlow project is associated with these invoices.
|
|
143
|
+
"ProjID" => 0,
|
|
144
|
+
# Assigned by KashFlow on insert; not known until then.
|
|
145
|
+
"LineID" => 0
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
##
|
|
150
|
+
# An approximation: Spree does not record which line items or VAT rates a
|
|
151
|
+
# partial refund applies to, so this spreads the order's overall VAT/net
|
|
152
|
+
# ratio across the refunded amount rather than deriving it from specific
|
|
153
|
+
# lines.
|
|
154
|
+
#
|
|
155
|
+
# Returns zero when there is no net to divide by — an order whose total is
|
|
156
|
+
# entirely VAT, or a zero-total order. Guarded rather than left to raise:
|
|
157
|
+
# `ZeroDivisionError` is not a {Spree::Kashflow::Error}, so it would escape
|
|
158
|
+
# {SyncRefundJob}'s rescue and the refund would fail with no
|
|
159
|
+
# `kashflow.sync_error` recorded anywhere.
|
|
160
|
+
#
|
|
161
|
+
# @return [BigDecimal] the order's blended VAT rate, expressed as a
|
|
162
|
+
# fraction of net (not a percentage)
|
|
163
|
+
#
|
|
164
|
+
def blended_vat_rate
|
|
165
|
+
net = order.total - order.included_tax_total
|
|
166
|
+
return BigDecimal(0) if net.zero?
|
|
167
|
+
|
|
168
|
+
order.included_tax_total / net
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
##
|
|
172
|
+
# @return [String, nil] the original KashFlow invoice number this credit
|
|
173
|
+
# note is for, read from the order's metafields
|
|
174
|
+
#
|
|
175
|
+
def invoice_number
|
|
176
|
+
order.get_metafield(INVOICE_NUMBER_METAFIELD_KEY)&.value
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Kashflow
|
|
5
|
+
##
|
|
6
|
+
# Maps a Spree order's billing details onto the shape KashFlow's `Customer`
|
|
7
|
+
# complex type expects. Pure object: reads the order and its associations, performs
|
|
8
|
+
# no network calls, and writes nothing back to Spree.
|
|
9
|
+
#
|
|
10
|
+
class CustomerPayload
|
|
11
|
+
# @return [Array<String>] ISO 3166-1 alpha-2 codes of EU member states, used to
|
|
12
|
+
# derive the `EC` VAT-treatment flag. The United Kingdom is deliberately absent:
|
|
13
|
+
# post-Brexit it is neither EU nor "outside" for KashFlow's purposes.
|
|
14
|
+
EU_COUNTRY_CODES = %w[
|
|
15
|
+
AT BE BG HR CY CZ DK EE FI FR DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
# @return [String] the ISO 3166-1 alpha-2 code KashFlow treats as the United Kingdom
|
|
19
|
+
UNITED_KINGDOM_CODE = "GB"
|
|
20
|
+
|
|
21
|
+
# @return [String] the order metadata key a host application must write for
|
|
22
|
+
# `VATNumber` to be included in the payload. Spree has no dedicated VAT number
|
|
23
|
+
# column, so this gem reads it out of `order.metadata` under this key.
|
|
24
|
+
VAT_NUMBER_METADATA_KEY = "vat_number"
|
|
25
|
+
|
|
26
|
+
##
|
|
27
|
+
# @param order [Spree::Order] a completed order with a billing address
|
|
28
|
+
#
|
|
29
|
+
def initialize(order)
|
|
30
|
+
@order = order
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
##
|
|
34
|
+
# Keys are emitted in the WSDL `Customer` `<s:sequence>`'s relative order —
|
|
35
|
+
# `… Address4, CountryName, CountryCode, Postcode, Website, EC, OutsideEC,
|
|
36
|
+
# … ContactFirstName, ContactLastName, … VATNumber`. Savon serialises a
|
|
37
|
+
# Hash body in insertion order, and a .NET ASMX endpoint enforcing that
|
|
38
|
+
# sequence drops or mis-binds an out-of-order element rather than raising,
|
|
39
|
+
# so the order of these keys is load-bearing, not cosmetic.
|
|
40
|
+
#
|
|
41
|
+
# @return [Hash{String => Object}] a KashFlow `Customer` structure keyed by the
|
|
42
|
+
# WSDL field names, ready to hand to KashFlow's customer-upsert operation
|
|
43
|
+
#
|
|
44
|
+
def to_h
|
|
45
|
+
payload = {
|
|
46
|
+
"Code" => order.email,
|
|
47
|
+
"Name" => customer_name,
|
|
48
|
+
"Email" => order.email,
|
|
49
|
+
"Address1" => bill_address&.address1,
|
|
50
|
+
"Address2" => bill_address&.address2,
|
|
51
|
+
"Address3" => bill_address&.city,
|
|
52
|
+
"Address4" => bill_address&.state_name_text,
|
|
53
|
+
"CountryCode" => billing_country_code,
|
|
54
|
+
"Postcode" => bill_address&.zipcode,
|
|
55
|
+
"EC" => ec_flag,
|
|
56
|
+
"OutsideEC" => outside_ec_flag,
|
|
57
|
+
"ContactFirstName" => bill_address&.firstname,
|
|
58
|
+
"ContactLastName" => bill_address&.lastname
|
|
59
|
+
}
|
|
60
|
+
payload["VATNumber"] = vat_number if vat_number.present?
|
|
61
|
+
payload
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# @return [Spree::Order]
|
|
67
|
+
attr_reader :order
|
|
68
|
+
|
|
69
|
+
##
|
|
70
|
+
# Nullable on purpose. A digital-only order can complete with no billing
|
|
71
|
+
# address at all, and a `NoMethodError` here would escape the sync job's
|
|
72
|
+
# `Spree::Kashflow::Error` rescue entirely — failing with no
|
|
73
|
+
# `kashflow.sync_error` recorded. Every caller navigates this safely and
|
|
74
|
+
# sends the address fields as absent instead.
|
|
75
|
+
#
|
|
76
|
+
# @return [Spree::Address, nil] the order's billing address, when it has one
|
|
77
|
+
#
|
|
78
|
+
def bill_address
|
|
79
|
+
order.bill_address
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
##
|
|
83
|
+
# KashFlow separates the customer (an organisation, for business buyers) from the
|
|
84
|
+
# contact person on the order. The billing company stands in for the former when
|
|
85
|
+
# present; an individual buyer's name is used otherwise.
|
|
86
|
+
#
|
|
87
|
+
# @return [String, nil] the billing company name, the billing full name when
|
|
88
|
+
# there is no company, or the order's email when there is no billing
|
|
89
|
+
# address at all
|
|
90
|
+
#
|
|
91
|
+
def customer_name
|
|
92
|
+
bill_address&.company.presence || bill_address&.full_name.presence || order.email
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
##
|
|
96
|
+
# @return [String, nil] the billing address's ISO 3166-1 alpha-2 country code
|
|
97
|
+
#
|
|
98
|
+
def billing_country_code
|
|
99
|
+
bill_address&.country&.iso
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
##
|
|
103
|
+
# @return [String, nil] the store's own ISO 3166-1 alpha-2 country code
|
|
104
|
+
#
|
|
105
|
+
def store_country_code
|
|
106
|
+
order.store&.default_country&.iso
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
##
|
|
110
|
+
# @return [String, nil] the order's VAT number, read from order metadata since
|
|
111
|
+
# Spree has no dedicated column for it
|
|
112
|
+
#
|
|
113
|
+
def vat_number
|
|
114
|
+
order.metadata&.dig(VAT_NUMBER_METADATA_KEY)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
##
|
|
118
|
+
# @return [Integer] 1 when the billing country is in the EU and differs from the
|
|
119
|
+
# store's own country, 0 otherwise
|
|
120
|
+
#
|
|
121
|
+
def ec_flag
|
|
122
|
+
return 0 if billing_country_code == store_country_code
|
|
123
|
+
EU_COUNTRY_CODES.include?(billing_country_code) ? 1 : 0
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
##
|
|
127
|
+
# @return [Integer] 1 when the billing country is outside both the UK and the EU,
|
|
128
|
+
# 0 otherwise (including when it matches the store's own country)
|
|
129
|
+
#
|
|
130
|
+
def outside_ec_flag
|
|
131
|
+
return 0 if billing_country_code == store_country_code
|
|
132
|
+
return 0 if EU_COUNTRY_CODES.include?(billing_country_code)
|
|
133
|
+
return 0 if billing_country_code == UNITED_KINGDOM_CODE
|
|
134
|
+
1
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|