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.
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Kashflow
5
+ ##
6
+ # Maps a Spree order onto the shape KashFlow's `Invoice` complex type expects.
7
+ #
8
+ # TKF prices are VAT-inclusive; KashFlow wants net (VAT-exclusive) per-unit rates.
9
+ # Because a per-unit `Rate` is rounded to 4 decimal places, `Rate * Quantity` is
10
+ # not guaranteed to reconcile back to the line's net total, or the assembled
11
+ # invoice back to the order's total. `#to_h` asserts both reconciliations before
12
+ # returning and raises {Spree::Kashflow::TotalMismatchError} rather than post a
13
+ # silently-wrong invoice. Pure object: reads the order and its associations,
14
+ # performs no network calls, and writes nothing back to Spree.
15
+ #
16
+ class InvoicePayload
17
+ # @return [String] the description used for the shipping line
18
+ SHIPPING_DESCRIPTION = "Shipping"
19
+
20
+ ##
21
+ # @param order [Spree::Order] a completed order to post as an invoice
22
+ # @param integration [Spree::Integrations::Kashflow] the integration whose
23
+ # nominal codes decide which ledger accounts the lines post to
24
+ #
25
+ def initialize(order, integration:)
26
+ @order = order
27
+ @integration = integration
28
+ end
29
+
30
+ ##
31
+ # @return [Array<Hash{String => Object}>] the KashFlow `InvoiceLine` structures:
32
+ # one per Spree line item, plus one for shipping
33
+ # @note Unreconciled. Only {#to_h} runs the correctness guard; a caller that
34
+ # needs the guarantee that these lines actually add up to the order's total
35
+ # (Task 6's credit notes, for instance) must go through {#to_h}, not call
36
+ # this directly.
37
+ #
38
+ def lines
39
+ line_entries.map { |entry| entry[:line] }
40
+ end
41
+
42
+ ##
43
+ # Assembles the KashFlow `Invoice` structure, guarding the arithmetic before
44
+ # returning it.
45
+ #
46
+ # @return [Hash{String => Object}] a KashFlow `Invoice` structure
47
+ # @raise [Spree::Kashflow::TotalMismatchError] when a line's `Rate * Quantity`
48
+ # fails to reconcile against its net total, or the assembled invoice fails
49
+ # to reconcile against the order's total
50
+ # @note Known limitation: orders with exclusive tax (`additional_tax_total`,
51
+ # added on top of the price rather than included in it) are not reconciled
52
+ # by this arithmetic and will always raise here — refused rather than
53
+ # mis-booked, but not otherwise handled by this mapper.
54
+ #
55
+ def to_h
56
+ entries = line_entries
57
+ assert_totals_reconcile!(entries)
58
+
59
+ {
60
+ "CurrencyCode" => order.currency,
61
+ "Lines" => entries.map { |entry| entry[:line] },
62
+ "NetAmount" => entries.sum { |entry| entry[:net_total] }.round(2),
63
+ "VATAmount" => entries.sum { |entry| entry[:line]["VatAmount"] }
64
+ }
65
+ end
66
+
67
+ private
68
+
69
+ # @return [Spree::Order]
70
+ attr_reader :order
71
+
72
+ # @return [Spree::Integrations::Kashflow]
73
+ attr_reader :integration
74
+
75
+ ##
76
+ # Assigns each entry's `Sort` as a 1-based line index (line items first, then
77
+ # shipping). 1-based, not 0-based: `Sort` is an ordering column and 0 is also
78
+ # the natural "unset" sentinel in the same .NET model, so a first line of 0
79
+ # would be ambiguous between "first" and "not sorted". Of every field this
80
+ # class fills with a placeholder, `Sort` is the one with observable
81
+ # behaviour — it controls line ordering on the rendered KashFlow invoice —
82
+ # and should be confirmed against a real sandbox call before relying on it.
83
+ #
84
+ # @return [Array<Hash{Symbol => Object}>] one entry per line item and one for
85
+ # shipping, each carrying both the public `:line` hash and the unrounded
86
+ # `:net_total` the guard reconciles it against
87
+ #
88
+ def line_entries
89
+ entries = order.line_items.map { |line_item| line_item_entry(line_item) } +
90
+ order.shipments.map { |shipment| shipment_entry(shipment) }
91
+ entries.each_with_index { |entry, index| entry[:line]["Sort"] = index + 1 }
92
+ allocate_rounding_residue!(entries)
93
+ entries
94
+ end
95
+
96
+ ##
97
+ # Largest-remainder penny allocation, run before the guard.
98
+ #
99
+ # `taxable_basis` returns an unrounded figure whenever a whole-order
100
+ # promotion applies: the discount is allocated across lines, so each
101
+ # line's share carries a fractional residue that only cancels when the
102
+ # whole set is summed. Three £10 lines with a £5 order-level discount give
103
+ # each line a basis of `8.333333…`, which rounds to `8.33` and assembles
104
+ # to `24.99` against an `order.total` of `25.00`. With two lines the
105
+ # residues happen to cancel, which is why this went unnoticed.
106
+ #
107
+ # Rather than loosen the guard — exact equality is the point of it — each
108
+ # entry's net total is rounded to the penny here and the leftover penny or
109
+ # two is pushed onto the largest line, the conventional allocation and the
110
+ # one where a penny is proportionally least visible.
111
+ #
112
+ # Only genuine rounding noise is absorbed: the residue is left alone (and
113
+ # the guard therefore still raises) once it exceeds one penny per line.
114
+ # That bound is what keeps the exclusive-tax case a refusal rather than a
115
+ # silent mis-booking — an order carrying `additional_tax_total` misses by
116
+ # the whole tax amount, not by pennies.
117
+ #
118
+ # @param entries [Array<Hash{Symbol => Object}>]
119
+ # @return [Array<Hash{Symbol => Object}>] the same entries, mutated in place
120
+ #
121
+ def allocate_rounding_residue!(entries)
122
+ return entries if entries.empty?
123
+
124
+ target = (order.total - entries.sum { |entry| entry[:line]["VatAmount"] }).round(2)
125
+ entries.each { |entry| entry[:net_total] = entry[:net_total].round(2) }
126
+ residue = target - entries.sum { |entry| entry[:net_total] }
127
+
128
+ if residue.nonzero? && residue.abs <= entries.length * BigDecimal("0.01")
129
+ entries.max_by { |entry| entry[:net_total].abs }[:net_total] += residue
130
+ end
131
+
132
+ entries.each { |entry| entry[:line]["Rate"] = (entry[:net_total] / entry[:line]["Quantity"]).round(4) }
133
+ entries
134
+ end
135
+
136
+ ##
137
+ # @param line_item [Spree::LineItem]
138
+ # @return [Hash{Symbol => Object}]
139
+ #
140
+ def line_item_entry(line_item)
141
+ build_entry(
142
+ gross: line_item.taxable_basis,
143
+ included_tax_total: line_item.included_tax_total,
144
+ quantity: BigDecimal(line_item.quantity),
145
+ description: line_item_description(line_item),
146
+ charge_type: integration.preferred_sales_nominal_code
147
+ )
148
+ end
149
+
150
+ ##
151
+ # @param shipment [Spree::Shipment]
152
+ # @return [Hash{Symbol => Object}]
153
+ #
154
+ def shipment_entry(shipment)
155
+ build_entry(
156
+ gross: shipment.taxable_basis,
157
+ included_tax_total: shipment.included_tax_total,
158
+ quantity: BigDecimal(1),
159
+ description: SHIPPING_DESCRIPTION,
160
+ charge_type: integration.preferred_shipping_nominal_code
161
+ )
162
+ end
163
+
164
+ ##
165
+ # @param gross [BigDecimal] the taxable basis: the discounted, VAT-inclusive
166
+ # amount Spree itself taxes (`taxable_basis`), which already accounts for
167
+ # both line-level and whole-order promotion allocations
168
+ # @param included_tax_total [BigDecimal] the VAT baked into `gross`
169
+ # @param quantity [BigDecimal]
170
+ # @param description [String]
171
+ # @param charge_type [Integer] the KashFlow nominal code for this line
172
+ # @return [Hash{Symbol => Object}]
173
+ #
174
+ def build_entry(gross:, included_tax_total:, quantity:, description:, charge_type:)
175
+ net_total = gross - included_tax_total
176
+ rate = (net_total / quantity).round(4)
177
+
178
+ {
179
+ net_total: net_total,
180
+ line: {
181
+ "Quantity" => quantity,
182
+ "Description" => description,
183
+ "Rate" => rate,
184
+ "ChargeType" => charge_type,
185
+ "VatRate" => vat_rate(included_tax_total, net_total),
186
+ "VatAmount" => included_tax_total,
187
+ # KashFlow does not sync against the Spree catalogue, so there is no
188
+ # KashFlow product to reference.
189
+ "ProductID" => 0,
190
+ # Seeded here (not just appended later) to hold this key's position in
191
+ # WSDL sequence order — Savon serialises a Hash body in insertion
192
+ # order, and a .NET ASMX endpoint enforcing <s:sequence> will drop or
193
+ # mis-bind an out-of-order element rather than raise. #line_entries
194
+ # overwrites this in place once every entry's position is known.
195
+ "Sort" => nil,
196
+ # No KashFlow project is associated with these invoices.
197
+ "ProjID" => 0,
198
+ # Assigned by KashFlow on insert; not known until then.
199
+ "LineID" => 0
200
+ }
201
+ }
202
+ end
203
+
204
+ ##
205
+ # @param included_tax_total [BigDecimal]
206
+ # @param net_total [BigDecimal]
207
+ # @return [BigDecimal] the VAT rate as a percentage, 0 when the line is
208
+ # entirely discounted away
209
+ #
210
+ def vat_rate(included_tax_total, net_total)
211
+ return BigDecimal(0) if net_total.zero?
212
+ (included_tax_total / net_total * 100).round(2)
213
+ end
214
+
215
+ ##
216
+ # @param line_item [Spree::LineItem]
217
+ # @return [String] the product name, suffixed with the applied promotion
218
+ # names when any eligible promotion adjustment applies to this line
219
+ #
220
+ def line_item_description(line_item)
221
+ names = promotion_names(line_item)
222
+ return line_item.name if names.empty?
223
+ "#{line_item.name} (#{names.join(", ")} applied)"
224
+ end
225
+
226
+ ##
227
+ # Uses `Spree::Promotion#name_for_order`, not `#code`. `code` is nilled out
228
+ # by a `before_validation` for *both* `multi_codes?` and `automatic?`
229
+ # promotions (spree_core `app/models/spree/promotion.rb:49`), so reading it
230
+ # directly drops the annotation entirely for automatic discounts — the
231
+ # common case. `name_for_order` returns the order's actual coupon code for
232
+ # a coupon promotion and the promotion's name otherwise.
233
+ #
234
+ # @param line_item [Spree::LineItem]
235
+ # @return [Array<String>] the names (or codes) of eligible promotions
236
+ # applied to this line
237
+ #
238
+ def promotion_names(line_item)
239
+ line_item.adjustments.select(&:promotion?).select(&:eligible?)
240
+ .map { |adjustment| adjustment.source.promotion.name_for_order(order).presence }
241
+ .compact.uniq
242
+ end
243
+
244
+ ##
245
+ # The correctness guard. Raises rather than returns a payload whose figures
246
+ # do not reconcile — a failed sync is a queryable flag, a wrong invoice is a
247
+ # discrepancy someone finds at year end.
248
+ #
249
+ # Exact equality is deliberate and must stay that way. Penny residue from a
250
+ # whole-order promotion is dealt with upstream in
251
+ # {#allocate_rounding_residue!}, not by widening the comparison here.
252
+ #
253
+ # @note Known limitation: this guard only accounts for VAT baked into the
254
+ # price (`included_tax_total`) and the whole-order allocation captured by
255
+ # `taxable_basis`. An order with exclusive tax (`additional_tax_total`,
256
+ # added on top of the price rather than included in it) is not reconciled
257
+ # by this arithmetic and will make the guard raise — refused rather than
258
+ # mis-booked, which is the safe direction, but exclusive-tax orders are not
259
+ # otherwise handled by this mapper.
260
+ #
261
+ # @param entries [Array<Hash{Symbol => Object}>]
262
+ # @return [void]
263
+ # @raise [Spree::Kashflow::TotalMismatchError] when any line's `Rate * Quantity`
264
+ # fails to reconcile against its own net total, or the sum across all lines
265
+ # fails to reconcile against `order.total`
266
+ #
267
+ def assert_totals_reconcile!(entries)
268
+ entries.each do |entry|
269
+ reconciled = (entry[:line]["Rate"] * entry[:line]["Quantity"]).round(2)
270
+ expected = entry[:net_total].round(2)
271
+ next if reconciled == expected
272
+
273
+ raise TotalMismatchError,
274
+ "line #{entry[:line]["Description"].inspect}: Rate * Quantity = #{reconciled}, " \
275
+ "net_total = #{expected}"
276
+ end
277
+
278
+ assembled = entries.sum { |entry| (entry[:line]["Rate"] * entry[:line]["Quantity"]).round(2) + entry[:line]["VatAmount"] }
279
+ return if assembled == order.total
280
+
281
+ raise TotalMismatchError,
282
+ "assembled invoice total = #{assembled}, order.total = #{order.total}"
283
+ end
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Kashflow
5
+ ##
6
+ # Enqueues {SyncOrderJob} whenever Spree publishes `order.completed`.
7
+ #
8
+ class OrderCompletedSubscriber < Spree::Subscriber
9
+ subscribes_to "order.completed"
10
+
11
+ ##
12
+ # @param event [Spree::Event] payload carries the order's prefixed id
13
+ # under `"id"`
14
+ # @return [void]
15
+ #
16
+ def handle(event)
17
+ order = Spree::Order.find_by_prefix_id(event.payload["id"])
18
+ return if order.nil?
19
+
20
+ SyncOrderJob.perform_later(order.id)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Kashflow
5
+ ##
6
+ # Enqueues {SyncRefundJob} for each refund on a reimbursement whenever
7
+ # Spree publishes `reimbursement.reimbursed`.
8
+ #
9
+ class ReimbursementSubscriber < Spree::Subscriber
10
+ subscribes_to "reimbursement.reimbursed"
11
+
12
+ ##
13
+ # @param event [Spree::Event] payload carries the reimbursement's
14
+ # prefixed id under `"id"`
15
+ # @return [void]
16
+ #
17
+ def handle(event)
18
+ reimbursement = Spree::Reimbursement.find_by_prefix_id(event.payload["id"])
19
+ return if reimbursement.nil?
20
+
21
+ reimbursement.refunds.each { |refund| SyncRefundJob.perform_later(refund.id) }
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,18 @@
1
+ <div class="row">
2
+ <div class="col-12">
3
+ <%= preference_field(@integration, form, 'username', i18n_scope: 'admin.integrations.kashflow') %>
4
+ <%= preference_field(@integration, form, 'password', i18n_scope: 'admin.integrations.kashflow') %>
5
+ <%= content_tag(:div, form.label("preferred_sales_nominal_code", Spree.t(:sales_nominal_code, scope: "admin.integrations.kashflow")) +
6
+ form.select("preferred_sales_nominal_code", options_for_select(@integration.nominal_code_options, @integration.preferred_sales_nominal_code)),
7
+ class: "form-group", id: "spree-integrations-kashflow-preference-sales_nominal_code") %>
8
+ <%= content_tag(:div, form.label("preferred_shipping_nominal_code", Spree.t(:shipping_nominal_code, scope: "admin.integrations.kashflow")) +
9
+ form.select("preferred_shipping_nominal_code", options_for_select(@integration.nominal_code_options, @integration.preferred_shipping_nominal_code)),
10
+ class: "form-group", id: "spree-integrations-kashflow-preference-shipping_nominal_code") %>
11
+ <%= content_tag(:div, form.label("preferred_bank_account_id", Spree.t(:bank_account_id, scope: "admin.integrations.kashflow")) +
12
+ form.select("preferred_bank_account_id", options_for_select(@integration.bank_account_options, @integration.preferred_bank_account_id)),
13
+ class: "form-group", id: "spree-integrations-kashflow-preference-bank_account_id") %>
14
+ <%= content_tag(:div, form.label("preferred_payment_method_id", Spree.t(:payment_method_id, scope: "admin.integrations.kashflow")) +
15
+ form.select("preferred_payment_method_id", options_for_select(@integration.payment_method_options, @integration.preferred_payment_method_id)),
16
+ class: "form-group", id: "spree-integrations-kashflow-preference-payment_method_id") %>
17
+ </div>
18
+ </div>
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The after_initialize wrapper is load-bearing: spree_core ASSIGNS
4
+ # config.spree.integrations = [] inside its own after_initialize, which runs
5
+ # AFTER engine initializers and after config/initializers files. Registering at
6
+ # file scope here would be silently clobbered and the integration would never
7
+ # appear in the admin. spec/spree/kashflow/registration_spec.rb pins this.
8
+ #
9
+ # The two subscriber registrations live in this same block for consistency
10
+ # with that rule, even though Spree.subscribers itself is only ever
11
+ # concatenated onto (never reassigned) after it is first set.
12
+ Rails.application.config.after_initialize do
13
+ Rails.application.config.spree.integrations << Spree::Integrations::Kashflow
14
+
15
+ Spree.subscribers << Spree::Kashflow::OrderCompletedSubscriber
16
+ Spree.subscribers << Spree::Kashflow::ReimbursementSubscriber
17
+ end
@@ -0,0 +1,12 @@
1
+ en:
2
+ spree:
3
+ admin:
4
+ integrations:
5
+ kashflow:
6
+ brand_name: "KashFlow"
7
+ username: "API username"
8
+ password: "API password"
9
+ sales_nominal_code: "Sales nominal code"
10
+ shipping_nominal_code: "Shipping nominal code"
11
+ bank_account_id: "Bank account"
12
+ payment_method_id: "Payment method"
data/config/routes.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rails.application.routes.draw do
4
+ end