xero_exporter 1.3.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 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: []