diff --git a/README.md b/README.md index 86cb861..c7e6a7a 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,15 @@ This project was heavily driven and refactored from scratch to production level --- ## ⚡ Features -- **Workday Tracking**: Precise control of entry, lunch, and exit times, with real-time calculation of overtime (75% and 100%) and night shift bonus. -- **Salary Calculator**: Exact calculation of INSS, IRRF, deductions, and extra gains (compatible with 2026 CLT rules). +- **Workday Tracking**: Precise control of entry, lunch, and exit times, with real-time overtime calculation and the reduced night hour (CLT art. 73). Overtime rates are configurable and default to the statutory floor of 50%/100%. +- **Work Regimes**: CLT, Empregado Público and Estatutário. The first two contribute under the RGPS table with its ceiling; Estatutário follows the federal RPPS ladder (7.5% to 22%, uncapped). +- **Salary Calculator**: INSS, IRRF, dependents, deductions and extra gains, using the tables in force for 2026 — including the R$ 607,20 simplified deduction and the reduction that exempts income up to R$ 5.000,00. +- **Any Period**: View your pay by hour, day, week, month or year, derived from your monthly workload and daily journey. - **Mobile-First & Premium UI**: An amazing interface developed from scratch to provide the best experience, with native support for Dark/Light Mode. - **Offline First**: Automatically saves all user preferences in `localStorage`. +> Tax tables are the ones in force for 2026: **Portaria Interministerial MPS/MF nº 13 de 09/01/2026** for social security, and the IRRF table as amended by **Lei 15.270/2025**. + --- ## 🏗️ Architecture & Best Practices diff --git a/__tests__/payroll.test.ts b/__tests__/payroll.test.ts index 20cbaf3..c9055d7 100644 --- a/__tests__/payroll.test.ts +++ b/__tests__/payroll.test.ts @@ -46,6 +46,42 @@ describe("calculateSocialSecurity", () => { expect(calculateSocialSecurity(-1000)).toBe(0); expect(calculateSocialSecurity(Number.NaN)).toBe(0); }); + + it("treats empregado público exactly like CLT", () => { + for (const salary of [1000, 5000, 8475.55, 30000]) { + expect(calculateSocialSecurity(salary, "empregado-publico")).toBe( + calculateSocialSecurity(salary, "clt"), + ); + } + }); + + it("matches CLT for estatutário up to the general regime ceiling", () => { + for (const salary of [1000, 5000, 8475.55]) { + expect(calculateSocialSecurity(salary, "estatutario")).toBe( + calculateSocialSecurity(salary, "clt"), + ); + } + }); + + it("keeps charging estatutário past the ceiling that caps CLT", () => { + expect(calculateSocialSecurity(20000, "clt")).toBe(988.09); + expect(calculateSocialSecurity(20000, "estatutario")).toBeGreaterThan( + 988.09, + ); + }); + + it("applies every civil service tier at its own boundary", () => { + expect(calculateSocialSecurity(14514.3, "estatutario")).toBe(1863.71); + expect(calculateSocialSecurity(29028.57, "estatutario")).toBe(4258.56); + expect(calculateSocialSecurity(56605.73, "estatutario")).toBe(9498.23); + }); + + it("charges the top civil service rate above the last boundary", () => { + const atBoundary = calculateSocialSecurity(56605.73, "estatutario"); + const above = calculateSocialSecurity(66605.73, "estatutario"); + + expect(above - atBoundary).toBeCloseTo(10000 * 0.22, 2); + }); }); describe("calculateIncomeTax", () => { @@ -93,4 +129,47 @@ describe("calculateIncomeTax", () => { expect(calculateIncomeTax(-5000, 0)).toBe(0); expect(calculateIncomeTax(Number.NaN, Number.NaN)).toBe(0); }); + + it("deducts each dependent from the taxable base", () => { + const contribution = calculateSocialSecurity(10000); + + expect(calculateIncomeTax(10000, contribution, 0)).toBe(1569.55); + expect(calculateIncomeTax(10000, contribution, 2)).toBe(1465.27); + }); + + it("tips the deduction away from the simplified one at the first dependent", () => { + const contribution = calculateSocialSecurity(5500); + expect(contribution).toBeLessThan(607.2); + + expect(calculateIncomeTax(5500, contribution, 0)).toBe( + calculateIncomeTax(5500, 0, 0), + ); + expect(calculateIncomeTax(5500, contribution, 1)).toBeLessThan( + calculateIncomeTax(5500, contribution, 0), + ); + }); + + it("ignores fractional and negative dependent counts", () => { + const contribution = calculateSocialSecurity(10000); + + expect(calculateIncomeTax(10000, contribution, 2.9)).toBe( + calculateIncomeTax(10000, contribution, 2), + ); + expect(calculateIncomeTax(10000, contribution, -3)).toBe( + calculateIncomeTax(10000, contribution, 0), + ); + }); + + it("taxes estatutário less because the contribution is deductible", () => { + const cltTax = calculateIncomeTax( + 20000, + calculateSocialSecurity(20000, "clt"), + ); + const civilServiceTax = calculateIncomeTax( + 20000, + calculateSocialSecurity(20000, "estatutario"), + ); + + expect(civilServiceTax).toBeLessThan(cltTax); + }); }); diff --git a/__tests__/salary-calculator.test.tsx b/__tests__/salary-calculator.test.tsx index 0ad69d3..0adba7b 100644 --- a/__tests__/salary-calculator.test.tsx +++ b/__tests__/salary-calculator.test.tsx @@ -20,10 +20,122 @@ describe("SalaryCalculator", () => { ).toBeInTheDocument(); expect(screen.getByLabelText("Salário Bruto (R$)")).toHaveValue("5.000,00"); expect(screen.getByLabelText("Carga Horária Mensal")).toHaveValue(220); - expect(screen.getByText("Valor da Hora")).toBeInTheDocument(); + expect(screen.getByText("Valor por Hora")).toBeInTheDocument(); expect(screen.getByText("Resumo Financeiro")).toBeInTheDocument(); }); + it("defaults to the CLT regime and offers the public service ones", () => { + render(); + + const regime = screen.getByLabelText("Regime de Trabalho"); + expect(regime).toHaveValue("clt"); + expect( + screen.getByRole("option", { name: "Empregado Público" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "Estatutário" }), + ).toBeInTheDocument(); + }); + + it("contributes the capped amount for CLT and empregado público alike", async () => { + const user = userEvent.setup(); + localStorage.setItem("grossSalary", "20000"); + render(); + + await user.click( + screen.getByRole("button", { name: /Impostos e Descontos/ }), + ); + expect(screen.getByLabelText("INSS (R$)")).toHaveAttribute( + "placeholder", + "988,09", + ); + + await user.selectOptions( + screen.getByLabelText("Regime de Trabalho"), + "empregado-publico", + ); + expect(screen.getByLabelText("INSS (R$)")).toHaveAttribute( + "placeholder", + "988,09", + ); + }); + + it("keeps contributing past the ceiling for estatutário", async () => { + const user = userEvent.setup(); + localStorage.setItem("grossSalary", "20000"); + render(); + + await user.selectOptions( + screen.getByLabelText("Regime de Trabalho"), + "estatutario", + ); + await user.click( + screen.getByRole("button", { name: /Impostos e Descontos/ }), + ); + + expect(screen.getByLabelText("INSS (R$)")).toHaveAttribute( + "placeholder", + "2.768,85", + ); + }); + + it("deducts declared dependents from the income tax", async () => { + const user = userEvent.setup(); + localStorage.setItem("grossSalary", "10000"); + render(); + + await user.click( + screen.getByRole("button", { name: /Impostos e Descontos/ }), + ); + expect(screen.getByLabelText("IRRF (R$)")).toHaveAttribute( + "placeholder", + "1.569,55", + ); + + await user.type(screen.getByLabelText("Dependentes"), "2"); + + expect(screen.getByLabelText("IRRF (R$)")).toHaveAttribute( + "placeholder", + "1.465,27", + ); + }); + + it("switches the headline value between periods", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByText("Valor por Hora")).toBeInTheDocument(); + + await user.click(screen.getByRole("radio", { name: "Mês" })); + expect(screen.getByText("Valor por Mês")).toBeInTheDocument(); + + await user.click(screen.getByRole("radio", { name: "Ano" })); + expect(screen.getByText("Valor por Ano")).toBeInTheDocument(); + }); + + it("derives each period from the monthly net and the daily journey", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("radio", { name: "Dia" })); + expect(screen.getByText(/163,58/)).toBeInTheDocument(); + + const dailyHours = screen.getByLabelText("Jornada Diária (horas)"); + await user.clear(dailyHours); + await user.type(dailyHours, "6"); + + expect(screen.getByText(/122,69/)).toBeInTheDocument(); + }); + + it("counts thirteen paid months in the yearly view", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("radio", { name: "Ano" })); + + expect(screen.getByText(/58\.480,37/)).toBeInTheDocument(); + }); + it("summarises net salary, total received and total deductions", () => { render(); diff --git a/__tests__/salary-period.test.ts b/__tests__/salary-period.test.ts new file mode 100644 index 0000000..07117ea --- /dev/null +++ b/__tests__/salary-period.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + amountForPeriod, + SALARY_PERIOD_LABELS, + SALARY_PERIODS, + type SalaryPeriod, +} from "@/lib/salary-period"; + +const MONTHLY_NET = 4498.49; +const MONTHLY_HOURS = 220; +const DAILY_HOURS = 8; + +const forPeriod = (period: SalaryPeriod) => + amountForPeriod(MONTHLY_NET, period, MONTHLY_HOURS, DAILY_HOURS); + +describe("SALARY_PERIODS", () => { + it("lists every period from shortest to longest", () => { + expect(SALARY_PERIODS).toEqual(["hour", "day", "week", "month", "year"]); + }); + + it("labels every period", () => { + for (const period of SALARY_PERIODS) { + expect(SALARY_PERIOD_LABELS[period]).toBeTruthy(); + } + }); +}); + +describe("amountForPeriod", () => { + it("divides the monthly amount by the monthly hours", () => { + expect(forPeriod("hour")).toBeCloseTo(20.4477, 4); + }); + + it("scales the hourly amount by the daily hours", () => { + expect(forPeriod("day")).toBeCloseTo(forPeriod("hour") * 8, 6); + }); + + it("counts five working days in a week", () => { + expect(forPeriod("week")).toBeCloseTo(forPeriod("day") * 5, 6); + }); + + it("returns the monthly amount untouched", () => { + expect(forPeriod("month")).toBe(MONTHLY_NET); + }); + + it("counts thirteen paid months in a year", () => { + expect(forPeriod("year")).toBeCloseTo(MONTHLY_NET * 13, 6); + }); + + it("honours a shorter daily journey", () => { + expect(amountForPeriod(MONTHLY_NET, "day", MONTHLY_HOURS, 6)).toBeCloseTo( + forPeriod("hour") * 6, + 6, + ); + }); + + it("avoids dividing by zero when the monthly hours are cleared", () => { + const hourly = amountForPeriod(MONTHLY_NET, "hour", 0, DAILY_HOURS); + + expect(hourly).toBe(MONTHLY_NET); + expect(Number.isFinite(hourly)).toBe(true); + }); +}); diff --git a/__tests__/tax-details-panel.test.tsx b/__tests__/tax-details-panel.test.tsx index a74cb2e..fd25bf2 100644 --- a/__tests__/tax-details-panel.test.tsx +++ b/__tests__/tax-details-panel.test.tsx @@ -9,6 +9,8 @@ vi.mock("@/lib/analytics", () => ({ })); const baseProps = { + dependents: 0, + onDependentsChange: vi.fn(), manualInss: null, manualIrrf: null, autoInss: 500, diff --git a/components/molecules/period-selector.tsx b/components/molecules/period-selector.tsx new file mode 100644 index 0000000..7e663cd --- /dev/null +++ b/components/molecules/period-selector.tsx @@ -0,0 +1,34 @@ +import { + SALARY_PERIOD_LABELS, + SALARY_PERIODS, + type SalaryPeriod, +} from "@/lib/salary-period"; + +interface PeriodSelectorProps { + value: SalaryPeriod; + onChange: (period: SalaryPeriod) => void; +} + +export function PeriodSelector({ value, onChange }: PeriodSelectorProps) { + return ( +
+ Visualizar o valor por período + {SALARY_PERIODS.map((period) => ( + + ))} +
+ ); +} diff --git a/components/molecules/select-field.tsx b/components/molecules/select-field.tsx new file mode 100644 index 0000000..e216e0b --- /dev/null +++ b/components/molecules/select-field.tsx @@ -0,0 +1,51 @@ +import type { ReactNode, SelectHTMLAttributes } from "react"; +import { cn } from "@/lib/utils"; +import { Label } from "../atoms/label"; + +interface SelectOption { + value: TValue; + label: string; +} + +interface SelectFieldProps + extends Omit, "onChange" | "value"> { + id: string; + label: string; + labelIcon?: ReactNode; + value: TValue; + options: readonly SelectOption[]; + onValueChange: (value: TValue) => void; +} + +export function SelectField({ + id, + label, + labelIcon, + value, + options, + onValueChange, + className, + ...props +}: SelectFieldProps) { + return ( +
+ + +
+ ); +} diff --git a/components/organisms/salary-calculator.tsx b/components/organisms/salary-calculator.tsx index 584f6c2..153d535 100644 --- a/components/organisms/salary-calculator.tsx +++ b/components/organisms/salary-calculator.tsx @@ -1,16 +1,20 @@ "use client"; import { + Briefcase, Calculator, ChevronDown, ChevronUp, Clock, + Sun, TrendingDown, TrendingUp, Wallet, } from "lucide-react"; import { useState } from "react"; import { useSalaryCalculator } from "@/hooks/use-salary-calculator"; +import type { WorkRegime } from "@/lib/payroll"; +import { SALARY_PERIOD_LABELS } from "@/lib/salary-period"; import { formatCurrency, formatCurrencySimple, @@ -19,18 +23,34 @@ import { import { CollapsiblePanel } from "../molecules/collapsible-panel"; import { FormField } from "../molecules/form-field"; import { HeroPanel } from "../molecules/hero-panel"; +import { PeriodSelector } from "../molecules/period-selector"; +import { SelectField } from "../molecules/select-field"; import { StatBox } from "../molecules/stat-box"; import { CalculatorLayout } from "../templates/calculator-layout"; import { TaxDetailsPanel } from "./tax-details-panel"; const DETAILS_PANEL_ID = "tax-details"; +const REGIME_OPTIONS: readonly { value: WorkRegime; label: string }[] = [ + { value: "clt", label: "CLT" }, + { value: "empregado-publico", label: "Empregado Público" }, + { value: "estatutario", label: "Estatutário" }, +]; + export function SalaryCalculator() { const { grossSalary, setGrossSalary, monthlyHours, setMonthlyHours, + dailyHours, + setDailyHours, + dependents, + setDependents, + regime, + setRegime, + period, + setPeriod, manualInss, setManualInss, manualIrrf, @@ -76,10 +96,19 @@ export function SalaryCalculator() { setGrossSalary(parseCurrency(event.target.value)) } /> +