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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +9 -2
- data/exe/luca-deal +70 -5
- data/lib/luca_deal/contract.rb +3 -3
- data/lib/luca_deal/fee.rb +147 -14
- data/lib/luca_deal/invoice.rb +158 -13
- data/lib/luca_deal/no_invoice.rb +20 -0
- data/lib/luca_deal/setup.rb +1 -1
- data/lib/luca_deal/templates/fee-report.html.erb +5 -1
- data/lib/luca_deal/templates/monthly-payment-list.html.erb +22 -8
- data/lib/luca_deal/version.rb +1 -1
- data/lib/luca_deal.rb +1 -0
- metadata +8 -7
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 060bcea890091dbe70e03718d1a8871bccf4873db86922d505e0e2c3ffefd94a
|
4
|
+
data.tar.gz: 3d202e5711a7e1d28cadb5a4debbda18900b7ab7ea4d6e5e413d52980c1cb84f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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[
|
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[
|
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('--
|
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[
|
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 '
|
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
|
data/lib/luca_deal/contract.rb
CHANGED
@@ -69,11 +69,11 @@ module LucaDeal
|
|
69
69
|
end
|
70
70
|
|
71
71
|
def active_period?(dat)
|
72
|
-
unless dat
|
73
|
-
defunct = dat
|
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
|
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 = {
|
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['
|
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('
|
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']
|
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
|
data/lib/luca_deal/invoice.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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(
|
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
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
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') !=
|
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
|
data/lib/luca_deal/setup.rb
CHANGED
@@ -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(
|
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:
|
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>
|
19
|
-
<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>
|
25
|
-
<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
|
-
<
|
37
|
+
<th><%= i %></th>
|
32
38
|
<td><%= invoice["customer_name"] %></td>
|
33
|
-
<td><%= invoice["amount1"]
|
34
|
-
<td><%= invoice["amount2"]
|
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>
|
data/lib/luca_deal/version.rb
CHANGED
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.
|
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:
|
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.
|
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: []
|