ekwo-os 0.5.0 → 0.6.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.
- package/README.md +95 -0
- package/dist/assets/expected-objects.json +107 -2
- package/dist/assets/migrations/20260922081645_books_taken_over_from_an_export.sql +410 -0
- package/dist/assets/migrations/20260922091348_schema_version_0_6_0.sql +37 -0
- package/dist/assets/seed/00_currencies.sql +2 -1
- package/dist/assets/seed/00_territories.sql +44 -0
- package/dist/assets/seed/62_pack_ch.sql +595 -0
- package/dist/assets/seed/63_pack_at.sql +644 -0
- package/dist/assets/seed/64_pack_pl.sql +698 -0
- package/dist/assets/seed/71_pack_sa.sql +620 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +36 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/import.d.ts +34 -0
- package/dist/commands/import.d.ts.map +1 -0
- package/dist/commands/import.js +228 -0
- package/dist/commands/import.js.map +1 -0
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +1 -1
- package/package.json +3 -3
- package/schema/output.1.json +39 -0
package/README.md
CHANGED
|
@@ -271,6 +271,7 @@ instead if the account already exists, and no key is needed.
|
|
|
271
271
|
| `ekwo reverse <entry>` | Undoes a posted entry keyed by hand, through `reverse_entry()`: its mirror, posted and matched against it. By id or by number; `--date` as for `cancel`. |
|
|
272
272
|
| `ekwo payment record` | Money in or out, booked and matched. With `--doc`, against that document. |
|
|
273
273
|
| `ekwo match <transaction> <document>` | A bank statement line pays a document, through `settle_from_statement()`. |
|
|
274
|
+
| `ekwo import <source> <file>…` | Books kept elsewhere — `trial-balance`, `fec`, `journal-items`, `journal-report` — whole or not at all, through `import_books()`, with a correspondence of accounts and journals you save and give back; or a bank statement — `camt.053`, `coda`, `cfonb120` — as pending lines. `--dry-run` rehearses. See [taking over books](#taking-over-books-ekwo-import). |
|
|
274
275
|
| `ekwo doc list` / `show` | What exists, and with `--unpaid` what is posted and still owed. See [keeping books](#keeping-books). |
|
|
275
276
|
|
|
276
277
|
There is no `eject`, because there is nothing to eject from. The schema is in
|
|
@@ -506,6 +507,100 @@ Two values are supplied when nobody gives them, and said when they are:
|
|
|
506
507
|
`--type` is `sale_invoice`, and `--date` is today on the machine running the
|
|
507
508
|
command. Whether that date may be booked on is the database's decision.
|
|
508
509
|
|
|
510
|
+
## Taking over books: `ekwo import`
|
|
511
|
+
|
|
512
|
+
To try Ekwo on your own books, bring them. One command, one reader per
|
|
513
|
+
source, and nothing is posted while an account has no answer:
|
|
514
|
+
|
|
515
|
+
```bash
|
|
516
|
+
ekwo import fec 123456789FEC20251231.txt --dry-run --open-years --save-mapping map.json
|
|
517
|
+
# read, propose a correspondence, rehearse the import in the database, take it back
|
|
518
|
+
$EDITOR map.json # answer what is null, correct what is wrong
|
|
519
|
+
ekwo import fec 123456789FEC20251231.txt --mapping map.json --open-years
|
|
520
|
+
ekwo import trial-balance balance.csv --opening-date 2026-01-01 --dry-run --save-mapping map.json
|
|
521
|
+
ekwo import journal-items items.csv accounts.csv partners.csv --dry-run --save-mapping map.json
|
|
522
|
+
ekwo import journal-report report.csv chart.csv --date-order dmy --dry-run --save-mapping map.json
|
|
523
|
+
ekwo import camt.053 statement.xml # or coda, cfonb120: pending lines for `ekwo match`
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
| Source | What it reads |
|
|
527
|
+
|---|---|
|
|
528
|
+
| `trial-balance` | A trial balance as CSV — `account`, `debit`, `credit`, or one signed `balance` — which becomes the opening entry of the year `--opening-date` starts |
|
|
529
|
+
| `fec` | A *fichier des écritures comptables*: the eighteen columns of the arrêté of 29 July 2013, tab or bar separated |
|
|
530
|
+
| `journal-items` | The lines of every entry exported as CSV from the list view of an ERP whose ledger is a table of lines, with the chart of accounts and the partners exported beside it |
|
|
531
|
+
| `journal-report` | A journal report or a general ledger detail, saved as CSV from a cloud service's spreadsheet export, with its Journal ID and Account Code columns, and the chart and the contacts beside it |
|
|
532
|
+
| `camt.053`, `coda`, `cfonb120` | A bank statement: its lines, pending, ready for `ekwo match` |
|
|
533
|
+
|
|
534
|
+
Each reader is a brick of [`packages/formats/`](../formats/README.md), named
|
|
535
|
+
after the file; its README says which columns it reads and which official
|
|
536
|
+
pages the format was read from. The exports of other ledgers can also be named
|
|
537
|
+
by the software they come from: `ekwo import --help` lists those names, and
|
|
538
|
+
[`docs/compatibility.md`](../../docs/compatibility.md) gives each one's export,
|
|
539
|
+
official page and state.
|
|
540
|
+
|
|
541
|
+
**The correspondence is yours.** Every account of the old chart has to become
|
|
542
|
+
an account of the company's chart, and every old journal a journal of the
|
|
543
|
+
company. The core proposes, from the codes alone — the same code, the same
|
|
544
|
+
digits without the zeros a chart pads with (`411` and `411000`), or the
|
|
545
|
+
account whose digits are the longest beginning of the old code, three at least
|
|
546
|
+
(`401ACME` → `401000`); a tie is no answer — and each proposal says which rule
|
|
547
|
+
found it. `--save-mapping` writes it as JSON:
|
|
548
|
+
|
|
549
|
+
```json
|
|
550
|
+
{
|
|
551
|
+
"version": 1,
|
|
552
|
+
"source": "fec",
|
|
553
|
+
"accounts": { "411000": "411000", "401ACME": "401000", "471200": null },
|
|
554
|
+
"journals": { "VE": "SAL", "AN": "@opening", "BQ1": "MISC" }
|
|
555
|
+
}
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
and `--mapping` gives it back: what it answers wins over the proposal, so the
|
|
559
|
+
second run posts what the first one showed. A journal mapped to `@opening`
|
|
560
|
+
becomes the opening entry of its year instead of ordinary entries — the
|
|
561
|
+
*à-nouveaux* of a FEC, typically. A journal whose code is the one the pack
|
|
562
|
+
opens years on is proposed as `@opening` on its own; `*` stands for entries
|
|
563
|
+
the source gives no journal.
|
|
564
|
+
|
|
565
|
+
**Whole or not at all.** `import_books()` is one call and one transaction: the
|
|
566
|
+
fiscal years it needs (with `--open-years`, as years of the same length and
|
|
567
|
+
first day as the company's own; without, an entry outside every year is
|
|
568
|
+
refused by name), the parties the lines name — found by their code or their
|
|
569
|
+
name, or created as customers or suppliers according to where their lines are
|
|
570
|
+
booked — every entry as a draft then posted by `post_entry()`, and the opening
|
|
571
|
+
through `opening_balance()`. One refusal — a locked period, an account the
|
|
572
|
+
chart does not have, a capability you do not hold — and nothing stays, not
|
|
573
|
+
even the years or the parties.
|
|
574
|
+
|
|
575
|
+
**`--dry-run` is the real thing, taken back.** The database runs the whole
|
|
576
|
+
import and rolls it back, so the numbers shown are the ones the entries would
|
|
577
|
+
take now and a refusal is the one the import would give. For a bank statement a
|
|
578
|
+
dry run reads the file and asks nothing: a statement books nothing anyway.
|
|
579
|
+
|
|
580
|
+
What else to know:
|
|
581
|
+
|
|
582
|
+
- **Refused before the database is asked**, and listed under `refusals` by a
|
|
583
|
+
dry run: an account or a journal with no answer, books whose reader found
|
|
584
|
+
something that does not add up (an entry that does not balance, a line of a
|
|
585
|
+
draft entry), a currency the files name that is not the company's.
|
|
586
|
+
- **The numbers** are drawn by the journal each entry goes to; the old number
|
|
587
|
+
is kept as the entry's reference. `--keep-numbers` posts each under its old
|
|
588
|
+
number instead, where the country allows a number chosen by hand or you hold
|
|
589
|
+
`entries.import`.
|
|
590
|
+
- **No tax.** An imported line carries an account and an amount, not the tax
|
|
591
|
+
that produced it: the history feeds the ledger, the trial balance and the
|
|
592
|
+
statements, and no box of a VAT return. A period kept elsewhere was declared
|
|
593
|
+
from where it was kept.
|
|
594
|
+
- **The same files twice are refused** (`import_already_done`): `book_imports`
|
|
595
|
+
keeps the checksum of what each import read.
|
|
596
|
+
- **Dates and encodings are said, never guessed**: `--encoding` for a file
|
|
597
|
+
that is not UTF-8, `--date-order` for a report that writes dates in digits.
|
|
598
|
+
- **Reconciliation marks are read and not re-applied yet**: the matching of an
|
|
599
|
+
imported receivable against its payment is done in Ekwo, with `reconcile`.
|
|
600
|
+
|
|
601
|
+
The MCP server offers the same as `import_books`, and `import_bank_statement`
|
|
602
|
+
for a statement. [`docs/import.md`](../../docs/import.md) is the long form.
|
|
603
|
+
|
|
509
604
|
## `ekwo module`
|
|
510
605
|
|
|
511
606
|
A module is a Postgres schema beside the socle — `assets` for fixed assets,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"schemaVersion": "0.
|
|
3
|
-
"migration": "
|
|
2
|
+
"schemaVersion": "0.6.0",
|
|
3
|
+
"migration": "20260922091348",
|
|
4
4
|
"socle": {
|
|
5
5
|
"schema": "public",
|
|
6
6
|
"tables": [
|
|
@@ -599,6 +599,59 @@
|
|
|
599
599
|
}
|
|
600
600
|
]
|
|
601
601
|
},
|
|
602
|
+
{
|
|
603
|
+
"name": "book_imports",
|
|
604
|
+
"columns": [
|
|
605
|
+
{
|
|
606
|
+
"name": "checksum",
|
|
607
|
+
"type": "text"
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
"name": "company_id",
|
|
611
|
+
"type": "uuid"
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
"name": "created_at",
|
|
615
|
+
"type": "timestamp with time zone"
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
"name": "created_by",
|
|
619
|
+
"type": "uuid"
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
"name": "entry_count",
|
|
623
|
+
"type": "integer"
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
"name": "file_names",
|
|
627
|
+
"type": "text[]"
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
"name": "first_number",
|
|
631
|
+
"type": "text"
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
"name": "id",
|
|
635
|
+
"type": "uuid"
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
"name": "last_number",
|
|
639
|
+
"type": "text"
|
|
640
|
+
},
|
|
641
|
+
{
|
|
642
|
+
"name": "line_count",
|
|
643
|
+
"type": "integer"
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
"name": "opening_number",
|
|
647
|
+
"type": "text"
|
|
648
|
+
},
|
|
649
|
+
{
|
|
650
|
+
"name": "source",
|
|
651
|
+
"type": "text"
|
|
652
|
+
}
|
|
653
|
+
]
|
|
654
|
+
},
|
|
602
655
|
{
|
|
603
656
|
"name": "capabilities",
|
|
604
657
|
"columns": [
|
|
@@ -4078,10 +4131,18 @@
|
|
|
4078
4131
|
"name": "import_bank_statement",
|
|
4079
4132
|
"arguments": "p_company_id uuid, p_file jsonb, p_source jsonb, p_bank_account_id uuid"
|
|
4080
4133
|
},
|
|
4134
|
+
{
|
|
4135
|
+
"name": "import_books",
|
|
4136
|
+
"arguments": "p_company_id uuid, p_books jsonb, p_dry_run boolean, p_open_years boolean, p_allow_result_accounts boolean"
|
|
4137
|
+
},
|
|
4081
4138
|
{
|
|
4082
4139
|
"name": "import_company",
|
|
4083
4140
|
"arguments": "p_archive jsonb, p_owner_user_id uuid"
|
|
4084
4141
|
},
|
|
4142
|
+
{
|
|
4143
|
+
"name": "import_fiscal_year_for",
|
|
4144
|
+
"arguments": "p_company_id uuid, p_date date"
|
|
4145
|
+
},
|
|
4085
4146
|
{
|
|
4086
4147
|
"name": "init_instance",
|
|
4087
4148
|
"arguments": "p_organization_name text, p_country character, p_edition instance_edition"
|
|
@@ -4569,6 +4630,16 @@
|
|
|
4569
4630
|
"name": "bank_transactions_write",
|
|
4570
4631
|
"command": "all"
|
|
4571
4632
|
},
|
|
4633
|
+
{
|
|
4634
|
+
"table": "book_imports",
|
|
4635
|
+
"name": "book_imports_insert",
|
|
4636
|
+
"command": "insert"
|
|
4637
|
+
},
|
|
4638
|
+
{
|
|
4639
|
+
"table": "book_imports",
|
|
4640
|
+
"name": "book_imports_select",
|
|
4641
|
+
"command": "select"
|
|
4642
|
+
},
|
|
4572
4643
|
{
|
|
4573
4644
|
"table": "capabilities",
|
|
4574
4645
|
"name": "capabilities_select",
|
|
@@ -5959,6 +6030,18 @@
|
|
|
5959
6030
|
"update"
|
|
5960
6031
|
]
|
|
5961
6032
|
},
|
|
6033
|
+
{
|
|
6034
|
+
"name": "book_imports",
|
|
6035
|
+
"anon": [],
|
|
6036
|
+
"authenticated": [
|
|
6037
|
+
"insert",
|
|
6038
|
+
"select"
|
|
6039
|
+
],
|
|
6040
|
+
"service_role": [
|
|
6041
|
+
"insert",
|
|
6042
|
+
"select"
|
|
6043
|
+
]
|
|
6044
|
+
},
|
|
5962
6045
|
{
|
|
5963
6046
|
"name": "capabilities",
|
|
5964
6047
|
"anon": [],
|
|
@@ -7696,6 +7779,17 @@
|
|
|
7696
7779
|
"execute"
|
|
7697
7780
|
]
|
|
7698
7781
|
},
|
|
7782
|
+
{
|
|
7783
|
+
"name": "import_books",
|
|
7784
|
+
"arguments": "p_company_id uuid, p_books jsonb, p_dry_run boolean, p_open_years boolean, p_allow_result_accounts boolean",
|
|
7785
|
+
"anon": [],
|
|
7786
|
+
"authenticated": [
|
|
7787
|
+
"execute"
|
|
7788
|
+
],
|
|
7789
|
+
"service_role": [
|
|
7790
|
+
"execute"
|
|
7791
|
+
]
|
|
7792
|
+
},
|
|
7699
7793
|
{
|
|
7700
7794
|
"name": "import_company",
|
|
7701
7795
|
"arguments": "p_archive jsonb, p_owner_user_id uuid",
|
|
@@ -7707,6 +7801,17 @@
|
|
|
7707
7801
|
"execute"
|
|
7708
7802
|
]
|
|
7709
7803
|
},
|
|
7804
|
+
{
|
|
7805
|
+
"name": "import_fiscal_year_for",
|
|
7806
|
+
"arguments": "p_company_id uuid, p_date date",
|
|
7807
|
+
"anon": [],
|
|
7808
|
+
"authenticated": [
|
|
7809
|
+
"execute"
|
|
7810
|
+
],
|
|
7811
|
+
"service_role": [
|
|
7812
|
+
"execute"
|
|
7813
|
+
]
|
|
7814
|
+
},
|
|
7710
7815
|
{
|
|
7711
7816
|
"name": "init_instance",
|
|
7712
7817
|
"arguments": "p_organization_name text, p_country character, p_edition instance_edition",
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
-- Ekwo OS — books taken over from whatever kept them before.
|
|
2
|
+
--
|
|
3
|
+
-- Somebody who wants to try Ekwo on their own books arrives with them: a FEC,
|
|
4
|
+
-- an export of journal items, a trial balance saved from a spreadsheet. Until
|
|
5
|
+
-- this migration the only way in was one function per act — `opening_balance()`
|
|
6
|
+
-- for the balances, a draft and `post_entry()` for each entry — called by
|
|
7
|
+
-- whoever wrote the loop, with nothing to say that the loop had run to the end.
|
|
8
|
+
-- A file of three thousand entries that stops at the one thousand and first is
|
|
9
|
+
-- the worst outcome an import has: books that look complete and are not.
|
|
10
|
+
--
|
|
11
|
+
-- So an import is **one call**, `import_books()`, and one transaction. It
|
|
12
|
+
-- takes the books as a reader of `packages/formats/` returns them, with every
|
|
13
|
+
-- account code already translated into this company's chart by the caller —
|
|
14
|
+
-- the correspondence is a decision of the user's, proposed and saved on their
|
|
15
|
+
-- side, and never guessed here — and it writes all of them or none:
|
|
16
|
+
--
|
|
17
|
+
-- * the fiscal years the entries fall in, where asked to, as years of the
|
|
18
|
+
-- same length and on the same day as the ones the company has;
|
|
19
|
+
-- * the parties the lines name, found by their code or their name, or
|
|
20
|
+
-- created;
|
|
21
|
+
-- * every entry as a draft, then posted by `post_entry()`, which is the only
|
|
22
|
+
-- way an entry is posted and the one that numbers it;
|
|
23
|
+
-- * the balances of a trial balance, through `opening_balance()`, which posts
|
|
24
|
+
-- through `post_entry()` in its turn.
|
|
25
|
+
--
|
|
26
|
+
-- **A rehearsal is the same call.** `p_dry_run` runs every one of those steps
|
|
27
|
+
-- and rolls them back, the way `rehearse_post_document()` does, so what it
|
|
28
|
+
-- answers is what the import would write and a refusal is the one the import
|
|
29
|
+
-- would give — a locked period, a capability the caller lacks, an unknown
|
|
30
|
+
-- account. Nothing about a rehearsal is computed a second way.
|
|
31
|
+
--
|
|
32
|
+
-- **Imported history carries no tax.** A line comes with an account and an
|
|
33
|
+
-- amount, and not with the tax that produced it: the file has no such thing to
|
|
34
|
+
-- give. So the entries of an import feed the ledger, the trial balance and the
|
|
35
|
+
-- statements, and no box of a VAT return. A declaration of a period that was
|
|
36
|
+
-- kept elsewhere was filed from where it was kept.
|
|
37
|
+
--
|
|
38
|
+
-- **The same files twice are refused.** `book_imports` keeps one row per
|
|
39
|
+
-- import with the checksum of what was read, unique per company: an import
|
|
40
|
+
-- that is repeated after a dropped connection finds the first one and writes
|
|
41
|
+
-- nothing, rather than doubling the books.
|
|
42
|
+
|
|
43
|
+
-- ---------------------------------------------------------------------------
|
|
44
|
+
-- 1. What was imported
|
|
45
|
+
-- ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
create table if not exists book_imports (
|
|
48
|
+
id uuid primary key default gen_random_uuid(),
|
|
49
|
+
company_id uuid not null references companies(id) on delete cascade,
|
|
50
|
+
source text not null,
|
|
51
|
+
checksum text not null,
|
|
52
|
+
file_names text[] not null default '{}',
|
|
53
|
+
entry_count integer not null,
|
|
54
|
+
line_count integer not null,
|
|
55
|
+
first_number text,
|
|
56
|
+
last_number text,
|
|
57
|
+
opening_number text,
|
|
58
|
+
created_by uuid default auth.uid(),
|
|
59
|
+
created_at timestamptz not null default now(),
|
|
60
|
+
constraint book_imports_source_named check (source ~ '^[a-z0-9][a-z0-9.-]{0,39}$'),
|
|
61
|
+
constraint book_imports_checksum_named check (checksum ~ '^sha256:[0-9a-f]{64}$'),
|
|
62
|
+
constraint book_imports_counts check (entry_count >= 0 and line_count >= 0)
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
comment on table book_imports is
|
|
66
|
+
'One row per set of books taken over by import_books(): which reader, the checksum of the files, how many entries and lines, and the numbers they were posted under. The same files twice in one company are refused by the unique checksum.';
|
|
67
|
+
comment on column book_imports.source is
|
|
68
|
+
'The reader the files were read with — fec, trial-balance, journal-items, journal-report — as the caller named it.';
|
|
69
|
+
comment on column book_imports.checksum is
|
|
70
|
+
'sha256 of the files read, in the order given, as the caller computed it. Unique per company, which is what refuses the same import twice.';
|
|
71
|
+
comment on column book_imports.first_number is
|
|
72
|
+
'Number of the first entry the import posted, and last_number of the last: the range to read, or to reverse, afterwards.';
|
|
73
|
+
comment on column book_imports.opening_number is
|
|
74
|
+
'Number of the opening entry the import posted through opening_balance(), when the files carried a trial balance.';
|
|
75
|
+
|
|
76
|
+
create unique index if not exists book_imports_company_checksum_idx on book_imports (company_id, checksum);
|
|
77
|
+
|
|
78
|
+
alter table book_imports enable row level security;
|
|
79
|
+
|
|
80
|
+
create policy book_imports_select on book_imports
|
|
81
|
+
for select using (company_id = any ((select companies_with_capability('entries.read'))::uuid[]));
|
|
82
|
+
create policy book_imports_insert on book_imports
|
|
83
|
+
for insert with check (company_id = any ((select companies_with_capability('entries.post'))::uuid[]));
|
|
84
|
+
|
|
85
|
+
grant select, insert on table book_imports to authenticated, service_role;
|
|
86
|
+
|
|
87
|
+
-- A company leaves with the record of how its history arrived.
|
|
88
|
+
insert into company_archive_registry (table_name, disposition, load_order, via_column, via_table, reason) values
|
|
89
|
+
('book_imports', 'exported', 65, null, null,
|
|
90
|
+
'Which files the history of the company was taken over from, and the entries each posted.')
|
|
91
|
+
on conflict (table_schema, table_name) do nothing;
|
|
92
|
+
|
|
93
|
+
-- ---------------------------------------------------------------------------
|
|
94
|
+
-- 2. The fiscal year a date falls in, opened on the company's own rhythm
|
|
95
|
+
--
|
|
96
|
+
-- A company has a first year from `create_company()`. The year before it, or
|
|
97
|
+
-- after it, is the same length and starts on the same day of the same month:
|
|
98
|
+
-- that is the only thing the books themselves say about how the company counts
|
|
99
|
+
-- its years, and it is the one thing read here. Where the company has no year
|
|
100
|
+
-- at all there is nothing to align on, and the caller is refused.
|
|
101
|
+
-- ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
create or replace function import_fiscal_year_for(p_company_id uuid, p_date date)
|
|
104
|
+
returns uuid
|
|
105
|
+
language plpgsql
|
|
106
|
+
as $$
|
|
107
|
+
declare
|
|
108
|
+
v_found uuid;
|
|
109
|
+
v_anchor date;
|
|
110
|
+
v_start date;
|
|
111
|
+
v_id uuid;
|
|
112
|
+
begin
|
|
113
|
+
v_found := fiscal_year_at(p_company_id, p_date);
|
|
114
|
+
if v_found is not null then
|
|
115
|
+
return v_found;
|
|
116
|
+
end if;
|
|
117
|
+
|
|
118
|
+
select f.start_date into v_anchor
|
|
119
|
+
from fiscal_years f
|
|
120
|
+
where f.company_id = p_company_id
|
|
121
|
+
order by f.start_date
|
|
122
|
+
limit 1;
|
|
123
|
+
if v_anchor is null then
|
|
124
|
+
raise exception 'no_fiscal_year: the company has no fiscal year to align a new one on; open one first'
|
|
125
|
+
using errcode = '55006';
|
|
126
|
+
end if;
|
|
127
|
+
|
|
128
|
+
v_start := v_anchor + make_interval(years => extract(year from p_date)::integer - extract(year from v_anchor)::integer);
|
|
129
|
+
while v_start > p_date loop
|
|
130
|
+
v_start := v_start - interval '1 year';
|
|
131
|
+
end loop;
|
|
132
|
+
while v_start + interval '1 year' <= p_date loop
|
|
133
|
+
v_start := v_start + interval '1 year';
|
|
134
|
+
end loop;
|
|
135
|
+
|
|
136
|
+
insert into fiscal_years (company_id, name, start_date, end_date)
|
|
137
|
+
values (p_company_id, 'FY' || extract(year from v_start)::integer::text, v_start,
|
|
138
|
+
(v_start + interval '1 year' - interval '1 day')::date)
|
|
139
|
+
returning id into v_id;
|
|
140
|
+
return v_id;
|
|
141
|
+
end;
|
|
142
|
+
$$;
|
|
143
|
+
|
|
144
|
+
comment on function import_fiscal_year_for(uuid, date) is
|
|
145
|
+
'The fiscal year a date falls in, opened when there is none as a year of the same length and on the same first day as the company''s earliest one. Called by import_books() when the caller asks for years to be opened.';
|
|
146
|
+
|
|
147
|
+
-- ---------------------------------------------------------------------------
|
|
148
|
+
-- 3. import_books
|
|
149
|
+
-- ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
create or replace function import_books(
|
|
152
|
+
p_company_id uuid,
|
|
153
|
+
p_books jsonb,
|
|
154
|
+
p_dry_run boolean default false,
|
|
155
|
+
p_open_years boolean default false,
|
|
156
|
+
p_allow_result_accounts boolean default false
|
|
157
|
+
)
|
|
158
|
+
returns jsonb
|
|
159
|
+
language plpgsql
|
|
160
|
+
volatile
|
|
161
|
+
security invoker
|
|
162
|
+
as $$
|
|
163
|
+
declare
|
|
164
|
+
v_checksum text := p_books ->> 'checksum';
|
|
165
|
+
v_source text := p_books ->> 'source';
|
|
166
|
+
v_previous book_imports;
|
|
167
|
+
v_result jsonb;
|
|
168
|
+
v_contacts jsonb := '{}'::jsonb;
|
|
169
|
+
v_contact jsonb;
|
|
170
|
+
v_contact_id uuid;
|
|
171
|
+
v_created integer := 0;
|
|
172
|
+
v_found integer := 0;
|
|
173
|
+
v_entry jsonb;
|
|
174
|
+
v_draft entries;
|
|
175
|
+
v_journal uuid;
|
|
176
|
+
v_date date;
|
|
177
|
+
v_missing text;
|
|
178
|
+
v_years jsonb := '[]'::jsonb;
|
|
179
|
+
v_year uuid;
|
|
180
|
+
v_entries integer := 0;
|
|
181
|
+
v_lines integer := 0;
|
|
182
|
+
v_first text;
|
|
183
|
+
v_last text;
|
|
184
|
+
v_opening jsonb := p_books -> 'opening';
|
|
185
|
+
v_opening_id uuid;
|
|
186
|
+
v_opening_no text;
|
|
187
|
+
v_debit numeric := 0;
|
|
188
|
+
v_credit numeric := 0;
|
|
189
|
+
v_journals jsonb;
|
|
190
|
+
begin
|
|
191
|
+
if not exists (select 1 from companies c where c.id = p_company_id) then
|
|
192
|
+
raise exception 'unknown_company: %', p_company_id using errcode = 'no_data_found';
|
|
193
|
+
end if;
|
|
194
|
+
-- Row level security is what refuses; this is what says why.
|
|
195
|
+
if not coalesce(is_installer(), false) then
|
|
196
|
+
if not coalesce(has_capability(p_company_id, 'entries.write'), false)
|
|
197
|
+
or not coalesce(has_capability(p_company_id, 'entries.post'), false) then
|
|
198
|
+
raise exception 'not_allowed: importing books into this company needs entries.write and entries.post'
|
|
199
|
+
using errcode = '42501';
|
|
200
|
+
end if;
|
|
201
|
+
end if;
|
|
202
|
+
if v_source is null or v_checksum is null then
|
|
203
|
+
raise exception 'invalid_books: the books name no source or no checksum — expected them as the importer builds them';
|
|
204
|
+
end if;
|
|
205
|
+
if jsonb_typeof(coalesce(p_books -> 'entries', '[]'::jsonb)) <> 'array' then
|
|
206
|
+
raise exception 'invalid_books: entries must be a list';
|
|
207
|
+
end if;
|
|
208
|
+
if jsonb_array_length(coalesce(p_books -> 'entries', '[]'::jsonb)) = 0
|
|
209
|
+
and (v_opening is null or jsonb_typeof(v_opening) = 'null') then
|
|
210
|
+
raise exception 'import_empty: the files hold no entry and no balance; there is nothing to import';
|
|
211
|
+
end if;
|
|
212
|
+
|
|
213
|
+
select * into v_previous from book_imports b
|
|
214
|
+
where b.company_id = p_company_id and b.checksum = v_checksum;
|
|
215
|
+
if found then
|
|
216
|
+
raise exception 'import_already_done: these files were imported on % (% entries, % to %). Importing them again would count every amount twice',
|
|
217
|
+
to_char(v_previous.created_at, 'YYYY-MM-DD'), v_previous.entry_count,
|
|
218
|
+
coalesce(v_previous.first_number, '—'), coalesce(v_previous.last_number, '—')
|
|
219
|
+
using errcode = '23505';
|
|
220
|
+
end if;
|
|
221
|
+
|
|
222
|
+
begin
|
|
223
|
+
-- 1. The years. Each date that falls in none is refused by name, or opened
|
|
224
|
+
-- when the caller asked for it.
|
|
225
|
+
for v_date in
|
|
226
|
+
select distinct d::date from (
|
|
227
|
+
select e ->> 'date' as d from jsonb_array_elements(coalesce(p_books -> 'entries', '[]'::jsonb)) e
|
|
228
|
+
union
|
|
229
|
+
select v_opening ->> 'date' where v_opening is not null and jsonb_typeof(v_opening) <> 'null'
|
|
230
|
+
) dates
|
|
231
|
+
where d is not null
|
|
232
|
+
order by 1
|
|
233
|
+
loop
|
|
234
|
+
if fiscal_year_at(p_company_id, v_date) is null then
|
|
235
|
+
if not p_open_years then
|
|
236
|
+
raise exception 'import_outside_fiscal_year: % falls in no fiscal year of this company. Open that year, or ask the import to open the years it needs',
|
|
237
|
+
v_date
|
|
238
|
+
using errcode = '55006';
|
|
239
|
+
end if;
|
|
240
|
+
v_year := import_fiscal_year_for(p_company_id, v_date);
|
|
241
|
+
select v_years || jsonb_build_object('name', f.name, 'start_date', f.start_date::text, 'end_date', f.end_date::text)
|
|
242
|
+
into v_years from fiscal_years f where f.id = v_year;
|
|
243
|
+
end if;
|
|
244
|
+
end loop;
|
|
245
|
+
|
|
246
|
+
-- 2. The parties: by the code the source gave them, then by their name,
|
|
247
|
+
-- or created. A party the file names once is still one party.
|
|
248
|
+
for v_contact in select value from jsonb_array_elements(coalesce(p_books -> 'contacts', '[]'::jsonb)) loop
|
|
249
|
+
v_contact_id := null;
|
|
250
|
+
if nullif(v_contact ->> 'auxiliary_code', '') is not null then
|
|
251
|
+
select c.id into v_contact_id from contacts c
|
|
252
|
+
where c.company_id = p_company_id and c.auxiliary_code = v_contact ->> 'auxiliary_code'
|
|
253
|
+
order by c.created_at limit 1;
|
|
254
|
+
end if;
|
|
255
|
+
if v_contact_id is null then
|
|
256
|
+
select c.id into v_contact_id from contacts c
|
|
257
|
+
where c.company_id = p_company_id and lower(c.name) = lower(v_contact ->> 'name')
|
|
258
|
+
order by c.created_at limit 1;
|
|
259
|
+
end if;
|
|
260
|
+
if v_contact_id is null then
|
|
261
|
+
insert into contacts (company_id, name, contact_type, auxiliary_code,
|
|
262
|
+
vat_number, registration_number, email, country)
|
|
263
|
+
values (p_company_id, v_contact ->> 'name',
|
|
264
|
+
coalesce(v_contact ->> 'contact_type', 'other')::contact_type,
|
|
265
|
+
nullif(v_contact ->> 'auxiliary_code', ''),
|
|
266
|
+
nullif(v_contact ->> 'vat_number', ''),
|
|
267
|
+
nullif(v_contact ->> 'registration_number', ''),
|
|
268
|
+
nullif(v_contact ->> 'email', ''),
|
|
269
|
+
nullif(upper(v_contact ->> 'country'), ''))
|
|
270
|
+
returning id into v_contact_id;
|
|
271
|
+
v_created := v_created + 1;
|
|
272
|
+
else
|
|
273
|
+
v_found := v_found + 1;
|
|
274
|
+
end if;
|
|
275
|
+
v_contacts := v_contacts || jsonb_build_object(v_contact ->> 'key', v_contact_id);
|
|
276
|
+
end loop;
|
|
277
|
+
|
|
278
|
+
-- 3. The accounts every line names, all of them, before anything is
|
|
279
|
+
-- written: one refusal that lists them is worth more than the first.
|
|
280
|
+
select string_agg(distinct code, ', ' order by code) into v_missing
|
|
281
|
+
from (
|
|
282
|
+
select l ->> 'account_code' as code
|
|
283
|
+
from jsonb_array_elements(coalesce(p_books -> 'entries', '[]'::jsonb)) e,
|
|
284
|
+
jsonb_array_elements(e -> 'lines') l
|
|
285
|
+
union
|
|
286
|
+
select l ->> 'account_code'
|
|
287
|
+
from jsonb_array_elements(coalesce(v_opening -> 'lines', '[]'::jsonb)) l
|
|
288
|
+
) wanted
|
|
289
|
+
where not exists (select 1 from accounts a where a.company_id = p_company_id and a.code = wanted.code);
|
|
290
|
+
if v_missing is not null then
|
|
291
|
+
raise exception 'unknown_account: % % not in the chart of this company. Map each to an account of the chart',
|
|
292
|
+
v_missing, case when v_missing like '%,%' then 'are' else 'is' end
|
|
293
|
+
using errcode = 'no_data_found';
|
|
294
|
+
end if;
|
|
295
|
+
|
|
296
|
+
-- 4. The entries, each a draft and then posted by post_entry().
|
|
297
|
+
for v_entry in select value from jsonb_array_elements(coalesce(p_books -> 'entries', '[]'::jsonb)) loop
|
|
298
|
+
select j.id into v_journal from journals j
|
|
299
|
+
where j.company_id = p_company_id and j.code = v_entry ->> 'journal_code';
|
|
300
|
+
if v_journal is null then
|
|
301
|
+
raise exception 'unknown_journal: % is not a journal of this company', v_entry ->> 'journal_code'
|
|
302
|
+
using errcode = 'no_data_found';
|
|
303
|
+
end if;
|
|
304
|
+
|
|
305
|
+
insert into entries (company_id, journal_id, entry_date, number, reference, description, state, currency_code)
|
|
306
|
+
select p_company_id, v_journal, (v_entry ->> 'date')::date,
|
|
307
|
+
nullif(v_entry ->> 'number', ''),
|
|
308
|
+
nullif(v_entry ->> 'reference', ''),
|
|
309
|
+
nullif(v_entry ->> 'description', ''),
|
|
310
|
+
'draft', c.currency_code
|
|
311
|
+
from companies c where c.id = p_company_id
|
|
312
|
+
returning * into v_draft;
|
|
313
|
+
|
|
314
|
+
insert into entry_lines (entry_id, company_id, account_id, sequence, name, debit, credit,
|
|
315
|
+
contact_id, currency_code, amount_currency, date_maturity)
|
|
316
|
+
select v_draft.id, p_company_id, a.id, (l.ordinality * 10)::integer,
|
|
317
|
+
nullif(l.value ->> 'label', ''),
|
|
318
|
+
coalesce((l.value ->> 'debit')::numeric, 0),
|
|
319
|
+
coalesce((l.value ->> 'credit')::numeric, 0),
|
|
320
|
+
case when l.value ->> 'contact_key' is null then null
|
|
321
|
+
else (v_contacts ->> (l.value ->> 'contact_key'))::uuid end,
|
|
322
|
+
nullif(l.value ->> 'currency_code', ''),
|
|
323
|
+
nullif(l.value ->> 'amount_currency', '')::numeric,
|
|
324
|
+
nullif(l.value ->> 'date_maturity', '')::date
|
|
325
|
+
from jsonb_array_elements(v_entry -> 'lines') with ordinality l
|
|
326
|
+
join accounts a on a.company_id = p_company_id and a.code = l.value ->> 'account_code';
|
|
327
|
+
|
|
328
|
+
v_draft := post_entry(v_draft.id);
|
|
329
|
+
v_entries := v_entries + 1;
|
|
330
|
+
v_lines := v_lines + jsonb_array_length(v_entry -> 'lines');
|
|
331
|
+
v_debit := v_debit + v_draft.total_debit;
|
|
332
|
+
v_credit := v_credit + v_draft.total_credit;
|
|
333
|
+
v_first := coalesce(v_first, v_draft.number);
|
|
334
|
+
v_last := v_draft.number;
|
|
335
|
+
end loop;
|
|
336
|
+
|
|
337
|
+
-- 5. The balances, as the opening entry of the year they open.
|
|
338
|
+
if v_opening is not null and jsonb_typeof(v_opening) <> 'null' then
|
|
339
|
+
v_year := fiscal_year_at(p_company_id, (v_opening ->> 'date')::date);
|
|
340
|
+
if (select f.start_date from fiscal_years f where f.id = v_year) <> (v_opening ->> 'date')::date then
|
|
341
|
+
raise exception 'opening_not_first_day: an opening balance is dated on the first day of a fiscal year, and % is not; the year it falls in starts on %',
|
|
342
|
+
v_opening ->> 'date', (select f.start_date from fiscal_years f where f.id = v_year);
|
|
343
|
+
end if;
|
|
344
|
+
v_opening_id := opening_balance(
|
|
345
|
+
p_company_id, v_year,
|
|
346
|
+
(select jsonb_agg(jsonb_build_object(
|
|
347
|
+
'account_code', l ->> 'account_code',
|
|
348
|
+
'debit', l ->> 'debit',
|
|
349
|
+
'credit', l ->> 'credit',
|
|
350
|
+
'label', coalesce(nullif(l ->> 'label', ''), 'Opening balance'),
|
|
351
|
+
'contact_id', case when l ->> 'contact_key' is null then null
|
|
352
|
+
else v_contacts ->> (l ->> 'contact_key') end))
|
|
353
|
+
from jsonb_array_elements(v_opening -> 'lines') l),
|
|
354
|
+
p_allow_result_accounts);
|
|
355
|
+
select e.number into v_opening_no from entries e where e.id = v_opening_id;
|
|
356
|
+
v_lines := v_lines + jsonb_array_length(v_opening -> 'lines');
|
|
357
|
+
end if;
|
|
358
|
+
|
|
359
|
+
select coalesce(jsonb_agg(jsonb_build_object('journal_code', j.code, 'entries', n) order by j.code), '[]'::jsonb)
|
|
360
|
+
into v_journals
|
|
361
|
+
from (select e ->> 'journal_code' as code, count(*) as n
|
|
362
|
+
from jsonb_array_elements(coalesce(p_books -> 'entries', '[]'::jsonb)) e
|
|
363
|
+
group by 1) per
|
|
364
|
+
join journals j on j.company_id = p_company_id and j.code = per.code;
|
|
365
|
+
|
|
366
|
+
insert into book_imports (company_id, source, checksum, file_names, entry_count, line_count,
|
|
367
|
+
first_number, last_number, opening_number)
|
|
368
|
+
values (p_company_id, v_source, v_checksum,
|
|
369
|
+
coalesce((select array_agg(value) from jsonb_array_elements_text(coalesce(p_books -> 'file_names', '[]'::jsonb))), '{}'),
|
|
370
|
+
v_entries, v_lines, v_first, v_last, v_opening_no);
|
|
371
|
+
|
|
372
|
+
v_result := jsonb_build_object(
|
|
373
|
+
'dry_run', p_dry_run,
|
|
374
|
+
'source', v_source,
|
|
375
|
+
'entries', v_entries,
|
|
376
|
+
'lines', v_lines,
|
|
377
|
+
'total_debit', v_debit::text,
|
|
378
|
+
'total_credit', v_credit::text,
|
|
379
|
+
'first_number', v_first,
|
|
380
|
+
'last_number', v_last,
|
|
381
|
+
'opening_number', v_opening_no,
|
|
382
|
+
'journals', v_journals,
|
|
383
|
+
'contacts_created', v_created,
|
|
384
|
+
'contacts_found', v_found,
|
|
385
|
+
'fiscal_years_opened', v_years
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
if p_dry_run then
|
|
389
|
+
-- The way out of the block that undoes everything done inside it. A
|
|
390
|
+
-- variable is not part of the transaction, so the answer survives.
|
|
391
|
+
raise exception using errcode = 'EKW01', message = 'rehearsal';
|
|
392
|
+
end if;
|
|
393
|
+
exception
|
|
394
|
+
when sqlstate 'EKW01' then
|
|
395
|
+
null;
|
|
396
|
+
end;
|
|
397
|
+
|
|
398
|
+
return v_result;
|
|
399
|
+
end;
|
|
400
|
+
$$;
|
|
401
|
+
|
|
402
|
+
comment on function import_books(uuid, jsonb, boolean, boolean, boolean) is
|
|
403
|
+
'Takes over books read from another system, whole or not at all: the fiscal years they need (p_open_years), their parties, every entry posted through post_entry() and a trial balance through opening_balance(). Account codes arrive already translated into the company''s chart. p_dry_run does all of it and rolls it back, so the answer and the refusals are the real ones. The same files twice are refused (import_already_done).';
|
|
404
|
+
|
|
405
|
+
revoke execute on all functions in schema public from public;
|
|
406
|
+
|
|
407
|
+
revoke execute on function import_fiscal_year_for(uuid, date) from public, anon;
|
|
408
|
+
grant execute on function import_fiscal_year_for(uuid, date) to authenticated, service_role;
|
|
409
|
+
revoke execute on function import_books(uuid, jsonb, boolean, boolean, boolean) from public, anon;
|
|
410
|
+
grant execute on function import_books(uuid, jsonb, boolean, boolean, boolean) to authenticated, service_role;
|