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,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