1:- module(eorscope_employer_costs,
    2          [ countries/1,                % -Countries
    3            country/2,                  % +Iso, -Country
    4            regions/1,                  % -Regions
    5            countries_in_region/2,      % +Region, -Countries
    6            contributions/2,            % +Iso, -Lines
    7            assumptions/2               % +Iso, -Assumptions
    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)).

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 true and false, dates are date(Y,M,D) and an empty optional value is the atom null.

A country with total_declared_floor true publishes 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_total false come 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.

license
- Code MIT; embedded data (data/) CC BY 4.0, see data/LICENSE.
 countries(-Countries:list(dict)) is det
Every country, sorted by ISO code.
   62countries(Countries) :-
   63    findall(C, summary_row(_, C), Countries).
 country(+Iso, -Country:dict) is semidet
The country with the ISO 3166-1 alpha-2 code Iso (atom or string, case-insensitive). Fails for an unknown code.
   70country(Iso, Country) :-
   71    iso_key(Iso, Key),
   72    summary_row(Key, Country).
 regions(-Regions:list(string)) is det
The distinct regions, sorted.
   78regions(Regions) :-
   79    findall(R, ( summary_row(_, C), get_dict(region, C, R) ), Rs),
   80    sort(Rs, Regions).
 countries_in_region(+Region, -Countries:list(dict)) is det
The countries of Region (case-insensitive), sorted by ISO code.
   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).
 contributions(+Iso, -Lines:list(dict)) is det
The contribution and statutory extra lines of a country, in dataset order; empty for an unknown code.
   99contributions(Iso, Lines) :-
  100    iso_key(Iso, Key),
  101    findall(L, contribution_row(Key, L), Lines).
 assumptions(+Iso, -Assumptions:list(dict)) is det
The declared assumptions of a country, in dataset order; empty for an unknown code.
  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                 /*******************************
  118                 *          PARSING             *
  119                 *******************************/
 parse_csv(+Text, +Name, -Records:list(list(string))) is det
RFC 4180 reader: comma separator, double-quoted fields that may hold commas, doubled quotes and line breaks, LF or CRLF records, optional BOM. Blank lines are skipped; every record must have as many fields as the first. Errors raise error(syntax_error(csv(Name, Message)), _).
  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)                   % blank line
  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
  200%   Field conversion. Column types follow the dataset's datapackage.json.
  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
  332%   The data are read once, when this file is compiled, and become facts.
  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