lucadeal 0.2.23 → 0.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 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: []