Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
79 changes: 79 additions & 0 deletions __tests__/payroll.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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", () => {
Expand DownExpand Up@@ -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);
});
});
114 changes: 113 additions & 1 deletion __tests__/salary-calculator.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(<SalaryCalculator />);

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(<SalaryCalculator />);

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(<SalaryCalculator />);

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(<SalaryCalculator />);

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(<SalaryCalculator />);

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(<SalaryCalculator />);

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(<SalaryCalculator />);

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(<SalaryCalculator />);

Expand Down
62 changes: 62 additions & 0 deletions __tests__/salary-period.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
2 changes: 2 additions & 0 deletions __tests__/tax-details-panel.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ vi.mock("@/lib/analytics", () => ({
}));

const baseProps = {
dependents: 0,
onDependentsChange: vi.fn(),
manualInss: null,
manualIrrf: null,
autoInss: 500,
Expand Down
34 changes: 34 additions & 0 deletions components/molecules/period-selector.tsx
Original file line numberDiff line numberDiff line change
@@ -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 (
<fieldset className="flex flex-wrap justify-center gap-1 rounded-2xl bg-white/15 p-1.5">
<legend className="sr-only">Visualizar o valor por período</legend>
{SALARY_PERIODS.map((period) => (
<label
key={period}
className="cursor-pointer rounded-xl px-4 py-2 text-sm font-bold transition-colors has-checked:bg-white has-checked:text-neutral-900 hover:bg-white/10 has-checked:hover:bg-white focus-within:ring-2 focus-within:ring-white"
>
<input
type="radio"
name="salary-period"
value={period}
checked={period === value}
onChange={() => onChange(period)}
className="sr-only"
/>
{SALARY_PERIOD_LABELS[period]}
</label>
))}
</fieldset>
);
}
51 changes: 51 additions & 0 deletions components/molecules/select-field.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import type { ReactNode, SelectHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
import { Label } from "../atoms/label";

interface SelectOption<TValue extends string> {
value: TValue;
label: string;
}

interface SelectFieldProps<TValue extends string>
extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "onChange" | "value"> {
id: string;
label: string;
labelIcon?: ReactNode;
value: TValue;
options: readonly SelectOption<TValue>[];
onValueChange: (value: TValue) => void;
}

export function SelectField<TValue extends string>({
id,
label,
labelIcon,
value,
options,
onValueChange,
className,
...props
}: SelectFieldProps<TValue>) {
return (
<div className={cn("space-y-3", className)}>
<Label htmlFor={id}>
{labelIcon}
{label}
</Label>
<select
id={id}
value={value}
onChange={(event) => onValueChange(event.target.value as TValue)}
className="flex h-14 w-full rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white/50 dark:bg-neutral-900/50 px-4 py-2 text-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 transition-all"
{...props}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
}
Loading