ekwo-os 0.5.0 → 0.7.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.
Files changed (58) hide show
  1. package/README.md +195 -17
  2. package/dist/assets/expected-objects.json +107 -2
  3. package/dist/assets/migrations/20260922081645_books_taken_over_from_an_export.sql +410 -0
  4. package/dist/assets/migrations/20260922091348_schema_version_0_6_0.sql +37 -0
  5. package/dist/assets/migrations/20260922115309_an_installation_without_a_country.sql +24 -0
  6. package/dist/assets/migrations/20260922120418_import_already_done_says_what.sql +284 -0
  7. package/dist/assets/migrations/20260922124012_schema_version_0_7_0.sql +40 -0
  8. package/dist/assets/seed/00_currencies.sql +2 -1
  9. package/dist/assets/seed/00_territories.sql +44 -0
  10. package/dist/assets/seed/62_pack_ch.sql +595 -0
  11. package/dist/assets/seed/63_pack_at.sql +644 -0
  12. package/dist/assets/seed/64_pack_pl.sql +698 -0
  13. package/dist/assets/seed/71_pack_sa.sql +620 -0
  14. package/dist/auth.d.ts +17 -0
  15. package/dist/auth.d.ts.map +1 -1
  16. package/dist/auth.js +24 -6
  17. package/dist/auth.js.map +1 -1
  18. package/dist/bootstrap.d.ts +17 -0
  19. package/dist/bootstrap.d.ts.map +1 -1
  20. package/dist/bootstrap.js +29 -8
  21. package/dist/bootstrap.js.map +1 -1
  22. package/dist/cli.d.ts +1 -1
  23. package/dist/cli.d.ts.map +1 -1
  24. package/dist/cli.js +61 -5
  25. package/dist/cli.js.map +1 -1
  26. package/dist/commands/company.d.ts +14 -2
  27. package/dist/commands/company.d.ts.map +1 -1
  28. package/dist/commands/company.js +185 -8
  29. package/dist/commands/company.js.map +1 -1
  30. package/dist/commands/import.d.ts +34 -0
  31. package/dist/commands/import.d.ts.map +1 -0
  32. package/dist/commands/import.js +247 -0
  33. package/dist/commands/import.js.map +1 -0
  34. package/dist/commands/init.d.ts +1 -1
  35. package/dist/commands/init.d.ts.map +1 -1
  36. package/dist/commands/init.js +132 -123
  37. package/dist/commands/init.js.map +1 -1
  38. package/dist/commands/status.js +1 -1
  39. package/dist/commands/status.js.map +1 -1
  40. package/dist/company-choices.d.ts +66 -0
  41. package/dist/company-choices.d.ts.map +1 -0
  42. package/dist/company-choices.js +165 -0
  43. package/dist/company-choices.js.map +1 -0
  44. package/dist/config.d.ts +11 -5
  45. package/dist/config.d.ts.map +1 -1
  46. package/dist/config.js +11 -6
  47. package/dist/config.js.map +1 -1
  48. package/dist/index.d.ts +2 -2
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +2 -2
  51. package/dist/index.js.map +1 -1
  52. package/dist/registry.d.ts +3 -2
  53. package/dist/registry.d.ts.map +1 -1
  54. package/dist/registry.js.map +1 -1
  55. package/dist/schema.d.ts +1 -1
  56. package/dist/schema.js +1 -1
  57. package/package.json +3 -3
  58. package/schema/output.1.json +110 -22
@@ -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;
@@ -0,0 +1,37 @@
1
+ -- Ekwo OS — the schema this release defines is 0.6.0.
2
+ --
3
+ -- `ekwo_schema_version()` is the one place the number lives, and a migration
4
+ -- is the only thing that moves it. A database then answers for the files it
5
+ -- has actually run rather than for what somebody believed it had run: the
6
+ -- column default and `init_instance()` both call this function, so a fresh
7
+ -- install records the new number without a second edit, and `ekwo migrate`
8
+ -- reads it back onto `instance.schema_version` for a database that was
9
+ -- installed at an older one.
10
+ --
11
+ -- Between 0.5.0 and here the schema learnt to take over books kept elsewhere:
12
+ -- `import_books()`, which opens the fiscal years asked for through
13
+ -- `import_fiscal_year_for()`, finds or creates the parties, posts every entry
14
+ -- through `post_entry()` and a balance through `opening_balance()` in one
15
+ -- transaction, and `book_imports`, which records each import and refuses the
16
+ -- same files twice. `ekwo import` and the `import_books` tool of the MCP
17
+ -- server call it, and a 0.5.0 database has none of it. So the packages
18
+ -- declare 0.6.0 as their floor and refuse an older database by name, rather
19
+ -- than improvising over a function that is not there.
20
+
21
+ create or replace function ekwo_schema_version()
22
+ returns text
23
+ language sql
24
+ immutable
25
+ as $$
26
+ select '0.6.0'::text;
27
+ $$;
28
+
29
+ comment on function ekwo_schema_version() is
30
+ 'Schema version of the installed release. Bumped by a migration, never by hand.';
31
+
32
+ -- `create or replace` keeps the privileges the function already had, and the
33
+ -- revoke is what every migration of this directory ends with: a function that
34
+ -- comes out with the built-in default is published by Supabase as an
35
+ -- anonymous RPC endpoint.
36
+ revoke execute on all functions in schema public from public;
37
+ grant execute on function ekwo_schema_version() to authenticated, service_role;
@@ -0,0 +1,24 @@
1
+ -- Ekwo OS — an installation is not in a country; its companies are.
2
+ --
3
+ -- `instance.country` was `not null` because the installation used to be set up
4
+ -- *for* one country: `ekwo init` created the first company in the same run and
5
+ -- wrote its country on the instance row. It never meant more than that. The
6
+ -- seeds of every pack are loaded whatever it says, `create_company()` takes a
7
+ -- country per company, and nothing in the schema reads the column — a firm
8
+ -- keeping the books of companies in two countries has had one installation
9
+ -- for both since the first release.
10
+ --
11
+ -- `ekwo init --no-company` installs the schema, the packs and the first
12
+ -- administrator and creates no company, so there is no country to write down,
13
+ -- and inventing one would be a default nobody chose. The column becomes
14
+ -- nullable. The format check stays: a country, when there is one, is still two
15
+ -- capital letters, and a null passes a check by definition.
16
+ --
17
+ -- `init_instance()` is unchanged: `upper(null)` is null, and the row now takes
18
+ -- it. An installation set up with a company keeps the country of that company,
19
+ -- as before.
20
+
21
+ alter table instance alter column country drop not null;
22
+
23
+ comment on column instance.country is
24
+ 'The country of the first company, when `ekwo init` created one with the installation. Null for an installation set up without a company. Informative: every company carries its own country, and nothing reads this one to decide anything.';