@jjlmoya/utils-finance 1.28.0 → 1.30.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.
Files changed (65) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +3 -1
  3. package/src/entries.ts +7 -1
  4. package/src/index.ts +2 -1
  5. package/src/tests/locale_completeness.test.ts +2 -3
  6. package/src/tests/tool_validation.test.ts +2 -2
  7. package/src/tool/pesetaValue/bibliography.astro +6 -0
  8. package/src/tool/pesetaValue/bibliography.ts +16 -0
  9. package/src/tool/pesetaValue/component.astro +86 -0
  10. package/src/tool/pesetaValue/controller.ts +126 -0
  11. package/src/tool/pesetaValue/dom-views.ts +83 -0
  12. package/src/tool/pesetaValue/entry.ts +31 -0
  13. package/src/tool/pesetaValue/evaluator.ts +18 -0
  14. package/src/tool/pesetaValue/i18n/de.ts +54 -0
  15. package/src/tool/pesetaValue/i18n/en.ts +138 -0
  16. package/src/tool/pesetaValue/i18n/es.ts +54 -0
  17. package/src/tool/pesetaValue/i18n/fr.ts +54 -0
  18. package/src/tool/pesetaValue/i18n/id.ts +48 -0
  19. package/src/tool/pesetaValue/i18n/it.ts +54 -0
  20. package/src/tool/pesetaValue/i18n/ja.ts +48 -0
  21. package/src/tool/pesetaValue/i18n/ko.ts +48 -0
  22. package/src/tool/pesetaValue/i18n/nl.ts +48 -0
  23. package/src/tool/pesetaValue/i18n/pl.ts +48 -0
  24. package/src/tool/pesetaValue/i18n/pt.ts +48 -0
  25. package/src/tool/pesetaValue/i18n/ru.ts +48 -0
  26. package/src/tool/pesetaValue/i18n/sv.ts +48 -0
  27. package/src/tool/pesetaValue/i18n/tr.ts +48 -0
  28. package/src/tool/pesetaValue/i18n/zh.ts +48 -0
  29. package/src/tool/pesetaValue/index.ts +11 -0
  30. package/src/tool/pesetaValue/logic.test.ts +63 -0
  31. package/src/tool/pesetaValue/logic.ts +162 -0
  32. package/src/tool/pesetaValue/pesetas-to-euros-with-inflation-calculator.css +564 -0
  33. package/src/tool/pesetaValue/seo.astro +15 -0
  34. package/src/tool/pesetaValue/storage.ts +25 -0
  35. package/src/tool/pesetaValue/ui.ts +33 -0
  36. package/src/tool/salaryEmployerCost/bibliography.astro +6 -0
  37. package/src/tool/salaryEmployerCost/bibliography.ts +16 -0
  38. package/src/tool/salaryEmployerCost/component.astro +130 -0
  39. package/src/tool/salaryEmployerCost/controller.ts +129 -0
  40. package/src/tool/salaryEmployerCost/dom-views.ts +87 -0
  41. package/src/tool/salaryEmployerCost/entry.ts +31 -0
  42. package/src/tool/salaryEmployerCost/evaluator.ts +9 -0
  43. package/src/tool/salaryEmployerCost/i18n/de.ts +159 -0
  44. package/src/tool/salaryEmployerCost/i18n/en.ts +148 -0
  45. package/src/tool/salaryEmployerCost/i18n/es.ts +148 -0
  46. package/src/tool/salaryEmployerCost/i18n/fr.ts +159 -0
  47. package/src/tool/salaryEmployerCost/i18n/id.ts +159 -0
  48. package/src/tool/salaryEmployerCost/i18n/it.ts +159 -0
  49. package/src/tool/salaryEmployerCost/i18n/ja.ts +159 -0
  50. package/src/tool/salaryEmployerCost/i18n/ko.ts +159 -0
  51. package/src/tool/salaryEmployerCost/i18n/nl.ts +159 -0
  52. package/src/tool/salaryEmployerCost/i18n/pl.ts +159 -0
  53. package/src/tool/salaryEmployerCost/i18n/pt.ts +159 -0
  54. package/src/tool/salaryEmployerCost/i18n/ru.ts +159 -0
  55. package/src/tool/salaryEmployerCost/i18n/sv.ts +159 -0
  56. package/src/tool/salaryEmployerCost/i18n/tr.ts +159 -0
  57. package/src/tool/salaryEmployerCost/i18n/zh.ts +159 -0
  58. package/src/tool/salaryEmployerCost/index.ts +11 -0
  59. package/src/tool/salaryEmployerCost/logic.test.ts +88 -0
  60. package/src/tool/salaryEmployerCost/logic.ts +133 -0
  61. package/src/tool/salaryEmployerCost/seo.astro +15 -0
  62. package/src/tool/salaryEmployerCost/spain-gross-net-salary-employer-cost-calculator.css +522 -0
  63. package/src/tool/salaryEmployerCost/storage.ts +35 -0
  64. package/src/tool/salaryEmployerCost/ui.ts +41 -0
  65. package/src/tools.ts +3 -3
@@ -0,0 +1,159 @@
1
+ import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
2
+ import type { ToolLocaleContent } from '../../../types';
3
+ import { bibliography } from '../bibliography';
4
+ import type { SalaryEmployerCostUI } from '../ui';
5
+
6
+ const slug = "ispanya-brut-net-maas-isveren-maliyeti-hesaplama";
7
+ const title = "İspanya Brüt Net Maaş ve İşveren Maliyeti Hesaplama 2026";
8
+ const description = "2026 kurallarına göre İspanya da net maaşı, IRPF vergi kesintisini, çalışan primlerini ve toplam işveren maliyetini hesaplayın.";
9
+
10
+ const faq = [
11
+ {
12
+ "question": "İspanya da bir çalışanın şirkete maliyeti ne kadardır?",
13
+ "answer": "Şirket brüt maaşı ve kendi sosyal güvenlik primlerini öder."
14
+ },
15
+ {
16
+ "question": "İspanya da brüt maaştan net maaşa nasıl hesaplama yapılır?",
17
+ "answer": "Yıllık brüt maaştan çalışan sosyal güvenlik primi ve IRPF vergisi düşülür."
18
+ },
19
+ {
20
+ "question": "12 veya 14 maaş seçimi yıllık maaşı değiştirir mi?",
21
+ "answer": "Hayır. Hesaplayıcı aynı yıllık brüt tutarı korur."
22
+ },
23
+ {
24
+ "question": "2026 yılı için hangi sosyal güvenlik oranları dahildir?",
25
+ "answer": "Genel sağlık, işsizlik, eğitim, MEI, FOGASA ve iş kazası primleri dahildir."
26
+ },
27
+ {
28
+ "question": "Maaş maksimum prim matrahını aşarsa ne olur?",
29
+ "answer": "2026 aylık tavanı (5.101,20 €) üzerindeki kısma kademeli dayanışma primi uygulanır."
30
+ },
31
+ {
32
+ "question": "Bu hesaplayıcı İspanya dışında geçerli midir?",
33
+ "answer": "Hayır. Yalnızca 2026 İspanya mevzuatına özeldir."
34
+ },
35
+ {
36
+ "question": "Maaş bilgileri bir sunucuya gönderiliyor mu?",
37
+ "answer": "Hayır. Tüm hesaplamalar tarayıcınızda yerel olarak yapılır."
38
+ }
39
+ ];
40
+
41
+ const howTo = [
42
+ {
43
+ "name": "Brüt maaşı girin",
44
+ "text": "Yıllık veya aylık seçeneğini belirleyip tutarı yazın."
45
+ },
46
+ {
47
+ "name": "İspanya bordro koşullarını ayarlayın",
48
+ "text": "12/14 ödeme, sözleşme türü, IRPF ve iş kazası primini seçin."
49
+ },
50
+ {
51
+ "name": "Maaş dağılımını inceleyin",
52
+ "text": "Net maaş, çalışan kesintileri ve işveren primlerini karşılaştırın."
53
+ },
54
+ {
55
+ "name": "Sonuç periyodunu değiştirin",
56
+ "text": "Yıllık toplamlar veya ödeme başına ortalama görünümüne geçin."
57
+ }
58
+ ];
59
+
60
+ const faqSchema: WithContext<FAQPage> = {
61
+ '@context': 'https://schema.org',
62
+ '@type': 'FAQPage',
63
+ mainEntity: faq.map((item) => ({
64
+ '@type': 'Question',
65
+ name: item.question,
66
+ acceptedAnswer: { '@type': 'Answer', text: item.answer },
67
+ })),
68
+ };
69
+
70
+ const howToSchema: WithContext<HowTo> = {
71
+ '@context': 'https://schema.org',
72
+ '@type': 'HowTo',
73
+ name: title,
74
+ description,
75
+ step: howTo.map((step, index) => ({
76
+ '@type': 'HowToStep',
77
+ position: index + 1,
78
+ name: step.name,
79
+ text: step.text,
80
+ })),
81
+ };
82
+
83
+ const appSchema: WithContext<SoftwareApplication> = {
84
+ '@context': 'https://schema.org',
85
+ '@type': 'SoftwareApplication',
86
+ name: title,
87
+ description,
88
+ applicationCategory: 'FinanceApplication',
89
+ operatingSystem: 'All',
90
+ offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' },
91
+ inLanguage: "tr",
92
+ };
93
+
94
+ export const content: ToolLocaleContent<SalaryEmployerCostUI> = {
95
+ slug,
96
+ title,
97
+ description,
98
+ ui: {
99
+ "labelGrossSalary": "Brüt maaş göstergesi",
100
+ "labelGrossSalaryHint": "Maaş tutarı",
101
+ "labelAnnual": "Yıllık",
102
+ "labelMonthly": "Aylık",
103
+ "labelPaySchedule": "Ödeme takvimi",
104
+ "labelTwelvePayments": "12 maaş",
105
+ "labelFourteenPayments": "14 maaş",
106
+ "labelContract": "Sözleşme türü",
107
+ "labelPermanent": "Süresiz (Kadrolu)",
108
+ "labelTemporary": "Süreli (Geçici)",
109
+ "labelIrpfRate": "IRPF Gelir Vergisi Kesintisi",
110
+ "labelIrpfHint": "Vergi dairesi hesaplayıcısı veya bordro oranınızı girin",
111
+ "labelRiskRate": "İş Kazası ve Meslek Hastalığı Primi (AT & EP)",
112
+ "labelRiskHint": "Pozisyona göre işveren iş kazası prim oranı",
113
+ "labelOfficePreset": "Ofis %1,50",
114
+ "labelTradePreset": "Saha / Satış %2,00",
115
+ "labelConstructionPreset": "İnşaat / Saha %6,70",
116
+ "labelNetCore": "Net maaş çekirdeği",
117
+ "labelEmployeeDeductions": "Çalışan kesintileri",
118
+ "labelDeductionsFormula": "IRPF artı çalışan sosyal güvenlik primi",
119
+ "labelEmployerHalo": "İşveren katkı payı halkası",
120
+ "labelEmployerCost": "Toplam işveren maliyeti",
121
+ "labelAnnualView": "Yıllık görünüm",
122
+ "labelPerPaymentView": "Ödeme başına ortalama",
123
+ "labelGross": "Brüt maaş",
124
+ "labelIrpf": "IRPF kesintisi",
125
+ "labelEmployeeSocialSecurity": "Çalışan sosyal güvenliği",
126
+ "labelContributionBase": "Aylık prim matrahı",
127
+ "labelCostMultiplier": "Brüte göre maliyet çarpanı",
128
+ "labelWorkerKeeps": "Toplam maliyet içinde net pay oranı",
129
+ "labelLeanGap": "Sıkı maliyet halkası",
130
+ "labelTypicalGap": "Standart maliyet halkası",
131
+ "labelWideGap": "Genişletilmiş maliyet halkası",
132
+ "labelModelScope": "Yalnızca İspanya. 2026 Genel Rejim, tam zamanlı çalışma. Sonuçlar tahmini niteliktedir.",
133
+ "labelReset": "Örneği sıfırla",
134
+ "labelInvalid": "Geçerli bir yıllık brüt maaş girin ve oranları sınırlar içinde tutun.",
135
+ "labelMinimumSalary": "Model 2026 İspanya asgari ücreti olan yıllık 17.094 € dan başlar.",
136
+ "labelPrivacy": "Tarayıcınızda yerel olarak hesaplanır"
137
+ },
138
+ seo: [
139
+ { type: 'title', text: "İspanya da brüt maaştan gerçek şirket maliyetine", level: 2 },
140
+ { type: 'paragraph', html: "2026 kurallarına göre İspanya da net maaşı, IRPF vergi kesintisini, çalışan primlerini ve toplam işveren maliyetini hesaplayın." },
141
+ { type: 'title', text: "İspanya işveren maliyeti hesaplama formülü", level: 2 },
142
+ { type: 'paragraph', html: "Şirket brüt maaşı ve kendi sosyal güvenlik primlerini öder." },
143
+ { type: 'table', headers: ['Layer', 'Details'], rows: [['Net', "Net maaş çekirdeği"], ['Employer Cost', "Toplam işveren maliyeti"]] },
144
+ { type: 'title', text: "2026 yılı İspanya sosyal güvenlik prim oranları", level: 2 },
145
+ { type: 'paragraph', html: "Genel sağlık, işsizlik, eğitim, MEI, FOGASA ve iş kazası primleri dahildir." },
146
+ { type: 'table', headers: ['Preset', 'Rate'], rows: [["Ofis %1,50", '1.50%'], ["Saha / Satış %2,00", '2.00%'], ["İnşaat / Saha %6,70", '6.70%']] },
147
+ { type: 'title', text: "Prim tavanları ve yüksek maaşlar", level: 2 },
148
+ { type: 'paragraph', html: "2026 aylık tavanı (5.101,20 €) üzerindeki kısma kademeli dayanışma primi uygulanır." },
149
+ { type: 'title', text: "12 maaş ve 14 maaş arasındaki fark", level: 2 },
150
+ { type: 'paragraph', html: "Hayır. Hesaplayıcı aynı yıllık brüt tutarı korur." },
151
+ { type: 'tip', title: "Ödeme takvimi", html: "12/14 ödeme, sözleşme türü, IRPF ve iş kazası primini seçin." },
152
+ { type: 'title', text: "İspanya maaş tahmin modelinin sınırları", level: 2 },
153
+ { type: 'paragraph', html: "Yalnızca İspanya. 2026 Genel Rejim, tam zamanlı çalışma. Sonuçlar tahmini niteliktedir." },
154
+ ],
155
+ faq,
156
+ bibliography,
157
+ howTo,
158
+ schemas: [faqSchema, howToSchema, appSchema],
159
+ };
@@ -0,0 +1,159 @@
1
+ import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
2
+ import type { ToolLocaleContent } from '../../../types';
3
+ import { bibliography } from '../bibliography';
4
+ import type { SalaryEmployerCostUI } from '../ui';
5
+
6
+ const slug = 'spain-gross-net-salary-employer-cost-calculator';
7
+ const title = "西班牙税前税后工资与企业雇佣成本计算器 2026";
8
+ const description = "根据2026年西班牙最新法规,计算税前年薪对应的税后到手工资、IRPF个人所得税、员工社保扣款及企业总雇佣成本。";
9
+
10
+ const faq = [
11
+ {
12
+ "question": "在西班牙雇佣一名员工,企业实际需要承担多少成本?",
13
+ "answer": "企业需支付税前薪资加上企业承担的社保费用。以3万欧元年薪的正规办公岗为例,2026年企业总成本约为 39,645 欧元。"
14
+ },
15
+ {
16
+ "question": "在西班牙如何从税前薪资计算税后到手薪资?",
17
+ "answer": "从税前年薪中扣除员工个人社保和IRPF个人所得税预扣款。"
18
+ },
19
+ {
20
+ "question": "选择12薪还是14薪会影响年总薪资吗?",
21
+ "answer": "不会。计算器保持年税前总薪资和企业总成本不变,仅调整单期平均发放金额。"
22
+ },
23
+ {
24
+ "question": "2026年计算中包含了哪些社保费率?",
25
+ "answer": "包含常见疾病、失业、职业培训、MEI跨代公平机制、FOGASA基金及工伤险。"
26
+ },
27
+ {
28
+ "question": "薪资超过最高社保缴费基数会怎样?",
29
+ "answer": "2026年月基数上限为5,101.20欧元,超过部分将按阶梯征收团结累进社保费。"
30
+ },
31
+ {
32
+ "question": "本计算器适用于西班牙以外的国家吗?",
33
+ "answer": "不适用。本计算器专为西班牙2026年通用社保制度设计。"
34
+ },
35
+ {
36
+ "question": "薪资数据会被发送到服务器吗?",
37
+ "answer": "不会。所有计算均在您的浏览器本地进行。"
38
+ }
39
+ ];
40
+
41
+ const howTo = [
42
+ {
43
+ "name": "输入税前薪资",
44
+ "text": "选择按年或按月输入,并填写税前金额。"
45
+ },
46
+ {
47
+ "name": "设置西班牙薪水参数",
48
+ "text": "选择12/14薪、合同类型、IRPF个税预扣率及工伤费率。"
49
+ },
50
+ {
51
+ "name": "查看薪资结构拆解",
52
+ "text": "对比税后到手金额、员工扣款及企业社保缴纳部分。"
53
+ },
54
+ {
55
+ "name": "切换结果展示周期",
56
+ "text": "查看按年总额或单期平均发放金额。"
57
+ }
58
+ ];
59
+
60
+ const faqSchema: WithContext<FAQPage> = {
61
+ '@context': 'https://schema.org',
62
+ '@type': 'FAQPage',
63
+ mainEntity: faq.map((item) => ({
64
+ '@type': 'Question',
65
+ name: item.question,
66
+ acceptedAnswer: { '@type': 'Answer', text: item.answer },
67
+ })),
68
+ };
69
+
70
+ const howToSchema: WithContext<HowTo> = {
71
+ '@context': 'https://schema.org',
72
+ '@type': 'HowTo',
73
+ name: title,
74
+ description,
75
+ step: howTo.map((step, index) => ({
76
+ '@type': 'HowToStep',
77
+ position: index + 1,
78
+ name: step.name,
79
+ text: step.text,
80
+ })),
81
+ };
82
+
83
+ const appSchema: WithContext<SoftwareApplication> = {
84
+ '@context': 'https://schema.org',
85
+ '@type': 'SoftwareApplication',
86
+ name: title,
87
+ description,
88
+ applicationCategory: 'FinanceApplication',
89
+ operatingSystem: 'All',
90
+ offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' },
91
+ inLanguage: "zh",
92
+ };
93
+
94
+ export const content: ToolLocaleContent<SalaryEmployerCostUI> = {
95
+ slug,
96
+ title,
97
+ description,
98
+ ui: {
99
+ "labelGrossSalary": "税前薪资输入",
100
+ "labelGrossSalaryHint": "薪资金额",
101
+ "labelAnnual": "按年",
102
+ "labelMonthly": "按月",
103
+ "labelPaySchedule": "发放薪期",
104
+ "labelTwelvePayments": "每年12薪",
105
+ "labelFourteenPayments": "每年14薪",
106
+ "labelContract": "合同类型",
107
+ "labelPermanent": "无固定期限合同 (正式员工)",
108
+ "labelTemporary": "固定期限合同 (临时/项目)",
109
+ "labelIrpfRate": "IRPF 个人所得税预扣率",
110
+ "labelIrpfHint": "请输入税务局计算器、Offer或工资单上的预扣税率",
111
+ "labelRiskRate": "工伤与职业病保险费率 (AT & EP)",
112
+ "labelRiskHint": "企业根据岗位风险支付的工伤保险费率",
113
+ "labelOfficePreset": "纯办公人员 1.50%",
114
+ "labelTradePreset": "销售/商务人员 2.00%",
115
+ "labelConstructionPreset": "建筑/现场施工 6.70%",
116
+ "labelNetCore": "税后净收入核心",
117
+ "labelEmployeeDeductions": "员工扣款项",
118
+ "labelDeductionsFormula": "IRPF个税 + 员工个人缴纳社保",
119
+ "labelEmployerHalo": "企业缴纳社保光环",
120
+ "labelEmployerCost": "企业总雇佣成本",
121
+ "labelAnnualView": "按年视图",
122
+ "labelPerPaymentView": "单期发放平均值",
123
+ "labelGross": "税前薪资",
124
+ "labelIrpf": "IRPF个税预扣",
125
+ "labelEmployeeSocialSecurity": "员工个人社保",
126
+ "labelContributionBase": "月社保缴水基数",
127
+ "labelCostMultiplier": "相对于税前薪资的成本倍数",
128
+ "labelWorkerKeeps": "到手净薪占企业总成本比例",
129
+ "labelLeanGap": "紧凑成本区间",
130
+ "labelTypicalGap": "标准成本区间",
131
+ "labelWideGap": "扩展成本区间",
132
+ "labelModelScope": "仅适用于西班牙。基于2026年通用社保制度、全职工作及现金薪资。计算结果仅供参考。",
133
+ "labelReset": "重置示例",
134
+ "labelInvalid": "请输入有效的全职年薪,并将税率与工伤费率保持在允许范围内。",
135
+ "labelMinimumSalary": "本模型自2026年西班牙全职最低工资(年薪 17,094 €)起计算。",
136
+ "labelPrivacy": "在您的浏览器中本地计算"
137
+ },
138
+ seo: [
139
+ { type: 'title', text: "从税前薪资到西班牙企业的实际雇佣成本", level: 2 },
140
+ { type: 'paragraph', html: "根据2026年西班牙最新法规,计算税前年薪对应的税后到手工资、IRPF个人所得税、员工社保扣款及企业总雇佣成本。" },
141
+ { type: 'title', text: "西班牙企业雇佣成本计算公式", level: 2 },
142
+ { type: 'paragraph', html: "企业需支付税前薪资加上企业承担的社保费用。以3万欧元年薪的正规办公岗为例,2026年企业总成本约为 39,645 欧元。" },
143
+ { type: 'table', headers: ['Layer', 'Details'], rows: [['Net', "税后净收入核心"], ['Employer Cost', "企业总雇佣成本"]] },
144
+ { type: 'title', text: "2026年西班牙社会保险费率构成", level: 2 },
145
+ { type: 'paragraph', html: "包含常见疾病、失业、职业培训、MEI跨代公平机制、FOGASA基金及工伤险。" },
146
+ { type: 'table', headers: ['Preset', 'Rate'], rows: [["纯办公人员 1.50%", '1.50%'], ["销售/商务人员 2.00%", '2.00%'], ["建筑/现场施工 6.70%", '6.70%']] },
147
+ { type: 'title', text: "缴费基数上限与高薪团结税", level: 2 },
148
+ { type: 'paragraph', html: "2026年月基数上限为5,101.20欧元,超过部分将按阶梯征收团结累进社保费。" },
149
+ { type: 'title', text: "12薪与14薪的区别", level: 2 },
150
+ { type: 'paragraph', html: "不会。计算器保持年税前总薪资和企业总成本不变,仅调整单期平均发放金额。" },
151
+ { type: 'tip', title: "发放薪期", html: "选择12/14薪、合同类型、IRPF个税预扣率及工伤费率。" },
152
+ { type: 'title', text: "本西班牙薪水估算模型的适用边界", level: 2 },
153
+ { type: 'paragraph', html: "仅适用于西班牙。基于2026年通用社保制度、全职工作及现金薪资。计算结果仅供参考。" },
154
+ ],
155
+ faq,
156
+ bibliography,
157
+ howTo,
158
+ schemas: [faqSchema, howToSchema, appSchema],
159
+ };
@@ -0,0 +1,11 @@
1
+ import type { ToolDefinition } from '../../types';
2
+ import { salaryEmployerCost } from './entry';
3
+
4
+ export * from './entry';
5
+
6
+ export const SALARY_EMPLOYER_COST_TOOL: ToolDefinition = {
7
+ entry: salaryEmployerCost,
8
+ Component: () => import('./component.astro'),
9
+ SEOComponent: () => import('./seo.astro'),
10
+ BibliographyComponent: () => import('./bibliography.astro'),
11
+ };
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ DEFAULT_SALARY_INPUT,
4
+ MINIMUM_ANNUAL_GROSS,
5
+ annualizeSalary,
6
+ calculateSalaryCost,
7
+ isSalaryInputValid,
8
+ type SalaryCostInput,
9
+ } from './logic';
10
+ import { evaluateCostGap } from './evaluator';
11
+
12
+ const withInput = (changes: Partial<SalaryCostInput>): SalaryCostInput => ({
13
+ ...DEFAULT_SALARY_INPUT,
14
+ ...changes,
15
+ });
16
+
17
+ describe('annualizeSalary', () => {
18
+ it('keeps an annual amount unchanged', () => {
19
+ expect(annualizeSalary(DEFAULT_SALARY_INPUT)).toBe(30_000);
20
+ });
21
+
22
+ it('turns a monthly amount into the selected pay schedule', () => {
23
+ expect(annualizeSalary(withInput({ amount: 2_000, amountMode: 'monthly' }))).toBe(28_000);
24
+ });
25
+ });
26
+
27
+ describe('isSalaryInputValid', () => {
28
+ it('accepts the full-time minimum and legal slider bounds', () => {
29
+ expect(isSalaryInputValid(withInput({ amount: MINIMUM_ANNUAL_GROSS, irpfRate: 0, riskRate: 0.9 }))).toBe(true);
30
+ expect(isSalaryInputValid(withInput({ amount: 200_000, irpfRate: 47, riskRate: 7.15 }))).toBe(true);
31
+ });
32
+
33
+ it('rejects unsupported and non-finite inputs', () => {
34
+ expect(isSalaryInputValid(withInput({ amount: MINIMUM_ANNUAL_GROSS - 1 }))).toBe(false);
35
+ expect(isSalaryInputValid(withInput({ irpfRate: 48 }))).toBe(false);
36
+ expect(isSalaryInputValid(withInput({ riskRate: 0.8 }))).toBe(false);
37
+ expect(isSalaryInputValid(withInput({ amount: Number.NaN }))).toBe(false);
38
+ });
39
+ });
40
+
41
+ describe('calculateSalaryCost', () => {
42
+ it('calculates the documented permanent office example', () => {
43
+ const result = calculateSalaryCost(DEFAULT_SALARY_INPUT);
44
+ expect(result).toMatchObject({
45
+ annualGross: 30_000,
46
+ annualNet: 23_550,
47
+ annualIrpf: 4_500,
48
+ annualEmployeeSocialSecurity: 1_950,
49
+ annualEmployerSocialSecurity: 9_645,
50
+ annualEmployerCost: 39_645,
51
+ monthlyContributionBase: 2_500,
52
+ grossPerPayment: 2_142.86,
53
+ netPerPayment: 1_682.14,
54
+ employerCostPerPayment: 2_831.79,
55
+ costMultiplier: 1.322,
56
+ workerKeepsPercent: 59.4,
57
+ });
58
+ });
59
+
60
+ it('uses temporary unemployment rates', () => {
61
+ const result = calculateSalaryCost(withInput({ contractType: 'temporary' }));
62
+ expect(result?.annualEmployeeSocialSecurity).toBe(1_965);
63
+ expect(result?.annualEmployerSocialSecurity).toBe(10_005);
64
+ });
65
+
66
+ it('caps the standard base and adds every solidarity tier', () => {
67
+ const result = calculateSalaryCost(withInput({ amount: 120_000, irpfRate: 20 }));
68
+ expect(result?.monthlyContributionBase).toBe(5_101.2);
69
+ expect(result?.annualEmployeeSocialSecurity).toBe(4_109.61);
70
+ expect(result?.annualEmployerSocialSecurity).toBe(20_337.62);
71
+ });
72
+
73
+ it('returns null for an invalid salary', () => {
74
+ expect(calculateSalaryCost(withInput({ amount: 10_000 }))).toBeNull();
75
+ });
76
+ });
77
+
78
+ describe('evaluateCostGap', () => {
79
+ it('classifies lean, typical and wide employer contribution halos', () => {
80
+ const lean = calculateSalaryCost(withInput({ riskRate: 0.9 }));
81
+ const typical = calculateSalaryCost(DEFAULT_SALARY_INPUT);
82
+ const wide = calculateSalaryCost(withInput({ contractType: 'temporary', riskRate: 7.15 }));
83
+ if (!lean || !typical || !wide) throw new Error('Expected valid salary examples');
84
+ expect(evaluateCostGap(lean)).toBe('lean');
85
+ expect(evaluateCostGap(typical)).toBe('typical');
86
+ expect(evaluateCostGap(wide)).toBe('wide');
87
+ });
88
+ });
@@ -0,0 +1,133 @@
1
+ export type SalaryAmountMode = 'annual' | 'monthly';
2
+ export type ContractType = 'permanent' | 'temporary';
3
+ export type PayPeriods = 12 | 14;
4
+
5
+ export interface SalaryCostInput {
6
+ amount: number;
7
+ amountMode: SalaryAmountMode;
8
+ payPeriods: PayPeriods;
9
+ contractType: ContractType;
10
+ irpfRate: number;
11
+ riskRate: number;
12
+ }
13
+
14
+ export interface SalaryCostResult {
15
+ annualGross: number;
16
+ annualNet: number;
17
+ annualIrpf: number;
18
+ annualEmployeeSocialSecurity: number;
19
+ annualEmployerSocialSecurity: number;
20
+ annualEmployerCost: number;
21
+ monthlyContributionBase: number;
22
+ grossPerPayment: number;
23
+ netPerPayment: number;
24
+ employerCostPerPayment: number;
25
+ costMultiplier: number;
26
+ workerKeepsPercent: number;
27
+ }
28
+
29
+ interface ContributionTier {
30
+ lower: number;
31
+ upper: number;
32
+ employeeRate: number;
33
+ employerRate: number;
34
+ }
35
+
36
+ export const MINIMUM_ANNUAL_GROSS = 17_094;
37
+ export const MINIMUM_MONTHLY_BASE = 1_424.4;
38
+ export const MAXIMUM_MONTHLY_BASE = 5_101.2;
39
+
40
+ export const DEFAULT_SALARY_INPUT: SalaryCostInput = {
41
+ amount: 30_000,
42
+ amountMode: 'annual',
43
+ payPeriods: 14,
44
+ contractType: 'permanent',
45
+ irpfRate: 15,
46
+ riskRate: 1.5,
47
+ };
48
+
49
+ const SOLIDARITY_TIERS: ContributionTier[] = [
50
+ { lower: 5_101.2, upper: 5_611.32, employeeRate: 0.19, employerRate: 0.96 },
51
+ { lower: 5_611.32, upper: 7_651.8, employeeRate: 0.21, employerRate: 1.04 },
52
+ { lower: 7_651.8, upper: Number.POSITIVE_INFINITY, employeeRate: 0.24, employerRate: 1.22 },
53
+ ];
54
+
55
+ const roundMoney = (value: number): number => Math.round((value + Number.EPSILON) * 100) / 100;
56
+
57
+ const clamp = (value: number, minimum: number, maximum: number): number =>
58
+ Math.min(Math.max(value, minimum), maximum);
59
+
60
+ export function annualizeSalary(input: SalaryCostInput): number {
61
+ if (input.amountMode === 'annual') return input.amount;
62
+ return input.amount * input.payPeriods;
63
+ }
64
+
65
+ export function isSalaryInputValid(input: SalaryCostInput): boolean {
66
+ const annualGross = annualizeSalary(input);
67
+ const validNumbers = [input.amount, input.irpfRate, input.riskRate].every(Number.isFinite);
68
+ return validNumbers
69
+ && annualGross >= MINIMUM_ANNUAL_GROSS
70
+ && input.irpfRate >= 0
71
+ && input.irpfRate <= 47
72
+ && input.riskRate >= 0.9
73
+ && input.riskRate <= 7.15;
74
+ }
75
+
76
+ function tierAmount(monthlyGross: number, tier: ContributionTier): number {
77
+ return Math.max(0, Math.min(monthlyGross, tier.upper) - tier.lower);
78
+ }
79
+
80
+ function solidarityContribution(monthlyGross: number, side: 'employee' | 'employer'): number {
81
+ const rateKey = side === 'employee' ? 'employeeRate' : 'employerRate';
82
+ const monthly = SOLIDARITY_TIERS.reduce(
83
+ (total, tier) => total + tierAmount(monthlyGross, tier) * tier[rateKey] / 100,
84
+ 0,
85
+ );
86
+ return monthly * 12;
87
+ }
88
+
89
+ function employeeRate(contractType: ContractType): number {
90
+ const unemployment = contractType === 'temporary' ? 1.6 : 1.55;
91
+ return 4.7 + unemployment + 0.1 + 0.15;
92
+ }
93
+
94
+ function employerRate(input: SalaryCostInput): number {
95
+ const unemployment = input.contractType === 'temporary' ? 6.7 : 5.5;
96
+ return 23.6 + unemployment + 0.6 + 0.2 + 0.75 + input.riskRate;
97
+ }
98
+
99
+ function socialSecurity(input: SalaryCostInput, annualGross: number) {
100
+ const monthlyGross = annualGross / 12;
101
+ const monthlyBase = clamp(monthlyGross, MINIMUM_MONTHLY_BASE, MAXIMUM_MONTHLY_BASE);
102
+ const standardBase = monthlyBase * 12;
103
+ return {
104
+ monthlyBase,
105
+ employee: standardBase * employeeRate(input.contractType) / 100
106
+ + solidarityContribution(monthlyGross, 'employee'),
107
+ employer: standardBase * employerRate(input) / 100
108
+ + solidarityContribution(monthlyGross, 'employer'),
109
+ };
110
+ }
111
+
112
+ export function calculateSalaryCost(input: SalaryCostInput): SalaryCostResult | null {
113
+ if (!isSalaryInputValid(input)) return null;
114
+ const annualGross = annualizeSalary(input);
115
+ const social = socialSecurity(input, annualGross);
116
+ const annualIrpf = annualGross * input.irpfRate / 100;
117
+ const annualNet = annualGross - social.employee - annualIrpf;
118
+ const annualEmployerCost = annualGross + social.employer;
119
+ return {
120
+ annualGross: roundMoney(annualGross),
121
+ annualNet: roundMoney(annualNet),
122
+ annualIrpf: roundMoney(annualIrpf),
123
+ annualEmployeeSocialSecurity: roundMoney(social.employee),
124
+ annualEmployerSocialSecurity: roundMoney(social.employer),
125
+ annualEmployerCost: roundMoney(annualEmployerCost),
126
+ monthlyContributionBase: roundMoney(social.monthlyBase),
127
+ grossPerPayment: roundMoney(annualGross / input.payPeriods),
128
+ netPerPayment: roundMoney(annualNet / input.payPeriods),
129
+ employerCostPerPayment: roundMoney(annualEmployerCost / input.payPeriods),
130
+ costMultiplier: Math.round(annualEmployerCost / annualGross * 1000) / 1000,
131
+ workerKeepsPercent: Math.round(annualNet / annualEmployerCost * 1000) / 10,
132
+ };
133
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { salaryEmployerCost } from './entry';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props;
11
+ const content = await salaryEmployerCost.i18n[locale]?.();
12
+ if (!content) return null;
13
+ ---
14
+
15
+ {content.seo.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}