From 488d55a8d2e69f5966059b87f10474e070fc1947 Mon Sep 17 00:00:00 2001 From: Rafael Martins Date: Sat, 1 Aug 2026 18:07:14 -0300 Subject: [PATCH 1/2] feat(payroll): support the public service regimes and period conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the domain rules behind #6. `calculateSocialSecurity` now takes a regime. CLT and Empregado Público are both RGPS contributors, so they share the existing four brackets and its 8.475,55 ceiling. Estatutário follows the federal RPPS ladder from Anexo III of Portaria Interministerial MPS/MF nº 13 de 09/01/2026 — which continues the RGPS brackets rather than replacing them, adding 14,5% / 16,5% / 19% / 22% and no cap. Expressing it as the general table plus four tiers keeps a single progressive implementation instead of two tables that could drift. The two regimes are therefore identical up to 8.475,55 and only diverge above it: at a 20.000,00 salary CLT contributes the capped 988,09 while Estatutário contributes 2.768,85. `calculateIncomeTax` now accepts a dependent count at 189,59 each, competing with the 607,20 simplified deduction — larger wins, as the Receita computes it. Worth noting the interaction: above the 5.000,00 exemption the contribution alone is already at least 501,51, so a single declared dependent always tips the comparison to the legal deduction. `lib/salary-period.ts` converts a monthly amount to any period, deriving the hourly rate from the monthly workload and the day from a configurable daily journey. A year is thirteen paid months, counting the 13th salary. Co-Authored-By: Claude Opus 5 --- __tests__/payroll.test.ts | 79 +++++++++++++++++++++++++++++++++ __tests__/salary-period.test.ts | 62 ++++++++++++++++++++++++++ lib/payroll.ts | 35 ++++++++++++--- lib/salary-period.ts | 38 ++++++++++++++++ 4 files changed, 207 insertions(+), 7 deletions(-) create mode 100644 __tests__/salary-period.test.ts create mode 100644 lib/salary-period.ts 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-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/lib/payroll.ts b/lib/payroll.ts index 21804e9..be6a6fe 100644 --- a/lib/payroll.ts +++ b/lib/payroll.ts @@ -12,13 +12,29 @@ interface IncomeTaxBracket extends IncomeTaxRate { readonly ceiling: number; } -const SOCIAL_SECURITY_BRACKETS: readonly ProgressiveBracket[] = [ +export type WorkRegime = "clt" | "empregado-publico" | "estatutario"; + +const GENERAL_REGIME_BRACKETS: readonly ProgressiveBracket[] = [ { ceiling: 1621.0, rate: 0.075 }, { ceiling: 2902.84, rate: 0.09 }, { ceiling: 4354.27, rate: 0.12 }, { ceiling: 8475.55, rate: 0.14 }, ]; +const CIVIL_SERVICE_BRACKETS: readonly ProgressiveBracket[] = [ + ...GENERAL_REGIME_BRACKETS, + { ceiling: 14514.3, rate: 0.145 }, + { ceiling: 29028.57, rate: 0.165 }, + { ceiling: 56605.73, rate: 0.19 }, + { ceiling: Number.POSITIVE_INFINITY, rate: 0.22 }, +]; + +const BRACKETS_BY_REGIME: Record = { + clt: GENERAL_REGIME_BRACKETS, + "empregado-publico": GENERAL_REGIME_BRACKETS, + estatutario: CIVIL_SERVICE_BRACKETS, +}; + const INCOME_TAX_BRACKETS: readonly IncomeTaxBracket[] = [ { ceiling: 2428.8, rate: 0, deduction: 0 }, { ceiling: 2826.65, rate: 0.075, deduction: 182.16 }, @@ -29,6 +45,7 @@ const INCOME_TAX_BRACKETS: readonly IncomeTaxBracket[] = [ const TOP_INCOME_TAX_RATE: IncomeTaxRate = { rate: 0.275, deduction: 908.73 }; const SIMPLIFIED_DEDUCTION = 607.2; +const DEPENDENT_DEDUCTION = 189.59; const EXEMPTION_CEILING = 5000; const REDUCTION_PHASE_OUT_CEILING = 7350; const REDUCTION_INTERCEPT = 978.62; @@ -59,10 +76,13 @@ function sumProgressiveBrackets( return roundToCents(total); } -export function calculateSocialSecurity(grossSalary: number): number { +export function calculateSocialSecurity( + grossSalary: number, + regime: WorkRegime = "clt", +): number { return sumProgressiveBrackets( sanitizeAmount(grossSalary), - SOCIAL_SECURITY_BRACKETS, + BRACKETS_BY_REGIME[regime], ); } @@ -81,14 +101,15 @@ function taxReductionFor(grossSalary: number): number { export function calculateIncomeTax( grossSalary: number, socialSecurity: number, + dependents = 0, ): number { const gross = sanitizeAmount(grossSalary); if (gross <= EXEMPTION_CEILING) return 0; - const deductible = Math.max( - sanitizeAmount(socialSecurity), - SIMPLIFIED_DEDUCTION, - ); + const legalDeduction = + sanitizeAmount(socialSecurity) + + DEPENDENT_DEDUCTION * Math.trunc(sanitizeAmount(dependents)); + const deductible = Math.max(legalDeduction, SIMPLIFIED_DEDUCTION); const base = sanitizeAmount(gross - deductible); const { rate, deduction } = findIncomeTaxRate(base); const tax = base * rate - deduction - taxReductionFor(gross); diff --git a/lib/salary-period.ts b/lib/salary-period.ts new file mode 100644 index 0000000..f578211 --- /dev/null +++ b/lib/salary-period.ts @@ -0,0 +1,38 @@ +export type SalaryPeriod = "hour" | "day" | "week" | "month" | "year"; + +const WORK_DAYS_PER_WEEK = 5; +const PAID_MONTHS_PER_YEAR = 13; + +export const SALARY_PERIOD_LABELS: Record = { + hour: "Hora", + day: "Dia", + week: "Semana", + month: "Mês", + year: "Ano", +}; + +export const SALARY_PERIODS = Object.keys( + SALARY_PERIOD_LABELS, +) as readonly SalaryPeriod[]; + +export function amountForPeriod( + monthlyAmount: number, + period: SalaryPeriod, + monthlyHours: number, + dailyHours: number, +): number { + const hourlyAmount = monthlyAmount / (monthlyHours > 0 ? monthlyHours : 1); + + switch (period) { + case "hour": + return hourlyAmount; + case "day": + return hourlyAmount * dailyHours; + case "week": + return hourlyAmount * dailyHours * WORK_DAYS_PER_WEEK; + case "month": + return monthlyAmount; + case "year": + return monthlyAmount * PAID_MONTHS_PER_YEAR; + } +} From a3925fcee80d2c53df822519785af30457903b26 Mon Sep 17 00:00:00 2001 From: Rafael Martins Date: Sat, 1 Aug 2026 18:07:14 -0300 Subject: [PATCH 2/2] feat(salary): choose a work regime and view pay by any period MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #6. The calculator gains a regime selector (CLT, Empregado Público, Estatutário), a dependent count in the taxes panel, a daily journey field, and a period selector on the headline card covering hour, day, week, month and year. Everything persists alongside the existing preferences, and a stored value that is not a known regime or period falls back to the default rather than being trusted. The period selector is a real radio group rather than a row of buttons, so grouping, the checked state and arrow-key navigation come from the platform instead of being reimplemented. Co-Authored-By: Claude Opus 5 --- README.md | 8 +- __tests__/salary-calculator.test.tsx | 114 ++++++++++++++++++++- __tests__/tax-details-panel.test.tsx | 2 + components/molecules/period-selector.tsx | 34 ++++++ components/molecules/select-field.tsx | 51 +++++++++ components/organisms/salary-calculator.tsx | 49 ++++++++- components/organisms/tax-details-panel.tsx | 16 +++ hooks/use-salary-calculator.ts | 82 ++++++++++++++- 8 files changed, 346 insertions(+), 10 deletions(-) create mode 100644 components/molecules/period-selector.tsx create mode 100644 components/molecules/select-field.tsx 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__/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__/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)) } /> +