1:- module(eorscope_employer_costs,
2 [ countries/1, 3 country/2, 4 regions/1, 5 countries_in_region/2, 6 contributions/2, 7 assumptions/2 8 ]). 9:- use_module(library(apply)). 10:- use_module(library(lists)). 11:- use_module(library(pairs)). 12:- use_module(library(error)). 13:- use_module(library(readutil)).
62countries(Countries) :-
63 findall(C, summary_row(_, C), Countries).
70country(Iso, Country) :-
71 iso_key(Iso, Key),
72 summary_row(Key, Country).
78regions(Regions) :-
79 findall(R, ( summary_row(_, C), get_dict(region, C, R) ), Rs),
80 sort(Rs, Regions).
86countries_in_region(Region, Countries) :-
87 must_be(text, Region),
88 string_lower(Region, Key),
89 findall(C, ( summary_row(_, C),
90 get_dict(region, C, R),
91 string_lower(R, Key)
92 ), Countries).
99contributions(Iso, Lines) :-
100 iso_key(Iso, Key),
101 findall(L, contribution_row(Key, L), Lines).
108assumptions(Iso, Assumptions) :-
109 iso_key(Iso, Key),
110 findall(A, assumption_row(Key, A), Assumptions).
111
112iso_key(Iso, Key) :-
113 must_be(text, Iso),
114 string_upper(Iso, Key).
115
116
117
128parse_csv(Text, Name, Records) :-
129 string_codes(Text, Cs0),
130 ( Cs0 = [0xFEFF|Cs] -> true ; Cs = Cs0 ),
131 csv_records(Cs, Name, Records),
132 ( Records = [Head|Rows]
133 -> length(Head, N),
134 forall(member(R, Rows),
135 ( length(R, M),
136 M == N
137 -> true
138 ; length(R, M),
139 format(atom(Msg), 'expected ~d fields, got ~d', [N, M]),
140 csv_fail(Name, Msg)
141 ))
142 ; csv_fail(Name, empty)
143 ).
144
145csv_records([], _, []) :- !.
146csv_records(Cs, Name, Records) :-
147 ( eol(Cs, Rest) 148 -> csv_records(Rest, Name, Records)
149 ; csv_record(Cs, Name, Fields, Rest),
150 Records = [Fields|More],
151 csv_records(Rest, Name, More)
152 ).
153
154eol([0'\r,0'\n|R], R) :- !.
155eol([0'\n|R], R) :- !.
156eol([0'\r|R], R).
157
158csv_record(Cs, Name, [F|Fs], Rest) :-
159 csv_field(Cs, Name, F, Cs1),
160 ( Cs1 = [0',|Cs2]
161 -> csv_record(Cs2, Name, Fs, Rest)
162 ; Cs1 == []
163 -> Fs = [], Rest = []
164 ; eol(Cs1, Rest)
165 -> Fs = []
166 ).
167
168csv_field([0'"|Cs], Name, Field, Rest) :-
169 !,
170 quoted(Cs, Name, Codes, Rest),
171 ( ( Rest == [] ; Rest = [C|_], memberchk(C, `,\r\n`) )
172 -> string_codes(Field, Codes)
173 ; csv_fail(Name, 'extraneous character after a closing quote')
174 ).
175csv_field(Cs, Name, Field, Rest) :-
176 unquoted(Cs, Name, Codes, Rest),
177 string_codes(Field, Codes).
178
179quoted([], Name, _, _) :-
180 csv_fail(Name, 'unterminated quoted field').
181quoted([0'",0'"|Cs], Name, [0'"|T], Rest) :- !,
182 quoted(Cs, Name, T, Rest).
183quoted([0'"|Cs], _, [], Cs) :- !.
184quoted([C|Cs], Name, [C|T], Rest) :-
185 quoted(Cs, Name, T, Rest).
186
187unquoted([], _, [], []).
188unquoted([C|Cs], Name, Codes, Rest) :-
189 ( memberchk(C, `,\r\n`)
190 -> Codes = [], Rest = [C|Cs]
191 ; C == 0'"
192 -> csv_fail(Name, 'bare quote in an unquoted field')
193 ; Codes = [C|T],
194 unquoted(Cs, Name, T, Rest)
195 ).
196
197csv_fail(Name, Msg) :-
198 throw(error(syntax_error(csv(Name, Msg)), _)).
199
201
202field(Row, Col, Value) :-
203 ( memberchk(Col-V0, Row)
204 -> Value = V0
205 ; throw(error(existence_error(csv_column, Col), _))
206 ).
207
208str(Row, Col, S) :- field(Row, Col, S).
209
210int(Row, Col, I) :-
211 field(Row, Col, S),
212 ( number_string(I, S), integer(I) -> true ; bad(Col, integer, S) ).
213
214num(Row, Col, F) :-
215 opt(Row, Col, F),
216 ( F == null -> bad(Col, number, "") ; true ).
217
218opt(Row, Col, F) :-
219 field(Row, Col, S),
220 ( S == ""
221 -> F = null
222 ; catch(number_string(N, S), _, fail)
223 -> F is float(N)
224 ; bad(Col, number, S)
225 ).
226
227bool(Row, Col, B) :-
228 field(Row, Col, S),
229 ( memberchk(S, ["true", "TRUE", "True", "1"]) -> B = true
230 ; memberchk(S, ["false", "FALSE", "False", "0"]) -> B = false
231 ; bad(Col, boolean, S)
232 ).
233
234day(Row, Col, D) :-
235 field(Row, Col, S),
236 ( S == ""
237 -> D = null
238 ; split_string(S, "-", "", [Ys, Ms, Ds]),
239 string_length(Ys, 4), string_length(Ms, 2), string_length(Ds, 2),
240 number_string(Y, Ys), number_string(M, Ms), number_string(Day, Ds),
241 integer(Y), integer(M), integer(Day),
242 catch(date_time_stamp(date(Y, M, Day, 0, 0, 0, 0, -, -), Stamp), _, fail),
243 stamp_date_time(Stamp, date(Y, M, Day, _, _, _, _, _, _), 0)
244 -> D = date(Y, M, Day)
245 ; bad(Col, date, S)
246 ).
247
248bad(Col, Type, S) :-
249 throw(error(type_error(Type, Col=S), _)).
250
251read_rows(File, Rows) :-
252 prolog_load_context(directory, Dir),
253 atomic_list_concat([Dir, '/../data/', File], Path),
254 read_file_to_string(Path, Text, [encoding(utf8)]),
255 parse_csv(Text, File, [Head|Recs]),
256 findall(Row, ( member(Rec, Recs), pairs_keys_values(Row, Head, Rec) ), Rows).
257
258summary(Row, C) :-
259 str(Row, "iso", Iso),
260 str(Row, "country", Name),
261 str(Row, "region", Region),
262 str(Row, "currency", Currency),
263 int(Row, "example_salary_usd", Salary),
264 str(Row, "example_role", Role),
265 str(Row, "example_place", Place),
266 opt(Row, "basic_share_of_gross", Basic),
267 num(Row, "employer_cost_pct", Pct),
268 num(Row, "employer_cost_pct_printed", Printed),
269 bool(Row, "total_declared_floor", Floor),
270 bool(Row, "total_declared_ceiling", Ceiling),
271 num(Row, "employer_cost_usd_month", USDMonth),
272 str(Row, "total_excludes", Excludes),
273 num(Row, "fx_local_per_usd", FX),
274 day(Row, "fx_date", FXDate),
275 day(Row, "last_reviewed", Reviewed),
276 C = country{iso:Iso, name:Name, region:Region, currency:Currency,
277 example_salary_usd:Salary, example_role:Role,
278 example_place:Place, basic_share_of_gross:Basic,
279 employer_cost_pct:Pct, employer_cost_pct_printed:Printed,
280 total_declared_floor:Floor, total_declared_ceiling:Ceiling,
281 employer_cost_usd_month:USDMonth, total_excludes:Excludes,
282 fx_local_per_usd:FX, fx_date:FXDate, last_reviewed:Reviewed}.
283
284contribution(Row, L) :-
285 str(Row, "iso", Iso),
286 str(Row, "country", Country),
287 str(Row, "currency", Currency),
288 str(Row, "line_type", Type),
289 str(Row, "line_id", ID),
290 str(Row, "line_name", Name),
291 opt(Row, "rate", Rate),
292 str(Row, "base", Base),
293 opt(Row, "base_factor", Factor),
294 opt(Row, "base_floor_annual_local", Floor0),
295 opt(Row, "base_cap_annual_local", Cap),
296 opt(Row, "flat_annual_local", Flat),
297 opt(Row, "min_gross_annual_local", MinGross),
298 opt(Row, "max_gross_annual_local", MaxGross),
299 str(Row, "extra_kind", Kind),
300 opt(Row, "extra_value", Extra),
301 bool(Row, "secondary_source", Secondary),
302 bool(Row, "thirteenth_month", Thirteenth),
303 bool(Row, "total_declared_floor", Floor),
304 bool(Row, "total_declared_ceiling", Ceiling),
305 bool(Row, "in_total", InTotal),
306 str(Row, "applies", Applies),
307 str(Row, "notes", Notes),
308 str(Row, "source_name", SourceName),
309 str(Row, "source_url", SourceURL),
310 day(Row, "source_checked_at", Checked),
311 L = contribution{iso:Iso, country:Country, currency:Currency,
312 line_type:Type, line_id:ID, line_name:Name, rate:Rate,
313 base:Base, base_factor:Factor,
314 base_floor_annual_local:Floor0,
315 base_cap_annual_local:Cap, flat_annual_local:Flat,
316 min_gross_annual_local:MinGross,
317 max_gross_annual_local:MaxGross, extra_kind:Kind,
318 extra_value:Extra, secondary_source:Secondary,
319 thirteenth_month:Thirteenth, total_declared_floor:Floor,
320 total_declared_ceiling:Ceiling, in_total:InTotal,
321 applies:Applies, notes:Notes, source_name:SourceName,
322 source_url:SourceURL, source_checked_at:Checked}.
323
324assumption(Row, A) :-
325 str(Row, "iso", Iso),
326 str(Row, "id", ID),
327 str(Row, "label", Label),
328 num(Row, "value", Value),
329 str(Row, "notes", Notes),
330 A = assumption{iso:Iso, id:ID, label:Label, value:Value, notes:Notes}.
331
333
334data_clauses(Clauses) :-
335 read_rows('country_summary.csv', SRows),
336 maplist(summary, SRows, Cs),
337 map_list_to_pairs([C, K]>>get_dict(iso, C, K), Cs, Pairs0),
338 keysort(Pairs0, Pairs),
339 findall(summary_row(K, C), member(K-C, Pairs), S),
340 read_rows('country_contributions.csv', CRows),
341 findall(contribution_row(K, L),
342 ( member(R, CRows), contribution(R, L), get_dict(iso, L, K) ), Ls),
343 read_rows('country_assumptions.csv', ARows),
344 findall(assumption_row(K, A),
345 ( member(R, ARows), assumption(R, A), get_dict(iso, A, K) ), As),
346 append([S, Ls, As], Clauses).
347
348term_expansion(eorscope_data, Clauses) :-
349 data_clauses(Clauses).
350
351eorscope_data
EOR Scope dataset of statutory employer costs (76 countries)
Read-only lookup of the EOR Scope open dataset (CC BY 4.0), embedded at compile time from the CSV files in the pack's data/ directory. No dependencies, no network calls.
The library returns the figures as exported and computes nothing. Employer cost totals are the ones published in the dataset; the library does not recompute them. For a cost at another salary, use the calculator at https://eorscope.com/eor-cost-calculator/.
Records are dicts:
Text is a string, integers and floats are numbers, booleans are the atoms
trueandfalse, dates aredate(Y,M,D)and an empty optional value is the atomnull.A country with total_declared_floor
truepublishes an "at least" figure, not a total. Under total_declared_ceiling the modelled contribution base is the highest the law allows, and lines with in_totalfalsecome on top. A contribution's rate applies to its base times base_factor (1 unless the law builds the base from more than the salary, as Mexico's integrated base does), before base_floor_annual_local and base_cap_annual_local.Sources and method: https://eorscope.com/methodology/. The figures are a cost comparison, not legal or tax advice.