saft 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,685 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+ require "tubby"
5
+
6
+ module SAFT::V2
7
+ module HTML
8
+ def self.css
9
+ File.read(css_path)
10
+ end
11
+
12
+ def self.css_path
13
+ Pathname.new(__dir__) + "html_dist.css"
14
+ end
15
+
16
+ def self.format_big_decimal(big_decimal)
17
+ integer, decimal = big_decimal.to_s("F").split(".")
18
+ integer = integer.reverse.scan(/.{1,3}/).join(" ").reverse
19
+
20
+ "#{integer},#{decimal.ljust(2, "0")}"
21
+ end
22
+
23
+ module DryStructRenderTubby
24
+ refine(String) do
25
+ def to_tubby
26
+ self
27
+ end
28
+ end
29
+
30
+ refine(Date) do
31
+ def to_tubby
32
+ to_s
33
+ end
34
+ end
35
+
36
+ refine(NilClass) do
37
+ def to_tubby
38
+ "-"
39
+ end
40
+ end
41
+
42
+ refine(DateTime) do
43
+ def to_tubby
44
+ to_s
45
+ end
46
+ end
47
+
48
+ refine(Integer) do
49
+ def to_tubby
50
+ to_s
51
+ end
52
+ end
53
+
54
+ refine(BigDecimal) do
55
+ def to_tubby
56
+ HTML.format_big_decimal(self)
57
+ end
58
+ end
59
+
60
+ refine(Array) do
61
+ def to_tubby
62
+ Tubby.new { |t| each { t << _1 } }
63
+ end
64
+ end
65
+
66
+ refine(Dry::Struct) do
67
+ def to_tubby
68
+ Tubby.new { |t| t.div(class: "mb-2 pl-2 border-l-2") { t << RenderHash.new(attributes) } }
69
+ end
70
+ end
71
+
72
+ refine(SAFT::V2::Types::AuditFile) do
73
+ def to_tubby
74
+ Tubby.new { |t|
75
+ t.div(class: "pl-2 border-l-2") {
76
+ t.div(class: "mb-2 border-b-2") { t.strong("Header") }
77
+ t << RenderHash.new(header.attributes)
78
+ }
79
+
80
+ if master_files
81
+ t.div(class: "pl-2 border-l-2") {
82
+ t.div(class: "mb-2 border-b-2") { t.strong("MasterFiles") }
83
+ t << master_files
84
+ }
85
+ end
86
+
87
+ if general_ledger_entries
88
+ t.div(class: "pl-2 border-l-2") {
89
+ t.div(class: "mb-2 border-b-2") { t.strong("GeneralLedgerEntries") }
90
+ t << general_ledger_entries
91
+ }
92
+ end
93
+ }
94
+ end
95
+ end
96
+
97
+ refine(SAFT::V2::Types::MasterFiles) do
98
+ def to_tubby
99
+ Tubby.new { |t|
100
+ if general_ledger_accounts
101
+ t.strong("General ledger accounts")
102
+ t.div(class: "pl-2 border-l-2") { t << RenderGeneralLedgerTable.new(general_ledger_accounts) }
103
+ end
104
+
105
+ if customers
106
+ t.strong("Customers")
107
+ t.div(class: "pl-2 border-l-2 flex flex-wrap") {
108
+ customers.each do |customer|
109
+ t << CompanyCard.new(customer)
110
+ end
111
+ }
112
+ end
113
+
114
+ if suppliers
115
+ t.strong("Suppliers")
116
+ t.div(class: "pl-2 border-l-2 flex flex-wrap") {
117
+ suppliers.each do |supplier|
118
+ t << CompanyCard.new(supplier)
119
+ end
120
+ }
121
+ end
122
+
123
+ if tax_table
124
+ t.strong("Tax able")
125
+ t.div(class: "pl-2 border-l-2") { t << TaxTable.new(tax_table) }
126
+ end
127
+
128
+ if analysis_type_table
129
+ t.strong("Analysis type table")
130
+ t.div(class: "pl-2 border-l-2") { t << AnalysisTypeTable.new(analysis_type_table) }
131
+ end
132
+
133
+ if owners
134
+ t.strong("Owners")
135
+ t.div(class: "pl-2 border-l-2") { owners.each { |owner| t << owner } }
136
+ end
137
+ }
138
+ end
139
+ end
140
+
141
+ refine(SAFT::V2::Types::Transaction) do
142
+ def to_tubby
143
+ Tubby.new { |t|
144
+ t.div(
145
+ id: "transaction-#{transaction_id}",
146
+ class: "mb-2 pl-2 border-l-2 flex",
147
+ ) {
148
+ t.div(class: "w-80") {
149
+ t
150
+ .a(
151
+ class: "whitespace-pre underline underline-offset-1 hover:underline-offset-2 visited:underline-decoration-2",
152
+ href: "#transaction-#{transaction_id}",
153
+ ) {
154
+ t.div {
155
+ t.strong("Transaction id ")
156
+ t << transaction_id
157
+ }
158
+ }
159
+ t.div { t << RenderHash.new(attributes.except(:transaction_id, :lines)) }
160
+ }
161
+ t.div {
162
+ t.strong("Lines ")
163
+ t << LinesTable.new(lines)
164
+ }
165
+ }
166
+ }
167
+ end
168
+ end
169
+ end
170
+
171
+ using DryStructRenderTubby
172
+
173
+ class RenderHash
174
+ def initialize(hash)
175
+ @hash = hash.select { |_, value| value }
176
+ end
177
+
178
+ def to_tubby
179
+ Tubby.new { |t|
180
+ @hash.each do |key, value|
181
+ t.div {
182
+ t.strong("#{key.to_s.tr("_", " ").capitalize} ")
183
+ t << value
184
+ }
185
+ end
186
+ }
187
+ end
188
+ end
189
+
190
+ class RenderGeneralLedgerTable
191
+ def initialize(accounts)
192
+ @accounts = accounts
193
+ end
194
+
195
+ def to_tubby
196
+ Tubby.new { |t|
197
+ t.table {
198
+ t.thead {
199
+ t.tr {
200
+ t.th("Id")
201
+ t.th("Description")
202
+ t.th("Std account")
203
+ t.th("Opening balance")
204
+ t.th("Closing balance")
205
+ t.th("Rest")
206
+ }
207
+ }
208
+ t.tbody {
209
+ @accounts.each do |account|
210
+ std_account = SAFT::V2::Norway.std_account(account.standard_account_id)
211
+ std_account_title = "Not found"
212
+ if std_account
213
+ std_account_title = <<~TEXT
214
+ Account no #{std_account.number}
215
+ #{std_account.description_en}
216
+ #{std_account.description_no}
217
+ TEXT
218
+ end
219
+
220
+ t.tr {
221
+ t.td(account.account_id)
222
+ t.td(account.account_description)
223
+ t.td(account.standard_account_id, title: std_account_title)
224
+ t.td {
225
+ t.div(class: "flex justify-between") {
226
+ if account.opening_debit_balance
227
+ t.span("Debit")
228
+ t.span(account.opening_debit_balance)
229
+ end
230
+
231
+ if account.opening_credit_balance
232
+ t.span("Credit")
233
+ t.span(-account.opening_credit_balance)
234
+ end
235
+ }
236
+ }
237
+ t.td {
238
+ t.div(class: "flex justify-between") {
239
+ if account.closing_debit_balance
240
+ t.span("Debit")
241
+ t.span(account.closing_debit_balance)
242
+ end
243
+
244
+ if account.closing_credit_balance
245
+ t.span("Credit")
246
+ t.span(-account.closing_credit_balance)
247
+ end
248
+ }
249
+ }
250
+ t.td(class: "pl-2") {
251
+ t.div(class: "pl-2 border-l-2") {
252
+ t <<
253
+ RenderHash.new(
254
+ account
255
+ .attributes
256
+ .except(
257
+ :account_id,
258
+ :account_description,
259
+ :standard_account_id,
260
+ :opening_debit_balance,
261
+ :opening_credit_balance,
262
+ :closing_debit_balance,
263
+ :closing_credit_balance,
264
+ ),
265
+ )
266
+ }
267
+ }
268
+ }
269
+ end
270
+ }
271
+ }
272
+ }
273
+ end
274
+ end
275
+
276
+ class CompanyCard
277
+ def initialize(company)
278
+ @company = company
279
+ end
280
+
281
+ def to_tubby
282
+ Tubby.new { |t|
283
+ t.div(class: "min-w-[20rem] max-w-[20rem] mr-8 mb-2") {
284
+ t.div(class: "pl-2 border-l-2") {
285
+ t.span("Supplier", class: "font-semibold") if @company.is_a?(Types::Supplier)
286
+ t.span("Customer", class: "font-semibold") if @company.is_a?(Types::Customer)
287
+ t << RenderHash.new(@company.attributes)
288
+ }
289
+ }
290
+ }
291
+ end
292
+ end
293
+
294
+ class TaxTable
295
+ def initialize(tax_table)
296
+ @tax_table = tax_table
297
+ end
298
+
299
+ def to_tubby
300
+ Tubby.new { |t|
301
+ t.table {
302
+ t.thead {
303
+ t.tr {
304
+ t.th("Tax code")
305
+ t.th("Description")
306
+ t.th("Country")
307
+ t.th("Std code")
308
+ t.th("Tax %", class: "text-right")
309
+ t.th("Base rate", class: "text-right")
310
+ t.th("rest")
311
+ }
312
+ }
313
+ t.tbody {
314
+ @tax_table.each { |table|
315
+ table.tax_code_details.each { |detail|
316
+ vat_code = SAFT::V2::Norway.vat_code(detail.standard_tax_code)
317
+ vat_code_title = "Not found"
318
+ if vat_code
319
+ vat_code_title = <<~TEXT
320
+ Vat Code #{vat_code.code}
321
+ #{vat_code.description_en}
322
+ #{vat_code.description_no}
323
+ #{vat_code.tax_rate}
324
+ #{"Can be used for compensation" if vat_code.compensation}
325
+ TEXT
326
+ end
327
+
328
+ t.tr {
329
+ t.td(detail.tax_code)
330
+ t.td(detail.description)
331
+ t.td(detail.country)
332
+ t.td(detail.standard_tax_code, title: vat_code_title)
333
+ t.td(detail.tax_percentage, class: "text-right")
334
+ t.td(class: "text-right") { detail.base_rates.each { t.div(_1) } }
335
+ t.td(class: "pl-2") {
336
+ t.div(class: "pl-2 border-l-2") {
337
+ t <<
338
+ RenderHash.new(
339
+ detail
340
+ .attributes
341
+ .except(
342
+ :tax_code,
343
+ :description,
344
+ :country,
345
+ :standard_tax_code,
346
+ :base_rate,
347
+ :tax_percentage,
348
+ ),
349
+ )
350
+ }
351
+ }
352
+ }
353
+ }
354
+ }
355
+ }
356
+ }
357
+ }
358
+ end
359
+ end
360
+
361
+ class AnalysisTypeTable
362
+ def initialize(analysis_type_table)
363
+ @analysis_type_table = analysis_type_table
364
+ end
365
+
366
+ def to_tubby
367
+ Tubby.new { |t|
368
+ t.table {
369
+ t.thead {
370
+ t.tr {
371
+ t.th("Type")
372
+ t.th("Type description")
373
+ t.th("ID")
374
+ t.th("ID Description")
375
+ t.th("Rest")
376
+ }
377
+ }
378
+ t.tbody {
379
+ @analysis_type_table.each { |entry|
380
+ html_analysis = t.get_analysis(entry.analysis_id, entry.analysis_type)
381
+ t.tr(id: html_analysis.html_id) {
382
+ t.td(entry.analysis_type)
383
+ t.td(entry.analysis_type_description)
384
+ t.td(entry.analysis_id)
385
+ t.td(entry.analysis_id_description)
386
+ t.td(class: "pl-2") {
387
+ t.div(class: "pl-2 border-l-2") {
388
+ t <<
389
+ RenderHash.new(
390
+ entry
391
+ .attributes
392
+ .except(
393
+ :analysis_type,
394
+ :analysis_type_description,
395
+ :analysis_id,
396
+ :analysis_id_description,
397
+ ),
398
+ )
399
+ }
400
+ }
401
+ }
402
+ }
403
+ }
404
+ }
405
+ }
406
+ end
407
+ end
408
+
409
+ class LinesTable
410
+ def initialize(lines)
411
+ @lines = lines
412
+ end
413
+
414
+ def to_tubby
415
+ Tubby.new { |t|
416
+ t.table {
417
+ t.thead {
418
+ t.tr {
419
+ t.th("RecordID")
420
+ t.th("AccountID")
421
+ t.th("Analysis")
422
+ t.th("ValueDate")
423
+ t.th("Description")
424
+ t.th("Dedit Amount", class: "text-right")
425
+ t.th("Credit Amount", class: "text-right")
426
+ t.th("Rest")
427
+ }
428
+ }
429
+
430
+ t.tbody {
431
+ @lines.each { |line|
432
+ t.tr {
433
+ t.td(line.record_id)
434
+ t.td {
435
+ account = t.get_account(line.account_id)
436
+ t.div(title: account.title) { t << line.account_id }
437
+ }
438
+
439
+ t.td {
440
+ line.analyses&.each do |line_analysis|
441
+ analysis = t.get_analysis(
442
+ line_analysis.analysis_id,
443
+ line_analysis.analysis_type,
444
+ )
445
+ t.div(title: analysis.title) { t << analysis.link { t << "#{line_analysis.analysis_type} #{line_analysis.analysis_id}" } }
446
+ end
447
+ }
448
+ t.td(line.value_date)
449
+ t.td(line.description)
450
+ t.td(line.debit_amount&.amount, class: "text-right")
451
+ t.td(line.credit_amount&.amount, class: "text-right")
452
+ t.td {
453
+ t.div(class: "mb-2 pl-2 border-l-2") {
454
+ if line.customer_id
455
+ customer = t.get_customer(line.customer_id)
456
+ t.div(title: customer.title) {
457
+ t.strong("Customer ")
458
+ t << line.customer_id
459
+ t << " #{customer.name}"
460
+ }
461
+ end
462
+
463
+ if line.supplier_id
464
+ supplier = t.get_supplier(line.supplier_id)
465
+ t.div(title: supplier.title) {
466
+ t.strong("Supplier ")
467
+ t << line.supplier_id
468
+ t << " #{supplier.name}"
469
+ }
470
+ end
471
+
472
+ t <<
473
+ RenderHash.new(
474
+ line
475
+ .attributes
476
+ .except(
477
+ :record_id,
478
+ :account_id,
479
+ :customer_id,
480
+ :supplier_id,
481
+ :analyses,
482
+ :value_date,
483
+ :description,
484
+ :debit_amount,
485
+ :credit_amount,
486
+ ),
487
+ )
488
+ }
489
+ }
490
+ }
491
+ }
492
+ }
493
+ }
494
+ }
495
+ end
496
+ end
497
+
498
+ class Analysis
499
+ def initialize(analysis)
500
+ @analysis = analysis
501
+ end
502
+
503
+ attr_reader :analysis
504
+
505
+ def title
506
+ <<~TEXT
507
+ #{analysis.analysis_type}(#{analysis.analysis_type_description})
508
+ #{analysis.analysis_id}(#{analysis.analysis_id_description})
509
+ TEXT
510
+ end
511
+
512
+ def html_id
513
+ "analysis-#{analysis.analysis_type}-#{analysis.analysis_id}"
514
+ end
515
+
516
+ def link
517
+ Tubby.new { |t| t.a(href: "##{html_id}") { yield } }
518
+ end
519
+ end
520
+
521
+ class NotFoundAnalysys
522
+ include Singleton
523
+
524
+ def title
525
+ "Could not find analysis"
526
+ end
527
+
528
+ def link
529
+ yield
530
+ nil
531
+ end
532
+ end
533
+
534
+ class Account
535
+ attr_reader(:account)
536
+
537
+ def initialize(account)
538
+ @account = account
539
+ end
540
+
541
+ def title
542
+ <<~TEXT
543
+ #{account.account_id} #{account.account_description}
544
+ Std account #{account.standard_account_id}
545
+ opening balance #{HTML.format_big_decimal(account.opening_debit_balance || -account.opening_credit_balance)}
546
+ closing balance #{HTML.format_big_decimal(account.closing_debit_balance || -account.closing_credit_balance)}
547
+ TEXT
548
+ end
549
+ end
550
+
551
+ class NotFoundAccount
552
+ include(Singleton)
553
+
554
+ def title
555
+ "Could not find account"
556
+ end
557
+ end
558
+
559
+ class Customer
560
+ attr_reader(:customer)
561
+
562
+ def initialize(customer)
563
+ @customer = customer
564
+ end
565
+
566
+ def title
567
+ <<~TEXT
568
+ #{customer.name} #{customer.registration_number}
569
+ opening balance #{HTML.format_big_decimal(customer.opening_debit_balance || -customer.opening_credit_balance)}
570
+ closing balance #{HTML.format_big_decimal(customer.closing_debit_balance || -customer.closing_credit_balance)}
571
+ TEXT
572
+ end
573
+
574
+ def name
575
+ customer.name
576
+ end
577
+ end
578
+
579
+ class NotFoundCustomer
580
+ include(Singleton)
581
+
582
+ def title
583
+ "Could not find customer"
584
+ end
585
+
586
+ def name
587
+ "Not found in Customers block"
588
+ end
589
+ end
590
+
591
+ class Supplier
592
+ attr_reader(:supplier)
593
+
594
+ def initialize(supplier)
595
+ @supplier = supplier
596
+ end
597
+
598
+ def title
599
+ <<~TEXT
600
+ #{supplier.name} #{supplier.registration_number}
601
+ opening balance #{HTML.format_big_decimal(supplier.opening_debit_balance || -supplier.opening_credit_balance)}
602
+ closing balance #{HTML.format_big_decimal(supplier.closing_debit_balance || -supplier.closing_credit_balance)}
603
+ TEXT
604
+ end
605
+
606
+ def name
607
+ supplier.name
608
+ end
609
+ end
610
+
611
+ class NotFoundSupplier
612
+ include(Singleton)
613
+
614
+ def title
615
+ "Could not find supplier"
616
+ end
617
+
618
+ def name
619
+ "Not found in Suppliers block"
620
+ end
621
+ end
622
+
623
+ class SaftRenderer < Tubby::Renderer
624
+ def <<(obj)
625
+ obj = obj.to_tubby if obj.respond_to?(:to_tubby)
626
+ if obj.is_a?(Tubby::Template)
627
+ obj.render_with(self)
628
+ else
629
+ @target << CGI.escape_html(obj.to_s)
630
+ end
631
+
632
+ self
633
+ end
634
+
635
+ def audit_file=(audit_file)
636
+ (audit_file.master_files&.analysis_type_table || [])
637
+ .each_with_object({}) { _2[[_1.analysis_id, _1.analysis_type]] = Analysis.new(_1) }
638
+ .tap { @analysis_lookup = _1 }
639
+
640
+ (audit_file.master_files&.customers || [])
641
+ .each_with_object({}) { _2[_1.customer_id] = Customer.new(_1) }
642
+ .tap { @customer_lookup = _1 }
643
+
644
+ (audit_file.master_files&.suppliers || [])
645
+ .each_with_object({}) { _2[_1.supplier_id] = Supplier.new(_1) }
646
+ .tap { @supplier_lookup = _1 }
647
+
648
+ (audit_file.master_files&.general_ledger_accounts || [])
649
+ .each_with_object({}) { _2[_1.account_id] = Account.new(_1) }
650
+ .tap { @account_lookup = _1 }
651
+ end
652
+
653
+ def get_analysis(id, type)
654
+ @analysis_lookup.fetch([id, type]) { NotFoundAnalysys.instance }
655
+ end
656
+
657
+ def get_account(id)
658
+ @account_lookup.fetch(id) { NotFoundAccount.instance }
659
+ end
660
+
661
+ def get_customer(id)
662
+ @customer_lookup.fetch(id) { NotFoundCustomer.instance }
663
+ end
664
+
665
+ def get_supplier(id)
666
+ @supplier_lookup.fetch(id) { NotFoundSupplier.instance }
667
+ end
668
+
669
+ def a(*args, **kwargs, &block)
670
+ kwargs[:class] ||= ""
671
+ kwargs[:class] += " whitespace-pre underline underline-offset-1 hover:underline-offset-2 visited:underline-decoration-2"
672
+ super(*args, **kwargs, &block)
673
+ end
674
+ end
675
+
676
+ def self.render(audit_file)
677
+ target = +""
678
+ renderer = SaftRenderer.new(target)
679
+ renderer.audit_file = audit_file
680
+ renderer << audit_file
681
+
682
+ Tubby.new { |t| t.raw!(target) }
683
+ end
684
+ end
685
+ end