ekwo-os 0.15.0 → 0.15.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.
package/README.md CHANGED
@@ -168,7 +168,7 @@ project reports what was already there and creates nothing a second time.
168
168
  | `ekwo use <company>` | Picks the company the next commands run on. |
169
169
  | `ekwo whoami` | Who you are on which instance, the companies you can see, and what you may do on the one in use. |
170
170
  | `ekwo contact add` / `list` | A customer or a supplier, and finding one again. |
171
- | `ekwo contact duplicates` | The pairs of contacts that are probably one party, strongest first, through `suggest_duplicate_contacts()`: which one to keep, a score, and the evidence — the same VAT or registration number, the same bank account, the same words of the name. Writes nothing. |
171
+ | `ekwo contact duplicates` | The pairs of contacts that are probably one party, strongest first, through `suggest_duplicate_contacts()`: which one to keep, a score, and the evidence — the same VAT or registration number, the same bank account, the words of the name weighed by how few contacts carry them. Writes nothing. |
172
172
  | `ekwo contact merge <keep> <absorb>` | One party created twice — two spellings, two imports — joined through `merge_contacts()`: everything that names the second, posted lines and issued documents included, names the first; the first takes the fields it left empty; the second is retired, never deleted, and named by nothing again. By name or id. |
173
173
  | `ekwo doc new` / `doc line add` | A draft document — any kind, with `--type` — and one more line on it. A draft books nothing. `ekwo invoice` is the old name of `ekwo doc`, kept as an alias. |
174
174
  | `ekwo post <document>` | Books it, through `post_document()`. `--dry-run` shows the entry the database would write and writes nothing. |
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": "0.15.0",
3
- "migration": "20261019160000",
3
+ "migration": "20261019161437",
4
4
  "socle": {
5
5
  "schema": "public",
6
6
  "tables": [
@@ -0,0 +1,270 @@
1
+ -- Ekwo OS — a pair of contacts by name weighs its words (decision 0075,
2
+ -- amended).
3
+ --
4
+ -- `suggest_duplicate_contacts()` (`20261019104812`) proposed a pair when every
5
+ -- significant word of one name was in the other. On books of about two hundred
6
+ -- contacts that made pairs of "LG SERVICES" and "ASBL UCM SERVICES", "P & V
7
+ -- Verzekeringen" and "KBC Verzekeringen", "Parking" and "Parking Cathédrale
8
+ -- SA", "DKV Belgium" and "APPLE RETAIL BELGIUM BV": one word in common, a
9
+ -- trade, a place, a legal form, and nothing else. The first rule only looked
10
+ -- at the words long enough to count, so the mark that tells two parties apart
11
+ -- — "LG", "KBC", "DKV", "CE" — was below the minimum length and never read;
12
+ -- and it never asked what the longer name said beyond the shorter.
13
+ --
14
+ -- **Each word weighs by how rare it is among the company's contacts.** A word
15
+ -- carried by one contact weighs 1, by two 1/2, by ten 1/10: a word many
16
+ -- contacts carry says little about which party a name is. Every word of the
17
+ -- name counts, at any length (`contacts.name_words`), so a legal form carried
18
+ -- by thirty contacts weighs 1/30 and the initials of one party weigh 1. No
19
+ -- list of legal forms: the core has none, it would be country data, and the
20
+ -- count is a fact about this company that catches the trade and the town as
21
+ -- well (`significant_words()`, `20260917090000`).
22
+ --
23
+ -- **A pair by name is two names whose words agree, weighed.** For the shorter
24
+ -- name `a` and the longer `b`:
25
+ --
26
+ -- * the rarest significant word of `a` — at the company's minimum length —
27
+ -- is in `b`: the pair holds on a word that names somebody;
28
+ -- * what only one of them says weighs no more than what they share. What
29
+ -- they share is the significant words of `a` that are in `b`. What only
30
+ -- one says is every word of `a` missing from `b`, at any length — a
31
+ -- shorter writing of a name drops words, it does not invent them — and the
32
+ -- significant words of `b` missing from `a`. The words of `b` below the
33
+ -- minimum length are not counted against the pair: a name written in full
34
+ -- grows by its legal form ("Anthropic, PBC", "Supabase Pte Ltd", "ISABEL
35
+ -- NV"), and those are the words the minimum length exists for.
36
+ --
37
+ -- So, where each of these words is carried by one contact or by the two of
38
+ -- the pair: "LG SERVICES" is not "ASBL UCM SERVICES" (`lg` and `asbl` weigh
39
+ -- 2, `services` 1/2), "Parking" is not "Parking Cathédrale SA" (`cathédrale`
40
+ -- weighs 1, `parking` 1/2), and "Garage Annez" is "Garage Annez et fils" (they
41
+ -- share 1; `fils` weighs 1, and `et` is a word of the longer below the minimum
42
+ -- length). The same name, in any case, is still a pair whatever its words.
43
+ --
44
+ -- The score is unchanged: `name_agreement()` and `name_among_several()`, as
45
+ -- `contact_candidates()` scores a name, capped at 0.600 when the shorter name
46
+ -- agrees with several contacts. `contact_candidates()` is not touched, and
47
+ -- neither is any suggestion for a statement or a ledger line. The identifiers
48
+ -- — VAT number, registration number, bank account — are not touched either.
49
+ --
50
+ -- Still bounded: the candidates of each name are the holders of its rarest
51
+ -- significant word, as before; the weighing reads the words of the pairs
52
+ -- found, joined once on the count of each word.
53
+
54
+ create or replace function suggest_duplicate_contacts(p_company_id uuid)
55
+ returns table (
56
+ keep_id uuid,
57
+ absorb_id uuid,
58
+ score numeric,
59
+ evidence text,
60
+ because text
61
+ )
62
+ language plpgsql
63
+ stable
64
+ security invoker
65
+ -- Every join below is on a value — a word, a number, an account, an id — and
66
+ -- the pool is the company's contacts, thousands at most: a hash join is right
67
+ -- whatever the statistics of the moment say. Left to an estimate of a handful
68
+ -- of rows, the planner nests loops over two thousand contacts and takes
69
+ -- seconds.
70
+ set enable_nestloop = off
71
+ as $$
72
+ #variable_conflict use_column
73
+ declare
74
+ v_policy matching_policy;
75
+ begin
76
+ v_policy := matching_policy_of(p_company_id);
77
+
78
+ return query
79
+ with
80
+ -- The contacts that may still be merged: active, merged into nobody. Two
81
+ -- sets of words: every word of the name (`tokens`), and those long enough
82
+ -- to count (`words`).
83
+ pool as materialized (
84
+ select c.id, c.name, c.created_at, c.parent_id,
85
+ c.name_words as tokens,
86
+ significant_words(c.name, v_policy.minimum_word_length) as words,
87
+ party_identifier(c.vat_number) as vat,
88
+ party_identifier(c.registration_number) as registration,
89
+ account_key(c.iban) as iban
90
+ from contacts c
91
+ where c.company_id = p_company_id
92
+ and c.active
93
+ and c.merged_into is null
94
+ ),
95
+ -- 1. An identifier both carry: a number, a bank account on the contact or
96
+ -- in a learned motif. Joined on its value, never contact by contact.
97
+ keys as (
98
+ select p.id, 'vat_number'::text as kind, p.vat as value, 'the VAT number'::text as what
99
+ from pool p where p.vat is not null
100
+ union
101
+ select p.id, 'registration_number', p.registration, 'the registration number'
102
+ from pool p where p.registration is not null
103
+ union
104
+ select p.id, 'account', p.iban, 'the bank account'
105
+ from pool p where p.iban is not null
106
+ union
107
+ select p.id, 'account', account_key(m.value), 'the bank account'
108
+ from contact_patterns m
109
+ join pool p on p.id = m.contact_id
110
+ where m.company_id = p_company_id
111
+ and m.active
112
+ and m.kind = 'counterparty_account'
113
+ and account_key(m.value) is not null
114
+ ),
115
+ by_key as (
116
+ select a.id as lo, b.id as hi, a.kind, 1.000::numeric as score,
117
+ format('%s and %s carry %s %s', pa.name, pb.name, a.what, a.value) as because
118
+ from keys a
119
+ join keys b on b.kind = a.kind and b.value = a.value and b.id > a.id
120
+ join pool pa on pa.id = a.id
121
+ join pool pb on pb.id = b.id
122
+ ),
123
+ -- 2. The name. How many contacts carry each word, at any length: what a
124
+ -- word weighs.
125
+ reach as (
126
+ select u.word, count(*) as contacts
127
+ from pool p cross join lateral unnest(p.tokens) as u(word)
128
+ group by u.word
129
+ ),
130
+ holders as (
131
+ select p.id, u.word
132
+ from pool p cross join lateral unnest(p.words) as u(word)
133
+ ),
134
+ rarest as (
135
+ select distinct on (h.id) h.id, h.word
136
+ from holders h join reach r on r.word = h.word
137
+ order by h.id, r.contacts, h.word
138
+ ),
139
+ -- Directed: `a` is the name read as the shorter, and its rarest
140
+ -- significant word is in the name of `b`.
141
+ name_pairs as (
142
+ select r.id as a_id, h.id as b_id
143
+ from rarest r
144
+ join holders h on h.word = r.word and h.id <> r.id
145
+ union
146
+ -- The same name with no word long enough to count still names one party.
147
+ select a.id, b.id
148
+ from pool a
149
+ join pool b on lower(trim(b.name)) = lower(trim(a.name)) and b.id <> a.id
150
+ ),
151
+ -- What the two share — the significant words of `a` in `b` — and what only
152
+ -- one of them says — every word of `a` missing from `b`, the significant
153
+ -- words of `b` missing from `a` — each word at 1 over the contacts that
154
+ -- carry it.
155
+ weighed as (
156
+ select n.a_id, n.b_id,
157
+ coalesce(sum(1::numeric / r.contacts)
158
+ filter (where s.side = 'a' and s.shared
159
+ and length(s.word) >= v_policy.minimum_word_length), 0) as shared,
160
+ coalesce(sum(1::numeric / r.contacts) filter (where not s.shared), 0) as apart
161
+ from name_pairs n
162
+ join pool a on a.id = n.a_id
163
+ join pool b on b.id = n.b_id
164
+ cross join lateral (
165
+ select 'a'::text as side, u.word, u.word = any (b.tokens) as shared
166
+ from unnest(a.tokens) as u(word)
167
+ union all
168
+ select 'b', u.word, u.word = any (a.tokens)
169
+ from unnest(b.words) as u(word)
170
+ ) s
171
+ join reach r on r.word = s.word
172
+ group by n.a_id, n.b_id
173
+ ),
174
+ agreeing as (
175
+ select n.a_id, n.b_id
176
+ from name_pairs n
177
+ join pool a on a.id = n.a_id
178
+ join pool b on b.id = n.b_id
179
+ left join weighed w on w.a_id = n.a_id and w.b_id = n.b_id
180
+ where lower(trim(a.name)) = lower(trim(b.name))
181
+ or (w.shared > 0 and w.apart <= w.shared)
182
+ ),
183
+ named as (
184
+ select n.a_id, n.b_id, a.name as a_name, b.name as b_name, a.words, b.words as b_words,
185
+ name_agreement(a.name, a.words, b.name, b.words) as agreement,
186
+ -- How many contacts the name of `a` agrees with, as
187
+ -- contact_candidates() counts what a text reaches.
188
+ count(*) over (partition by n.a_id) as reached
189
+ from agreeing n
190
+ join pool a on a.id = n.a_id
191
+ join pool b on b.id = n.b_id
192
+ ),
193
+ -- One sentence a pair, from the direction that says it best: the higher
194
+ -- score, then the name whose words are all in the other, then the shorter.
195
+ by_name as (
196
+ select distinct on (least(n.a_id, n.b_id), greatest(n.a_id, n.b_id))
197
+ least(n.a_id, n.b_id) as lo, greatest(n.a_id, n.b_id) as hi, 'name'::text as kind,
198
+ name_among_several(n.agreement, n.reached) as score,
199
+ case
200
+ when n.agreement >= 0.950 then format('%s and %s carry the same name', n.a_name, n.b_name)
201
+ when n.words @> n.b_words and n.words <@ n.b_words
202
+ then format('%s and %s carry the same words: %s', n.a_name, n.b_name,
203
+ array_to_string(array(select unnest(n.words) order by 1), ', '))
204
+ when n.words <@ n.b_words then format('every word of %s is in %s', n.a_name, n.b_name)
205
+ else format('%s and %s share %s, and what only one of them carries is common', n.a_name, n.b_name,
206
+ array_to_string(array(select unnest(n.words) intersect select unnest(n.b_words) order by 1), ', '))
207
+ end
208
+ || case when n.reached > 1 and n.agreement < 0.950
209
+ then format('; the words of %s are in the names of %s contacts', n.a_name, n.reached)
210
+ else '' end as because
211
+ from named n
212
+ where n.agreement is not null
213
+ and name_among_several(n.agreement, n.reached) >= v_policy.name_threshold
214
+ order by least(n.a_id, n.b_id), greatest(n.a_id, n.b_id),
215
+ name_among_several(n.agreement, n.reached) desc,
216
+ (n.words <@ n.b_words) desc, cardinality(n.words), n.a_name
217
+ ),
218
+ -- The strongest sentence of each kind of evidence, for each pair.
219
+ facts as (
220
+ select distinct on (e.lo, e.hi, e.kind) e.lo, e.hi, e.kind, e.score, e.because,
221
+ array_position(array['vat_number', 'registration_number', 'account', 'name'], e.kind) as rank
222
+ from (select * from by_key union all select * from by_name) e
223
+ order by e.lo, e.hi, e.kind, e.score desc, e.because
224
+ ),
225
+ pairs as (
226
+ select e.lo, e.hi, max(e.score) as score,
227
+ string_agg(e.kind, ',' order by e.score desc, e.rank) as evidence,
228
+ string_agg(e.because, '; ' order by e.score desc, e.rank) as because,
229
+ count(*) as kinds
230
+ from facts e
231
+ join pool a on a.id = e.lo
232
+ join pool b on b.id = e.hi
233
+ -- What merge_contacts() would refuse, and a contact with its own branch.
234
+ where not (a.vat is not null and b.vat is not null and a.vat <> b.vat)
235
+ and not (a.registration is not null and b.registration is not null and a.registration <> b.registration)
236
+ and a.parent_id is distinct from b.id
237
+ and b.parent_id is distinct from a.id
238
+ group by e.lo, e.hi
239
+ ),
240
+ -- What the books name each contact of a pair by: its ledger lines and its
241
+ -- documents.
242
+ activity as (
243
+ select p.id, p.created_at,
244
+ (select count(*) from entry_lines l where l.contact_id = p.id and l.company_id = p_company_id)
245
+ + (select count(*) from documents d where d.contact_id = p.id and d.company_id = p_company_id) as uses
246
+ from pool p
247
+ where p.id in (select x.lo from pairs x union select x.hi from pairs x)
248
+ ),
249
+ ordered as (
250
+ select x.*,
251
+ (a.uses > b.uses
252
+ or (a.uses = b.uses and (a.created_at < b.created_at
253
+ or (a.created_at = b.created_at and a.id < b.id)))) as lo_kept
254
+ from pairs x
255
+ join activity a on a.id = x.lo
256
+ join activity b on b.id = x.hi
257
+ )
258
+ select case when o.lo_kept then o.lo else o.hi end,
259
+ case when o.lo_kept then o.hi else o.lo end,
260
+ o.score, o.evidence, o.because
261
+ from ordered o
262
+ order by o.score desc, o.kinds desc, o.because;
263
+ end;
264
+ $$;
265
+
266
+ comment on function suggest_duplicate_contacts(uuid) is
267
+ 'The contacts of a company that are probably one party (decision 0075): one row per pair of active contacts merged into nobody, strongest first — keep_id, the one more ledger lines and documents name (then the older), absorb_id, score from 0 to 1, evidence (the kinds, strongest first, comma-separated: vat_number, registration_number, account, name) and because (the evidence in sentences). The same VAT or registration number (party_identifier()) and the same bank account on the contact or in a learned motif (account_key()) score 1. By name, each word weighs 1 over the contacts of the company that carry it: a pair needs the rarest significant word of the shorter name in the longer, and what only one name says — every word of the shorter missing from the longer, the significant words of the longer missing from the shorter — weighing no more than the significant words they share; the same name is always a pair. It scores as contact_candidates() scores a name (name_agreement(), capped by name_among_several() when the shorter name agrees with several contacts), from the company''s matching policy. A pair across companies, with two different VAT or registration numbers, or of a contact and its own parent is never proposed. It writes nothing and decides nothing; merge_contacts() joins a pair a person chose.';
268
+
269
+ revoke execute on function suggest_duplicate_contacts(uuid) from public, anon;
270
+ grant execute on function suggest_duplicate_contacts(uuid) to authenticated, service_role;
@@ -0,0 +1,207 @@
1
+ -- Ekwo OS — a return settles what it carries, and knows the day it is due.
2
+ --
3
+ -- Found by loading a company that buys a service from abroad and declares
4
+ -- it on a second form (19 October 2026). Two things, both generic.
5
+ --
6
+ -- **1. A settlement clears the tax of its own form.** `filing_tax_movements()`
7
+ -- — what `settle_filing()` clears and what `lock_filed_period()` waits for —
8
+ -- summed every tax line of the period, whatever form it belonged to and
9
+ -- whether or not it belonged to any. A tax a pack books on a liability of its
10
+ -- own, declared on another return — a self-assessed tax on an imported
11
+ -- service, a withholding, a provincial tax beside a federal one — was carried
12
+ -- into the debt of the periodic return, which then owed more than its boxes
13
+ -- said. A tax on a blocked input booked to an expense account was moved off
14
+ -- the expense. And a tax due on collection, still waiting on its transition
15
+ -- account, was settled in the period of the invoice.
16
+ --
17
+ -- The rule now: a tax line of the period is on the filing's form when
18
+ --
19
+ -- (a) it names a box, and the form it was frozen with
20
+ -- (`entry_lines.report_code`, or `declaration_boxes_of()` on a line
21
+ -- restored from an archive older than the freeze) is the filing's — or
22
+ -- names none, which is the single return of a country that files one,
23
+ -- the reading `vat_return()` already makes; or
24
+ -- (b) it names no box, its tax writes a box of the filing's form through
25
+ -- another posting — the form prints the base and computes the tax from
26
+ -- it, as a rate of the base box — and it is not on the account the tax
27
+ -- waits on before it falls due.
28
+ --
29
+ -- A tax line that answers neither stays where it was posted, for the return
30
+ -- that declares it or for an entry somebody writes; the settlement leaves it
31
+ -- alone and so does the lock. No figure of any return moves: `vat_return()`
32
+ -- is not touched.
33
+ --
34
+ -- **2. A filing carries its due date.** `filing_deadline()` has computed the
35
+ -- day from the pack's rule since `20260917170000`, and `upcoming_filings()`
36
+ -- read it, but `prepare_filing()` never wrote it on the filing — so
37
+ -- `tax_filings.due_date` stayed empty, and with it the maturity of the debt a
38
+ -- settlement posts. `prepare_filing()` now writes it when it creates a filing
39
+ -- and fills it on a draft it refreshes, keeping a date somebody already set.
40
+ -- The filings already written get it once, below. A pack that names no rule,
41
+ -- or `depends_on_taxpayer`, leaves it empty, as `filing_deadline()` does.
42
+
43
+ -- ---------------------------------------------------------------------------
44
+ -- 1. filing_tax_movements — `20261005160900`, the tax lines of its own form
45
+ -- ---------------------------------------------------------------------------
46
+
47
+ create or replace function filing_tax_movements(p_filing_id uuid)
48
+ returns table (account_id uuid, balance numeric)
49
+ language sql
50
+ stable
51
+ security invoker
52
+ as $$
53
+ with f as (
54
+ select * from tax_filings where id = p_filing_id
55
+ ),
56
+ moved as (
57
+ select l.account_id, sum(l.balance) as balance
58
+ from f
59
+ cross join lateral declared_lines(f.company_id, f.period_start, f.period_end) l
60
+ join entries e on e.id = l.entry_id
61
+ left join taxes t on t.id = l.tax_id
62
+ where e.state = 'posted'
63
+ and l.tax_line
64
+ and case
65
+ -- (a) A line that names a box is on the form it was frozen with.
66
+ when l.declaration_box is not null then
67
+ coalesce(
68
+ case when l.declaration_boxes is not null then l.report_code
69
+ else (select d.report_code
70
+ from declaration_boxes_of(l.tax_id, l.declaration_box, l.tax_line) d)
71
+ end,
72
+ f.report_code) = f.report_code
73
+ -- (b) A line that names none is on the form its tax reaches, off
74
+ -- the account the tax waits on before it falls due.
75
+ else l.account_id is distinct from t.cash_basis_transition_account_id
76
+ and exists (
77
+ select 1 from tax_postings tp
78
+ where tp.tax_id = l.tax_id
79
+ and tp.declaration_box is not null
80
+ and coalesce(tp.report_code, f.report_code) = f.report_code)
81
+ end
82
+ group by l.account_id
83
+ ),
84
+ already as (
85
+ select l.account_id, sum(l.balance) as balance
86
+ from f
87
+ join tax_filings p
88
+ on p.company_id = f.company_id
89
+ and p.report_code = f.report_code
90
+ and p.period_start = f.period_start
91
+ and p.period_end = f.period_end
92
+ and p.id <> f.id
93
+ and p.settlement_entry_id is not null
94
+ join entry_lines l on l.entry_id = p.settlement_entry_id
95
+ join moved m on m.account_id = l.account_id
96
+ group by l.account_id
97
+ )
98
+ select m.account_id,
99
+ round_amount(m.balance + coalesce(a.balance, 0), rounding_of(f.company_id))
100
+ from f, moved m
101
+ left join already a on a.account_id = m.account_id
102
+ where round_amount(m.balance + coalesce(a.balance, 0), rounding_of(f.company_id)) <> 0;
103
+ $$;
104
+
105
+ comment on function filing_tax_movements(uuid) is
106
+ 'The tax accounts a declared period moved on the filing''s own form and by how much, on the same window and the same tax-point rule the return read, net of what an earlier settlement of the same period already carried. A tax line is on the form when the box it names is on it (the form frozen on the line; none named is the single return), or when it names no box, its tax writes a box of the form through another posting, and it is not on the tax''s cash-basis transition account. A tax booked on a liability no box of the form carries stays for its own return. What settle_filing() clears — the whole period the first time, the difference on a corrective — what lock_filed_period() waits for, and what anybody can read before it does.';
107
+
108
+ revoke execute on function filing_tax_movements(uuid) from public, anon;
109
+ grant execute on function filing_tax_movements(uuid) to authenticated, service_role;
110
+
111
+ -- ---------------------------------------------------------------------------
112
+ -- 2. prepare_filing — `20260921145412`, with the due date of the period
113
+ -- ---------------------------------------------------------------------------
114
+
115
+ create or replace function prepare_filing(
116
+ p_company_id uuid,
117
+ p_from date,
118
+ p_to date,
119
+ p_report_code text default null
120
+ )
121
+ returns tax_filings
122
+ language plpgsql
123
+ volatile
124
+ security invoker
125
+ as $$
126
+ declare
127
+ v_code text;
128
+ v_filing tax_filings;
129
+ v_round money_rounding;
130
+ begin
131
+ v_code := coalesce(p_report_code, periodic_return_code(p_company_id));
132
+ if v_code is null then
133
+ raise exception 'no_report_code: the pack of this company names no periodic return, so say which form this is';
134
+ end if;
135
+
136
+ select * into v_filing
137
+ from tax_filings f
138
+ where f.company_id = p_company_id
139
+ and f.report_code = v_code
140
+ and f.period_start = p_from
141
+ and f.period_end = p_to
142
+ and f.state <> 'superseded';
143
+
144
+ if found and v_filing.state not in ('draft', 'ready') then
145
+ raise exception 'filing_already_% : % for % to % has gone; a change to it is a corrective',
146
+ v_filing.state, v_code, p_from, p_to;
147
+ end if;
148
+
149
+ -- The day the period is due, under the pack's rule: written with the
150
+ -- filing, and filled on a draft that has none. A date already there stays,
151
+ -- whoever set it.
152
+ if not found then
153
+ insert into tax_filings (company_id, report_code, period_start, period_end, due_date)
154
+ values (p_company_id, v_code, p_from, p_to, filing_deadline(p_company_id, v_code, p_to))
155
+ returning * into v_filing;
156
+ else
157
+ update tax_filings
158
+ set state = 'draft',
159
+ due_date = coalesce(due_date, filing_deadline(p_company_id, v_code, p_to))
160
+ where id = v_filing.id
161
+ returning * into v_filing;
162
+ delete from tax_filing_boxes where filing_id = v_filing.id;
163
+ end if;
164
+
165
+ v_round := filing_rounding(p_company_id, v_code);
166
+
167
+ insert into tax_filing_boxes (filing_id, box, kind, amount)
168
+ select v_filing.id, r.box, r.kind, round_amount(r.amount, v_round)
169
+ from vat_return(p_company_id, p_from, p_to, v_code) r
170
+ where not r.hidden;
171
+
172
+ update tax_filings set prepared_at = now() where id = v_filing.id
173
+ returning * into v_filing;
174
+
175
+ return v_filing;
176
+ end;
177
+ $$;
178
+
179
+ comment on function prepare_filing(uuid, date, date, text) is
180
+ 'Computes the declaration and keeps the answer, box by box and kind by kind, each figure at the unit the form is filed in (filing_rounding()), with the day it is due under the pack''s rule (filing_deadline()) — written when the filing is created, filled on a draft that has none, never overwritten. Called again on a draft it refreshes; on a declaration that has gone it refuses and says the word for what is needed instead — a corrective.';
181
+
182
+ revoke execute on function prepare_filing(uuid, date, date, text) from public, anon;
183
+ grant execute on function prepare_filing(uuid, date, date, text) to authenticated, service_role;
184
+
185
+ comment on column tax_filings.due_date is
186
+ 'The day the declaration of this period is due: filing_deadline() of its form and its last day, written by prepare_filing() and copied by supersede_filing(). Null where the pack names no rule for the form, or names depends_on_taxpayer. The maturity of the debt settle_filing() posts.';
187
+
188
+ -- ---------------------------------------------------------------------------
189
+ -- 3. The filings already written
190
+ --
191
+ -- Every filing with no date gets the one its pack's rule gives, whatever its
192
+ -- state: a declaration that has gone was due on that day all the same. The
193
+ -- `updated_at` trigger is lifted for the statement, so no filing is dated as
194
+ -- edited by a column it should always have had.
195
+ -- ---------------------------------------------------------------------------
196
+
197
+ alter table tax_filings disable trigger tax_filings_set_updated_at;
198
+
199
+ update tax_filings f
200
+ set due_date = filing_deadline(f.company_id, f.report_code, f.period_end)
201
+ where f.due_date is null
202
+ and filing_deadline(f.company_id, f.report_code, f.period_end) is not null;
203
+
204
+ alter table tax_filings enable trigger tax_filings_set_updated_at;
205
+
206
+ -- Rule 6 of supabase/migrations/README.md.
207
+ revoke execute on all functions in schema public from public;
@@ -1,6 +1,6 @@
1
1
  -- Ekwo OS — Malaysia: chart of accounts, journals, taxes and defaults.
2
2
  --
3
- -- Generated from packs/my at version 0.3.0, do not edit.
3
+ -- Generated from packs/my at version 0.3.1, do not edit.
4
4
  -- Change the pack and run `ekwo pack build my`; `ekwo pack check --all`
5
5
  -- refuses a seed that is not the exact output of its pack, and the CI runs it.
6
6
  --
@@ -42,7 +42,7 @@ insert into country_packs
42
42
  (country, name, version, released_at, schema_min, certification_status,
43
43
  certified_by, certified_at, checksum, sources)
44
44
  values
45
- ('MY', 'Malaysia', '0.3.0', date '2026-10-19', '20261018023930', 'community', null, null, '722f76908d32381bf690d38be7ba502e489fd1441dd95fc771d262df286cd7cf', '[{"key":"sales-tax-act","title":"Sales Tax Act 2018 (Act 806) — section 8 charges the tax on a taxable person manufacturing taxable goods in Malaysia, section 9 on the importation of taxable goods, section 13 the registration threshold, section 15 the rate of tax, and section 41 the exemption of goods on which it is proved to the satisfaction of the Director General that the goods have been exported","publisher":"Attorney General''s Chambers of Malaysia — Federal Legislation Portal","url":"https://lom.agc.gov.my/ilims/upload/portal/akta/LOM/EN/Act%20806%20-%20Reprint%20Online%20(1-10-2020).pdf","consulted_on":"2026-09-25","kind":"law"},{"key":"service-tax-act","title":"Service Tax Act 2018 (Act 807) — section 7 charges the tax on any taxable person who provides taxable services in Malaysia, section 13 the registration threshold, section 26 the rate of tax, and section 26A the remittance of tax by a person in Malaysia who imports a taxable service","publisher":"Attorney General''s Chambers of Malaysia — Federal Legislation Portal","url":"https://lom.agc.gov.my/ilims/upload/portal/akta/outputaktap/2590513_BI/Act%20807%20(Online%202024).pdf","consulted_on":"2026-09-25","kind":"law"},{"key":"sst-rates-guide","title":"Guide on Sales Tax Rates for Various Goods, and the General Guide on Sales Tax — the First Schedule of the Sales Tax (Rates of Tax) Order 2018 taxes a specified list of goods at 5 %, the Second Schedule and the residual rule tax everything else taxable at 10 %, and a specific rate applies to petroleum","publisher":"Royal Malaysian Customs Department (Jabatan Kastam Diraja Malaysia) — MySST","url":"https://mysst.customs.gov.my/assets/document/General%20Guides/V4.0%20Guide%20On%20Sales%20Tax%20Rates%20%20for%20Various%20Goods_07.09.pdf","consulted_on":"2026-09-25","kind":"form"},{"key":"mof-sst-2025","title":"Targeted Revision of Sales Tax Rate and Expansion of Service Tax Scope, effective 1 July 2025 — the Sales Tax (Rates of Tax) (Amendment) Order 2025 narrows the 0 % rate to essential goods and taxes selected non-essential and discretionary goods at 5 % or 10 %; the Service Tax scope is expanded to leasing and rental, construction, financial services, private healthcare and education, each with its own rate and registration threshold, with targeted exemptions","publisher":"Ministry of Finance Malaysia (Kementerian Kewangan Malaysia)","url":"https://www.mof.gov.my/portal/en/news/press-release/targeted-revision-of-sales-tax-rate-and-expansion-of-service-tax-scope-effective-1-july-2025","consulted_on":"2026-09-25","kind":"guidance"},{"key":"sst02-return-guide","title":"Guidelines: SST-02 Return — the taxable period, the fields of Part B2 of the form (11a and 11b the value of taxable goods at 5 % and 10 %, 11c and 11d the value of taxable services, 13 and 13d credit-note and bad-debt adjustments, 18a to 18e and 19 to 21 the exempted values), and the filing and payment deadline","publisher":"Royal Malaysian Customs Department (Jabatan Kastam Diraja Malaysia) — MySST","url":"https://mysst.customs.gov.my/assets/document/Specific%20Guides/Appendix%20II_Return%20SST02%20Guidelines.pdf","consulted_on":"2026-09-25","kind":"form"},{"key":"mysst-portal","title":"MySST — registration, the SST-02 and SST-02A returns, and payment","publisher":"Royal Malaysian Customs Department (Jabatan Kastam Diraja Malaysia)","url":"https://mysst.customs.gov.my/","consulted_on":"2026-09-25","kind":"portal"},{"key":"companies-act-my","title":"Companies Act 2016 (Act 777) — section 245 requires a company''s financial statements to comply with approved accounting standards, and section 248 the accounting period","publisher":"Companies Commission of Malaysia (Suruhanjaya Syarikat Malaysia)","url":"https://www.ssm.com.my/Pages/Legal_Framework/Document/Act%20777%20Reprint.pdf","consulted_on":"2026-09-25","kind":"law"},{"key":"masb-standards","title":"MASB Approved Accounting Standards — the Malaysian Financial Reporting Standards (MFRS), which converge with IFRS, and the Malaysian Private Entities Reporting Standard (MPERS), which converges with the IFRS for SMEs Accounting Standard","publisher":"Malaysian Accounting Standards Board (MASB)","url":"https://www.masb.org.my/pages.php?id=20","consulted_on":"2026-09-25","kind":"guidance"},{"key":"ita-1967-82c","title":"Income Tax Act 1967 (Act 53), section 82C — the duty to issue an electronic invoice for a transaction in respect of goods sold or services performed, phased in by the Minister''s order; the Income Tax (Issuance of Electronic Invoice) Rules 2024 [P.U. (A) 265/2024], in force 1 October 2024, prescribe the particulars and the manner of issuance","publisher":"Attorney General''s Chambers of Malaysia, and Inland Revenue Board of Malaysia (Lembaga Hasil Dalam Negeri Malaysia, LHDNM)","url":"https://www.hasil.gov.my/en/e-invoice/","consulted_on":"2026-09-25","kind":"law"},{"key":"einvoice-guideline","title":"e-Invoice Guideline — the phased mandatory implementation timeline by annual turnover or revenue (1 August 2024 above RM100 million, 1 January 2025 above RM25 million, 1 July 2025 above RM5 million, 1 January 2026 above RM1 million), the exemption threshold since raised in stages, and the MyInvois validation flow: a taxpayer submits the invoice to LHDNM for validation, which returns a Unique Identifier Number (UIN) and a QR code before the document is shared with the buyer","publisher":"Inland Revenue Board of Malaysia (Lembaga Hasil Dalam Negeri Malaysia, LHDNM)","url":"https://www.hasil.gov.my/media/fzagbaj2/irbm-e-invoice-guideline.pdf","consulted_on":"2026-09-25","kind":"guidance"},{"key":"einvoice-exemption","title":"e-Invoice General Guideline, version 4.8 (30 August 2026), section 1.6.1(e) — a taxpayer with an annual turnover or revenue below RM3,000,000, subject to a related-party carve-out, is exempted from issuing an e-Invoice, including a self-billed e-Invoice, at the date this pack was released","publisher":"Inland Revenue Board of Malaysia (Lembaga Hasil Dalam Negeri Malaysia, LHDNM)","url":"https://www.hasil.gov.my/wp-content/uploads/IRBM-e-Invoice-Guideline.pdf","consulted_on":"2026-09-25","kind":"guidance"},{"key":"pint-my","title":"Peppol International (PINT) model for Billing, Malaysia — PINT MY, the Peppol-network data format aligned with LHDNM''s MyInvois particulars, for a taxpayer that chooses to transmit through a Peppol access point rather than the MyInvois Portal or API directly","publisher":"OpenPeppol, with the Malaysia Digital Economy Corporation (MDEC) as the Malaysia Peppol Authority","url":"https://docs.peppol.eu/poac/my/pint-my-sb/bis/","consulted_on":"2026-09-25","kind":"standard"},{"key":"peppol-scheme","title":"Peppol Code Lists — participant identifier schemes: 0230, the Malaysia National ID Registration Number (SSM registration number) scheme","publisher":"OpenPeppol","url":"https://docs.peppol.eu/edelivery/codelists/","consulted_on":"2026-09-25","kind":"standard"},{"key":"myinvois-sdk","title":"MyInvois SDK — the code tables of the e-Invoice (tax types: 01 Sales Tax, 02 Service Tax, 03 Tourism Tax, 04 High-Value Goods Tax, 05 Sales Tax on Low Value Goods, 06 Not Applicable, E Tax exemption (where applicable); the 45 classification codes, 022 Others among them), and the data structure of the e-Invoice v1.1, whose fields \"Details of Tax Exemption\" and \"Amount Exempted from Tax\" are mandatory where a tax exemption is applicable and name as examples \"Buyer''s sales tax exemption certificate number, special exemption as per gazette orders\"","publisher":"Inland Revenue Board of Malaysia (Lembaga Hasil Dalam Negeri Malaysia, LHDNM)","url":"https://sdk.myinvois.hasil.gov.my/codes/tax-types/","consulted_on":"2026-10-19","kind":"standard"}]'::jsonb)
45
+ ('MY', 'Malaysia', '0.3.1', date '2026-10-19', '20261019161437', 'community', null, null, '5d6b6bf33a3e064a1bf5bf305a2ebee21cd84fd16d18605a36ea8b3be8cd9c66', '[{"key":"sales-tax-act","title":"Sales Tax Act 2018 (Act 806) — section 8 charges the tax on a taxable person manufacturing taxable goods in Malaysia, section 9 on the importation of taxable goods, section 13 the registration threshold, section 15 the rate of tax, and section 41 the exemption of goods on which it is proved to the satisfaction of the Director General that the goods have been exported","publisher":"Attorney General''s Chambers of Malaysia — Federal Legislation Portal","url":"https://lom.agc.gov.my/ilims/upload/portal/akta/LOM/EN/Act%20806%20-%20Reprint%20Online%20(1-10-2020).pdf","consulted_on":"2026-09-25","kind":"law"},{"key":"service-tax-act","title":"Service Tax Act 2018 (Act 807) — section 7 charges the tax on any taxable person who provides taxable services in Malaysia, section 13 the registration threshold, section 26 the rate of tax, and section 26A the remittance of tax by a person in Malaysia who imports a taxable service","publisher":"Attorney General''s Chambers of Malaysia — Federal Legislation Portal","url":"https://lom.agc.gov.my/ilims/upload/portal/akta/outputaktap/2590513_BI/Act%20807%20(Online%202024).pdf","consulted_on":"2026-09-25","kind":"law"},{"key":"sst-rates-guide","title":"Guide on Sales Tax Rates for Various Goods, and the General Guide on Sales Tax — the First Schedule of the Sales Tax (Rates of Tax) Order 2018 taxes a specified list of goods at 5 %, the Second Schedule and the residual rule tax everything else taxable at 10 %, and a specific rate applies to petroleum","publisher":"Royal Malaysian Customs Department (Jabatan Kastam Diraja Malaysia) — MySST","url":"https://mysst.customs.gov.my/assets/document/General%20Guides/V4.0%20Guide%20On%20Sales%20Tax%20Rates%20%20for%20Various%20Goods_07.09.pdf","consulted_on":"2026-09-25","kind":"form"},{"key":"mof-sst-2025","title":"Targeted Revision of Sales Tax Rate and Expansion of Service Tax Scope, effective 1 July 2025 — the Sales Tax (Rates of Tax) (Amendment) Order 2025 narrows the 0 % rate to essential goods and taxes selected non-essential and discretionary goods at 5 % or 10 %; the Service Tax scope is expanded to leasing and rental, construction, financial services, private healthcare and education, each with its own rate and registration threshold, with targeted exemptions","publisher":"Ministry of Finance Malaysia (Kementerian Kewangan Malaysia)","url":"https://www.mof.gov.my/portal/en/news/press-release/targeted-revision-of-sales-tax-rate-and-expansion-of-service-tax-scope-effective-1-july-2025","consulted_on":"2026-09-25","kind":"guidance"},{"key":"sst02-return-guide","title":"Guidelines: SST-02 Return — the taxable period, the fields of Part B2 of the form (11a and 11b the value of taxable goods at 5 % and 10 %, 11c and 11d the value of taxable services, 13 and 13d credit-note and bad-debt adjustments, 18a to 18e and 19 to 21 the exempted values), and the filing and payment deadline","publisher":"Royal Malaysian Customs Department (Jabatan Kastam Diraja Malaysia) — MySST","url":"https://mysst.customs.gov.my/assets/document/Specific%20Guides/Appendix%20II_Return%20SST02%20Guidelines.pdf","consulted_on":"2026-09-25","kind":"form"},{"key":"mysst-portal","title":"MySST — registration, the SST-02 and SST-02A returns, and payment","publisher":"Royal Malaysian Customs Department (Jabatan Kastam Diraja Malaysia)","url":"https://mysst.customs.gov.my/","consulted_on":"2026-09-25","kind":"portal"},{"key":"companies-act-my","title":"Companies Act 2016 (Act 777) — section 245 requires a company''s financial statements to comply with approved accounting standards, and section 248 the accounting period","publisher":"Companies Commission of Malaysia (Suruhanjaya Syarikat Malaysia)","url":"https://www.ssm.com.my/Pages/Legal_Framework/Document/Act%20777%20Reprint.pdf","consulted_on":"2026-09-25","kind":"law"},{"key":"masb-standards","title":"MASB Approved Accounting Standards — the Malaysian Financial Reporting Standards (MFRS), which converge with IFRS, and the Malaysian Private Entities Reporting Standard (MPERS), which converges with the IFRS for SMEs Accounting Standard","publisher":"Malaysian Accounting Standards Board (MASB)","url":"https://www.masb.org.my/pages.php?id=20","consulted_on":"2026-09-25","kind":"guidance"},{"key":"ita-1967-82c","title":"Income Tax Act 1967 (Act 53), section 82C — the duty to issue an electronic invoice for a transaction in respect of goods sold or services performed, phased in by the Minister''s order; the Income Tax (Issuance of Electronic Invoice) Rules 2024 [P.U. (A) 265/2024], in force 1 October 2024, prescribe the particulars and the manner of issuance","publisher":"Attorney General''s Chambers of Malaysia, and Inland Revenue Board of Malaysia (Lembaga Hasil Dalam Negeri Malaysia, LHDNM)","url":"https://www.hasil.gov.my/en/e-invoice/","consulted_on":"2026-09-25","kind":"law"},{"key":"einvoice-guideline","title":"e-Invoice Guideline — the phased mandatory implementation timeline by annual turnover or revenue (1 August 2024 above RM100 million, 1 January 2025 above RM25 million, 1 July 2025 above RM5 million, 1 January 2026 above RM1 million), the exemption threshold since raised in stages, and the MyInvois validation flow: a taxpayer submits the invoice to LHDNM for validation, which returns a Unique Identifier Number (UIN) and a QR code before the document is shared with the buyer","publisher":"Inland Revenue Board of Malaysia (Lembaga Hasil Dalam Negeri Malaysia, LHDNM)","url":"https://www.hasil.gov.my/media/fzagbaj2/irbm-e-invoice-guideline.pdf","consulted_on":"2026-09-25","kind":"guidance"},{"key":"einvoice-exemption","title":"e-Invoice General Guideline, version 4.8 (30 August 2026), section 1.6.1(e) — a taxpayer with an annual turnover or revenue below RM3,000,000, subject to a related-party carve-out, is exempted from issuing an e-Invoice, including a self-billed e-Invoice, at the date this pack was released","publisher":"Inland Revenue Board of Malaysia (Lembaga Hasil Dalam Negeri Malaysia, LHDNM)","url":"https://www.hasil.gov.my/wp-content/uploads/IRBM-e-Invoice-Guideline.pdf","consulted_on":"2026-09-25","kind":"guidance"},{"key":"pint-my","title":"Peppol International (PINT) model for Billing, Malaysia — PINT MY, the Peppol-network data format aligned with LHDNM''s MyInvois particulars, for a taxpayer that chooses to transmit through a Peppol access point rather than the MyInvois Portal or API directly","publisher":"OpenPeppol, with the Malaysia Digital Economy Corporation (MDEC) as the Malaysia Peppol Authority","url":"https://docs.peppol.eu/poac/my/pint-my-sb/bis/","consulted_on":"2026-09-25","kind":"standard"},{"key":"peppol-scheme","title":"Peppol Code Lists — participant identifier schemes: 0230, the Malaysia National ID Registration Number (SSM registration number) scheme","publisher":"OpenPeppol","url":"https://docs.peppol.eu/edelivery/codelists/","consulted_on":"2026-09-25","kind":"standard"},{"key":"myinvois-sdk","title":"MyInvois SDK — the code tables of the e-Invoice (tax types: 01 Sales Tax, 02 Service Tax, 03 Tourism Tax, 04 High-Value Goods Tax, 05 Sales Tax on Low Value Goods, 06 Not Applicable, E Tax exemption (where applicable); the 45 classification codes, 022 Others among them), and the data structure of the e-Invoice v1.1, whose fields \"Details of Tax Exemption\" and \"Amount Exempted from Tax\" are mandatory where a tax exemption is applicable and name as examples \"Buyer''s sales tax exemption certificate number, special exemption as per gazette orders\"","publisher":"Inland Revenue Board of Malaysia (Lembaga Hasil Dalam Negeri Malaysia, LHDNM)","url":"https://sdk.myinvois.hasil.gov.my/codes/tax-types/","consulted_on":"2026-10-19","kind":"standard"}]'::jsonb)
46
46
  on conflict (country) do update set
47
47
  name = excluded.name,
48
48
  version = excluded.version,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ekwo-os",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "Install and operate Ekwo OS on your own Supabase project: npx ekwo-os init.",
5
5
  "keywords": [
6
6
  "accounting",
@@ -50,7 +50,7 @@
50
50
  "typecheck": "tsc -p tsconfig.json --noEmit"
51
51
  },
52
52
  "dependencies": {
53
- "@ekwo-ai/core": "^0.15.0",
53
+ "@ekwo-ai/core": "^0.15.1",
54
54
  "postgres": "^3.4.5"
55
55
  }
56
56
  }