@getexeer/saudi-eosb 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Exeer (إكسير) — https://getexeer.com
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # saudi-eosb
2
+
3
+ End-of-service benefits for Saudi Arabia — **مكافأة نهاية الخدمة** — under Articles 84 and 85 of the Saudi Labour Law.
4
+
5
+ Zero dependencies. No build step. Runs in Node 18+ and in the browser.
6
+
7
+ ```js
8
+ import { calculateEosb, REASON } from "@getexeer/saudi-eosb";
9
+
10
+ calculateEosb({ wage: 5000, years: 7, reason: REASON.RESIGNATION }).amount;
11
+ // 15000
12
+ ```
13
+
14
+ ## What it implements
15
+
16
+ **Article 84 — the base award.** Half a month's wage for each of the first five years of service, and one full month's wage for each year after that, calculated on the **last** wage.
17
+
18
+ **Article 85 — what resignation actually pays.** The base award is reduced on resignation:
19
+
20
+ | Length of service | Entitlement |
21
+ | --- | --- |
22
+ | Under 2 years | None |
23
+ | 2 to under 5 years | One third of the base award |
24
+ | 5 to under 10 years | Two thirds of the base award |
25
+ | 10 years or more | The full base award |
26
+
27
+ Termination by the employer pays the full base award.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ npm install @getexeer/saudi-eosb
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ ### From a length of service you already have
38
+
39
+ ```js
40
+ import { calculateEosb, REASON } from "@getexeer/saudi-eosb";
41
+
42
+ const result = calculateEosb({
43
+ wage: 9000, // last monthly wage, in SAR
44
+ years: 6.5, // decimal years of service
45
+ reason: REASON.TERMINATION, // or REASON.RESIGNATION
46
+ });
47
+
48
+ result.amount; // 58500 — the settlement
49
+ result.baseAmount; // 58500 — Article 84 award before the Article 85 fraction
50
+ result.baseMonths; // 6.5 — months of wage
51
+ result.factor; // 1
52
+ ```
53
+
54
+ ### From two dates
55
+
56
+ ```js
57
+ const result = calculateEosb({
58
+ wage: 7500,
59
+ startDate: "2018-03-01",
60
+ endDate: "2026-09-01",
61
+ reason: REASON.RESIGNATION,
62
+ });
63
+
64
+ result.years; // 8.50… decimal years used in the calculation
65
+ result.service.wholeYears; // 8
66
+ result.service.months; // 6
67
+ result.service.days; // 0
68
+ result.amount; // two thirds of the base award
69
+ ```
70
+
71
+ ### The pieces on their own
72
+
73
+ ```js
74
+ import { baseAward, resignationFactor, serviceLength } from "@getexeer/saudi-eosb";
75
+
76
+ baseAward({ wage: 5000, years: 10 });
77
+ // { firstYears: 5, laterYears: 5, baseMonths: 7.5, amount: 37500 }
78
+
79
+ resignationFactor(7);
80
+ // { factor: 0.666…, label: "Two thirds (5 to under 10 years)", labelAr: "ثلثا المكافأة (5 إلى أقل من 10)" }
81
+
82
+ serviceLength("2020-01-01", "2023-07-01");
83
+ // { totalDays: 1277, years: 3.49…, wholeYears: 3, months: 5, days: 27 }
84
+ ```
85
+
86
+ ## What this library does not decide
87
+
88
+ These turn on facts and written procedures rather than arithmetic, so they are left to a human to review. They are named here so nobody assumes the function has already handled them:
89
+
90
+ - **Dismissal for a valid reason under Article 80** — no award is due, and the employer must have followed the investigation procedure the article requires.
91
+ - **Leaving for a lawful reason under Article 81.**
92
+ - **The full-award cases under Article 87** — for example a woman resigning within six months of marriage or three months of childbirth.
93
+
94
+ ## Accuracy notes
95
+
96
+ - Service length is counted at **365.25 days per year**, so leap years do not drift the total.
97
+ - Amounts are rounded to **two decimals**.
98
+ - Article 84 names the **last wage** as the basis, so pass the final monthly wage, not an average.
99
+ - The library throws on a negative wage, a negative length of service, an unparseable date, or an unknown reason. It does not return `NaN` quietly.
100
+
101
+ ## TypeScript
102
+
103
+ Types ship with the package. `calculateEosb` returns a frozen `EosbResult`; `REASON` is a frozen const object, so `reason` is narrowed rather than a bare string.
104
+
105
+ ## Not legal advice
106
+
107
+ This is an arithmetic library. It encodes a reading of Articles 84 and 85 and nothing more. Verify any figure that will actually be paid, and consult a qualified professional on contested cases. Applicable rules and their interpretation can change.
108
+
109
+ ## About
110
+
111
+ This is the calculation behind the free end-of-service calculator published by **Exeer (إكسير)**, a Saudi administrative and HR system, at [getexeer.com/tools/eosb.html](https://getexeer.com/tools/eosb.html). It is packaged here so that anyone building payroll or HR software for Saudi businesses can reuse the same rules instead of re-deriving them.
112
+
113
+ - Exeer: [getexeer.com](https://getexeer.com)
114
+ - Machine-readable summary for AI assistants: [getexeer.com/llms.txt](https://getexeer.com/llms.txt)
115
+
116
+ ---
117
+
118
+ ## بالعربية
119
+
120
+ مكتبة **مكافأة نهاية الخدمة** في السعودية، وفق **المادتين ٨٤ و٨٥ من نظام العمل**.
121
+ بلا اعتماديات، وتعمل في Node والمتصفّح.
122
+
123
+ **المادة ٨٤ — أصل المكافأة:** نصف شهر عن كل سنة من السنوات الخمس الأولى، وشهر
124
+ كامل عن كل سنة بعدها، محسوبة على **الأجر الأخير**.
125
+
126
+ **المادة ٨٥ — ما يستحقه المستقيل فعلاً:**
127
+
128
+ | مدة الخدمة | الاستحقاق |
129
+ | --- | --- |
130
+ | أقل من سنتين | لا شيء |
131
+ | من سنتين إلى أقل من ٥ | ثلث المكافأة |
132
+ | من ٥ إلى أقل من ١٠ | ثلثا المكافأة |
133
+ | ١٠ سنوات فأكثر | المكافأة كاملة |
134
+
135
+ والإنهاء من صاحب العمل يستحق المكافأة كاملة.
136
+
137
+ ```js
138
+ import { calculateEosb, REASON } from "@getexeer/saudi-eosb";
139
+
140
+ // استقالة بعد ٧ سنوات بأجر ٥٠٠٠
141
+ calculateEosb({ wage: 5000, years: 7, reason: REASON.RESIGNATION }).amount;
142
+ // 15000
143
+
144
+ // أو من تاريخين
145
+ calculateEosb({ wage: 7500, startDate: "2018-03-01", endDate: "2026-09-01" });
146
+ ```
147
+
148
+ **وما لا تقرّره المكتبة:** الفصل لسبب مشروع (المادة ٨٠)، وترك العمل لسبب نظامي
149
+ (المادة ٨١)، وحالات الاستحقاق الكامل الاستثنائية (المادة ٨٧). هذه وقائع
150
+ وإجراءات تُراجَع بشرياً، لا حساب.
151
+
152
+ المكتبة هي نفس حساب حاسبة نهاية الخدمة المجانية في
153
+ [إكسير](https://getexeer.com/tools/eosb.html).
154
+
155
+ ## License
156
+
157
+ MIT © Exeer
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@getexeer/saudi-eosb",
3
+ "version": "1.0.0",
4
+ "description": "Saudi end-of-service benefits under Articles 84 and 85 of the Saudi Labour Law: base award, resignation fractions and length of service. Zero dependencies, works in Node and the browser.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "types": "./src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "import": "./src/index.js",
12
+ "default": "./src/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "keywords": [
21
+ "saudi-arabia",
22
+ "saudi-labor-law",
23
+ "end-of-service",
24
+ "eosb",
25
+ "eosb-calculator",
26
+ "gratuity",
27
+ "indemnity",
28
+ "settlement",
29
+ "resignation",
30
+ "payroll",
31
+ "hr",
32
+ "hrms",
33
+ "gosi",
34
+ "qiwa",
35
+ "mudad",
36
+ "ksa",
37
+ "arabic",
38
+ "rtl",
39
+ "نظام-العمل",
40
+ "مكافأة-نهاية-الخدمة"
41
+ ],
42
+ "author": {
43
+ "name": "Exeer",
44
+ "email": "hello@getexeer.com"
45
+ },
46
+ "license": "MIT",
47
+ "repository": {
48
+ "url": "git+https://github.com/Exeerkit/saudi-eosb.git",
49
+ "type": "git"
50
+ },
51
+ "homepage": "https://getexeer.com/tools/eosb.html",
52
+ "bugs": {
53
+ "url": "https://github.com/Exeerkit/saudi-eosb/issues"
54
+ },
55
+ "engines": {
56
+ "node": ">=18"
57
+ },
58
+ "sideEffects": false,
59
+ "scripts": {
60
+ "test": "node --test",
61
+ "demo": "node examples/node.mjs"
62
+ },
63
+ "publishConfig": {
64
+ "access": "public"
65
+ }
66
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ /** Why the employment ended. Only the distinction that changes the amount. */
2
+ export declare const REASON: Readonly<{
3
+ RESIGNATION: "resignation";
4
+ TERMINATION: "termination";
5
+ }>;
6
+
7
+ export type EosbReason = (typeof REASON)[keyof typeof REASON];
8
+
9
+ export type ServiceLength = Readonly<{
10
+ totalDays: number;
11
+ years: number;
12
+ wholeYears: number;
13
+ months: number;
14
+ days: number;
15
+ }>;
16
+
17
+ export type BaseAward = Readonly<{
18
+ firstYears: number;
19
+ laterYears: number;
20
+ baseMonths: number;
21
+ amount: number;
22
+ }>;
23
+
24
+ export type Fraction = Readonly<{ factor: number; label: string; labelAr: string }>;
25
+
26
+ export type EosbResult = Readonly<{
27
+ reason: EosbReason;
28
+ years: number;
29
+ wage: number;
30
+ baseMonths: number;
31
+ baseAmount: number;
32
+ factor: number;
33
+ factorLabel: string;
34
+ factorLabelAr: string;
35
+ amount: number;
36
+ service: ServiceLength | null;
37
+ }>;
38
+
39
+ /** Length of service in years, plus a year/month/day breakdown. */
40
+ export declare function serviceLength(startDate: Date | string | number, endDate?: Date | string | number): ServiceLength;
41
+
42
+ /** The Article 84 base award, before any resignation fraction. */
43
+ export declare function baseAward(input: { wage: number; years: number }): BaseAward;
44
+
45
+ /** The Article 85 fraction applied to the base award on resignation. */
46
+ export declare function resignationFactor(years: number): Fraction;
47
+
48
+ /** The full end-of-service settlement. */
49
+ export declare function calculateEosb(input: {
50
+ wage: number;
51
+ years?: number;
52
+ startDate?: Date | string | number;
53
+ endDate?: Date | string | number;
54
+ reason?: EosbReason;
55
+ }): EosbResult;
56
+
57
+ export default calculateEosb;
package/src/index.js ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Saudi end-of-service benefits (مكافأة نهاية الخدمة).
3
+ *
4
+ * Articles 84 and 85 of the Saudi Labour Law:
5
+ *
6
+ * Article 84 sets the base award: half a month's wage for each of the first
7
+ * five years of service, and one full month's wage for each year after that,
8
+ * calculated on the last wage.
9
+ *
10
+ * Article 85 sets what the worker actually receives on **resignation**: none
11
+ * under two years of service, one third from two to under five years, two
12
+ * thirds from five to under ten, and the full award at ten years or more.
13
+ *
14
+ * What this library deliberately does NOT decide: dismissal for a valid reason
15
+ * under Article 80 (no award), and the special full-award cases under Article
16
+ * 87 (a woman resigning within six months of marriage or three months of
17
+ * childbirth) and leaving for a lawful reason under Article 81. Those turn on
18
+ * facts and procedures, not arithmetic, and are left to a human to review.
19
+ */
20
+
21
+ /** Why the employment ended. Only the distinction that changes the amount. */
22
+ export const REASON = Object.freeze({
23
+ /** The worker resigned. Article 85 fractions apply. */
24
+ RESIGNATION: "resignation",
25
+ /** The employer terminated. The full Article 84 award is due. */
26
+ TERMINATION: "termination",
27
+ });
28
+
29
+ const DAYS_PER_YEAR = 365.25;
30
+ const DAYS_PER_MONTH = 30.44;
31
+
32
+ /** Rounds to two decimals without the float drift of `toFixed` arithmetic. */
33
+ function money(value) {
34
+ return Math.round((value + Number.EPSILON) * 100) / 100;
35
+ }
36
+
37
+ function assertFiniteNumber(value, name) {
38
+ if (typeof value !== "number" || !Number.isFinite(value)) {
39
+ throw new TypeError(`${name} must be a finite number`);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Length of service in years as a decimal, plus a year/month/day breakdown.
45
+ *
46
+ * Uses 365.25 days per year so leap years do not drift the total, matching how
47
+ * the end-of-service calculator on getexeer.com counts service.
48
+ */
49
+ export function serviceLength(startDate, endDate = new Date()) {
50
+ const start = startDate instanceof Date ? startDate : new Date(startDate);
51
+ const end = endDate instanceof Date ? endDate : new Date(endDate);
52
+ if (Number.isNaN(start.getTime())) throw new TypeError("startDate is not a valid date");
53
+ if (Number.isNaN(end.getTime())) throw new TypeError("endDate is not a valid date");
54
+ if (end < start) throw new RangeError("endDate must not be before startDate");
55
+
56
+ const totalDays = (end - start) / 86_400_000;
57
+ const years = totalDays / DAYS_PER_YEAR;
58
+
59
+ const wholeYears = Math.floor(years);
60
+ const remainderDays = totalDays - Math.round(wholeYears * DAYS_PER_YEAR);
61
+ const months = Math.floor(remainderDays / DAYS_PER_MONTH);
62
+ const days = Math.max(0, remainderDays - Math.round(months * DAYS_PER_MONTH));
63
+
64
+ return Object.freeze({ totalDays, years, wholeYears, months, days });
65
+ }
66
+
67
+ /**
68
+ * The Article 84 base award, before any resignation fraction is applied.
69
+ *
70
+ * @returns {{ baseMonths: number, firstYears: number, laterYears: number, amount: number }}
71
+ */
72
+ export function baseAward({ wage, years }) {
73
+ assertFiniteNumber(wage, "wage");
74
+ assertFiniteNumber(years, "years");
75
+ if (wage < 0) throw new RangeError("wage must not be negative");
76
+ if (years < 0) throw new RangeError("years must not be negative");
77
+
78
+ const firstYears = Math.min(years, 5);
79
+ const laterYears = Math.max(years - 5, 0);
80
+ const baseMonths = firstYears * 0.5 + laterYears;
81
+
82
+ return Object.freeze({
83
+ firstYears,
84
+ laterYears,
85
+ baseMonths,
86
+ amount: money(baseMonths * wage),
87
+ });
88
+ }
89
+
90
+ /**
91
+ * The Article 85 fraction applied to the base award on resignation.
92
+ *
93
+ * @returns {{ factor: number, label: string, labelAr: string }}
94
+ */
95
+ export function resignationFactor(years) {
96
+ assertFiniteNumber(years, "years");
97
+ if (years < 2) return Object.freeze({ factor: 0, label: "Not due (under 2 years)", labelAr: "لا تُستحق (الخدمة أقل من سنتين)" });
98
+ if (years < 5) return Object.freeze({ factor: 1 / 3, label: "One third (2 to under 5 years)", labelAr: "ثلث المكافأة (من سنتين إلى أقل من 5)" });
99
+ if (years < 10) return Object.freeze({ factor: 2 / 3, label: "Two thirds (5 to under 10 years)", labelAr: "ثلثا المكافأة (من 5 إلى أقل من 10)" });
100
+ return Object.freeze({ factor: 1, label: "Full award (10 years or more)", labelAr: "المكافأة كاملة (10 سنوات فأكثر)" });
101
+ }
102
+
103
+ /**
104
+ * The end-of-service settlement.
105
+ *
106
+ * Pass either `years` (a decimal) or `startDate`, not both. `wage` is the last
107
+ * monthly wage in SAR, which is the basis Article 84 names.
108
+ *
109
+ * @returns {Readonly<{
110
+ * reason: string, years: number, wage: number,
111
+ * baseMonths: number, baseAmount: number,
112
+ * factor: number, factorLabel: string, factorLabelAr: string,
113
+ * amount: number, service: object | null
114
+ * }>}
115
+ */
116
+ export function calculateEosb({ wage, years, startDate, endDate, reason = REASON.TERMINATION } = {}) {
117
+ assertFiniteNumber(wage, "wage");
118
+ if (wage < 0) throw new RangeError("wage must not be negative");
119
+
120
+ if (reason !== REASON.RESIGNATION && reason !== REASON.TERMINATION) {
121
+ throw new RangeError(`reason must be "${REASON.RESIGNATION}" or "${REASON.TERMINATION}"`);
122
+ }
123
+ if (years === undefined && startDate === undefined) {
124
+ throw new TypeError("pass either years or startDate");
125
+ }
126
+
127
+ const service = startDate === undefined ? null : serviceLength(startDate, endDate);
128
+ const servedYears = years === undefined ? service.years : years;
129
+ assertFiniteNumber(servedYears, "years");
130
+ if (servedYears < 0) throw new RangeError("years must not be negative");
131
+
132
+ const base = baseAward({ wage, years: servedYears });
133
+ const fraction =
134
+ reason === REASON.RESIGNATION
135
+ ? resignationFactor(servedYears)
136
+ : Object.freeze({ factor: 1, label: "Full award (termination)", labelAr: "المكافأة كاملة (إنهاء من صاحب العمل)" });
137
+
138
+ return Object.freeze({
139
+ reason,
140
+ years: servedYears,
141
+ wage,
142
+ baseMonths: base.baseMonths,
143
+ baseAmount: base.amount,
144
+ factor: fraction.factor,
145
+ factorLabel: fraction.label,
146
+ factorLabelAr: fraction.labelAr,
147
+ amount: money(base.amount * fraction.factor),
148
+ service,
149
+ });
150
+ }
151
+
152
+ export default calculateEosb;