lucadeal 0.2.23 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f3d23a1f082ee44833db42f3cf4dbdecc0d078ea3ca6499d5cf77e6d235580a
4
- data.tar.gz: c2ea468ea064bb865d0b481d9717a03f11afe212f0bfd3b58ac43722116a95e7
3
+ metadata.gz: 060bcea890091dbe70e03718d1a8871bccf4873db86922d505e0e2c3ffefd94a
4
+ data.tar.gz: 3d202e5711a7e1d28cadb5a4debbda18900b7ab7ea4d6e5e413d52980c1cb84f
5
5
  SHA512:
6
- metadata.gz: 545d6acb5b7837497c30d17a42c6a32611186f2e12f9821d116c9aa3ed9735dc1c8bc2c072d1439c9e21e1fd38d7398de770ffd9fd835352f4565f96a18a19ca
7
- data.tar.gz: c8cf01067781332a241138ab9b253d282902a3838a19d2f9623d9eb29c6c4a3432e7d0e6818bc67a3e3b3e682e5ba3c4c5c128a90f93b3f91dcadca0285ea2f4
6
+ metadata.gz: 7a61af30d3b434edf10ab8553b3f878646354dd6e838a93405483b476ab3d8456713a745c0eb2a99eefdbc233ed662a87d4645c7a94b375924e2d8f347208c00
7
+ data.tar.gz: ca88eff9ed7c7c8dd815089ccdb34bd8dcd0813ae58dbb3b026db4833373a4cb33cb2b91eea27fce67b599a9a43b6e27796ed14ef1c2da6c60cf7308e389ea7f
data/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ ## LucaDeal 0.3.0
2
+
3
+ * implement `luca-deal reports balance` for unsettled balance by customer
4
+ * implement `luca-deal invoices settle` for import payment data from LucaBook
5
+
6
+ ## LucaDeal 0.2.25
7
+
8
+ * implement deduction rate for fee calculation.
9
+ * implement `luca-deal fee export`
10
+ * refine export label for luca-book compatibility
11
+ * add `luca-deal invoice create --monthly --with-fee` option.
12
+ * preview_mail can deliver regardless of `mail_delivered` status
13
+ * `luca-deal fee mail` skip no item record by default.
14
+
15
+ ## LucaDeal 0.2.24
16
+
17
+ * add `luca-deal invoices create --monthly --mail`, send payment list after monthly invoice creation.
18
+ * add 'other_payments' tracking with no invoices.
19
+ * can have limit on fee calculation.
20
+ * initial implment of `luca-deal fee list`
21
+
1
22
  ## LucaDeal 0.2.23
2
23
 
3
24
  * implement `luca-deal invoices list --mail`: payment list via HTML mail
data/README.md CHANGED
@@ -135,7 +135,7 @@ Fields for subscription customers are as bellows:
135
135
  | Top level | Second level | | historical | Description |
136
136
  |-----------|---------------|----------|------------|------------------------------------------------------------------------------------------------------|
137
137
  | terms | | | | |
138
- | | billing_cycle | optional | | If 'monthly', invoices are generated on each month. |
138
+ | | billing_cycle | optional | | If 'monthly', invoices are generated on each month. If 'other_payments', no_invoices are generated on each month. `no_invoices` are mostly same as invoices, but not sending email. |
139
139
  | | category | optional | | Default: 'subscription' |
140
140
  | products | | | | Array of products. |
141
141
  | | id | | | reference for Product |
@@ -154,9 +154,12 @@ Fields for sales fee are as bellows:
154
154
  |-----------|--------------|----------|------------|-------------------------------------------------------------------------------------|
155
155
  | terms | | | | |
156
156
  | | category | | | If 'sales_fee', contract is treated as selling commission. |
157
+ | | limit | | | If set, fees are calculated as mas as `limit` months. |
158
+ | | deduction_label | | | Label for deduction. Used on export |
157
159
  | rate | | optional | | |
158
160
  | | default | | | sales fee rate. |
159
161
  | | initial | | | sales fee rate for items of type=initial. |
162
+ | | deduction | | | deduction rate(if any) multiplied by fee |
160
163
 
161
164
 
162
165
  ### Invoice
@@ -164,7 +167,7 @@ Fields for sales fee are as bellows:
164
167
  Invoice is basically auto generated from Customer and Contract objects.
165
168
 
166
169
  | Top level | Second level | Description |
167
- |------------|--------------|------------------------------------------|
170
+ |------------+--------------+------------------------------------------|
168
171
  | id | | uuid |
169
172
  | issue_date | | |
170
173
  | due_date | | |
@@ -180,6 +183,10 @@ Invoice is basically auto generated from Customer and Contract objects.
180
183
  | | qty | quantity. Default: 1. |
181
184
  | | type | |
182
185
  | | product_id | refrence for Product |
186
+ | settled | | |
187
+ | | id | data source id for duplication check |
188
+ | | date | payment date |
189
+ | | amount | payment amount |
183
190
  | subtotal | | Array of subtotal by tax category. |
184
191
  | | items | amount of items |
185
192
  | | tax | amount of tax |
data/exe/luca-deal CHANGED
@@ -124,10 +124,15 @@ class LucaCmd
124
124
 
125
125
  class Invoice < LucaCmd
126
126
  def self.create(args = nil, params = {})
127
- case params['mode']
127
+ case params[:mode]
128
128
  when 'monthly'
129
129
  date = "#{args[0]}-#{args[1]}-#{args[2] || '1'}" if !args.empty?
130
130
  LucaDeal::Invoice.new(date).monthly_invoice
131
+ LucaDeal::NoInvoice.new(date).monthly_invoice
132
+ LucaDeal::Fee.new(date).monthly_fee if params[:fee]
133
+ if params[:mail]
134
+ LucaDeal::Invoice.new(date).stats_email
135
+ end
131
136
  else
132
137
  date = "#{args[1]}-#{args[2]}-#{args[3] || '1'}" if !args.empty?
133
138
  list = LucaDeal::Contract.id_completion(args[0] || '', label: 'customer_name')
@@ -181,6 +186,11 @@ class LucaCmd
181
186
  end
182
187
  end
183
188
 
189
+ def self.report(args = nil, params = {})
190
+ date = Date.new(args[0].to_i, args[1].to_i, 1)
191
+ render(LucaDeal::Invoice.report(date, detail: params[:detail], due: params[:due]), params)
192
+ end
193
+
184
194
  def self.mail(args = nil, params = {})
185
195
  date = "#{args[0]}-#{args[1]}-#{args[2] || '1'}" if !args.empty?
186
196
  case params['mode']
@@ -190,11 +200,16 @@ class LucaCmd
190
200
  LucaDeal::Invoice.new(date).deliver_mail
191
201
  end
192
202
  end
203
+
204
+ def self.settle(args = nil, _params = nil)
205
+ str = args[0].nil? ? STDIN.read : File.read(args[0])
206
+ LucaDeal::Invoice.settle(str)
207
+ end
193
208
  end
194
209
 
195
210
  class Fee < LucaCmd
196
211
  def self.create(args = nil, params = {})
197
- case params['mode']
212
+ case params[:mode]
198
213
  when 'monthly'
199
214
  date = "#{args[0]}-#{args[1]}-#{args[2] || '1'}" if !args.empty?
200
215
  LucaDeal::Fee.new(date).monthly_fee
@@ -213,6 +228,15 @@ class LucaCmd
213
228
  end
214
229
  end
215
230
 
231
+ def self.export(args = nil, _params = nil)
232
+ if args
233
+ args << 28 if args.length == 2 # specify safe last day
234
+ LucaDeal::Fee.new(args.join('-')).export_json
235
+ else
236
+ LucaDeal::Fee.new.export_json
237
+ end
238
+ end
239
+
216
240
  def self.list(args = nil, params = {})
217
241
  date = "#{args[0]}-#{args[1]}-#{args[2] || '1'}" if !args.empty?
218
242
  if args.empty?
@@ -243,6 +267,12 @@ class LucaCmd
243
267
  puts JSON.dump(dat)
244
268
  when 'nu'
245
269
  LucaSupport::View.nushell(YAML.dump(dat))
270
+ when 'csv'
271
+ str = CSV.generate(String.new, col_sep: "\t") do |row|
272
+ row << dat.first.keys
273
+ dat.each { |d| row << d.values }
274
+ end
275
+ puts str
246
276
  else
247
277
  puts YAML.dump(dat)
248
278
  end
@@ -334,7 +364,9 @@ when /invoices?/, 'i'
334
364
  when 'create'
335
365
  OptionParser.new do |opt|
336
366
  opt.banner = 'Usage: luca-deal invoices create [options] --monthly|contract_id year month [date]'
337
- opt.on('--monthly', 'generate monthly data') { |_v| params['mode'] = 'monthly' }
367
+ opt.on('--mail', 'send payment list by email. Only works with --monthly') { |_v| params[:mail] = true }
368
+ opt.on('--monthly', 'generate monthly data') { |_v| params[:mode] = 'monthly' }
369
+ opt.on('--with-fee', 'generate sales fee data after monthly invoice creation') { |_v| params[:fee] = true }
338
370
  args = opt.parse(ARGV)
339
371
  LucaCmd::Invoice.create(args, params)
340
372
  end
@@ -357,6 +389,12 @@ when /invoices?/, 'i'
357
389
  args = opt.parse(ARGV)
358
390
  LucaCmd::Invoice.mail(args, params)
359
391
  end
392
+ when 'settle'
393
+ OptionParser.new do |opt|
394
+ opt.banner = 'Usage: luca-deal invoices settle [filepath]'
395
+ args = opt.parse(ARGV)
396
+ end
397
+ LucaCmd::Invoice.settle(ARGV)
360
398
  else
361
399
  puts 'Proper subcommand needed.'
362
400
  puts
@@ -365,6 +403,29 @@ when /invoices?/, 'i'
365
403
  puts ' delete'
366
404
  puts ' list'
367
405
  puts ' mail: send mail with invoice'
406
+ puts ' settle'
407
+ exit 1
408
+ end
409
+ when /reports?/, 'r'
410
+ subcmd = ARGV.shift
411
+ case subcmd
412
+ when 'balance'
413
+ params[:detail] = false
414
+ params[:due] = false
415
+ OptionParser.new do |opt|
416
+ opt.banner = 'Usage: luca-deal r[eports] balance [options] [year month]'
417
+ opt.on('--nu', 'show table in nushell') { |_v| params[:output] = 'nu' }
418
+ opt.on('-o', '--output VAL', 'output serialized data') { |v| params[:output] = v }
419
+ opt.on('--detail', 'show detail info') { |_v| params[:detail] = true }
420
+ opt.on('--force-due', 'respect due date over actual payment') { |_v| params[:due] = true }
421
+ args = opt.parse(ARGV)
422
+ LucaCmd::Invoice.report(args, params)
423
+ end
424
+ else
425
+ puts 'Proper subcommand needed.'
426
+ puts
427
+ puts 'Usage: luca-deal r[eports] subcommand [--help|options]'
428
+ puts ' balance'
368
429
  exit 1
369
430
  end
370
431
  when 'new'
@@ -380,12 +441,14 @@ when /fee/
380
441
  when 'create'
381
442
  OptionParser.new do |opt|
382
443
  opt.banner = 'Usage: luca-deal fee create [options] year month [date]'
383
- opt.on('--monthly', 'generate monthly data') { |_v| params['mode'] = 'monthly' }
444
+ opt.on('--monthly', 'generate monthly data') { |_v| params[:mode] = 'monthly' }
384
445
  args = opt.parse(ARGV)
385
446
  LucaCmd::Fee.create(args, params)
386
447
  end
387
448
  when 'delete'
388
449
  LucaCmd::Fee.delete(ARGV)
450
+ when 'export'
451
+ LucaCmd::Fee.export(ARGV)
389
452
  when 'list'
390
453
  OptionParser.new do |opt|
391
454
  opt.banner = 'Usage: luca-deal fee list [options] year month [date]'
@@ -418,7 +481,9 @@ else
418
481
  puts 'Usage: luca-deal subcommand [options]'
419
482
  puts ' customers'
420
483
  puts ' contracts'
421
- puts ' invoices'
484
+ puts ' i[nvoices]'
485
+ puts ' fee'
486
+ puts ' r[eports]'
422
487
  puts ' new: initialize project dir'
423
488
  puts ' export: puts invoice data for LucaBook import'
424
489
  exit 1
@@ -69,11 +69,11 @@ module LucaDeal
69
69
  end
70
70
 
71
71
  def active_period?(dat)
72
- unless dat.dig('defunct').nil?
73
- defunct = dat.dig('defunct').respond_to?(:year) ? dat.dig('defunct') : Date.parse(dat.dig('defunct'))
72
+ unless dat['defunct'].nil?
73
+ defunct = dat['defunct'].respond_to?(:year) ? dat['defunct'] : Date.parse(dat['defunct'])
74
74
  return false if @date > defunct
75
75
  end
76
- effective = dat.dig('effective').respond_to?(:year) ? dat.dig('effective') : Date.parse(dat.dig('effective'))
76
+ effective = dat['effective'].respond_to?(:year) ? dat['effective'] : Date.parse(dat['effective'])
77
77
  @date >= effective
78
78
  end
79
79
 
data/lib/luca_deal/fee.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  require 'luca_deal/version'
2
2
 
3
3
  require 'mail'
4
+ require 'json'
4
5
  require 'yaml'
5
6
  require 'pathname'
6
7
  require 'bigdecimal'
@@ -25,41 +26,58 @@ module LucaDeal
25
26
 
26
27
  @rate = { 'default' => BigDecimal(contract.dig('rate', 'default')) }
27
28
  @rate['initial'] = contract.dig('rate', 'initial') ? BigDecimal(contract.dig('rate', 'initial')) : @rate['default']
29
+ limit = contract.dig('terms', 'limit')
28
30
 
29
- fee = { 'contract_id' => contract['id'], 'items' => [] }
31
+ fee = {
32
+ 'contract_id' => contract['id'],
33
+ 'items' => [],
34
+ 'sales_fee' => {
35
+ 'fee' => 0,
36
+ 'tax' => 0,
37
+ 'deduction' => 0,
38
+ 'deduction_label' => contract.dig('terms', 'deduction_label')
39
+ }
40
+ }
30
41
  fee['customer'] = get_customer(contract['customer_id'])
31
42
  fee['issue_date'] = @date
32
43
  Invoice.asof(@date.year, @date.month) do |invoice|
33
44
  next if invoice.dig('sales_fee', 'id') != contract['id']
45
+ next if exceed_limit?(invoice, limit)
34
46
 
35
47
  invoice['items'].each do |item|
36
48
  rate = item['type'] == 'initial' ? @rate['initial'] : @rate['default']
37
- fee['items'] << {
38
- 'invoice_id' => invoice['id'],
39
- 'customer_name' => invoice.dig('customer', 'name'),
40
- 'name' => item['name'],
41
- 'price' => item['price'],
42
- 'qty' => item['qty'],
43
- 'fee' => item['price'] * item['qty'] * rate
44
- }
49
+ fee['items'] << fee_record(invoice, item, rate)
45
50
  end
46
- fee['sales_fee'] = subtotal(fee['items'])
51
+ subtotal(fee['items']).each{ |k, v| fee['sales_fee'][k] += v }
47
52
  end
53
+ NoInvoice.asof(@date.year, @date.month) do |no_invoice|
54
+ next if no_invoice.dig('sales_fee', 'id') != contract['id']
55
+ next if exceed_limit?(no_invoice, limit)
56
+
57
+ no_invoice['items'].each do |item|
58
+ rate = item['type'] == 'initial' ? @rate['initial'] : @rate['default']
59
+ fee['items'] << fee_record(no_invoice, item, rate)
60
+ end
61
+ subtotal(fee['items']).each{ |k, v| fee['sales_fee'][k] += v }
62
+ end
63
+ deduction_rate = contract.dig('rate', 'deduction')
64
+ fee['sales_fee']['deduction'] = -1 * (fee['sales_fee']['fee'] * deduction_rate).floor if deduction_rate
48
65
  self.class.create(fee, date: @date, codes: Array(contract['id']))
49
66
  end
50
67
  end
51
68
 
52
- def deliver_mail(attachment_type = nil, mode: nil)
69
+ def deliver_mail(attachment_type = nil, mode: nil, skip_no_item: true)
53
70
  attachment_type = CONFIG.dig('fee', 'attachment') || :html
54
71
  fees = self.class.asof(@date.year, @date.month)
55
72
  raise "No report for #{@date.year}/#{@date.month}" if fees.count.zero?
56
73
 
57
74
  fees.each do |dat, path|
58
75
  next if has_status?(dat, 'mail_delivered')
76
+ next if skip_no_item && dat['items'].empty?
59
77
 
60
78
  mail = compose_mail(dat, mode: mode, attachment: attachment_type.to_sym)
61
79
  LucaSupport::Mail.new(mail, PJDIR).deliver
62
- self.class.add_status!(path, 'mail_delivered')
80
+ self.class.add_status!(path, 'mail_delivered') if mode.nil?
63
81
  end
64
82
  end
65
83
 
@@ -83,7 +101,7 @@ module LucaDeal
83
101
 
84
102
  mail = Mail.new
85
103
  mail.to = dat.dig('customer', 'to') if mode.nil?
86
- mail.subject = CONFIG.dig('invoice', 'mail_subject') || 'Your Report is available'
104
+ mail.subject = CONFIG.dig('fee', 'mail_subject') || 'Your Report is available'
87
105
  if mode == :preview
88
106
  mail.cc = CONFIG.dig('mail', 'preview') || CONFIG.dig('mail', 'from')
89
107
  mail.subject = '[preview] ' + mail.subject
@@ -93,6 +111,76 @@ module LucaDeal
93
111
  mail
94
112
  end
95
113
 
114
+ # Output seriarized fee data to stdout.
115
+ # Returns previous N months on multiple count
116
+ #
117
+ # === Example YAML output
118
+ # ---
119
+ # - records:
120
+ # - customer: Example Co.
121
+ # subtotal: 100000
122
+ # tax: 10000
123
+ # due: 2020-10-31
124
+ # issue_date: '2020-09-30'
125
+ # count: 1
126
+ # total: 100000
127
+ # tax: 10000
128
+ #
129
+ def stats(count = 1)
130
+ [].tap do |collection|
131
+ scan_date = @date.next_month
132
+ count.times do
133
+ scan_date = scan_date.prev_month
134
+ {}.tap do |stat|
135
+ stat['records'] = self.class.asof(scan_date.year, scan_date.month).map do |fee|
136
+ {
137
+ 'customer' => fee.dig('customer', 'name'),
138
+ 'client' => fee['items'].map{ |item| item.dig('customer_name') }.join(' / '),
139
+ 'subtotal' => fee.dig('sales_fee', 'fee'),
140
+ 'tax' => fee.dig('sales_fee', 'tax'),
141
+ 'due' => fee.dig('due_date'),
142
+ 'mail' => fee.dig('status')&.select { |a| a.keys.include?('mail_delivered') }&.first
143
+ }
144
+ end
145
+ stat['issue_date'] = scan_date.to_s
146
+ stat['count'] = stat['records'].count
147
+ stat['total'] = stat['records'].inject(0) { |sum, rec| sum + rec.dig('subtotal') }
148
+ stat['tax'] = stat['records'].inject(0) { |sum, rec| sum + rec.dig('tax') }
149
+ collection << readable(stat)
150
+ end
151
+ end
152
+ end
153
+ end
154
+
155
+ def export_json
156
+ labels = export_labels
157
+ [].tap do |res|
158
+ self.class.asof(@date.year, @date.month) do |dat|
159
+ item = {}
160
+ item['date'] = dat['issue_date']
161
+ item['debit'] = []
162
+ item['credit'] = []
163
+ sub = dat['sales_fee']
164
+ if readable(sub['fee']) != 0
165
+ item['debit'] << { 'label' => labels[:debit][:fee], 'amount' => readable(sub['fee']) }
166
+ item['credit'] << { 'label' => labels[:credit][:fee], 'amount' => readable(sub['fee']) }
167
+ end
168
+ if readable(sub['tax']) != 0
169
+ item['debit'] << { 'label' => labels[:debit][:tax], 'amount' => readable(sub['tax']) }
170
+ item['credit'] << { 'label' => labels[:credit][:tax], 'amount' => readable(sub['tax']) }
171
+ end
172
+ if readable(sub['deduction']) != 0
173
+ item['debit'] << { 'label' => labels[:debit][:deduction], 'amount' => readable(sub['deduction'] * -1) }
174
+ item['credit'] << { 'label' => sub['deduction_label'] || labels[:credit][:deduction], 'amount' => readable(sub['deduction'] * -1) }
175
+ end
176
+ item['x-customer'] = dat['customer']['name'] if dat.dig('customer', 'name')
177
+ item['x-editor'] = 'LucaDeal'
178
+ res << item
179
+ end
180
+ puts JSON.dump(res)
181
+ end
182
+ end
183
+
96
184
  def render_report(file_type = :html)
97
185
  case file_type
98
186
  when :html
@@ -127,13 +215,32 @@ module LucaDeal
127
215
  @sales_fee = readable(fee_dat['sales_fee'])
128
216
  @issue_date = fee_dat['issue_date']
129
217
  @due_date = fee_dat['due_date']
130
- @amount = readable(fee_dat['sales_fee'].inject(0) { |sum, (_k, v)| sum + v })
218
+ @amount = readable(fee_dat['sales_fee']
219
+ .reject{ |k, _v| k == 'deduction_label' }
220
+ .inject(0) { |sum, (_k, v)| sum + v })
131
221
  end
132
222
 
133
223
  def lib_path
134
224
  __dir__
135
225
  end
136
226
 
227
+ # TODO: load labels from CONFIG before country defaults
228
+ #
229
+ def export_labels
230
+ case CONFIG['country']
231
+ when 'jp'
232
+ {
233
+ debit: { fee: '支払手数料', tax: '支払手数料', deduction: '未払費用' },
234
+ credit: { fee: '未払費用', tax: '未払費用', deduction: '雑収入' }
235
+ }
236
+ else
237
+ {
238
+ debit: { fee: 'Fees and commisions', tax: 'Fees and commisions', deduction: 'Accounts payable - other' },
239
+ credit: { fee: 'Accounts payable - other', tax: 'Accounts payable - other', deduction: 'Miscellaneous income' }
240
+ }
241
+ end
242
+ end
243
+
137
244
  # load user company profile from config.
138
245
  #
139
246
  def set_company
@@ -155,6 +262,11 @@ module LucaDeal
155
262
  end
156
263
  end
157
264
 
265
+ def attachment_name(dat, type)
266
+ id = %r{/}.match(dat['id']) ? dat['id'].gsub('/', '') : dat['id'][0, 7]
267
+ "feereport-#{id}.#{type}"
268
+ end
269
+
158
270
  def issue_date(date)
159
271
  base = date.nil? ? Date.today : Date.parse(date)
160
272
  Date.new(base.year, base.month, -1)
@@ -174,11 +286,32 @@ module LucaDeal
174
286
  BigDecimal(take_current(CONFIG['tax_rate'], name).to_s)
175
287
  end
176
288
 
289
+ # Fees are unique contract_id in each month
290
+ # If update needed, remove the target fee file.
291
+ #
177
292
  def duplicated_contract?(id)
178
293
  self.class.asof(@date.year, @date.month, @date.day) do |_f, path|
179
294
  return true if path.include?(id)
180
295
  end
181
296
  false
182
297
  end
298
+
299
+ def fee_record(invoice, item, rate)
300
+ {
301
+ 'invoice_id' => invoice['id'],
302
+ 'customer_name' => invoice.dig('customer', 'name'),
303
+ 'name' => item['name'],
304
+ 'price' => item['price'],
305
+ 'qty' => item['qty'],
306
+ 'fee' => item['price'] * item['qty'] * rate
307
+ }
308
+ end
309
+
310
+ def exceed_limit?(invoice, limit)
311
+ return false if limit.nil?
312
+
313
+ contract_start = Contract.find(invoice['contract_id']).dig('terms', 'effective')
314
+ contract_start.next_month(limit).prev_day < @date
315
+ end
183
316
  end
184
317
  end
@@ -5,6 +5,7 @@ require 'json'
5
5
  require 'yaml'
6
6
  require 'pathname'
7
7
  require 'bigdecimal'
8
+ require 'luca_support/code'
8
9
  require 'luca_support/config'
9
10
  require 'luca_support/mail'
10
11
  require 'luca_deal/contract'
@@ -20,21 +21,23 @@ module LucaDeal
20
21
  end
21
22
 
22
23
  def deliver_mail(attachment_type = nil, mode: nil)
23
- attachment_type = CONFIG.dig('invoice', 'attachment') || :html
24
24
  invoices = self.class.asof(@date.year, @date.month)
25
25
  raise "No invoice for #{@date.year}/#{@date.month}" if invoices.count.zero?
26
26
 
27
27
  invoices.each do |dat, path|
28
28
  next if has_status?(dat, 'mail_delivered')
29
29
 
30
- mail = compose_mail(dat, mode: mode, attachment: attachment_type.to_sym)
31
- LucaSupport::Mail.new(mail, PJDIR).deliver
32
- self.class.add_status!(path, 'mail_delivered')
30
+ deliver_one(dat, path, mode: mode, attachment_type: attachment_type)
33
31
  end
34
32
  end
35
33
 
36
34
  def preview_mail(attachment_type = nil)
37
- deliver_mail(attachment_type, mode: :preview)
35
+ invoices = self.class.asof(@date.year, @date.month)
36
+ raise "No invoice for #{@date.year}/#{@date.month}" if invoices.count.zero?
37
+
38
+ invoices.each do |dat, path|
39
+ deliver_one(dat, path, mode: :preview, attachment_type: attachment_type)
40
+ end
38
41
  end
39
42
 
40
43
  # Render HTML to console
@@ -74,6 +77,113 @@ module LucaDeal
74
77
  end
75
78
  end
76
79
 
80
+ def self.report(date, scan_years = 10, detail: false, due: false)
81
+ fy_end = Date.new(date.year, date.month, -1)
82
+ if detail
83
+ customers = {}.tap do |h|
84
+ Customer.all.each { |c| h[c['name']] = c }
85
+ end
86
+ end
87
+ [].tap do |res|
88
+ items = {}
89
+ head = date.prev_year(scan_years)
90
+ e = Enumerator.new do |yielder|
91
+ while head <= date
92
+ yielder << head
93
+ head = head.next_month
94
+ end
95
+ end
96
+ e.each do |d|
97
+ asof(d.year, d.month).map do |invoice|
98
+ if invoice['settled']
99
+ next if !due
100
+ settle_date = invoice['settled']['date'].class.name == "String" ? Date.parse(invoice['settled']['date']) : invoice['settled']['date']
101
+ next if (settle_date && settle_date <= fy_end)
102
+ end
103
+
104
+ customer = invoice.dig('customer', 'name')
105
+ items[customer] ||= { 'unsettled' => BigDecimal('0'), 'invoices' => [] }
106
+ items[customer]['unsettled'] += (invoice.dig('subtotal', 0, 'items') + invoice.dig('subtotal', 0, 'tax')||0)
107
+ items[customer]['invoices'] << invoice
108
+ end
109
+ end
110
+ items.each do |k, item|
111
+ row = {
112
+ 'customer' => k,
113
+ 'unsettled' => LucaSupport::Code.readable(item['unsettled']),
114
+ }
115
+ if detail
116
+ row['address'] = %Q(#{customers.dig(k, 'address')}#{customers.dig(k, 'address2')})
117
+ row['invoices'] = item['invoices'].map{ |i| { 'id' => i['id'], 'issue' => i['issue_date'].to_s } }
118
+ end
119
+ res << row
120
+ end
121
+ res.sort! { |a, b| b['unsettled'] <=> a['unsettled'] }
122
+ end
123
+ end
124
+
125
+ # === JSON Format:
126
+ # [
127
+ # {
128
+ # "journals" : [
129
+ # {
130
+ # "id": "2021A/U001",
131
+ # "header": "customer name",
132
+ # "diff": -20000
133
+ # }
134
+ # ]
135
+ # }
136
+ # ]
137
+ #
138
+ def self.settle(io, payment_terms = 1)
139
+ customers = {}.tap do |h|
140
+ Customer.all.each { |c| h[c['name']] = c }
141
+ end
142
+ contracts = {}.tap do |h|
143
+ Contract.all.each { |c| h[c['customer_id']] ||= []; h[c['customer_id']] << c }
144
+ end
145
+ JSON.parse(io).each do |d|
146
+ next if d['journals'].nil?
147
+
148
+ d['journals'].each do |j|
149
+ next if j['diff'] >= 0
150
+
151
+ if j['header'] == 'others'
152
+ STDERR.puts "#{j['id']}: no customer header found. skip"
153
+ next
154
+ end
155
+
156
+ ord = customers.map do |k, v|
157
+ [v, LucaSupport::Code.match_score(j['header'], k, 2)]
158
+ end
159
+ customer = ord.max { |x, y| x[1] <=> y[1] }.dig(0, 'id')
160
+
161
+ if customer
162
+ contract = contracts[customer].length == 1 ? contracts.dig(customer, 0, 'id') : nil
163
+ date = Date.parse(j['date'])
164
+ invoices = term(date.prev_month(payment_terms).year, date.prev_month(payment_terms).month, date.year, date.month, contract)
165
+ invoices.each do |invoice, _path|
166
+ next if invoice['customer']['id'] != customer
167
+ next if invoice['issue_date'] > date
168
+ if Regexp.new("^LucaBook/#{j['id']}").match invoice.dig('settled', 'id')||''
169
+ break
170
+ end
171
+
172
+ invoice['settled'] = {
173
+ 'id' => "LucaBook/#{j['id']}",
174
+ 'date' => j['date'],
175
+ 'amount' => j['diff']
176
+ }
177
+ save(invoice)
178
+ break
179
+ end
180
+ else
181
+ STDERR.puts "#{j['id']}: no customer found"
182
+ end
183
+ end
184
+ end
185
+ end
186
+
77
187
  # Output seriarized invoice data to stdout.
78
188
  # Returns previous N months on multiple count
79
189
  #
@@ -120,17 +230,23 @@ module LucaDeal
120
230
  #
121
231
  def stats_email
122
232
  {}.tap do |res|
123
- stats(2).each.with_index(1) do |stat, i|
124
- @issue_date = stat['issue_date'] if i == 1
233
+ stats(3).each.with_index(1) do |stat, i|
125
234
  stat['records'].each do |record|
126
235
  res[record['customer']] ||= {}
127
236
  res[record['customer']]['customer_name'] ||= record['customer']
128
237
  res[record['customer']]["amount#{i}"] ||= record['subtotal']
129
238
  res[record['customer']]["tax#{i}"] ||= record['tax']
130
239
  end
240
+ if i == 1
241
+ @issue_date = stat['issue_date']
242
+ @total_amount = stat['total']
243
+ @total_tax = stat['tax']
244
+ @total_count = stat['count']
245
+ end
131
246
  end
132
247
  @invoices = res.values
133
248
  end
249
+ @company = CONFIG.dig('company', 'name')
134
250
 
135
251
  mail = Mail.new
136
252
  mail.to = CONFIG.dig('mail', 'preview') || CONFIG.dig('mail', 'from')
@@ -140,6 +256,7 @@ module LucaDeal
140
256
  end
141
257
 
142
258
  def export_json
259
+ labels = export_labels
143
260
  [].tap do |res|
144
261
  self.class.asof(@date.year, @date.month) do |dat|
145
262
  item = {}
@@ -147,10 +264,14 @@ module LucaDeal
147
264
  item['debit'] = []
148
265
  item['credit'] = []
149
266
  dat['subtotal'].map do |sub|
150
- item['debit'] << { 'label' => '売掛金', 'amount' => readable(sub['items']) }
151
- item['debit'] << { 'label' => '売掛金', 'amount' => readable(sub['tax']) }
152
- item['credit'] << { 'label' => '売上高', 'amount' => readable(sub['items']) }
153
- item['credit'] << { 'label' => '売上高', 'amount' => readable(sub['tax']) }
267
+ if readable(sub['items']) != 0
268
+ item['debit'] << { 'label' => labels[:debit][:items], 'amount' => readable(sub['items']) }
269
+ item['credit'] << { 'label' => labels[:credit][:items], 'amount' => readable(sub['items']) }
270
+ end
271
+ if readable(sub['tax']) != 0
272
+ item['debit'] << { 'label' => labels[:debit][:tax], 'amount' => readable(sub['tax']) }
273
+ item['credit'] << { 'label' => labels[:credit][:tax], 'amount' => readable(sub['tax']) }
274
+ end
154
275
  end
155
276
  item['x-customer'] = dat['customer']['name'] if dat.dig('customer', 'name')
156
277
  item['x-editor'] = 'LucaDeal'
@@ -167,9 +288,9 @@ module LucaDeal
167
288
  gen_invoice!(invoice_object(contract))
168
289
  end
169
290
 
170
- def monthly_invoice
291
+ def monthly_invoice(target = 'monthly')
171
292
  Contract.new(@date.to_s).active do |contract|
172
- next if contract.dig('terms', 'billing_cycle') != 'monthly'
293
+ next if contract.dig('terms', 'billing_cycle') != target
173
294
  # TODO: provide another I/F for force re-issue if needed
174
295
  next if duplicated_contract? contract['id']
175
296
 
@@ -251,10 +372,34 @@ module LucaDeal
251
372
  @amount = readable(invoice_dat['subtotal'].inject(0) { |sum, i| sum + i['items'] + i['tax'] })
252
373
  end
253
374
 
375
+ def deliver_one(invoice, path, mode: nil, attachment_type: nil)
376
+ attachment_type ||= CONFIG.dig('invoice', 'attachment') || :html
377
+ mail = compose_mail(invoice, mode: mode, attachment: attachment_type.to_sym)
378
+ LucaSupport::Mail.new(mail, PJDIR).deliver
379
+ self.class.add_status!(path, 'mail_delivered') if mode.nil?
380
+ end
381
+
254
382
  def lib_path
255
383
  __dir__
256
384
  end
257
385
 
386
+ # TODO: load labels from CONFIG before country defaults
387
+ #
388
+ def export_labels
389
+ case CONFIG['country']
390
+ when 'jp'
391
+ {
392
+ debit: { items: '売掛金', tax: '売掛金' },
393
+ credit: { items: '売上高', tax: '売上高' }
394
+ }
395
+ else
396
+ {
397
+ debit: { items: 'Accounts receivable - trade', tax: 'Accounts receivable - trade' },
398
+ credit: { items: 'Amount of Sales', tax: 'Amount of Sales' }
399
+ }
400
+ end
401
+ end
402
+
258
403
  # load user company profile from config.
259
404
  #
260
405
  def set_company
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'luca_deal/invoice'
4
+
5
+ module LucaDeal #:nodoc:
6
+ # Invoice compatible transactions for other payment methods.
7
+ #
8
+ class NoInvoice < Invoice
9
+ @dirname = 'no_invoices'
10
+
11
+ def monthly_invoice
12
+ super('other_payments')
13
+ end
14
+
15
+ # Override not to send mail to customer.
16
+ #
17
+ def deliver_mail(attachment_type = nil, mode: nil)
18
+ end
19
+ end
20
+ end
@@ -12,7 +12,7 @@ module LucaDeal
12
12
  FileUtils.cp("#{__dir__}/templates/config.yml", 'config.yml') unless File.exist?('config.yml')
13
13
  Dir.mkdir('data') unless Dir.exist?('data')
14
14
  Dir.chdir('data') do
15
- %w[contracts customers invoices].each do |subdir|
15
+ %w[contracts customers invoices no_invoices].each do |subdir|
16
16
  Dir.mkdir(subdir) unless Dir.exist?(subdir)
17
17
  end
18
18
  end
@@ -39,9 +39,13 @@
39
39
  <td class="price" colspan="3">Tax</td>
40
40
  <td class="price"><%= delimit_num( @sales_fee['tax'] ) %></td>
41
41
  </tr>
42
+ <tr>
43
+ <td class="price" colspan="3">Deduction</td>
44
+ <td class="price"><%= delimit_num( @sales_fee['deduction'] ) %></td>
45
+ </tr>
42
46
  <tr>
43
47
  <td class="price" colspan="3">Total</td>
44
- <td class="price"><%= delimit_num( @sales_fee['fee'] + @sales_fee['tax'] ) %></td>
48
+ <td class="price"><%= delimit_num(@sales_fee['fee'] + @sales_fee['tax'] + @sales_fee['deduction']) %></td>
45
49
  </tr>
46
50
  </table>
47
51
 
@@ -3,37 +3,51 @@
3
3
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
4
4
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
5
5
  <style>
6
- td { text-align: right; line-height: 2em; min-width: 7em }
6
+ td { text-align: right; line-height: 2em; min-width: 6em }
7
7
  thead th, thead td { text-align: center }
8
+ thead { border-bottom: solid 1px #aaa }
9
+ tr#total { border-top: solid 1px #aaa }
8
10
  tr.sub { font-size: .8em; color: #aaa }
11
+ .past { color: #777 }
9
12
  </style>
10
13
  </head>
11
14
  <body>
15
+ <div style="margin: 1em 0"><%= @company %></div>
12
16
  <div style="margin: 1em 0">Issue date: <%= @issue_date %></div>
13
17
  <table>
14
18
  <thead>
15
19
  <tr>
16
20
  <th>#</th>
17
21
  <th>Customer</th>
18
- <th>Amount</th><th>Tax</th>
19
- <th>Amount</th><th>Tax</th>
22
+ <th>This month</th>
23
+ <th>Last Month</th>
24
+ <th>2 Month ago</th>
20
25
  </tr>
21
26
  <tr class="sub">
22
27
  <th></th>
23
28
  <th></th>
24
- <th>This month</th><th>This month</th>
25
- <th>Last Month</th><th>Last Month</th>
29
+ <th>Amount / Tax</th>
30
+ <th class="past">Amount / Tax</th>
31
+ <th class="past">Amount / Tax</th>
26
32
  </tr>
27
33
  </thead>
28
34
  <tbody>
29
35
  <% @invoices.each.with_index(1) do |invoice, i| %>
30
36
  <tr>
31
- <td><%= i %></td>
37
+ <th><%= i %></th>
32
38
  <td><%= invoice["customer_name"] %></td>
33
- <td><%= invoice["amount1"] %></td><td><%= invoice["tax1"] %></td>
34
- <td><%= invoice["amount2"] %></td><td><%= invoice["tax2"] %></td>
39
+ <td><%= delimit_num(invoice["amount1"]) %><br /><%= delimit_num(invoice["tax1"]) %></td>
40
+ <td class="past"><%= delimit_num(invoice["amount2"]) %><br /><%= delimit_num(invoice["tax2"]) %></td>
41
+ <td class="past"><%= delimit_num(invoice["amount3"]) %><br /><%= delimit_num(invoice["tax3"]) %></td>
35
42
  </tr>
36
43
  <% end %>
44
+ <tr id="total">
45
+ <td></td>
46
+ <td>Total (<%= @total_count %> records)</td>
47
+ <td><%= delimit_num(@total_amount) %><br /><%= delimit_num(@total_tax) %></td>
48
+ <td></td>
49
+ <td></td>
50
+ </tr>
37
51
  </tbody>
38
52
  </table>
39
53
  </body>
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LucaDeal
4
- VERSION = '0.2.23'
4
+ VERSION = '0.3.0'
5
5
  end
data/lib/luca_deal.rb CHANGED
@@ -8,6 +8,7 @@ module LucaDeal
8
8
  autoload :Contract, 'luca_deal/contract'
9
9
  autoload :Fee, 'luca_deal/fee'
10
10
  autoload :Invoice, 'luca_deal/invoice'
11
+ autoload :NoInvoice, 'luca_deal/no_invoice'
11
12
  autoload :Product, 'luca_deal/product'
12
13
  autoload :Setup, 'luca_deal/setup'
13
14
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lucadeal
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.23
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuma Takahiro
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-01-31 00:00:00.000000000 Z
11
+ date: 2022-03-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lucarecord
@@ -68,7 +68,7 @@ dependencies:
68
68
  version: 12.3.3
69
69
  description: 'Deal with contracts
70
70
 
71
- '
71
+ '
72
72
  email:
73
73
  - co.chuma@gmail.com
74
74
  executables:
@@ -85,6 +85,7 @@ files:
85
85
  - lib/luca_deal/customer.rb
86
86
  - lib/luca_deal/fee.rb
87
87
  - lib/luca_deal/invoice.rb
88
+ - lib/luca_deal/no_invoice.rb
88
89
  - lib/luca_deal/product.rb
89
90
  - lib/luca_deal/setup.rb
90
91
  - lib/luca_deal/templates/.keep
@@ -102,7 +103,7 @@ metadata:
102
103
  homepage_uri: https://github.com/chumaltd/luca/tree/master/lucadeal
103
104
  source_code_uri: https://github.com/chumaltd/luca/tree/master/lucadeal
104
105
  changelog_uri: https://github.com/chumaltd/luca/tree/master/lucadeal/CHANGELOG.md
105
- post_install_message:
106
+ post_install_message:
106
107
  rdoc_options: []
107
108
  require_paths:
108
109
  - lib
@@ -117,8 +118,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
117
118
  - !ruby/object:Gem::Version
118
119
  version: '0'
119
120
  requirements: []
120
- rubygems_version: 3.2.3
121
- signing_key:
121
+ rubygems_version: 3.2.5
122
+ signing_key:
122
123
  specification_version: 4
123
124
  summary: Deal with contracts
124
125
  test_files: []