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 +1 -1
- package/dist/assets/expected-objects.json +1 -1
- package/dist/assets/migrations/20261019140000_a_pair_by_name_weighs_its_words.sql +270 -0
- package/dist/assets/migrations/20261019161437_a_return_settles_what_it_carries.sql +207 -0
- package/dist/assets/seed/39_pack_my.sql +2 -2
- package/package.json +2 -2
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
|
|
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. |
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
53
|
+
"@ekwo-ai/core": "^0.15.1",
|
|
54
54
|
"postgres": "^3.4.5"
|
|
55
55
|
}
|
|
56
56
|
}
|