lucabook 0.2.20 → 0.2.25

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,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'luca_book'
5
+ require 'luca_support'
6
+
7
+ module LucaBook
8
+ class Import
9
+ # TODO: need to be separated into pluggable l10n module.
10
+ # TODO: gensen rate >1m yen.
11
+ # TODO: gensen & consumption `round()` rules need to be confirmed.
12
+ # Profit or Loss account should be specified as code1.
13
+ #
14
+ def tax_extension(code1, code2, amount, options)
15
+ return nil if options.nil? || options[:tax_options].nil?
16
+ return nil if !options[:tax_options].include?('jp-gensen') && !options[:tax_options].include?('jp-consumption')
17
+
18
+ gensen_rate = BigDecimal('0.1021')
19
+ consumption_rate = BigDecimal('0.1')
20
+ gensen_code = @code_map.dig(options[:gensen_label]) || @code_map.dig('預り金')
21
+ gensen_idx = /^[5-8B-G]/.match(code1) ? 1 : 0
22
+ consumption_idx = /^[A-G]/.match(code1) ? 0 : 1
23
+ consumption_code = @code_map.dig(options[:consumption_label])
24
+ consumption_code ||= /^[A]/.match(code1) ? @code_map.dig('仮受消費税等') : @code_map.dig('仮払消費税等')
25
+ if options[:tax_options].include?('jp-gensen') && options[:tax_options].include?('jp-consumption')
26
+ paid_rate = BigDecimal('1') + consumption_rate - gensen_rate
27
+ gensen_amount = (amount / paid_rate * gensen_rate).round
28
+ consumption_amount = (amount / paid_rate * consumption_rate).round
29
+ [].tap do |res|
30
+ res << [].tap do |res1|
31
+ amount1 = amount
32
+ amount1 -= consumption_amount if consumption_idx == 0
33
+ amount1 += gensen_amount if gensen_idx == 1
34
+ res1 << { 'code' => code1, 'amount' => amount1 }
35
+ res1 << { 'code' => consumption_code, 'amount' => consumption_amount } if consumption_idx == 0
36
+ res1 << { 'code' => gensen_code, 'amount' => gensen_amount } if gensen_idx == 0
37
+ end
38
+ res << [].tap do |res2|
39
+ amount2 = amount
40
+ amount2 -= consumption_amount if consumption_idx == 1
41
+ amount2 += gensen_amount if gensen_idx == 0
42
+ res2 << { 'code' => code2, 'amount' => amount2 }
43
+ res2 << { 'code' => consumption_code, 'amount' => consumption_amount } if consumption_idx == 1
44
+ res2 << { 'code' => gensen_code, 'amount' => gensen_amount } if gensen_idx == 1
45
+ end
46
+ end
47
+ elsif options[:tax_options].include?('jp-gensen')
48
+ paid_rate = BigDecimal('1') - gensen_rate
49
+ gensen_amount = (amount / paid_rate * gensen_rate).round
50
+ [].tap do |res|
51
+ res << [].tap do |res1|
52
+ amount1 = amount
53
+ amount1 += gensen_amount if gensen_idx == 1
54
+ res1 << { 'code' => code, 'amount' => amount1 }
55
+ res1 << { 'code' => gensen_code, 'amount' => gensen_amount } if gensen_idx == 0
56
+ end
57
+ res << [].tap do |res2|
58
+ amount2 = amount
59
+ amount2 += gensen_amount if gensen_idx == 0
60
+ mount2 ||= amount
61
+ res2 << { 'code' => code2, 'amount' => amount2 }
62
+ res2 << { 'code' => gensen_code, 'amount' => gensen_amount } if gensen_idx == 1
63
+ end
64
+ end
65
+ elsif options[:tax_options].include?('jp-consumption')
66
+ paid_rate = BigDecimal('1') + consumption_rate - gensen_rate
67
+ consumption_amount = (amount / paid_rate * consumption_rate).round
68
+ res << [].tap do |res1|
69
+ amount1 = amount
70
+ amount1 -= consumption_amount if consumption_idx == 0
71
+ res1 << { 'code' => code1, 'amount' => amount1 }
72
+ res1 << { 'code' => consumption_code, 'amount' => consumption_amount } if consumption_idx == 0
73
+ end
74
+ res << [].tap do |res2|
75
+ amount2 = amount
76
+ amount2 -= consumption_amount if consumption_idx == 1
77
+ res2 << { 'code' => code2, 'amount' => amount2 }
78
+ res2 << { 'code' => consumption_code, 'amount' => consumption_amount } if consumption_idx == 1
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -1,43 +1,90 @@
1
- #
2
- # manipulate files based on transaction date
3
- #
1
+ # frozen_string_literal: true
4
2
 
5
3
  require 'csv'
6
4
  require 'date'
7
5
  require 'luca_record'
8
6
 
9
- module LucaBook
7
+ module LucaBook #:nodoc:
8
+ # Journal has several annotations on headers:
9
+ #
10
+ # x-customer::
11
+ # Identifying customer.
12
+ # x-editor::
13
+ # Application name editing the journal.
14
+ # x-tax::
15
+ # For tracking tax related transaction.
16
+ #
10
17
  class Journal < LucaRecord::Base
18
+ ACCEPTED_HEADERS = ['x-customer', 'x-editor', 'x-tax']
11
19
  @dirname = 'journals'
12
20
 
13
21
  # create journal from hash
14
22
  #
15
- def self.create(d)
23
+ def self.create(dat)
24
+ d = LucaSupport::Code.keys_stringify(dat)
25
+ validate(d)
26
+ raise 'NoDateKey' unless d.key?('date')
27
+
16
28
  date = Date.parse(d['date'])
17
29
 
18
- debit_amount = LucaSupport::Code.decimalize(serialize_on_key(d['debit'], 'value'))
19
- credit_amount = LucaSupport::Code.decimalize(serialize_on_key(d['credit'], 'value'))
30
+ # TODO: need to sync filename & content. Limit code length for filename
31
+ # codes = (debit_code + credit_code).uniq
32
+ codes = nil
33
+
34
+ create_record(nil, date, codes) { |f| f.write journal2csv(d) }
35
+ end
36
+
37
+ # update journal with hash.
38
+ # If record not found with id, no record will be created.
39
+ #
40
+ def self.save(dat)
41
+ d = LucaSupport::Code.keys_stringify(dat)
42
+ raise 'record has no id.' if d['id'].nil?
43
+
44
+ validate(d)
45
+ parts = d['id'].split('/')
46
+ raise 'invalid ID' if parts.length != 2
47
+
48
+ codes = nil
49
+ open_records(@dirname, parts[0], parts[1], codes, 'w') { |f, _path| f.write journal2csv(d) }
50
+ end
51
+
52
+ # Convert journal object to TSV format.
53
+ #
54
+ def self.journal2csv(d)
55
+ debit_amount = LucaSupport::Code.decimalize(serialize_on_key(d['debit'], 'amount'))
56
+ credit_amount = LucaSupport::Code.decimalize(serialize_on_key(d['credit'], 'amount'))
20
57
  raise 'BalanceUnmatch' if debit_amount.inject(:+) != credit_amount.inject(:+)
21
58
 
22
59
  debit_code = serialize_on_key(d['debit'], 'code')
23
60
  credit_code = serialize_on_key(d['credit'], 'code')
24
61
 
25
- # TODO: need to sync filename & content. Limit code length for filename
26
- # codes = (debit_code + credit_code).uniq
27
- codes = nil
28
- create_record!(date, codes) do |f|
62
+ csv = CSV.generate(String.new, col_sep: "\t", headers: false) do |f|
29
63
  f << debit_code
30
64
  f << LucaSupport::Code.readable(debit_amount)
31
65
  f << credit_code
32
66
  f << LucaSupport::Code.readable(credit_amount)
33
- ['x-customer', 'x-editor'].each do |x_header|
34
- f << [x_header, d[x_header]] if d.dig(x_header)
67
+ ACCEPTED_HEADERS.each do |x_header|
68
+ f << [x_header, d['headers'][x_header]] if d.dig('headers', x_header)
35
69
  end
36
70
  f << []
37
71
  f << [d.dig('note')]
38
72
  end
39
73
  end
40
74
 
75
+ # Set accepted header with key/value, update record if exists.
76
+ #
77
+ def self.add_header(journal_hash, key, val)
78
+ return journal_hash if val.nil?
79
+ return journal_hash unless ACCEPTED_HEADERS.include?(key)
80
+
81
+ journal_hash.tap do |o|
82
+ o[:headers] = {} unless o.dig(:headers)
83
+ o[:headers][key] = val
84
+ save o if o[:id]
85
+ end
86
+ end
87
+
41
88
  def self.update_codes(obj)
42
89
  debit_code = serialize_on_key(obj[:debit], :code)
43
90
  credit_code = serialize_on_key(obj[:credit], :code)
@@ -45,11 +92,19 @@ module LucaBook
45
92
  change_codes(obj[:id], codes)
46
93
  end
47
94
 
48
- # define new transaction ID & write data at once
49
- def self.create_record!(date_obj, codes = nil)
50
- create_record(nil, date_obj, codes) do |f|
51
- f.write CSV.generate('', col_sep: "\t", headers: false) { |c| yield(c) }
52
- end
95
+ def self.validate(obj)
96
+ raise 'NoDebitKey' unless obj.key?('debit')
97
+ raise 'NoCreditKey' unless obj.key?('credit')
98
+ debit_codes = serialize_on_key(obj['debit'], 'code').compact
99
+ debit_amount = serialize_on_key(obj['debit'], 'amount').compact
100
+ raise 'NoDebitCode' if debit_codes.empty?
101
+ raise 'NoDebitAmount' if debit_amount.empty?
102
+ raise 'UnmatchDebit' if debit_codes.length != debit_amount.length
103
+ credit_codes = serialize_on_key(obj['credit'], 'code').compact
104
+ credit_amount = serialize_on_key(obj['credit'], 'amount').compact
105
+ raise 'NoCreditCode' if credit_codes.empty?
106
+ raise 'NoCreditAmount' if credit_amount.empty?
107
+ raise 'UnmatchCredit' if credit_codes.length != credit_amount.length
53
108
  end
54
109
 
55
110
  # collect values on specified key
@@ -58,7 +113,21 @@ module LucaBook
58
113
  array_of_hash.map { |h| h[key] }
59
114
  end
60
115
 
61
- # override de-serializing journal format
116
+ # override de-serializing journal format. Sample format is:
117
+ #
118
+ # {
119
+ # id: '2021A/V001',
120
+ # headers: {
121
+ # 'x-customer' => 'Some Customer Co.'
122
+ # },
123
+ # debit: [
124
+ # { code: 'A12', amount: 1000 }
125
+ # ],
126
+ # credit: [
127
+ # { code: '311', amount: 1000 }
128
+ # ],
129
+ # note: 'note for each journal'
130
+ # }
62
131
  #
63
132
  def self.load_data(io, path)
64
133
  {}.tap do |record|
@@ -76,10 +145,16 @@ module LucaBook
76
145
  when 3
77
146
  line.each_with_index { |amount, j| record[:credit][j][:amount] = BigDecimal(amount.to_s) }
78
147
  else
79
- if body == false && line.empty?
80
- record[:note] ||= []
81
- body = true
82
- else
148
+ case body
149
+ when false
150
+ if line.empty?
151
+ record[:note] ||= []
152
+ body = true
153
+ else
154
+ record[:headers] ||= {}
155
+ record[:headers][line[0]] = line[1]
156
+ end
157
+ when true
83
158
  record[:note] << line.join(' ') if body
84
159
  end
85
160
  end
@@ -7,11 +7,12 @@ require 'luca_record'
7
7
  require 'luca_record/dict'
8
8
  require 'luca_book'
9
9
 
10
- # Journal List on specified term
11
- #
12
- module LucaBook
10
+ module LucaBook #:nodoc:
11
+ # Journal List on specified term
12
+ #
13
13
  class List < LucaBook::Journal
14
14
  @dirname = 'journals'
15
+ attr_reader :data
15
16
 
16
17
  def initialize(data, start_date, code = nil)
17
18
  @data = data
@@ -31,7 +32,17 @@ module LucaBook
31
32
  new data, Date.new(from_year.to_i, from_month.to_i, 1), code
32
33
  end
33
34
 
34
- def list_on_code
35
+ def self.add_header(from_year, from_month, to_year = from_year, to_month = from_month, code: nil, header_key: nil, header_val: nil)
36
+ return nil if code.nil?
37
+ return nil unless Journal::ACCEPTED_HEADERS.include?(header_key)
38
+
39
+ term(from_year, from_month, to_year, to_month, code: code)
40
+ .data.each do |journal|
41
+ Journal.add_header(journal, header_key, header_val)
42
+ end
43
+ end
44
+
45
+ def list_by_code
35
46
  calc_code
36
47
  convert_label
37
48
  @data = [code_header] + @data.map do |dat|
@@ -47,7 +58,7 @@ module LucaBook
47
58
  res['note'] = dat[:note]
48
59
  end
49
60
  end
50
- self
61
+ readable(@data)
51
62
  end
52
63
 
53
64
  def list_journals
@@ -59,13 +70,13 @@ module LucaBook
59
70
  res['no'] = txid
60
71
  res['id'] = dat[:id]
61
72
  res['debit_code'] = dat[:debit].length == 1 ? dat[:debit][0][:code] : dat[:debit].map { |d| d[:code] }
62
- res['debit_amount'] = dat[:debit].inject(0) { |sum, d| sum + d[:amount] }
73
+ res['debit_amount'] = dat[:debit].inject(0) { |sum, d| sum + d[:amount] }
63
74
  res['credit_code'] = dat[:credit].length == 1 ? dat[:credit][0][:code] : dat[:credit].map { |d| d[:code] }
64
75
  res['credit_amount'] = dat[:credit].inject(0) { |sum, d| sum + d[:amount] }
65
76
  res['note'] = dat[:note]
66
77
  end
67
78
  end
68
- self
79
+ readable(@data)
69
80
  end
70
81
 
71
82
  def accumulate_code
@@ -74,10 +85,6 @@ module LucaBook
74
85
  end
75
86
  end
76
87
 
77
- def to_yaml
78
- YAML.dump(LucaSupport::Code.readable(@data)).tap { |data| puts data }
79
- end
80
-
81
88
  private
82
89
 
83
90
  def set_balance
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'date'
5
+ require 'luca_support'
6
+ require 'luca_record'
7
+ require 'luca_record/dict'
8
+ require 'luca_book'
9
+
10
+ module LucaBook #:nodoc:
11
+ # Journal List on specified term
12
+ #
13
+ class ListByHeader < LucaBook::Journal
14
+ @dirname = 'journals'
15
+
16
+ def initialize(data, start_date, code = nil, header_name = nil)
17
+ @data = data
18
+ @code = code
19
+ @header = header_name
20
+ @start = start_date
21
+ @dict = LucaRecord::Dict.load('base.tsv')
22
+ end
23
+
24
+ def self.term(from_year, from_month, to_year = from_year, to_month = from_month, code: nil, header: nil, basedir: @dirname)
25
+ data = Journal.term(from_year, from_month, to_year, to_month, code).select do |dat|
26
+ if code.nil?
27
+ true
28
+ else
29
+ [:debit, :credit].map { |key| serialize_on_key(dat[key], :code) }.flatten.include?(code)
30
+ end
31
+ end
32
+ new data, Date.new(from_year.to_i, from_month.to_i, 1), code, header
33
+ end
34
+
35
+ def list_by_code
36
+ calc_code
37
+ convert_label
38
+ @data = @data.each_with_object([]) do |(k, v), a|
39
+ journals = v.map do |dat|
40
+ date, txid = decode_id(dat[:id])
41
+ {}.tap do |res|
42
+ res['header'] = k
43
+ res['date'] = date
44
+ res['no'] = txid
45
+ res['id'] = dat[:id]
46
+ res['diff'] = dat[:diff]
47
+ res['balance'] = dat[:balance]
48
+ res['counter_code'] = dat[:counter_code].length == 1 ? dat[:counter_code].first : dat[:counter_code]
49
+ res['note'] = dat[:note]
50
+ end
51
+ end
52
+ a << { 'code' => v.last[:code], 'header' => k, 'balance' => v.last[:balance], 'count' => v.count, 'jounals' => journals }
53
+ end
54
+ readable(@data)
55
+ end
56
+
57
+ def accumulate_code
58
+ @data.each_with_object({}) do |dat, sum|
59
+ idx = dat.dig(:headers, @header) || 'others'
60
+ sum[idx] ||= BigDecimal('0')
61
+ sum[idx] += Util.diff_by_code(dat[:debit], @code) - Util.diff_by_code(dat[:credit], @code)
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def set_balance
68
+ return BigDecimal('0') if @code.nil? || /^[A-H]/.match(@code)
69
+
70
+ balance_dict = Dict.latest_balance
71
+ start_balance = BigDecimal(balance_dict.dig(@code.to_s, :balance) || '0')
72
+ start = Dict.issue_date(balance_dict)&.next_month
73
+ last = @start.prev_month
74
+ if last.year >= start.year && last.month >= start.month
75
+ #TODO: start_balance to be implemented by header
76
+ self.class.term(start.year, start.month, last.year, last.month, code: @code).accumulate_code
77
+ else
78
+ #start_balance
79
+ end
80
+ end
81
+
82
+ def calc_code
83
+ raise 'no account code specified' if @code.nil?
84
+
85
+ @balance = set_balance
86
+ balance = @balance
87
+ res = {}
88
+ @data.each do |dat|
89
+ idx = dat.dig(:headers, @header) || 'others'
90
+ balance[idx] ||= BigDecimal('0')
91
+ res[idx] ||= []
92
+ {}.tap do |h|
93
+ h[:id] = dat[:id]
94
+ h[:diff] = Util.diff_by_code(dat[:debit], @code) - Util.diff_by_code(dat[:credit], @code)
95
+ balance[idx] += h[:diff]
96
+ h[:balance] = balance[idx]
97
+ h[:code] = @code
98
+ counter = h[:diff] * Util.pn_debit(@code) > 0 ? :credit : :debit
99
+ h[:counter_code] = dat[counter].map { |d| d[:code] }
100
+ h[:note] = dat[:note]
101
+ res[idx] << h
102
+ end
103
+ end
104
+ @data = res
105
+ self
106
+ end
107
+
108
+ def convert_label
109
+ @data.each do |_k, v|
110
+ v.each do |dat|
111
+ raise 'no account code specified' if @code.nil?
112
+
113
+ dat[:code] = "#{dat[:code]} #{@dict.dig(dat[:code], :label)}"
114
+ dat[:counter_code] = dat[:counter_code].map { |counter| "#{counter} #{@dict.dig(counter, :label)}" }
115
+ end
116
+ end
117
+ self
118
+ end
119
+ end
120
+ end
@@ -6,7 +6,7 @@ require 'fileutils'
6
6
  module LucaBook
7
7
  class Setup
8
8
  # create project skeleton under specified directory
9
- def self.create_project(country = nil, dir = LucaSupport::Config::Pjdir)
9
+ def self.create_project(country = nil, dir = LucaSupport::PJDIR)
10
10
  FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
11
11
  Dir.chdir(dir) do
12
12
  %w[data/journals data/balance dict].each do |subdir|
@@ -18,6 +18,7 @@ module LucaBook
18
18
  'dict-en.tsv'
19
19
  end
20
20
  FileUtils.cp("#{__dir__}/templates/#{dict}", 'dict/base.tsv') unless File.exist?('dict/base.tsv')
21
+ FileUtils.cp("#{__dir__}/templates/config.yml", 'config.yml') unless File.exist?('config.yml')
21
22
  prepare_starttsv(dict) unless File.exist? 'data/balance/start.tsv'
22
23
  end
23
24
  end