xero_exporter 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bde627d6af9da6d7c27966b98a072973cfdd6f379e24112dd99a6412fbbf07e5
4
+ data.tar.gz: 35363a38bd97be4bef05ce1baab513c4b88998ebe8c467a0b5fd6f0d76648abb
5
+ SHA512:
6
+ metadata.gz: afbea5fce74d9c032f2835cf37cc19e007b37d0192197abf432904512f77bd7bd6233fd1db44deb91fd695495d3ed126f9fb950a43238f36cdde278ad3f4986b
7
+ data.tar.gz: 33feef318188bb760c235a55684d3684db8d6b919a0e5875563632cd54688b67a0a459f39984186a8e4cf3ae99574a20f534709c65c6408fbf17d688a770e8ed
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ v1.3.0
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+
6
+ module XeroExporter
7
+ class API
8
+
9
+ class APIConnectionError < Error
10
+ end
11
+
12
+ class APIError < Error
13
+
14
+ attr_reader :status, :body
15
+
16
+ def initialize(status, message, body = nil)
17
+ @status = status
18
+ @message = message
19
+ @body = body
20
+ end
21
+
22
+ def message
23
+ to_s
24
+ end
25
+
26
+ def to_s
27
+ "[#{@status}] #{@message}"
28
+ end
29
+
30
+ end
31
+
32
+ METHOD_MAP = {
33
+ get: Net::HTTP::Get,
34
+ post: Net::HTTP::Post,
35
+ put: Net::HTTP::Put
36
+ }.freeze
37
+
38
+ def initialize(access_token, tenant_id, **options)
39
+ @access_token = access_token
40
+ @tenant_id = tenant_id
41
+ @logger = options[:logger]
42
+ end
43
+
44
+ def get(path, params = {})
45
+ request(:get, path, params)
46
+ end
47
+
48
+ def post(path, params = {})
49
+ request(:post, path, params)
50
+ end
51
+
52
+ def put(path, params = {})
53
+ request(:put, path, params)
54
+ end
55
+
56
+ private
57
+
58
+ def request(method, path, params = {})
59
+ http = Net::HTTP.new('api.xero.com', 443)
60
+ http.use_ssl = true
61
+
62
+ path = "/api.xro/2.0/#{path}"
63
+
64
+ if method == :get && !params.empty?
65
+ query_string = URI.encode_www_form(params)
66
+ path = "#{path}?#{query_string}"
67
+ end
68
+
69
+ request = METHOD_MAP[method].new(path)
70
+ request['Authorization'] = "Bearer #{@access_token}"
71
+ request['Xero-Tenant-ID'] = @tenant_id
72
+ request['Accept'] = 'application/json'
73
+
74
+ if method != :get && !params.empty?
75
+ request['Content-Type'] = 'application/json'
76
+ request.body = params.to_json
77
+ end
78
+
79
+ logger.debug "[#{method.to_s.upcase}] to #{path}"
80
+ response = make_request_with_error_handling(http, request)
81
+
82
+ if response.is_a?(Net::HTTPOK)
83
+ logger.debug 'Status: 200 OK'
84
+ JSON.parse(response.body)
85
+ elsif response.code.to_i == 429
86
+ logger.debug 'Status: 429 Rate Limit Exceeded'
87
+ handle_retry(response, method, path, params)
88
+ else
89
+ handle_error(response)
90
+ end
91
+ end
92
+
93
+ def make_request_with_error_handling(http, request)
94
+ http.request(request)
95
+ rescue StandardError => e
96
+ logger.error "#{e.class}: #{e.message}"
97
+ raise APIConnectionError, "#{e.class}: #{e.message}"
98
+ end
99
+
100
+ def handle_retry(response, method, path, params)
101
+ problem = response['X-Rate-Limit-Problem']
102
+ if problem != 'minute'
103
+ raise APIConnectionError, "Rate limit exceeded (retry not possible) (problem: #{problem})"
104
+ end
105
+
106
+ retry_after = response['Retry-After'].to_i + 2
107
+ logger.debug "Waiting #{retry_after} seconds"
108
+ sleep retry_after
109
+
110
+ logger.debug 'Retrying after rate limit pause'
111
+ request(method, path, params)
112
+ end
113
+
114
+ def handle_error(response)
115
+ logger.error "Status: #{response.code}"
116
+ logger.debug response.body
117
+
118
+ if response['Content-Type'] =~ /application\/json/
119
+ json = JSON.parse(response.body)
120
+ logger.debug 'Error was JSON encoded'
121
+ raise APIError.new(response.code.to_i, "#{json['Type']}: #{json['Message']}", response.body)
122
+ end
123
+
124
+ raise APIError.new(response.code.to_i, response.body)
125
+ end
126
+
127
+ def logger
128
+ @logger || XeroExporter.logger
129
+ end
130
+
131
+ end
132
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module XeroExporter
6
+ class Country
7
+
8
+ CODES_TO_NAMES = JSON.parse(File.read(File.expand_path('../../resource/country-codes.json', __dir__)))
9
+
10
+ attr_reader :code
11
+
12
+ def initialize(code)
13
+ @code = code
14
+ end
15
+
16
+ def to_s
17
+ @code
18
+ end
19
+
20
+ def name
21
+ CODES_TO_NAMES[@code.upcase] || @code.upcase
22
+ end
23
+
24
+ def eql?(other)
25
+ @code == other.code
26
+ end
27
+
28
+ def hash
29
+ @code.hash
30
+ end
31
+
32
+ end
33
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XeroExporter
4
+ class Error < StandardError
5
+ end
6
+ end
@@ -0,0 +1,426 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'xero_exporter/proposal'
5
+ require 'bigdecimal'
6
+ require 'bigdecimal/util'
7
+
8
+ module XeroExporter
9
+ class Executor
10
+
11
+ attr_accessor :state_reader
12
+ attr_accessor :state_writer
13
+
14
+ def initialize(export, api, **options)
15
+ @export = export
16
+ @api = api
17
+ @logger = options[:logger]
18
+ @proposal = Proposal.new(export)
19
+ end
20
+
21
+ # Execute all actions required to perform this export
22
+ #
23
+ # @return [void]
24
+ def execute_all
25
+ create_invoice
26
+ create_invoice_payment
27
+ create_credit_note
28
+ create_credit_note_payment
29
+ add_payments
30
+ add_refunds
31
+ add_fees
32
+ true
33
+ end
34
+
35
+ # Create an invoice for all invoice lines in the export
36
+ #
37
+ # @return [void]
38
+ def create_invoice
39
+ run_task :create_invoice do
40
+ line_items = create_xero_line_items(@proposal.invoice_lines)
41
+ if line_items.empty?
42
+ logger.debug 'Not creating an invoice because there are no invoice lines'
43
+ current_state[:status] = 'not required because no invoice lines'
44
+ return false
45
+ end
46
+
47
+ logger.debug 'Creating new invoice'
48
+
49
+ spec = {
50
+ 'Type' => 'ACCREC',
51
+ 'Contact' => {
52
+ 'ContactID' => find_or_create_xero_contact(@export.invoice_contact_name)
53
+ },
54
+ 'Date' => @export.date.strftime('%Y-%m-%d'),
55
+ 'DueDate' => @export.date.strftime('%Y-%m-%d'),
56
+ 'InvoiceNumber' => @export.invoice_number,
57
+ 'Reference' => @export.invoice_reference || @export.reference,
58
+ 'CurrencyCode' => @export.currency,
59
+ 'Status' => 'AUTHORISED',
60
+ 'LineItems' => line_items
61
+ }
62
+
63
+ invoice = @api.post('Invoices', spec)['Invoices'].first
64
+
65
+ logger.debug "Invoice created with ID #{invoice['InvoiceID']} for #{invoice['AmountDue']}"
66
+ current_state[:status] = 'invoice created'
67
+ current_state[:invoice_id] = invoice['InvoiceID']
68
+ current_state[:amount] = invoice['AmountDue']
69
+ end
70
+ end
71
+
72
+ # Create a credit note for all credit note lines in the export
73
+ #
74
+ # @return [void]
75
+ def create_credit_note
76
+ run_task :create_credit_note do
77
+ line_items = create_xero_line_items(@proposal.credit_note_lines)
78
+ if line_items.empty?
79
+ logger.debug 'Not creating a credit note because there are no lines'
80
+ current_state[:status] = 'not required because no credit note lines'
81
+ return false
82
+ end
83
+
84
+ logger.debug 'Creating new credit note'
85
+ spec = {
86
+ 'Type' => 'ACCRECCREDIT',
87
+ 'Contact' => {
88
+ 'ContactID' => find_or_create_xero_contact(@export.invoice_contact_name)
89
+ },
90
+ 'Date' => @export.date.strftime('%Y-%m-%d'),
91
+ 'Reference' => @export.reference,
92
+ 'CurrencyCode' => @export.currency,
93
+ 'Status' => 'AUTHORISED',
94
+ 'LineItems' => line_items
95
+ }
96
+
97
+ credit_note = @api.post('CreditNotes', spec)['CreditNotes'].first
98
+
99
+ logger.debug "Credit note created with ID #{credit_note['CreditNoteID']} for #{credit_note['RemainingCredit']}"
100
+ current_state[:credit_note_id] = credit_note['CreditNoteID']
101
+ current_state[:amount] = credit_note['RemainingCredit']
102
+ end
103
+ end
104
+
105
+ # Create payments for all payments in the export
106
+ #
107
+ # @return [void]
108
+ def add_payments
109
+ @proposal.payments.each do |bank_account, amount|
110
+ run_task "add_payments_#{bank_account}" do
111
+ transfer = add_bank_transfer(@export.receivables_account, bank_account, amount)
112
+ current_state[:transfer_id] = transfer['BankTransferID']
113
+ end
114
+ end
115
+ end
116
+
117
+ # Create refunds for all payments in the export
118
+ #
119
+ # @return [void]
120
+ def add_refunds
121
+ @proposal.refunds.each do |bank_account, amount|
122
+ run_task "add_refunds_#{bank_account}" do
123
+ if transfer = add_bank_transfer(bank_account, @export.receivables_account, amount)
124
+ current_state[:transfer_id] = transfer['BankTransferID']
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ # Create payments for all payments in the export
131
+ #
132
+ # @return [void]
133
+ def add_fees
134
+ @proposal.fees.each do |bank_account, amounts_by_category|
135
+ amounts_by_category.each do |category, amount|
136
+ category_for_key = category&.downcase&.gsub(' ', '_') || 'none'
137
+ run_task "add_fees_#{bank_account}_#{category_for_key}" do
138
+ if transaction = add_fee_transaction(bank_account, amount, category)
139
+ current_state[:transaction_id] = transaction['BankTransactionID']
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+
146
+ # Create a new payment that fully pays the given invoice
147
+ #
148
+ # @param invoice [Hash]
149
+ # @return [void]
150
+ def create_invoice_payment
151
+ run_task :create_invoice_payment do
152
+ if state[:create_invoice].nil?
153
+ raise Error, 'create_invoice task must be executed before this action'
154
+ end
155
+
156
+ if state[:create_invoice][:amount].nil? || !state[:create_invoice][:amount].positive?
157
+ logger.debug 'Not adding a payment because the amount is not present or not positive'
158
+ return
159
+ end
160
+
161
+ logger.debug "Creating payment for invoice #{state[:create_invoice][:invoice_id]} " \
162
+ "for #{state[:create_invoice][:amount]}"
163
+ logger.debug "Using receivables account: #{@export.receivables_account}"
164
+
165
+ payment = @api.put('Payments', {
166
+ 'Invoice' => { 'InvoiceID' => state[:create_invoice][:invoice_id] },
167
+ 'Account' => { 'Code' => @export.receivables_account },
168
+ 'Date' => @export.date.strftime('%Y-%m-%d'),
169
+ 'Amount' => state[:create_invoice][:amount],
170
+ 'Reference' => @export.reference
171
+ })['Payments'].first
172
+
173
+ current_state[:payment_id] = payment['PaymentID']
174
+ current_state[:amount] = payment['Amount']
175
+ end
176
+ end
177
+
178
+ # Create a new payment that fully pays a given credit note
179
+ #
180
+ # @param credit_note [Hash]
181
+ # @return [void]
182
+ def create_credit_note_payment
183
+ run_task :create_credit_note_payment do
184
+ if state[:create_credit_note].nil?
185
+ raise Error, 'create_credit_note task must be executed before this action'
186
+ end
187
+
188
+ if state[:create_credit_note][:amount].nil? || !state[:create_credit_note][:amount].positive?
189
+ logger.debug 'Not adding a payment because the amount is not present or not positive'
190
+ return
191
+ end
192
+
193
+ logger.debug "Creating payment for credit note #{state[:create_credit_note][:credit_note_id]} " \
194
+ "for #{state[:create_credit_note][:amount]}"
195
+ logger.debug "Using receivables account: #{@export.receivables_account}"
196
+
197
+ payment = @api.put('Payments', {
198
+ 'CreditNote' => { 'CreditNoteID' => state[:create_credit_note][:credit_note_id] },
199
+ 'Account' => { 'Code' => @export.receivables_account },
200
+ 'Date' => @export.date.strftime('%Y-%m-%d'),
201
+ 'Amount' => state[:create_credit_note][:amount],
202
+ 'Reference' => @export.reference
203
+ })['Payments'].first
204
+ current_state[:payment_id] = payment['PaymentID']
205
+ current_state[:amount] = payment['Amount']
206
+ end
207
+ end
208
+
209
+ private
210
+
211
+ # Transfer an amount of money from one bank account to another
212
+ #
213
+ # @param from [String]
214
+ # @param to [String]
215
+ # @param amount [Float]
216
+ # @return [void]
217
+ def add_bank_transfer(from, to, amount)
218
+ amount = amount.round(2)
219
+ logger.debug "Transferring #{amount} from #{from} to #{to}"
220
+ @api.put('BankTransfers', {
221
+ 'FromBankAccount' => { 'Code' => from },
222
+ 'ToBankAccount' => { 'Code' => to },
223
+ 'Amount' => amount,
224
+ 'Date' => @export.date.strftime('%Y-%m-%d')
225
+ })['BankTransfers'].first
226
+ end
227
+
228
+ # Create an array of line item hashes which can be submitted to the Xero
229
+ # API for the given array of lines
230
+ #
231
+ # @param lines [Hash]
232
+ # @return [Array<Hash>]
233
+ def create_xero_line_items(lines)
234
+ lines.map do |(account, country, tax_rate), amounts|
235
+ xero_tax_rate = find_or_create_tax_rate(country, tax_rate)
236
+
237
+ if xero_tax_rate.nil?
238
+ raise Error, "Could not determine tax rate for #{country} (#{tax_rate})"
239
+ end
240
+
241
+ {
242
+ 'Description' => @proposal.invoice_line_description(account, country, tax_rate),
243
+ 'Quantity' => 1,
244
+ 'AccountCode' => account,
245
+ 'TaxAmount' => amounts[:tax],
246
+ 'LineAmount' => amounts[:amount],
247
+ 'TaxType' => xero_tax_rate,
248
+ 'Tracking' => tracking_options
249
+ }
250
+ end
251
+ end
252
+
253
+ # Return an array of tracking options
254
+ #
255
+ # @return [Array<Hash>]
256
+ def tracking_options
257
+ return [] if @export.tracking.empty?
258
+
259
+ @export.tracking.map do |key, value|
260
+ {
261
+ 'Name' => key,
262
+ 'Option' => value
263
+ }
264
+ end
265
+ end
266
+
267
+ # Add a fee transaction for a given amount to a given bank account
268
+ #
269
+ # @param bank_account [String]
270
+ # @param amount [Float]
271
+ # @return [void]
272
+ def add_fee_transaction(bank_account, amount, description = nil)
273
+ return if amount.zero?
274
+
275
+ logger.debug "Creating fee transaction for #{amount} from #{bank_account} (#{description})"
276
+
277
+ @api.post('BankTransactions', {
278
+ 'Type' => amount.negative? ? 'RECEIVE' : 'SPEND',
279
+ 'Contact' => {
280
+ 'ContactID' => find_or_create_xero_contact(@export.payment_providers[bank_account] ||
281
+ 'Generic Payment Processor')
282
+ },
283
+ 'Date' => @export.date.strftime('%Y-%m-%d'),
284
+ 'BankAccount' => { 'Code' => bank_account },
285
+ 'Reference' => @export.reference,
286
+ 'LineItems' => [
287
+ {
288
+ 'Description' => description || 'Fees',
289
+ 'UnitAmount' => amount.abs,
290
+ 'AccountCode' => @export.fee_accounts[bank_account] || '404',
291
+ 'Tracking' => tracking_options
292
+ }
293
+ ]
294
+ })['BankTransactions'].first
295
+ end
296
+
297
+ # Find or create a contact with a given name and return the ID of that
298
+ # contact
299
+ #
300
+ # @param name [String]
301
+ # @return [String]
302
+ def find_or_create_xero_contact(name)
303
+ existing = @api.get('Contacts', where: "Name=\"#{name}\"")['Contacts']&.first
304
+ if existing
305
+ logger.debug "Found existing contact with name: #{name}"
306
+ logger.debug "ID: #{existing['ContactID']}"
307
+ return existing['ContactID']
308
+ end
309
+
310
+ logger.debug "Creating new contact with name: #{name}"
311
+ response = @api.post('Contacts', 'Name' => name)
312
+ id = response['Contacts'].first['ContactID']
313
+ logger.debug "Contact created with ID: #{id}"
314
+ id
315
+ end
316
+
317
+ # Find or create a tax rate for the given country and tax rate.
318
+ #
319
+ # @param country [XeroExporter::Country]
320
+ # @param tax_rate [XeroExporter::TaxRate]
321
+ # @return [String]
322
+ def find_or_create_tax_rate(country, tax_rate)
323
+ @tax_rate_cache ||= {}
324
+ if cached_rate = @tax_rate_cache[[country, tax_rate]]
325
+ return cached_rate
326
+ end
327
+
328
+ existing = tax_rates.find do |rate|
329
+ rate['Status'] == 'ACTIVE' &&
330
+ rate['ReportTaxType'] == tax_rate.xero_report_type &&
331
+ rate['EffectiveRate'].to_d == tax_rate.rate.to_d &&
332
+ (rate['Name'].include?(country.name) || rate['Name'].include?(country.code.upcase))
333
+ end
334
+
335
+ if existing
336
+ @tax_rate_cache[[country, tax_rate]] = existing['TaxType']
337
+ return existing['TaxType']
338
+ end
339
+
340
+ rates = @api.post('TaxRates', {
341
+ 'Name' => tax_rate.xero_name(country),
342
+ 'ReportTaxType' => tax_rate.xero_report_type,
343
+ 'TaxComponents' => [
344
+ {
345
+ 'Name' => 'Tax',
346
+ 'Rate' => tax_rate.rate
347
+ }
348
+ ]
349
+ })
350
+
351
+ type = rates['TaxRates'].first['TaxType']
352
+ @tax_rate_cache[[country, tax_rate]] = type
353
+ type
354
+ end
355
+
356
+ # Return a full list of all tax rates currently stored within the API
357
+ #
358
+ # @return [Array<Hash>]
359
+ def tax_rates
360
+ @tax_rates ||= @api.get('TaxRates')['TaxRates'] || []
361
+ end
362
+
363
+ # Return the logger instance
364
+ #
365
+ # @return [Logger]
366
+ def logger
367
+ @logger || XeroExporter.logger
368
+ end
369
+
370
+ # Executes a named task if it is suitable to be executed
371
+ #
372
+ # @return [void]
373
+ def run_task(name)
374
+ if @state.nil?
375
+ @state = load_state
376
+ end
377
+
378
+ if @state[name.to_sym] && @state[name.to_sym][:state] == 'complete'
379
+ logger.debug "Not executing #{name} because it has already been run"
380
+ return
381
+ end
382
+
383
+ logger.debug "Running #{name} task"
384
+
385
+ @current_state = @state[name.to_sym] ||= {}
386
+ @current_state[:state] = 'running'
387
+ @current_state.delete(:error)
388
+ yield if block_given?
389
+ rescue StandardError => e
390
+ if @current_state
391
+ @current_state[:state] = 'error'
392
+ @current_state[:error] = { class: e.class.name, message: e.message }
393
+ end
394
+ raise
395
+ ensure
396
+ if @current_state && @current_state[:state] == 'running'
397
+ @current_state[:state] = 'complete'
398
+ end
399
+
400
+ @current_state = nil
401
+ save_state
402
+ end
403
+
404
+ # Loads state as apprppriate
405
+ #
406
+ # @return [Hash]
407
+ def load_state
408
+ return {} unless @state_reader
409
+
410
+ @state_reader.call
411
+ end
412
+
413
+ # Saves the current state as appropriate
414
+ #
415
+ # @return [void]
416
+ def save_state
417
+ return unless @state_writer
418
+
419
+ @state_writer.call(@state)
420
+ end
421
+
422
+ attr_reader :state
423
+ attr_reader :current_state
424
+
425
+ end
426
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'xero_exporter/invoice'
4
+ require 'xero_exporter/payment'
5
+ require 'xero_exporter/refund'
6
+ require 'xero_exporter/fee'
7
+ require 'xero_exporter/executor'
8
+
9
+ module XeroExporter
10
+ class Export
11
+
12
+ attr_accessor :id
13
+ attr_accessor :date
14
+ attr_accessor :currency
15
+ attr_accessor :invoice_contact_name
16
+ attr_accessor :invoice_number
17
+ attr_accessor :invoice_reference
18
+
19
+ attr_accessor :receivables_account
20
+ attr_accessor :fee_accounts
21
+
22
+ attr_reader :invoices
23
+ attr_reader :payments
24
+ attr_reader :refunds
25
+ attr_reader :fees
26
+
27
+ attr_reader :payment_providers
28
+ attr_reader :account_names
29
+ attr_reader :tracking
30
+
31
+ def initialize
32
+ @date = Date.today
33
+ @currency = 'GBP'
34
+ @invoice_contact_name = 'Generic Customer'
35
+ @invoices = []
36
+ @payments = []
37
+ @refunds = []
38
+ @fees = []
39
+ @payment_providers = {}
40
+ @fee_accounts = {}
41
+ @account_names = {}
42
+ @tracking = {}
43
+ end
44
+
45
+ def reference
46
+ "#{@date&.strftime('%Y%m%d')}-#{@currency&.upcase}-#{@id}"
47
+ end
48
+
49
+ def add_invoice
50
+ invoice = Invoice.new
51
+ yield invoice if block_given?
52
+ @invoices << invoice
53
+ invoice
54
+ end
55
+
56
+ def add_credit_note
57
+ invoice = Invoice.new
58
+ invoice.type = :credit_note
59
+ yield invoice if block_given?
60
+ @invoices << invoice
61
+ invoice
62
+ end
63
+
64
+ def add_payment
65
+ payment = Payment.new
66
+ yield payment if block_given?
67
+ @payments << payment
68
+ payment
69
+ end
70
+
71
+ def add_refund
72
+ refund = Refund.new
73
+ yield refund if block_given?
74
+ @refunds << refund
75
+ refund
76
+ end
77
+
78
+ def add_fee
79
+ fee = Fee.new
80
+ yield fee if block_given?
81
+ @fees << fee
82
+ fee
83
+ end
84
+
85
+ def execute(api, **options)
86
+ executor = Executor.new(self, api, **options)
87
+ yield executor if block_given?
88
+ executor.execute_all
89
+ end
90
+
91
+ private
92
+
93
+ def logger
94
+ XeroExporter.logger
95
+ end
96
+
97
+ end
98
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XeroExporter
4
+ class Fee
5
+
6
+ attr_accessor :amount
7
+ attr_accessor :category
8
+ attr_accessor :bank_account
9
+
10
+ end
11
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'xero_exporter/invoice_line'
4
+ require 'xero_exporter/tax_rate'
5
+ require 'xero_exporter/country'
6
+
7
+ module XeroExporter
8
+ class Invoice
9
+
10
+ attr_accessor :id
11
+ attr_accessor :number
12
+ attr_accessor :type
13
+ attr_writer :country
14
+ attr_writer :tax_rate
15
+ attr_writer :tax_type
16
+
17
+ attr_reader :lines
18
+
19
+ def initialize
20
+ @type = :invoice
21
+ @lines = []
22
+ @tax_type = :normal
23
+ end
24
+
25
+ def tax_rate
26
+ TaxRate.new(@tax_rate, @tax_type)
27
+ end
28
+
29
+ def country
30
+ Country.new(@country)
31
+ end
32
+
33
+ def add_line(account_code:, amount:, tax: 0.0)
34
+ line = InvoiceLine.new
35
+ line.account_code = account_code
36
+ line.amount = amount
37
+ line.tax = tax
38
+ @lines << line
39
+ line
40
+ end
41
+
42
+ end
43
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XeroExporter
4
+ class InvoiceLine
5
+
6
+ attr_accessor :account_code
7
+ attr_accessor :amount
8
+ attr_accessor :tax
9
+
10
+ end
11
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module XeroExporter
6
+
7
+ class << self
8
+
9
+ attr_writer :logger
10
+
11
+ def logger
12
+ @logger ||= Logger.new($stdout)
13
+ end
14
+
15
+ end
16
+
17
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XeroExporter
4
+ class Payment
5
+
6
+ attr_accessor :id
7
+ attr_accessor :bank_account
8
+ attr_accessor :amount
9
+
10
+ end
11
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XeroExporter
4
+ class Proposal
5
+
6
+ def initialize(export)
7
+ @export = export
8
+ end
9
+
10
+ # Returns an array of lines which need to be included on the invoice that will
11
+ # be generated in Xero.
12
+ #
13
+ # @return [Hash]
14
+ def invoice_lines
15
+ get_invoice_lines_from_invoices(@export.invoices.select { |i| i.type == :invoice })
16
+ end
17
+
18
+ # Returns an array of lines which need to be included on the credit note that will
19
+ # be generated in Xero.
20
+ #
21
+ # @return [Hash]
22
+ def credit_note_lines
23
+ get_invoice_lines_from_invoices(@export.invoices.select { |i| i.type == :credit_note })
24
+ end
25
+
26
+ # Return the total number of payments per bank account which
27
+ #
28
+ # @return [Hash]
29
+ def payments
30
+ export_payments = Hash.new(0.0)
31
+ @export.payments.each do |payment|
32
+ key = payment.bank_account
33
+ export_payments[key] += payment.amount || 0.0
34
+ end
35
+ export_payments
36
+ end
37
+
38
+ # Return the total number of refund per bank account which
39
+ #
40
+ # @return [Hash]
41
+ def refunds
42
+ export_refunds = Hash.new(0.0)
43
+ @export.refunds.each do |refund|
44
+ key = refund.bank_account
45
+ export_refunds[key] += refund.amount || 0.0
46
+ end
47
+ export_refunds
48
+ end
49
+
50
+ # Return the fees grouped by bank account & category
51
+ #
52
+ # @return [Hash]
53
+ def fees
54
+ initial_hash = Hash.new { |h, k| h[k] = Hash.new(0.0) }
55
+ @export.fees.each_with_object(initial_hash) do |fee, hash|
56
+ hash[fee.bank_account][fee.category] += fee.amount || 0.0
57
+ end
58
+ end
59
+
60
+ # Return the text to go with an invoice line
61
+ #
62
+ # @return [String]
63
+ def invoice_line_description(account, country, tax_rate)
64
+ name = @export.account_names[account.to_s] || "#{account} Sales"
65
+ "#{name} (#{country.code}, #{tax_rate.rate}%)"
66
+ end
67
+
68
+ private
69
+
70
+ def get_invoice_lines_from_invoices(invoices)
71
+ export_lines = Hash.new { |h, k| h[k] = { amount: 0.0, tax: 0.0 } }
72
+ invoices.each do |invoice|
73
+ invoice.lines.each do |line|
74
+ key = [line.account_code, invoice.country, invoice.tax_rate]
75
+ export_lines[key][:amount] += line.amount || 0.0
76
+ export_lines[key][:tax] += line.tax || 0.0
77
+ end
78
+ end
79
+ export_lines
80
+ end
81
+
82
+ end
83
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XeroExporter
4
+ class Refund
5
+
6
+ attr_accessor :id
7
+ attr_accessor :bank_account
8
+ attr_accessor :amount
9
+
10
+ end
11
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XeroExporter
4
+ class TaxRate
5
+
6
+ attr_reader :rate
7
+ attr_reader :type
8
+
9
+ def initialize(rate, type)
10
+ @rate = rate&.to_f || 0.0
11
+ @type = type
12
+ end
13
+
14
+ def to_s
15
+ "#{@rate}[#{@type}]"
16
+ end
17
+
18
+ def eql?(other)
19
+ @rate == other.rate && @type == other.type
20
+ end
21
+
22
+ def hash
23
+ [@rate, @type].hash
24
+ end
25
+
26
+ def xero_name(country)
27
+ case @type
28
+ when :moss
29
+ "MOSS #{country.name} #{@rate}%"
30
+ when :reverse_charge
31
+ "Reverse Charge (#{country.code})"
32
+ when :ec_services
33
+ "EC Services for #{country.code} (#{@rate}%)"
34
+ else
35
+ "Tax for #{country.code} (#{@rate}%)"
36
+ end
37
+ end
38
+
39
+ def xero_report_type
40
+ return 'MOSSSALES' if @type == :moss
41
+ return 'ECOUTPUTSERVICES' if @type == :ec_services
42
+
43
+ 'OUTPUT'
44
+ end
45
+
46
+ end
47
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XeroExporter
4
+
5
+ VERSION_FILE_ROOT = File.expand_path('../../VERSION', __dir__)
6
+ if File.file?(VERSION_FILE_ROOT)
7
+ VERSION = File.read(VERSION_FILE_ROOT).strip.sub(/\Av/, '')
8
+ else
9
+ VERSION = '0.0.0.dev'
10
+ end
11
+
12
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'xero_exporter/logger'
5
+ require 'xero_exporter/error'
6
+
7
+ require 'xero_exporter/export'
8
+ require 'xero_exporter/api'
9
+ require 'xero_exporter/executor'
@@ -0,0 +1,252 @@
1
+ {
2
+ "AD": "Andorra",
3
+ "AE": "United Arab Emirates",
4
+ "AF": "Afghanistan",
5
+ "AG": "Antigua and Barbuda",
6
+ "AI": "Anguilla",
7
+ "AL": "Albania",
8
+ "AM": "Armenia",
9
+ "AO": "Angola",
10
+ "AQ": "Antarctica",
11
+ "AR": "Argentina",
12
+ "AS": "American Samoa",
13
+ "AT": "Austria",
14
+ "AU": "Australia",
15
+ "AW": "Aruba",
16
+ "AX": "Aland Islands",
17
+ "AZ": "Azerbaijan",
18
+ "BA": "Bosnia and Herzegovina",
19
+ "BB": "Barbados",
20
+ "BD": "Bangladesh",
21
+ "BE": "Belgium",
22
+ "BF": "Burkina Faso",
23
+ "BG": "Bulgaria",
24
+ "BH": "Bahrain",
25
+ "BI": "Burundi",
26
+ "BJ": "Benin",
27
+ "BL": "Saint Barthelemy",
28
+ "BM": "Bermuda",
29
+ "BN": "Brunei",
30
+ "BO": "Bolivia",
31
+ "BQ": "Bonaire, Saint Eustatius and Saba ",
32
+ "BR": "Brazil",
33
+ "BS": "Bahamas",
34
+ "BT": "Bhutan",
35
+ "BV": "Bouvet Island",
36
+ "BW": "Botswana",
37
+ "BY": "Belarus",
38
+ "BZ": "Belize",
39
+ "CA": "Canada",
40
+ "CC": "Cocos Islands",
41
+ "CD": "Democratic Republic of the Congo",
42
+ "CF": "Central African Republic",
43
+ "CG": "Republic of the Congo",
44
+ "CH": "Switzerland",
45
+ "CI": "Ivory Coast",
46
+ "CK": "Cook Islands",
47
+ "CL": "Chile",
48
+ "CM": "Cameroon",
49
+ "CN": "China",
50
+ "CO": "Colombia",
51
+ "CR": "Costa Rica",
52
+ "CU": "Cuba",
53
+ "CV": "Cape Verde",
54
+ "CW": "Curacao",
55
+ "CX": "Christmas Island",
56
+ "CY": "Cyprus",
57
+ "CZ": "Czech Republic",
58
+ "DE": "Germany",
59
+ "DJ": "Djibouti",
60
+ "DK": "Denmark",
61
+ "DM": "Dominica",
62
+ "DO": "Dominican Republic",
63
+ "DZ": "Algeria",
64
+ "EC": "Ecuador",
65
+ "EE": "Estonia",
66
+ "EG": "Egypt",
67
+ "EH": "Western Sahara",
68
+ "ER": "Eritrea",
69
+ "ES": "Spain",
70
+ "ET": "Ethiopia",
71
+ "FI": "Finland",
72
+ "FJ": "Fiji",
73
+ "FK": "Falkland Islands",
74
+ "FM": "Micronesia",
75
+ "FO": "Faroe Islands",
76
+ "FR": "France",
77
+ "GA": "Gabon",
78
+ "GB": "United Kingdom",
79
+ "GD": "Grenada",
80
+ "GE": "Georgia",
81
+ "GF": "French Guiana",
82
+ "GG": "Guernsey",
83
+ "GH": "Ghana",
84
+ "GI": "Gibraltar",
85
+ "GL": "Greenland",
86
+ "GM": "Gambia",
87
+ "GN": "Guinea",
88
+ "GP": "Guadeloupe",
89
+ "GQ": "Equatorial Guinea",
90
+ "GR": "Greece",
91
+ "GS": "South Georgia and the South Sandwich Islands",
92
+ "GT": "Guatemala",
93
+ "GU": "Guam",
94
+ "GW": "Guinea-Bissau",
95
+ "GY": "Guyana",
96
+ "HK": "Hong Kong",
97
+ "HM": "Heard Island and McDonald Islands",
98
+ "HN": "Honduras",
99
+ "HR": "Croatia",
100
+ "HT": "Haiti",
101
+ "HU": "Hungary",
102
+ "ID": "Indonesia",
103
+ "IE": "Ireland",
104
+ "IL": "Israel",
105
+ "IM": "Isle of Man",
106
+ "IN": "India",
107
+ "IO": "British Indian Ocean Territory",
108
+ "IQ": "Iraq",
109
+ "IR": "Iran",
110
+ "IS": "Iceland",
111
+ "IT": "Italy",
112
+ "JE": "Jersey",
113
+ "JM": "Jamaica",
114
+ "JO": "Jordan",
115
+ "JP": "Japan",
116
+ "KE": "Kenya",
117
+ "KG": "Kyrgyzstan",
118
+ "KH": "Cambodia",
119
+ "KI": "Kiribati",
120
+ "KM": "Comoros",
121
+ "KN": "Saint Kitts and Nevis",
122
+ "KP": "North Korea",
123
+ "KR": "South Korea",
124
+ "KW": "Kuwait",
125
+ "KY": "Cayman Islands",
126
+ "KZ": "Kazakhstan",
127
+ "LA": "Laos",
128
+ "LB": "Lebanon",
129
+ "LC": "Saint Lucia",
130
+ "LI": "Liechtenstein",
131
+ "LK": "Sri Lanka",
132
+ "LR": "Liberia",
133
+ "LS": "Lesotho",
134
+ "LT": "Lithuania",
135
+ "LU": "Luxembourg",
136
+ "LV": "Latvia",
137
+ "LY": "Libya",
138
+ "MA": "Morocco",
139
+ "MC": "Monaco",
140
+ "MD": "Moldova",
141
+ "ME": "Montenegro",
142
+ "MF": "Saint Martin",
143
+ "MG": "Madagascar",
144
+ "MH": "Marshall Islands",
145
+ "MK": "Macedonia",
146
+ "ML": "Mali",
147
+ "MM": "Myanmar",
148
+ "MN": "Mongolia",
149
+ "MO": "Macao",
150
+ "MP": "Northern Mariana Islands",
151
+ "MQ": "Martinique",
152
+ "MR": "Mauritania",
153
+ "MS": "Montserrat",
154
+ "MT": "Malta",
155
+ "MU": "Mauritius",
156
+ "MV": "Maldives",
157
+ "MW": "Malawi",
158
+ "MX": "Mexico",
159
+ "MY": "Malaysia",
160
+ "MZ": "Mozambique",
161
+ "NA": "Namibia",
162
+ "NC": "New Caledonia",
163
+ "NE": "Niger",
164
+ "NF": "Norfolk Island",
165
+ "NG": "Nigeria",
166
+ "NI": "Nicaragua",
167
+ "NL": "Netherlands",
168
+ "NO": "Norway",
169
+ "NP": "Nepal",
170
+ "NR": "Nauru",
171
+ "NU": "Niue",
172
+ "NZ": "New Zealand",
173
+ "OM": "Oman",
174
+ "PA": "Panama",
175
+ "PE": "Peru",
176
+ "PF": "French Polynesia",
177
+ "PG": "Papua New Guinea",
178
+ "PH": "Philippines",
179
+ "PK": "Pakistan",
180
+ "PL": "Poland",
181
+ "PM": "Saint Pierre and Miquelon",
182
+ "PN": "Pitcairn",
183
+ "PR": "Puerto Rico",
184
+ "PS": "Palestinian Territory",
185
+ "PT": "Portugal",
186
+ "PW": "Palau",
187
+ "PY": "Paraguay",
188
+ "QA": "Qatar",
189
+ "RE": "Reunion",
190
+ "RO": "Romania",
191
+ "RS": "Serbia",
192
+ "RU": "Russia",
193
+ "RW": "Rwanda",
194
+ "SA": "Saudi Arabia",
195
+ "SB": "Solomon Islands",
196
+ "SC": "Seychelles",
197
+ "SD": "Sudan",
198
+ "SE": "Sweden",
199
+ "SG": "Singapore",
200
+ "SH": "Saint Helena",
201
+ "SI": "Slovenia",
202
+ "SJ": "Svalbard and Jan Mayen",
203
+ "SK": "Slovakia",
204
+ "SL": "Sierra Leone",
205
+ "SM": "San Marino",
206
+ "SN": "Senegal",
207
+ "SO": "Somalia",
208
+ "SR": "Suriname",
209
+ "SS": "South Sudan",
210
+ "ST": "Sao Tome and Principe",
211
+ "SV": "El Salvador",
212
+ "SX": "Sint Maarten",
213
+ "SY": "Syria",
214
+ "SZ": "Swaziland",
215
+ "TC": "Turks and Caicos Islands",
216
+ "TD": "Chad",
217
+ "TF": "French Southern Territories",
218
+ "TG": "Togo",
219
+ "TH": "Thailand",
220
+ "TJ": "Tajikistan",
221
+ "TK": "Tokelau",
222
+ "TL": "East Timor",
223
+ "TM": "Turkmenistan",
224
+ "TN": "Tunisia",
225
+ "TO": "Tonga",
226
+ "TR": "Turkey",
227
+ "TT": "Trinidad and Tobago",
228
+ "TV": "Tuvalu",
229
+ "TW": "Taiwan",
230
+ "TZ": "Tanzania",
231
+ "UA": "Ukraine",
232
+ "UG": "Uganda",
233
+ "UM": "United States Minor Outlying Islands",
234
+ "US": "United States",
235
+ "UY": "Uruguay",
236
+ "UZ": "Uzbekistan",
237
+ "VA": "Vatican",
238
+ "VC": "Saint Vincent and the Grenadines",
239
+ "VE": "Venezuela",
240
+ "VG": "British Virgin Islands",
241
+ "VI": "U.S. Virgin Islands",
242
+ "VN": "Vietnam",
243
+ "VU": "Vanuatu",
244
+ "WF": "Wallis and Futuna",
245
+ "WS": "Samoa",
246
+ "XK": "Kosovo",
247
+ "YE": "Yemen",
248
+ "YT": "Mayotte",
249
+ "ZA": "South Africa",
250
+ "ZM": "Zambia",
251
+ "ZW": "Zimbabwe"
252
+ }
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: xero_exporter
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Adam Cooke
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-01-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: json
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ description: A library for exporting financial data to the Xero API.
28
+ email:
29
+ - adam@krystal.uk
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - VERSION
35
+ - lib/xero_exporter.rb
36
+ - lib/xero_exporter/api.rb
37
+ - lib/xero_exporter/country.rb
38
+ - lib/xero_exporter/error.rb
39
+ - lib/xero_exporter/executor.rb
40
+ - lib/xero_exporter/export.rb
41
+ - lib/xero_exporter/fee.rb
42
+ - lib/xero_exporter/invoice.rb
43
+ - lib/xero_exporter/invoice_line.rb
44
+ - lib/xero_exporter/logger.rb
45
+ - lib/xero_exporter/payment.rb
46
+ - lib/xero_exporter/proposal.rb
47
+ - lib/xero_exporter/refund.rb
48
+ - lib/xero_exporter/tax_rate.rb
49
+ - lib/xero_exporter/version.rb
50
+ - resource/country-codes.json
51
+ homepage: https://github.com/krystal/xero_exporter
52
+ licenses: []
53
+ metadata: {}
54
+ post_install_message:
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '2.5'
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 3.2.21
70
+ signing_key:
71
+ specification_version: 4
72
+ summary: A library for exporting financial data to the Xero API.
73
+ test_files: []