From 7d33eed5bc676cfbf284d11c9eb7e8c6f6d05e0b Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:40:45 -0300 Subject: [PATCH] feat(business-days): count Saturday with includeSaturday MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The business day family counts Monday to Friday minus holidays, which is the banking count. The payroll deadline of CLT art. 459 § 1º ("até o quinto dia útil do mês subsequente ao vencido") is not counted that way: labour inspection reads it through Instrução Normativa MTP nº 2/2021, art. 14, I, "na contagem dos dias será incluído o sábado, excluindo-se o domingo e o feriado, inclusive o municipal", so the fifth business day of the labour count can fall a day before the banking one. includeSaturday is a new key on the shared BusinessDayOptions, so isBusinessDay, addBusinessDays, subBusinessDays, differenceInBusinessDays, getNextBusinessDay, getNthBusinessDay and getLastBusinessDayOfMonth all honour it. It defaults to false, the current behaviour, so no existing call changes. With it on, Sunday and holidays are still excluded, which means a holiday that falls on a Saturday is still not a business day. The one part of the article it cannot cover is "inclusive o municipal": getHolidays carries national and state holidays only, and Lei 9.093/1995 art. 2º leaves the municipal feriados religiosos to each municipality's own law. The JSDoc and both docs say so instead of implying the option gives the full rule. The weekday test also stops going through a module level Set, which the tree-shaking rule in CONTRIBUTING.md asks modules to avoid. --- docs/pt-br/utilities.md | 32 +++++-- docs/utilities.md | 32 +++++-- reports/api/brazilian-utils.api.md | 1 + src/_internals/test/arbitraries.ts | 7 +- .../add-business-days.test.ts | 45 ++++++++++ src/add-business-days/add-business-days.ts | 14 +++- .../difference-in-business-days.test.ts | 61 ++++++++++++++ .../difference-in-business-days.ts | 14 +++- .../get-last-business-day-of-month.test.ts | 23 +++++ .../get-last-business-day-of-month.ts | 14 +++- .../get-next-business-day.test.ts | 26 ++++++ .../get-next-business-day.ts | 13 ++- .../get-nth-business-day.test.ts | 80 ++++++++++++++++++ .../get-nth-business-day.ts | 21 +++-- src/is-business-day/is-business-day.test.ts | 83 +++++++++++++++---- src/is-business-day/is-business-day.ts | 50 +++++++++-- .../sub-business-days.test.ts | 32 +++++++ src/sub-business-days/sub-business-days.ts | 17 +++- 18 files changed, 511 insertions(+), 54 deletions(-) diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index bb87d1a29..2201169b0 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -1806,7 +1806,10 @@ isHoliday(); // false Verifica se uma data é dia útil no Brasil: não é sábado, domingo nem um feriado que `getHolidays` lista para o seu dia de calendário local. -- **Opções** (`BusinessDayOptions`, as mesmas de todos os utilitários de dias úteis): `includeOptional` (padrão `true`) também conta os feriados `"optional"`, Carnaval e Corpus Christi, como dias não úteis; `stateCode` também conta os feriados daquele estado. +- **Opções** (`BusinessDayOptions`, as mesmas de todos os utilitários de dias úteis): `includeOptional` (padrão `true`) também conta os feriados `"optional"`, Carnaval e Corpus Christi, como dias não úteis; `includeSaturday` (padrão `false`) conta o sábado como dia útil; `stateCode` também conta os feriados daquele estado. +- Com `includeSaturday` desligado, é a contagem de segunda a sexta usada por bancos e tribunais. Ligado, é a contagem trabalhista do prazo de pagamento do salário do art. 459, § 1º, da CLT, a que a fiscalização do trabalho lê pela Instrução Normativa MTP nº 2/2021, art. 14, I: "na contagem dos dias será incluído o sábado, excluindo-se o domingo e o feriado, inclusive o municipal". +- Com `includeSaturday` ligado, o domingo e os feriados continuam excluídos, então um feriado que cai em um sábado continua não sendo dia útil. +- O trecho "inclusive o municipal" dessa regra não é coberto: `getHolidays` tem apenas feriados nacionais e estaduais, então um feriado municipal é contado aqui como dia útil comum. Retire os feriados municipais por conta própria quando a contagem precisar ser exata para um município. - Retorna `false` quando `value` não é um `Date` válido ou o seu ano está fora de 1900 a 2099, ou quando `stateCode` está presente e não é string. ```javascript @@ -1815,6 +1818,9 @@ import { isBusinessDay } from '@brazilian-utils/brazilian-utils'; isBusinessDay(new Date(2024, 0, 2)); // true (terça-feira, não é feriado) isBusinessDay(new Date(2024, 0, 1)); // false (Ano novo) isBusinessDay(new Date(2024, 0, 6)); // false (sábado) +isBusinessDay(new Date(2024, 0, 6), { includeSaturday: true }); // true (contagem trabalhista) +isBusinessDay(new Date(2024, 8, 7), { includeSaturday: true }); // false (Independência, feriado em um sábado) +isBusinessDay(new Date(2024, 0, 7), { includeSaturday: true }); // false (o domingo nunca é incluído) isBusinessDay(new Date(2024, 1, 13)); // false (Carnaval, feriado facultativo, conta por padrão) isBusinessDay(new Date(2024, 1, 13), { includeOptional: false }); // true isBusinessDay(new Date(2024, 6, 9), { stateCode: 'SP' }); // false (Revolução Constitucionalista) @@ -1826,7 +1832,7 @@ isBusinessDay(new Date('not a date')); // false Soma dias úteis a uma data, pulando sábados, domingos e os feriados que `isBusinessDay` considera. Assinatura: `addBusinessDays(date, amount, options?)`, a mesma do date-fns. -- **Opções** (`BusinessDayOptions`, as mesmas de `isBusinessDay`): `includeOptional` (padrão `true`) também pula Carnaval e Corpus Christi; `stateCode` também pula os feriados daquele estado. +- **Opções** (`BusinessDayOptions`, as mesmas de `isBusinessDay`): `includeOptional` (padrão `true`) também pula Carnaval e Corpus Christi; `includeSaturday` (padrão `false`) conta o sábado como dia útil; `stateCode` também pula os feriados daquele estado. - Retorna um novo `Date`, com o horário preservado; `date` não é alterado. - `amount` igual a `0` retorna a mesma data, mesmo em fim de semana ou feriado. Um `amount` negativo anda para trás. - Retorna `null` quando `date` é inválido, `amount` não é um inteiro finito, `stateCode` não é string ou o resultado sai dos anos de 1900 a 2099. @@ -1838,6 +1844,8 @@ addBusinessDays(new Date(2024, 0, 2, 12), 1); // Date, 2024-01-03 12:00 (o dia s addBusinessDays(new Date(2024, 11, 31, 12), 1); // Date, 2025-01-02 12:00 (2025-01-01 é Ano novo, pulado) addBusinessDays(new Date(2024, 0, 5, 12), -1); // Date, 2024-01-04 12:00 (anda para trás) addBusinessDays(new Date(2024, 0, 6, 12), 0); // Date, 2024-01-06 12:00 (sem alteração, mesmo o sábado não sendo dia útil) +addBusinessDays(new Date(2024, 0, 5, 12), 1, { includeSaturday: true }); // Date, 2024-01-06 12:00 (contagem trabalhista, o sábado conta) +addBusinessDays(new Date(2024, 10, 1, 12), 1, { includeSaturday: true }); // Date, 2024-11-04 12:00 (2024-11-02 é Finados, feriado em um sábado) addBusinessDays(new Date(2024, 6, 8, 12), 1, { stateCode: 'SP' }); // Date, 2024-07-10 12:00 (2024-07-09 é a Revolução Constitucionalista em SP, pulado) addBusinessDays(new Date('not a date'), 1); // null addBusinessDays(new Date(2024, 0, 2), 1.5); // null (não é um número inteiro) @@ -1857,6 +1865,8 @@ subBusinessDays(new Date(2024, 0, 8, 12), 1); // Date, 2024-01-05 12:00 (anda pa subBusinessDays(new Date(2025, 0, 2, 12), 1); // Date, 2024-12-31 12:00 (2025-01-01 é Ano novo, pulado) subBusinessDays(new Date(2024, 0, 5, 12), -1); // Date, 2024-01-08 12:00 (anda para frente) subBusinessDays(new Date(2024, 0, 6, 12), 0); // Date, 2024-01-06 12:00 (sem alteração, mesmo o sábado não sendo dia útil) +subBusinessDays(new Date(2024, 0, 8, 12), 1, { includeSaturday: true }); // Date, 2024-01-06 12:00 (contagem trabalhista, o sábado conta) +subBusinessDays(new Date(2024, 10, 4, 12), 1, { includeSaturday: true }); // Date, 2024-11-01 12:00 (2024-11-02 é Finados, feriado em um sábado) subBusinessDays(new Date(2024, 6, 10, 12), 1, { stateCode: 'SP' }); // Date, 2024-07-08 12:00 (2024-07-09 é a Revolução Constitucionalista em SP, pulado) subBusinessDays(new Date('not a date'), 1); // null subBusinessDays(new Date(2024, 0, 2), 1.5); // null (não é um número inteiro) @@ -1866,7 +1876,7 @@ subBusinessDays(new Date(2024, 0, 2), 1.5); // null (não é um número inteiro) Conta os dias úteis entre duas datas. Assinatura: `differenceInBusinessDays(laterDate, earlierDate, options?)`, a mesma do date-fns. -- **Opções** (`BusinessDayOptions`, as mesmas de `isBusinessDay`): `includeOptional` (padrão `true`) também pula Carnaval e Corpus Christi; `stateCode` também pula os feriados daquele estado. +- **Opções** (`BusinessDayOptions`, as mesmas de `isBusinessDay`): `includeOptional` (padrão `true`) também pula Carnaval e Corpus Christi; `includeSaturday` (padrão `false`) conta o sábado como dia útil; `stateCode` também pula os feriados daquele estado. - Conta `earlierDate` quando é dia útil e cada dia útil estritamente entre as duas datas; `laterDate` nunca é contado. O horário é ignorado. - O resultado é negativo quando `laterDate` é anterior a `earlierDate`, e `0` no mesmo dia de calendário. - Retorna `null` quando uma das datas não é um `Date` válido ou está fora dos anos de 1900 a 2099, ou quando `stateCode` não é string. @@ -1878,6 +1888,9 @@ differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 1)); // 0 (01/0 differenceInBusinessDays(new Date(2024, 0, 3), new Date(2024, 0, 2)); // 1 (02/01 contado, uma terça-feira; 03/01 não) differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 3)); // -1 (a data posterior vem primeiro, então a contagem é negativa) differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 2)); // 0 (mesmo dia) +differenceInBusinessDays(new Date(2024, 0, 8), new Date(2024, 0, 1)); // 4 (contagem bancária, de 2024-01-02 a 2024-01-05) +differenceInBusinessDays(new Date(2024, 0, 8), new Date(2024, 0, 1), { includeSaturday: true }); // 5 (2024-01-06, um sábado, também conta) +differenceInBusinessDays(new Date(2024, 10, 4), new Date(2024, 10, 1), { includeSaturday: true }); // 1 (2024-11-02 é Finados, feriado em um sábado) differenceInBusinessDays(new Date(2024, 6, 10), new Date(2024, 6, 8), { stateCode: 'SP' }); // 1 (09/07/2024 é feriado estadual em SP) differenceInBusinessDays(new Date(), new Date('not a date')); // null ``` @@ -1886,7 +1899,7 @@ differenceInBusinessDays(new Date(), new Date('not a date')); // null Retorna o primeiro dia útil brasileiro estritamente depois de uma data. `getNextBusinessDay(date, options?)` é `addBusinessDays(date, 1, options)`. -- **Opções** (`BusinessDayOptions`, as mesmas de `isBusinessDay`): `includeOptional` (padrão `true`) também pula Carnaval e Corpus Christi; `stateCode` também pula os feriados daquele estado. +- **Opções** (`BusinessDayOptions`, as mesmas de `isBusinessDay`): `includeOptional` (padrão `true`) também pula Carnaval e Corpus Christi; `includeSaturday` (padrão `false`) conta o sábado como dia útil; `stateCode` também pula os feriados daquele estado. - "Próximo" significa estritamente depois, o sentido que o date-fns dá em `nextDay`: um `date` que já é dia útil nunca é retornado. Para "no dia ou depois", consulte `isBusinessDay(date)` antes e fique com `date` quando a resposta for `true`. - Retorna um novo `Date`, com o horário preservado; `date` nunca é modificado. - Retorna `null` quando `date` é inválido, `stateCode` não é string, ou o resultado sai dos anos de 1900 a 2099. @@ -1897,6 +1910,8 @@ import { getNextBusinessDay } from '@brazilian-utils/brazilian-utils'; getNextBusinessDay(new Date(2024, 0, 2, 12)); // Date, 2024-01-03 12:00 (estritamente depois, embora 02/01/2024 seja dia útil) getNextBusinessDay(new Date(2024, 0, 5, 12)); // Date, 2024-01-08 12:00 (pula o fim de semana) getNextBusinessDay(new Date(2024, 0, 6, 12)); // Date, 2024-01-08 12:00 (a partir de um sábado) +getNextBusinessDay(new Date(2024, 0, 5, 12), { includeSaturday: true }); // Date, 2024-01-06 12:00 (contagem trabalhista, o sábado conta) +getNextBusinessDay(new Date(2024, 10, 1, 12), { includeSaturday: true }); // Date, 2024-11-04 12:00 (2024-11-02 é Finados, feriado em um sábado) getNextBusinessDay(new Date(2024, 11, 31, 12)); // Date, 2025-01-02 12:00 (01/01/2025 é Ano novo, pulado) getNextBusinessDay(new Date(2024, 6, 8, 12), { stateCode: 'SP' }); // Date, 2024-07-10 12:00 (09/07/2024 é Revolução Constitucionalista em SP, pulado) getNextBusinessDay(new Date('not a date')); // null @@ -1907,12 +1922,12 @@ getNextBusinessDay(new Date(2099, 11, 31)); // null (o percurso sai dos anos sup Retorna o n-ésimo dia útil brasileiro do mês em que uma data cai. Assinatura: `getNthBusinessDay(date, n, options?)`, a ordem de argumentos de `addBusinessDays`. -- **Opções** (`BusinessDayOptions`, as mesmas de `isBusinessDay`): `includeOptional` (padrão `true`) também pula Carnaval e Corpus Christi; `stateCode` também pula os feriados daquele estado. +- **Opções** (`BusinessDayOptions`, as mesmas de `isBusinessDay`): `includeOptional` (padrão `true`) também pula Carnaval e Corpus Christi; `includeSaturday` (padrão `false`) conta o sábado como dia útil; `stateCode` também pula os feriados daquele estado. - `date` é qualquer data dentro do mês: o que importa são o ano e o mês locais, o dia do mês e o horário são ignorados. - Um `n` positivo conta a partir do primeiro dia do mês (`1` é o primeiro dia útil) e um `n` negativo conta a partir do último (`-1` é o último dia útil, `-2` o anterior a ele), como `Array#at` lê um índice. - Retorna um novo `Date` no início daquele dia local (00:00); `date` nunca é modificado. - Retorna `null` quando `date` é inválido, `n` não é um inteiro finito, é `0` ou vai além dos dias úteis que o mês tem (a resposta nunca transborda para um mês vizinho), `stateCode` não é string, ou o mês sai dos anos de 1900 a 2099. Um `options` que não é um objeto é ignorado. -- O prazo de pagamento de salário do art. 459, § 1º, da CLT ("até o quinto dia útil do mês subsequente ao vencido") é contado de outro modo pela inspeção do trabalho, que conta o sábado como dia útil e ignora os feriados municipais. O quinto dia útil retornado aqui é o bancário, que pode cair depois do trabalhista. +- O prazo de pagamento de salário do art. 459, § 1º, da CLT ("até o quinto dia útil do mês subsequente ao vencido") é a contagem trabalhista, não a bancária que esta função dá por padrão: passe `{ includeSaturday: true }`, como em `getNthBusinessDay(date, 5, { includeSaturday: true })`. Os feriados municipais continuam contando como dias úteis, e essa regra os exclui. - A contagem percorre dias do calendário local, então vale em qualquer fuso horário. Quando o dia local resultante não tem 00:00 (o horário de verão brasileiro sempre começava à meia-noite, então 6 de outubro de 1997 começa à 01:00 em São Paulo), é retornado o instante mais próximo daquele dia, o mesmo `Date` que o `startOfDay` do date-fns dá ali. - Um dia do calendário local que o fuso nunca teve, como 30 de dezembro de 2011 em `Pacific/Apia`, não é contado nem retornado. @@ -1923,6 +1938,9 @@ getNthBusinessDay(new Date(2024, 0, 15), 1); // Date, 2024-01-02 00:00 (01/01/20 getNthBusinessDay(new Date(2024, 0, 15), 5); // Date, 2024-01-08 00:00 getNthBusinessDay(new Date(2024, 1, 1), 10); // Date, 2024-02-15 00:00 (Carnaval, 13/02/2024, pulado) getNthBusinessDay(new Date(2024, 1, 1), 10, { includeOptional: false }); // Date, 2024-02-14 00:00 +getNthBusinessDay(new Date(2024, 2, 1), 5); // Date, 2024-03-07 00:00 (contagem bancária) +getNthBusinessDay(new Date(2024, 2, 1), 5, { includeSaturday: true }); // Date, 2024-03-06 00:00 (contagem trabalhista, 2024-03-02 é um sábado) +getNthBusinessDay(new Date(2024, 10, 1), 5, { includeSaturday: true }); // Date, 2024-11-07 00:00 (2024-11-02 é Finados, feriado em um sábado) getNthBusinessDay(new Date(2024, 6, 1), 7, { stateCode: 'SP' }); // Date, 2024-07-10 00:00 (09/07/2024 é Revolução Constitucionalista em SP, pulado) getNthBusinessDay(new Date(2024, 0, 15), -1); // Date, 2024-01-31 00:00 (o último dia útil) getNthBusinessDay(new Date(2024, 0, 15), -2); // Date, 2024-01-30 00:00 @@ -1946,6 +1964,8 @@ getLastBusinessDayOfMonth(new Date(2024, 2, 1)); // Date, 2024-03-28 00:00 (29/0 getLastBusinessDayOfMonth(new Date(2024, 7, 31, 18, 30)); // Date, 2024-08-30 00:00 (31/08/2024 é um sábado) getLastBusinessDayOfMonth(new Date(2018, 4, 1)); // Date, 2018-05-30 00:00 (31/05/2018 é Corpus Christi, facultativo, conta por padrão) getLastBusinessDayOfMonth(new Date(2018, 4, 1), { includeOptional: false }); // Date, 2018-05-31 00:00 +getLastBusinessDayOfMonth(new Date(2024, 7, 1), { includeSaturday: true }); // Date, 2024-08-31 00:00 (contagem trabalhista, o sábado conta) +getLastBusinessDayOfMonth(new Date(2024, 10, 1), { stateCode: 'DF', includeSaturday: true }); // Date, 2024-11-29 00:00 (o sábado 2024-11-30 é o Dia do Evangélico no DF) getLastBusinessDayOfMonth(new Date(2023, 10, 1), { stateCode: 'DF' }); // Date, 2023-11-29 00:00 (30/11/2023 é Dia do Evangélico no DF) getLastBusinessDayOfMonth(new Date('not a date')); // null ``` diff --git a/docs/utilities.md b/docs/utilities.md index 884a4ecef..a4729b7ef 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -1806,7 +1806,10 @@ isHoliday(); // false Check if a date is a Brazilian business day (dia útil): not a Saturday, a Sunday or a holiday `getHolidays` lists for its local calendar day. -- **Options** (`BusinessDayOptions`, shared by every business day util): `includeOptional` (default `true`) also counts the `"optional"` holidays, Carnaval and Corpus Christi, as non-business days; `stateCode` also counts that state's holidays. +- **Options** (`BusinessDayOptions`, shared by every business day util): `includeOptional` (default `true`) also counts the `"optional"` holidays, Carnaval and Corpus Christi, as non-business days; `includeSaturday` (default `false`) counts Saturday as a business day; `stateCode` also counts that state's holidays. +- `includeSaturday` off is the Monday to Friday count banks and courts use. On, it is the labour law count of the payroll deadline of CLT art. 459 § 1º, the one labour inspection reads through Instrução Normativa MTP nº 2/2021, art. 14, I: "na contagem dos dias será incluído o sábado, excluindo-se o domingo e o feriado, inclusive o municipal". +- Sunday and holidays are still excluded with `includeSaturday` on, so a holiday that falls on a Saturday is still not a business day. +- The "inclusive o municipal" part of that rule is not covered: `getHolidays` carries national and state holidays only, so a municipal holiday counts here as an ordinary business day. Remove the municipal holidays yourself when a count has to be exact for one municipality. - Returns `false` when `value` is not a valid `Date` or its year is outside 1900 to 2099, or when `stateCode` is present and not a string. ```javascript @@ -1815,6 +1818,9 @@ import { isBusinessDay } from '@brazilian-utils/brazilian-utils'; isBusinessDay(new Date(2024, 0, 2)); // true (Tuesday, not a holiday) isBusinessDay(new Date(2024, 0, 1)); // false (Ano novo) isBusinessDay(new Date(2024, 0, 6)); // false (Saturday) +isBusinessDay(new Date(2024, 0, 6), { includeSaturday: true }); // true (labour law count) +isBusinessDay(new Date(2024, 8, 7), { includeSaturday: true }); // false (Independência, a holiday on a Saturday) +isBusinessDay(new Date(2024, 0, 7), { includeSaturday: true }); // false (Sunday is never included) isBusinessDay(new Date(2024, 1, 13)); // false (Carnaval, optional holiday, counts by default) isBusinessDay(new Date(2024, 1, 13), { includeOptional: false }); // true isBusinessDay(new Date(2024, 6, 9), { stateCode: 'SP' }); // false (Revolução Constitucionalista) @@ -1826,7 +1832,7 @@ isBusinessDay(new Date('not a date')); // false Add a number of Brazilian business days (dias úteis) to a date, skipping Saturdays, Sundays and the holidays `isBusinessDay` skips. Signature: `addBusinessDays(date, amount, options?)`, the same as date-fns. -- **Options** (`BusinessDayOptions`, shared with `isBusinessDay`): `includeOptional` (default `true`) also skips Carnaval and Corpus Christi; `stateCode` also skips that state's holidays. +- **Options** (`BusinessDayOptions`, shared with `isBusinessDay`): `includeOptional` (default `true`) also skips Carnaval and Corpus Christi; `includeSaturday` (default `false`) counts Saturday as a business day; `stateCode` also skips that state's holidays. - Returns a new `Date`, time of day preserved; `date` is never mutated. - An `amount` of `0` returns the same date, even on a weekend or holiday. A negative `amount` walks backwards. - Returns `null` when `date` is invalid, `amount` is not a finite integer, `stateCode` is not a string, or the result leaves the years 1900 to 2099. @@ -1838,6 +1844,8 @@ addBusinessDays(new Date(2024, 0, 2, 12), 1); // Date, 2024-01-03 12:00 (next da addBusinessDays(new Date(2024, 11, 31, 12), 1); // Date, 2025-01-02 12:00 (2025-01-01 is Ano novo, skipped) addBusinessDays(new Date(2024, 0, 5, 12), -1); // Date, 2024-01-04 12:00 (walks backwards) addBusinessDays(new Date(2024, 0, 6, 12), 0); // Date, 2024-01-06 12:00 (unchanged, even though Saturday is not a business day) +addBusinessDays(new Date(2024, 0, 5, 12), 1, { includeSaturday: true }); // Date, 2024-01-06 12:00 (labour law count, Saturday counts) +addBusinessDays(new Date(2024, 10, 1, 12), 1, { includeSaturday: true }); // Date, 2024-11-04 12:00 (2024-11-02 is Finados, a holiday on a Saturday) addBusinessDays(new Date(2024, 6, 8, 12), 1, { stateCode: 'SP' }); // Date, 2024-07-10 12:00 (2024-07-09 is Revolução Constitucionalista in SP, skipped) addBusinessDays(new Date('not a date'), 1); // null addBusinessDays(new Date(2024, 0, 2), 1.5); // null (not an integer) @@ -1857,6 +1865,8 @@ subBusinessDays(new Date(2024, 0, 8, 12), 1); // Date, 2024-01-05 12:00 (walks b subBusinessDays(new Date(2025, 0, 2, 12), 1); // Date, 2024-12-31 12:00 (2025-01-01 is Ano novo, skipped) subBusinessDays(new Date(2024, 0, 5, 12), -1); // Date, 2024-01-08 12:00 (walks forwards) subBusinessDays(new Date(2024, 0, 6, 12), 0); // Date, 2024-01-06 12:00 (unchanged, even though Saturday is not a business day) +subBusinessDays(new Date(2024, 0, 8, 12), 1, { includeSaturday: true }); // Date, 2024-01-06 12:00 (labour law count, Saturday counts) +subBusinessDays(new Date(2024, 10, 4, 12), 1, { includeSaturday: true }); // Date, 2024-11-01 12:00 (2024-11-02 is Finados, a holiday on a Saturday) subBusinessDays(new Date(2024, 6, 10, 12), 1, { stateCode: 'SP' }); // Date, 2024-07-08 12:00 (2024-07-09 is Revolução Constitucionalista in SP, skipped) subBusinessDays(new Date('not a date'), 1); // null subBusinessDays(new Date(2024, 0, 2), 1.5); // null (not an integer) @@ -1866,7 +1876,7 @@ subBusinessDays(new Date(2024, 0, 2), 1.5); // null (not an integer) Count the Brazilian business days (dias úteis) between two dates. Signature: `differenceInBusinessDays(laterDate, earlierDate, options?)`, the same as date-fns. -- **Options** (`BusinessDayOptions`, shared with `isBusinessDay`): `includeOptional` (default `true`) also skips Carnaval and Corpus Christi; `stateCode` also skips that state's holidays. +- **Options** (`BusinessDayOptions`, shared with `isBusinessDay`): `includeOptional` (default `true`) also skips Carnaval and Corpus Christi; `includeSaturday` (default `false`) counts Saturday as a business day; `stateCode` also skips that state's holidays. - Counts `earlierDate` when it is a business day and every business day strictly between the two dates; `laterDate` is never counted. The time of day is ignored. - The result is negative when `laterDate` is before `earlierDate`, and `0` on the same calendar day. - Returns `null` when either date is not a valid `Date` or is outside the years 1900 to 2099, or `stateCode` is not a string. @@ -1878,6 +1888,9 @@ differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 1)); // 0 (Jan differenceInBusinessDays(new Date(2024, 0, 3), new Date(2024, 0, 2)); // 1 (Jan 2 counted, a Tuesday; Jan 3 is not) differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 3)); // -1 (the later date comes first, so the count is negative) differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 2)); // 0 (same day) +differenceInBusinessDays(new Date(2024, 0, 8), new Date(2024, 0, 1)); // 4 (banking count, 2024-01-02 to 2024-01-05) +differenceInBusinessDays(new Date(2024, 0, 8), new Date(2024, 0, 1), { includeSaturday: true }); // 5 (2024-01-06, a Saturday, also counts) +differenceInBusinessDays(new Date(2024, 10, 4), new Date(2024, 10, 1), { includeSaturday: true }); // 1 (2024-11-02 is Finados, a holiday on a Saturday) differenceInBusinessDays(new Date(2024, 6, 10), new Date(2024, 6, 8), { stateCode: 'SP' }); // 1 (2024-07-09 is a state holiday in SP) differenceInBusinessDays(new Date(), new Date('not a date')); // null ``` @@ -1886,7 +1899,7 @@ differenceInBusinessDays(new Date(), new Date('not a date')); // null Get the first Brazilian business day (dia útil) strictly after a date. `getNextBusinessDay(date, options?)` is `addBusinessDays(date, 1, options)`. -- **Options** (`BusinessDayOptions`, shared with `isBusinessDay`): `includeOptional` (default `true`) also skips Carnaval and Corpus Christi; `stateCode` also skips that state's holidays. +- **Options** (`BusinessDayOptions`, shared with `isBusinessDay`): `includeOptional` (default `true`) also skips Carnaval and Corpus Christi; `includeSaturday` (default `false`) counts Saturday as a business day; `stateCode` also skips that state's holidays. - "Next" means strictly after, the meaning date-fns gives it in `nextDay`: a `date` that is itself a business day is never returned. For "on or after", check `isBusinessDay(date)` first and keep `date` when it answers `true`. - Returns a new `Date`, time of day preserved; `date` is never mutated. - Returns `null` when `date` is invalid, `stateCode` is not a string, or the result leaves the years 1900 to 2099. @@ -1897,6 +1910,8 @@ import { getNextBusinessDay } from '@brazilian-utils/brazilian-utils'; getNextBusinessDay(new Date(2024, 0, 2, 12)); // Date, 2024-01-03 12:00 (strictly after, even though 2024-01-02 is a business day) getNextBusinessDay(new Date(2024, 0, 5, 12)); // Date, 2024-01-08 12:00 (skips the weekend) getNextBusinessDay(new Date(2024, 0, 6, 12)); // Date, 2024-01-08 12:00 (from a Saturday) +getNextBusinessDay(new Date(2024, 0, 5, 12), { includeSaturday: true }); // Date, 2024-01-06 12:00 (labour law count, Saturday counts) +getNextBusinessDay(new Date(2024, 10, 1, 12), { includeSaturday: true }); // Date, 2024-11-04 12:00 (2024-11-02 is Finados, a holiday on a Saturday) getNextBusinessDay(new Date(2024, 11, 31, 12)); // Date, 2025-01-02 12:00 (2025-01-01 is Ano novo, skipped) getNextBusinessDay(new Date(2024, 6, 8, 12), { stateCode: 'SP' }); // Date, 2024-07-10 12:00 (2024-07-09 is Revolução Constitucionalista in SP, skipped) getNextBusinessDay(new Date('not a date')); // null @@ -1907,12 +1922,12 @@ getNextBusinessDay(new Date(2099, 11, 31)); // null (the walk leaves the support Get the n-th Brazilian business day (dia útil) of the month a date falls in. Signature: `getNthBusinessDay(date, n, options?)`, the argument order of `addBusinessDays`. -- **Options** (`BusinessDayOptions`, shared with `isBusinessDay`): `includeOptional` (default `true`) also skips Carnaval and Corpus Christi; `stateCode` also skips that state's holidays. +- **Options** (`BusinessDayOptions`, shared with `isBusinessDay`): `includeOptional` (default `true`) also skips Carnaval and Corpus Christi; `includeSaturday` (default `false`) counts Saturday as a business day; `stateCode` also skips that state's holidays. - `date` is any date inside the month: its local year and month are what matter, the day of the month and the time of day are ignored. - A positive `n` counts from the first day of the month (`1` is the first business day) and a negative `n` counts from the last one (`-1` is the last business day, `-2` the one before it), the way `Array#at` reads an index. - Returns a new `Date` at the start of that local day (00:00); `date` is never mutated. - Returns `null` when `date` is invalid, `n` is not a finite integer, is `0` or goes beyond the business days the month has (the answer never spills into a neighbouring month), `stateCode` is not a string, or the month leaves the years 1900 to 2099. An `options` that is not an object is ignored. -- The payroll deadline of CLT art. 459 § 1º ("até o quinto dia útil do mês subsequente ao vencido") is counted differently by labour inspection, which counts Saturday as a business day and ignores municipal holidays. The fifth business day returned here is the banking one, which can fall after the labour one. +- The payroll deadline of CLT art. 459 § 1º ("até o quinto dia útil do mês subsequente ao vencido") is the labour count, not the banking one this gives by default: pass `{ includeSaturday: true }`, as in `getNthBusinessDay(date, 5, { includeSaturday: true })`. Municipal holidays are still counted as business days, which that rule excludes. - The walk counts local calendar days, so it holds in every time zone. Where the resulting local day has no 00:00 (Brazilian summer time always started at midnight, so 6 October 1997 begins at 01:00 in São Paulo), the nearest instant of that day is returned, the same `Date` date-fns' `startOfDay` gives there. - A local calendar day a zone never had, such as 30 December 2011 in `Pacific/Apia`, is neither counted nor returned. @@ -1923,6 +1938,9 @@ getNthBusinessDay(new Date(2024, 0, 15), 1); // Date, 2024-01-02 00:00 (2024-01- getNthBusinessDay(new Date(2024, 0, 15), 5); // Date, 2024-01-08 00:00 getNthBusinessDay(new Date(2024, 1, 1), 10); // Date, 2024-02-15 00:00 (Carnaval, 2024-02-13, skipped) getNthBusinessDay(new Date(2024, 1, 1), 10, { includeOptional: false }); // Date, 2024-02-14 00:00 +getNthBusinessDay(new Date(2024, 2, 1), 5); // Date, 2024-03-07 00:00 (banking count) +getNthBusinessDay(new Date(2024, 2, 1), 5, { includeSaturday: true }); // Date, 2024-03-06 00:00 (labour law count, 2024-03-02 is a Saturday) +getNthBusinessDay(new Date(2024, 10, 1), 5, { includeSaturday: true }); // Date, 2024-11-07 00:00 (2024-11-02 is Finados, a holiday on a Saturday) getNthBusinessDay(new Date(2024, 6, 1), 7, { stateCode: 'SP' }); // Date, 2024-07-10 00:00 (2024-07-09 is Revolução Constitucionalista in SP, skipped) getNthBusinessDay(new Date(2024, 0, 15), -1); // Date, 2024-01-31 00:00 (the last business day) getNthBusinessDay(new Date(2024, 0, 15), -2); // Date, 2024-01-30 00:00 @@ -1946,6 +1964,8 @@ getLastBusinessDayOfMonth(new Date(2024, 2, 1)); // Date, 2024-03-28 00:00 (2024 getLastBusinessDayOfMonth(new Date(2024, 7, 31, 18, 30)); // Date, 2024-08-30 00:00 (2024-08-31 is a Saturday) getLastBusinessDayOfMonth(new Date(2018, 4, 1)); // Date, 2018-05-30 00:00 (2018-05-31 is Corpus Christi, optional, counts by default) getLastBusinessDayOfMonth(new Date(2018, 4, 1), { includeOptional: false }); // Date, 2018-05-31 00:00 +getLastBusinessDayOfMonth(new Date(2024, 7, 1), { includeSaturday: true }); // Date, 2024-08-31 00:00 (labour law count, Saturday counts) +getLastBusinessDayOfMonth(new Date(2024, 10, 1), { stateCode: 'DF', includeSaturday: true }); // Date, 2024-11-29 00:00 (Saturday 2024-11-30 is Dia do Evangélico in DF) getLastBusinessDayOfMonth(new Date(2023, 10, 1), { stateCode: 'DF' }); // Date, 2023-11-29 00:00 (2023-11-30 is Dia do Evangélico in DF) getLastBusinessDayOfMonth(new Date('not a date')); // null ``` diff --git a/reports/api/brazilian-utils.api.md b/reports/api/brazilian-utils.api.md index 78c542748..42dbb29fd 100644 --- a/reports/api/brazilian-utils.api.md +++ b/reports/api/brazilian-utils.api.md @@ -48,6 +48,7 @@ export type BoletoInfo = { export type BusinessDayOptions = { stateCode?: StateCode; includeOptional?: boolean; + includeSaturday?: boolean; }; // @public diff --git a/src/_internals/test/arbitraries.ts b/src/_internals/test/arbitraries.ts index b5c9a94aa..bcefa0f7b 100644 --- a/src/_internals/test/arbitraries.ts +++ b/src/_internals/test/arbitraries.ts @@ -195,11 +195,16 @@ export const anyBusinessDayAmount: fc.Arbitrary = fc.oneof( const anyStateCode = fc.oneof(fc.constantFrom(...PROTOTYPE_KEYS, "SP", "xx"), fc.anything()); const anyIncludeOptional = fc.oneof(fc.boolean(), fc.anything()); +const anyIncludeSaturday = fc.oneof(fc.boolean(), fc.anything()); /** Business day options, or anything at all, prototype chain keys as the state code included. */ export const anyBusinessDayOptions: fc.Arbitrary = fc.oneof( fc.anything(), - fc.record({ stateCode: anyStateCode, includeOptional: anyIncludeOptional }), + fc.record({ + stateCode: anyStateCode, + includeOptional: anyIncludeOptional, + includeSaturday: anyIncludeSaturday, + }), ); /** An amount with at most two decimals, the precision currency formatting round-trips. */ diff --git a/src/add-business-days/add-business-days.test.ts b/src/add-business-days/add-business-days.test.ts index 691c22505..09628839b 100644 --- a/src/add-business-days/add-business-days.test.ts +++ b/src/add-business-days/add-business-days.test.ts @@ -97,6 +97,51 @@ describe("addBusinessDays", () => { }); }); + describe("includeSaturday", () => { + it("should land on Saturday when it counts (Fri 2024-01-05 + 1 -> Sat 2024-01-06)", () => { + const result = addBusinessDays(new Date(2024, 0, 5, 12), 1, { includeSaturday: true }); + + expect(result).toEqual(new Date(2024, 0, 6, 12)); + }); + + it("should keep skipping the whole weekend without the option (Fri 2024-01-05 + 1 -> Mon 2024-01-08)", () => { + const result = addBusinessDays(new Date(2024, 0, 5, 12), 1); + + expect(result).toEqual(new Date(2024, 0, 8, 12)); + }); + + it("should never land on a Sunday (Sat 2024-01-06 + 1 -> Mon 2024-01-08)", () => { + const result = addBusinessDays(new Date(2024, 0, 6, 12), 1, { includeSaturday: true }); + + expect(result).toEqual(new Date(2024, 0, 8, 12)); + }); + + it("should still skip a holiday that falls on a Saturday (Fri 2024-11-01 + 1 -> Mon 2024-11-04, Finados)", () => { + const result = addBusinessDays(new Date(2024, 10, 1, 12), 1, { includeSaturday: true }); + + expect(result).toEqual(new Date(2024, 10, 4, 12)); + }); + + it("should count a full week as six business days (Mon 2024-01-08 + 6 -> Mon 2024-01-15, with Sat 2024-01-13)", () => { + expect(addBusinessDays(new Date(2024, 0, 8, 12), 6, { includeSaturday: true })).toEqual( + new Date(2024, 0, 15, 12), + ); + expect(addBusinessDays(new Date(2024, 0, 8, 12), 6)).toEqual(new Date(2024, 0, 16, 12)); + }); + + it("should walk backwards over Saturday too (Mon 2024-01-08 - 1 -> Sat 2024-01-06)", () => { + const result = addBusinessDays(new Date(2024, 0, 8, 12), -1, { includeSaturday: true }); + + expect(result).toEqual(new Date(2024, 0, 6, 12)); + }); + + it("should give the same result as no options when includeSaturday is false", () => { + expect(addBusinessDays(new Date(2024, 0, 5, 12), 1, { includeSaturday: false })).toEqual( + new Date(2024, 0, 8, 12), + ); + }); + }); + describe("negative amounts", () => { it("should walk backwards, skipping weekends (Fri 2024-01-05 - 1 -> Thu 2024-01-04)", () => { const result = addBusinessDays(new Date(2024, 0, 5, 12), -1); diff --git a/src/add-business-days/add-business-days.ts b/src/add-business-days/add-business-days.ts index 5939161ca..57f9c3a1b 100644 --- a/src/add-business-days/add-business-days.ts +++ b/src/add-business-days/add-business-days.ts @@ -10,7 +10,8 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * Adds a number of Brazilian business days (dias úteis) to a date. * * A business day is a day for which `isBusinessDay` returns `true` (not a Saturday, a - * Sunday, or a Brazilian holiday), evaluated with the same `options`. The function walks one + * Sunday, or a Brazilian holiday; `options.includeSaturday` keeps Saturday), evaluated with + * the same `options`. The function walks one * calendar day at a time, in the direction of `amount`, counting only business days, so it is * exact regardless of the arrangement of holidays around `date` (cheap in practice: * `getHolidays` is memoized per year). @@ -28,6 +29,12 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * not have, such as `00:30` on a day whose clocks jump from `00:00` to `01:00`: the result is * then the nearest instant of that day, `01:30`. * + * `options.includeSaturday` defaults to `false`, the Monday to Friday banking count. Pass `true` + * for the labour law count of Instrução Normativa MTP nº 2/2021, art. 14, I, which includes + * Saturday and still excludes Sunday and holidays, so a holiday that falls on a Saturday is never + * counted. See `isBusinessDay` for the law behind it and for what it does not cover: municipal + * holidays, which `getHolidays` does not carry. + * * If `options.stateCode` is provided but is not a valid/known state code, it is ignored and * only national holidays are considered (same behavior as `getHolidays`/`isBusinessDay`), so a * prototype-chain key such as `"__proto__"` is an unknown state code like any other. An @@ -38,9 +45,10 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * * @param {Date} date - The date to count from. Never mutated: a new `Date` is returned. * @param {number} amount - The number of business days to add; a negative value walks backwards. - * @param {BusinessDayOptions} [options] - Which holidays count as non-business days. + * @param {BusinessDayOptions} [options] - Which days count as business days. * @param {StateCode} [options.stateCode] - Brazilian state code whose state holidays are also considered. * @param {boolean} [options.includeOptional] - Whether optional holidays count as non-business days (default: `true`). + * @param {boolean} [options.includeSaturday] - Whether Saturday counts as a business day (default: `false`). * @returns {Date | null} A new `Date`, `amount` business days after `date`. `null` on bad * input: a `date` that is not a valid `Date` or is outside 1900-2099, an `amount` that is not a * finite integer, a `stateCode` that is not a string, or a walk that leaves the supported years. @@ -51,6 +59,8 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * addBusinessDays(new Date(2024, 11, 31, 12), 1); // Thu 2025-01-02, 12:00 (Jan 1 is Ano novo, skipped) * addBusinessDays(new Date(2024, 0, 5, 12), -1); // Thu 2024-01-04, 12:00 (walks backwards) * addBusinessDays(new Date(2024, 0, 6, 12), 0); // Sat 2024-01-06, 12:00 (unchanged, even though Saturday is not a business day) + * addBusinessDays(new Date(2024, 0, 5, 12), 1, { includeSaturday: true }); // Sat 2024-01-06, 12:00 (labour count) + * addBusinessDays(new Date(2024, 10, 1, 12), 1, { includeSaturday: true }); // Mon 2024-11-04, 12:00 (Nov 2 is Finados, a holiday on a Saturday) * addBusinessDays(new Date(2024, 6, 8, 12), 1, { stateCode: "SP" }); // Wed 2024-07-10, 12:00 (Jul 9 is a state holiday in SP) * addBusinessDays(new Date("not a date"), 1); // null * addBusinessDays(new Date(2024, 0, 2), 1.5); // null (not an integer) diff --git a/src/difference-in-business-days/difference-in-business-days.test.ts b/src/difference-in-business-days/difference-in-business-days.test.ts index aef047991..c5cd34fe0 100644 --- a/src/difference-in-business-days/difference-in-business-days.test.ts +++ b/src/difference-in-business-days/difference-in-business-days.test.ts @@ -100,6 +100,51 @@ describe("differenceInBusinessDays", () => { }); }); + describe("includeSaturday", () => { + it("should count the Saturday of the first week of 2024 (Mon 2024-01-01 to Mon 2024-01-08: 4 without the option, 5 with it)", () => { + expect(differenceInBusinessDays(new Date(2024, 0, 8), new Date(2024, 0, 1))).toBe(4); + expect( + differenceInBusinessDays(new Date(2024, 0, 8), new Date(2024, 0, 1), { + includeSaturday: true, + }), + ).toBe(5); + }); + + it("should skip Finados on Saturday 2024-11-02 and count the plain Saturday 2024-11-09 (Nov 1 to Nov 11: 6 without the option, 7 with it)", () => { + expect(differenceInBusinessDays(new Date(2024, 10, 11), new Date(2024, 10, 1))).toBe(6); + expect( + differenceInBusinessDays(new Date(2024, 10, 11), new Date(2024, 10, 1), { + includeSaturday: true, + }), + ).toBe(7); + }); + + it("should count only Friday 2024-11-01 over a Saturday holiday and a Sunday (Nov 1 to Nov 4)", () => { + const result = differenceInBusinessDays(new Date(2024, 10, 4), new Date(2024, 10, 1), { + includeSaturday: true, + }); + + expect(result).toBe(1); + }); + + it("should keep the sign convention when the later date comes first (Mon 2024-01-08 down to Tue 2024-01-02: -5 without the option, -6 with it)", () => { + expect(differenceInBusinessDays(new Date(2024, 0, 1), new Date(2024, 0, 8))).toBe(-5); + expect( + differenceInBusinessDays(new Date(2024, 0, 1), new Date(2024, 0, 8), { + includeSaturday: true, + }), + ).toBe(-6); + }); + + it("should give the same count as no options when includeSaturday is false", () => { + const result = differenceInBusinessDays(new Date(2024, 0, 8), new Date(2024, 0, 1), { + includeSaturday: false, + }); + + expect(result).toBe(4); + }); + }); + describe("invalid input", () => { it("should return null when called without arguments", () => { // @ts-expect-error: intentionally invalid input @@ -168,12 +213,28 @@ describe("differenceInBusinessDays", () => { it("should count the 20 business days December 2011 has there, the missing 30th excluded", () => { expect(differenceInBusinessDays(new Date(2011, 11, 1), new Date(2011, 11, 31))).toBe(-20); }); + + it("should add the five Saturdays of 2 to 31 December 2011 with includeSaturday, still without the 30th (-25)", () => { + const result = differenceInBusinessDays(new Date(2011, 11, 1), new Date(2011, 11, 31), { + includeSaturday: true, + }); + + expect(result).toBe(-25); + }); }); inTimeZone("UTC", () => { it("should count 21 for the same December, where the 30th is an ordinary Friday", () => { expect(differenceInBusinessDays(new Date(2011, 11, 1), new Date(2011, 11, 31))).toBe(-21); }); + + it("should count 26 for the same December with includeSaturday", () => { + const result = differenceInBusinessDays(new Date(2011, 11, 1), new Date(2011, 11, 31), { + includeSaturday: true, + }); + + expect(result).toBe(-26); + }); }); inTimeZone("America/Sao_Paulo", () => { diff --git a/src/difference-in-business-days/difference-in-business-days.ts b/src/difference-in-business-days/difference-in-business-days.ts index f0f412b95..383c3034e 100644 --- a/src/difference-in-business-days/difference-in-business-days.ts +++ b/src/difference-in-business-days/difference-in-business-days.ts @@ -23,7 +23,13 @@ const toLocalDayTimestamp = (date: Date): number => * zero, never `-0`). * * A business day is a day for which `isBusinessDay` returns `true` (not a Saturday, a Sunday, - * or a Brazilian holiday), evaluated with the same `options`. + * or a Brazilian holiday; `options.includeSaturday` keeps Saturday), evaluated with the same `options`. + * + * `options.includeSaturday` defaults to `false`, the Monday to Friday banking count. Pass `true` + * for the labour law count of Instrução Normativa MTP nº 2/2021, art. 14, I, which includes + * Saturday and still excludes Sunday and holidays, so a holiday that falls on a Saturday is never + * counted. See `isBusinessDay` for the law behind it and for what it does not cover: municipal + * holidays, which `getHolidays` does not carry. * * If `options.stateCode` is provided but is not a valid/known state code, it is ignored and only * national holidays are considered (same behavior as `getHolidays`/`isBusinessDay`), so a @@ -35,9 +41,10 @@ const toLocalDayTimestamp = (date: Date): number => * * @param {Date} laterDate - The date to count to. Never counted itself, regardless of whether it is a business day. * @param {Date} earlierDate - The date to count from. Counted as a business day when it is one; never mutated. - * @param {BusinessDayOptions} [options] - Which holidays count as non-business days. + * @param {BusinessDayOptions} [options] - Which days count as business days. * @param {StateCode} [options.stateCode] - Brazilian state code whose state holidays are also considered. * @param {boolean} [options.includeOptional] - Whether optional holidays count as non-business days (default: `true`). + * @param {boolean} [options.includeSaturday] - Whether Saturday counts as a business day (default: `false`). * @returns {number | null} The number of business days between the two dates, or `null` on bad * input: a `laterDate`/`earlierDate` that is not a valid `Date` or is outside 1900-2099, or a * `stateCode` that is not a string. @@ -48,6 +55,9 @@ const toLocalDayTimestamp = (date: Date): number => * differenceInBusinessDays(new Date(2024, 0, 3), new Date(2024, 0, 2)); // 1 (Jan 2 counted, a Tuesday; Jan 3 is not) * differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 3)); // -1 (the later date comes first, so the count is negative) * differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 2)); // 0 (same day) + * differenceInBusinessDays(new Date(2024, 0, 8), new Date(2024, 0, 1)); // 4 (Jan 2 to Jan 5, banking count) + * differenceInBusinessDays(new Date(2024, 0, 8), new Date(2024, 0, 1), { includeSaturday: true }); // 5 (Jan 6, a Saturday, also counts) + * differenceInBusinessDays(new Date(2024, 10, 4), new Date(2024, 10, 1), { includeSaturday: true }); // 1 (Nov 2 is Finados, a holiday on a Saturday) * differenceInBusinessDays(new Date(2024, 6, 10), new Date(2024, 6, 8), { stateCode: "SP" }); // 1 (Jul 9 is a state holiday in SP) * differenceInBusinessDays(new Date(), new Date("not a date")); // null * differenceInBusinessDays(new Date(2100, 0, 5), new Date(2100, 0, 4)); // null (outside the supported years) diff --git a/src/get-last-business-day-of-month/get-last-business-day-of-month.test.ts b/src/get-last-business-day-of-month/get-last-business-day-of-month.test.ts index 9841387ab..aafc86525 100644 --- a/src/get-last-business-day-of-month/get-last-business-day-of-month.test.ts +++ b/src/get-last-business-day-of-month/get-last-business-day-of-month.test.ts @@ -71,6 +71,29 @@ describe("getLastBusinessDayOfMonth", () => { }); }); + describe("includeSaturday", () => { + it("should return the Saturday August 2024 ends on when it counts (Sat 2024-08-31, Fri 2024-08-30 without the option)", () => { + expect(getLastBusinessDayOfMonth(new Date(2024, 7, 1), { includeSaturday: true })).toEqual( + new Date(2024, 7, 31), + ); + expect(getLastBusinessDayOfMonth(new Date(2024, 7, 1), { includeSaturday: false })).toEqual( + new Date(2024, 7, 30), + ); + }); + + it("should return Sat 2024-11-30 nationally, and Fri 2024-11-29 in the DF, where that Saturday is Dia do Evangélico", () => { + expect(getLastBusinessDayOfMonth(new Date(2024, 10, 1), { includeSaturday: true })).toEqual( + new Date(2024, 10, 30), + ); + expect( + getLastBusinessDayOfMonth(new Date(2024, 10, 1), { + stateCode: "DF", + includeSaturday: true, + }), + ).toEqual(new Date(2024, 10, 29)); + }); + }); + describe("invalid input", () => { it("should return null for a date that is not a valid Date", () => { // @ts-expect-error: intentionally invalid input diff --git a/src/get-last-business-day-of-month/get-last-business-day-of-month.ts b/src/get-last-business-day-of-month/get-last-business-day-of-month.ts index cbcc35e66..05adca8ac 100644 --- a/src/get-last-business-day-of-month/get-last-business-day-of-month.ts +++ b/src/get-last-business-day-of-month/get-last-business-day-of-month.ts @@ -8,7 +8,8 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * * It is `getNthBusinessDay(date, -1, options)`, which it delegates to, down to the last detail. A * business day is a day for which `isBusinessDay` returns `true` (not a Saturday, a Sunday, or a - * Brazilian holiday), evaluated with the same `options`, and the month is the one of `date`'s + * Brazilian holiday; `options.includeSaturday` keeps Saturday), evaluated with the same + * `options`, and the month is the one of `date`'s * **local calendar day**; the day of the month and the time of day of `date` are ignored. * * The name and the result follow date-fns' `lastDayOfMonth`: a new `Date` at the start of that @@ -16,6 +17,12 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * over that local midnight, the nearest representable instant of the day is returned instead, * exactly as in `getNthBusinessDay`. * + * `options.includeSaturday` defaults to `false`, the Monday to Friday banking count. Pass `true` + * for the labour law count of Instrução Normativa MTP nº 2/2021, art. 14, I, which includes + * Saturday and still excludes Sunday and holidays, so a holiday that falls on a Saturday is never + * counted. See `isBusinessDay` for the law behind it and for what it does not cover: municipal + * holidays, which `getHolidays` does not carry. + * * If `options.stateCode` is provided but is not a valid/known state code, it is ignored and only * national holidays are considered (same behavior as `getHolidays`/`isBusinessDay`), so a * prototype-chain key such as `"__proto__"` is an unknown state code like any other. An `options` @@ -25,9 +32,10 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * outside it returns `null`. * * @param {Date} date - Any date inside the month to look at. Never mutated: a new `Date` is returned. - * @param {BusinessDayOptions} [options] - Which holidays count as non-business days. + * @param {BusinessDayOptions} [options] - Which days count as business days. * @param {StateCode} [options.stateCode] - Brazilian state code whose state holidays are also considered. * @param {boolean} [options.includeOptional] - Whether optional holidays count as non-business days (default: `true`). + * @param {boolean} [options.includeSaturday] - Whether Saturday counts as a business day (default: `false`). * @returns {Date | null} A new `Date` at 00:00 local time of the last business day of the month. * `null` on bad input: a `date` that is not a valid `Date` or is outside 1900-2099, or a * `stateCode` that is not a string. @@ -39,6 +47,8 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * getLastBusinessDayOfMonth(new Date(2024, 7, 31, 18, 30)); // Fri 2024-08-30, 00:00 (Aug 31 is a Saturday) * getLastBusinessDayOfMonth(new Date(2018, 4, 1)); // Wed 2018-05-30, 00:00 (May 31 is Corpus Christi, optional, counted by default) * getLastBusinessDayOfMonth(new Date(2018, 4, 1), { includeOptional: false }); // Thu 2018-05-31, 00:00 + * getLastBusinessDayOfMonth(new Date(2024, 7, 1), { includeSaturday: true }); // Sat 2024-08-31, 00:00 (labour count) + * getLastBusinessDayOfMonth(new Date(2024, 10, 1), { stateCode: "DF", includeSaturday: true }); // Fri 2024-11-29, 00:00 (Sat Nov 30 is Dia do Evangélico in DF) * getLastBusinessDayOfMonth(new Date(2023, 10, 1), { stateCode: "DF" }); // Wed 2023-11-29, 00:00 (Nov 30 is Dia do Evangélico in DF) * getLastBusinessDayOfMonth(new Date("not a date")); // null * getLastBusinessDayOfMonth(new Date(2100, 0, 15)); // null (outside the supported years) diff --git a/src/get-next-business-day/get-next-business-day.test.ts b/src/get-next-business-day/get-next-business-day.test.ts index d953a0dab..a4bcd5761 100644 --- a/src/get-next-business-day/get-next-business-day.test.ts +++ b/src/get-next-business-day/get-next-business-day.test.ts @@ -75,6 +75,32 @@ describe("getNextBusinessDay", () => { }); }); + describe("includeSaturday", () => { + it("should return the Saturday when it counts (Fri 2024-01-05 -> Sat 2024-01-06)", () => { + const result = getNextBusinessDay(new Date(2024, 0, 5, 12), { includeSaturday: true }); + + expect(result).toEqual(new Date(2024, 0, 6, 12)); + }); + + it("should return the Monday without the option (Fri 2024-01-05 -> Mon 2024-01-08)", () => { + expect(getNextBusinessDay(new Date(2024, 0, 5, 12), { includeSaturday: false })).toEqual( + new Date(2024, 0, 8, 12), + ); + }); + + it("should never return a Sunday (Sat 2024-01-06 -> Mon 2024-01-08)", () => { + const result = getNextBusinessDay(new Date(2024, 0, 6, 12), { includeSaturday: true }); + + expect(result).toEqual(new Date(2024, 0, 8, 12)); + }); + + it("should skip a holiday that falls on a Saturday (Fri 2024-11-01 -> Mon 2024-11-04, Finados)", () => { + const result = getNextBusinessDay(new Date(2024, 10, 1, 12), { includeSaturday: true }); + + expect(result).toEqual(new Date(2024, 10, 4, 12)); + }); + }); + describe("invalid input", () => { it("should return null for a date that is not a valid Date", () => { // @ts-expect-error: intentionally invalid input diff --git a/src/get-next-business-day/get-next-business-day.ts b/src/get-next-business-day/get-next-business-day.ts index 381ed32d4..ed64ade00 100644 --- a/src/get-next-business-day/get-next-business-day.ts +++ b/src/get-next-business-day/get-next-business-day.ts @@ -8,7 +8,7 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * * It is `addBusinessDays(date, 1, options)`, which it delegates to, down to the last detail. A * business day is a day for which `isBusinessDay` returns `true` (not a Saturday, a Sunday, or a - * Brazilian holiday), evaluated with the same `options`. + * Brazilian holiday; `options.includeSaturday` keeps Saturday), evaluated with the same `options`. * * "Next" is **strictly after**, the meaning date-fns gives it in `nextDay`, `nextMonday` and * their siblings, verified against its source: `nextDay(date, day)` on a date that already is @@ -19,6 +19,12 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * The time-of-day (hours, minutes, seconds, milliseconds) of `date` is preserved in the result, * as date-fns' `nextDay` and `addBusinessDays` do, and `date` itself is never mutated. * + * `options.includeSaturday` defaults to `false`, the Monday to Friday banking count. Pass `true` + * for the labour law count of Instrução Normativa MTP nº 2/2021, art. 14, I, which includes + * Saturday and still excludes Sunday and holidays, so a holiday that falls on a Saturday is never + * counted. See `isBusinessDay` for the law behind it and for what it does not cover: municipal + * holidays, which `getHolidays` does not carry. + * * If `options.stateCode` is provided but is not a valid/known state code, it is ignored and only * national holidays are considered (same behavior as `getHolidays`/`isBusinessDay`), so a * prototype-chain key such as `"__proto__"` is an unknown state code like any other. An `options` @@ -28,9 +34,10 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * outside it, or a walk that leaves it, returns `null`. * * @param {Date} date - The date to look after. Never mutated: a new `Date` is returned. - * @param {BusinessDayOptions} [options] - Which holidays count as non-business days. + * @param {BusinessDayOptions} [options] - Which days count as business days. * @param {StateCode} [options.stateCode] - Brazilian state code whose state holidays are also considered. * @param {boolean} [options.includeOptional] - Whether optional holidays count as non-business days (default: `true`). + * @param {boolean} [options.includeSaturday] - Whether Saturday counts as a business day (default: `false`). * @returns {Date | null} A new `Date`, the first business day after `date`. `null` on bad input: * a `date` that is not a valid `Date` or is outside 1900-2099, a `stateCode` that is not a * string, or a walk that leaves the supported years. @@ -41,6 +48,8 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * getNextBusinessDay(new Date(2024, 0, 5, 12)); // Mon 2024-01-08, 12:00 (skips the weekend) * getNextBusinessDay(new Date(2024, 0, 6, 12)); // Mon 2024-01-08, 12:00 (from a Saturday) * getNextBusinessDay(new Date(2024, 11, 31, 12)); // Thu 2025-01-02, 12:00 (Jan 1 is Ano novo, skipped) + * getNextBusinessDay(new Date(2024, 0, 5, 12), { includeSaturday: true }); // Sat 2024-01-06, 12:00 (labour count) + * getNextBusinessDay(new Date(2024, 10, 1, 12), { includeSaturday: true }); // Mon 2024-11-04, 12:00 (Nov 2 is Finados, a holiday on a Saturday) * getNextBusinessDay(new Date(2024, 6, 8, 12), { stateCode: "SP" }); // Wed 2024-07-10, 12:00 (Jul 9 is a state holiday in SP) * getNextBusinessDay(new Date("not a date")); // null * getNextBusinessDay(new Date(2099, 11, 31)); // null (the walk leaves the supported years) diff --git a/src/get-nth-business-day/get-nth-business-day.test.ts b/src/get-nth-business-day/get-nth-business-day.test.ts index e341361c0..7f417b5c5 100644 --- a/src/get-nth-business-day/get-nth-business-day.test.ts +++ b/src/get-nth-business-day/get-nth-business-day.test.ts @@ -172,6 +172,21 @@ describe("getNthBusinessDay", () => { expect(getNthBusinessDay(new Date(2011, 11, 15), 22)).toBeNull(); expect(getNthBusinessDay(new Date(2011, 11, 15), -1)).toEqual(new Date(2011, 11, 29)); }); + + it("should step over the missing Friday 30th with includeSaturday too (-1 is Sat 2011-12-31, -2 is Thu 2011-12-29)", () => { + const options = { includeSaturday: true }; + + expect(getNthBusinessDay(new Date(2011, 11, 15), -1, options)).toEqual( + new Date(2011, 11, 31), + ); + expect(getNthBusinessDay(new Date(2011, 11, 15), -2, options)).toEqual( + new Date(2011, 11, 29), + ); + expect(getNthBusinessDay(new Date(2011, 11, 15), 26, options)).toEqual( + new Date(2011, 11, 31), + ); + expect(getNthBusinessDay(new Date(2011, 11, 15), 27, options)).toBeNull(); + }); }); inTimeZone("UTC", () => { @@ -179,6 +194,17 @@ describe("getNthBusinessDay", () => { expect(getNthBusinessDay(new Date(2011, 11, 15), 22)).toEqual(new Date(2011, 11, 30)); expect(getNthBusinessDay(new Date(2011, 11, 15), 23)).toBeNull(); }); + + it("should count the 27 the same December has with includeSaturday (-2 is Fri 2011-12-30)", () => { + const options = { includeSaturday: true }; + + expect(getNthBusinessDay(new Date(2011, 11, 15), -2, options)).toEqual( + new Date(2011, 11, 30), + ); + expect(getNthBusinessDay(new Date(2011, 11, 15), 27, options)).toEqual( + new Date(2011, 11, 31), + ); + }); }); inTimeZone("America/Havana", () => { @@ -207,6 +233,60 @@ describe("getNthBusinessDay", () => { }); }); + describe("includeSaturday", () => { + it("should give the labour law fifth business day of March 2024 (Wed 2024-03-06, Sat 2024-03-02 counted) against the banking one (Thu 2024-03-07)", () => { + expect(getNthBusinessDay(new Date(2024, 2, 1), 5, { includeSaturday: true })).toEqual( + new Date(2024, 2, 6), + ); + expect(getNthBusinessDay(new Date(2024, 2, 1), 5)).toEqual(new Date(2024, 2, 7)); + }); + + it("should give the labour law fifth business day of February 2024 (Tue 2024-02-06, Sat 2024-02-03 counted) against the banking one (Wed 2024-02-07)", () => { + expect(getNthBusinessDay(new Date(2024, 1, 1), 5, { includeSaturday: true })).toEqual( + new Date(2024, 1, 6), + ); + expect(getNthBusinessDay(new Date(2024, 1, 1), 5)).toEqual(new Date(2024, 1, 7)); + }); + + it("should give the same fifth business day of November 2024 either way (Thu 2024-11-07), since Sat 2024-11-02 is Finados", () => { + expect(getNthBusinessDay(new Date(2024, 10, 1), 5, { includeSaturday: true })).toEqual( + new Date(2024, 10, 7), + ); + expect(getNthBusinessDay(new Date(2024, 10, 1), 5)).toEqual(new Date(2024, 10, 7)); + }); + + it("should skip Independência on Saturday 2024-09-07 and count Sat 2024-09-14 as the 11th (Mon 2024-09-16 without the option)", () => { + expect(getNthBusinessDay(new Date(2024, 8, 1), 11, { includeSaturday: true })).toEqual( + new Date(2024, 8, 14), + ); + expect(getNthBusinessDay(new Date(2024, 8, 1), 11)).toEqual(new Date(2024, 8, 16)); + }); + + it("should give January 2024 its 26 business days (22 plus the Saturdays 6, 13, 20 and 27), the 26th being Wed 2024-01-31", () => { + expect(getNthBusinessDay(new Date(2024, 0, 15), 26, { includeSaturday: true })).toEqual( + new Date(2024, 0, 31), + ); + expect(getNthBusinessDay(new Date(2024, 0, 15), 27, { includeSaturday: true })).toBeNull(); + expect(getNthBusinessDay(new Date(2024, 0, 15), 23)).toBeNull(); + }); + + it("should count backwards from a Saturday at the end of the month (-1 of November 2024 -> Sat 2024-11-30, Fri 2024-11-29 without the option)", () => { + expect(getNthBusinessDay(new Date(2024, 10, 15), -1, { includeSaturday: true })).toEqual( + new Date(2024, 10, 30), + ); + expect(getNthBusinessDay(new Date(2024, 10, 15), -1)).toEqual(new Date(2024, 10, 29)); + }); + + it("should still exclude that Saturday when it is a state holiday (DF, Dia do Evangélico 2024-11-30)", () => { + const result = getNthBusinessDay(new Date(2024, 10, 15), -1, { + stateCode: "DF", + includeSaturday: true, + }); + + expect(result).toEqual(new Date(2024, 10, 29)); + }); + }); + describe("properties", () => { test("should never throw, regardless of the input, prototype chain state codes included", () => { expectNeverThrowsWithArguments( diff --git a/src/get-nth-business-day/get-nth-business-day.ts b/src/get-nth-business-day/get-nth-business-day.ts index 6140a177e..54846e551 100644 --- a/src/get-nth-business-day/get-nth-business-day.ts +++ b/src/get-nth-business-day/get-nth-business-day.ts @@ -9,7 +9,8 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * Gets the n-th Brazilian business day (dia útil) of the month a date falls in. * * A business day is a day for which `isBusinessDay` returns `true` (not a Saturday, a Sunday, - * or a Brazilian holiday), evaluated with the same `options`. The month is the one of `date`'s + * or a Brazilian holiday; `options.includeSaturday` keeps Saturday), evaluated with the same + * `options`. The month is the one of `date`'s * **local calendar day** (its local year and month, as read by `Date#getFullYear`/`getMonth`), * the convention every business day util shares; the day of the month and the time of day of * `date` are ignored. @@ -34,9 +35,13 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * Mind the payroll deadline of CLT art. 459 § 1º ("até o quinto dia útil do mês subsequente ao * vencido"): labour inspection counts **Saturday as a business day** for that deadline * (Instrução Normativa MTP nº 2/2021, art. 14, I: "na contagem dos dias será incluído o sábado, - * excluindo-se o domingo e o feriado, inclusive o municipal"), while `isBusinessDay`, and - * therefore this function, never counts a Saturday and does not know municipal holidays. The - * fifth business day returned here is the banking one, which can fall after the labour one. + * excluindo-se o domingo e o feriado, inclusive o municipal"). By default this function gives + * the banking count, Monday to Friday, which can fall after the labour one; pass + * `options.includeSaturday: true` for the labour count, as in + * `getNthBusinessDay(date, 5, { includeSaturday: true })`. A holiday that falls on a Saturday is + * still not counted, exactly as the article says. The one part of the article the option cannot + * cover is "inclusive o municipal": `getHolidays` carries national and state holidays only, so a + * municipal holiday is counted here as an ordinary business day. * * If `options.stateCode` is provided but is not a valid/known state code, it is ignored and only * national holidays are considered (same behavior as `getHolidays`/`isBusinessDay`), so a @@ -48,9 +53,10 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * * @param {Date} date - Any date inside the month to look at. Never mutated: a new `Date` is returned. * @param {number} n - Which business day to get: `1` is the first of the month, `-1` the last. - * @param {BusinessDayOptions} [options] - Which holidays count as non-business days. + * @param {BusinessDayOptions} [options] - Which days count as business days. * @param {StateCode} [options.stateCode] - Brazilian state code whose state holidays are also considered. * @param {boolean} [options.includeOptional] - Whether optional holidays count as non-business days (default: `true`). + * @param {boolean} [options.includeSaturday] - Whether Saturday counts as a business day (default: `false`). * @returns {Date | null} A new `Date` at 00:00 local time of the n-th business day of the month. * `null` on bad input: a `date` that is not a valid `Date` or is outside 1900-2099, an `n` that is * not a finite integer, is `0` or goes beyond the business days of the month, or a `stateCode` @@ -62,6 +68,9 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * getNthBusinessDay(new Date(2024, 0, 15), 5); // Mon 2024-01-08, 00:00 * getNthBusinessDay(new Date(2024, 1, 1), 10); // Thu 2024-02-15, 00:00 (Carnaval, Feb 13, skipped) * getNthBusinessDay(new Date(2024, 1, 1), 10, { includeOptional: false }); // Wed 2024-02-14, 00:00 + * getNthBusinessDay(new Date(2024, 2, 1), 5); // Thu 2024-03-07, 00:00 (banking count) + * getNthBusinessDay(new Date(2024, 2, 1), 5, { includeSaturday: true }); // Wed 2024-03-06, 00:00 (labour count, Mar 2 is a Saturday) + * getNthBusinessDay(new Date(2024, 10, 1), 5, { includeSaturday: true }); // Thu 2024-11-07, 00:00 (Nov 2 is Finados, a holiday on a Saturday) * getNthBusinessDay(new Date(2024, 6, 1), 7, { stateCode: "SP" }); // Wed 2024-07-10, 00:00 (Jul 9 is a state holiday in SP) * getNthBusinessDay(new Date(2024, 0, 15), -1); // Wed 2024-01-31, 00:00 (the last business day) * getNthBusinessDay(new Date(2024, 0, 15), -2); // Tue 2024-01-30, 00:00 @@ -75,7 +84,7 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * CLT art. 459 § 1º, the "quinto dia útil" payroll deadline that motivates the util. * @see Official: https://www.gov.br/trabalho-e-emprego/pt-br/assuntos/inspecao-do-trabalho/areas-de-atuacao/in-2-de-8-denovembro-de-2021.pdf * Instrução Normativa MTP nº 2/2021, art. 14, I: Saturday counts towards that labour deadline, - * which is why the caveat above exists. + * which is what `options.includeSaturday` switches on. * @see Based on: https://unpkg.com/date-fns@4.1.0/lastDayOfMonth.js * Reference for returning the start of the local day and for never mutating the input. The * underlying holiday determination's official sources are cited in `isBusinessDay`/`getHolidays`. diff --git a/src/is-business-day/is-business-day.test.ts b/src/is-business-day/is-business-day.test.ts index 314b179c2..aab4eda88 100644 --- a/src/is-business-day/is-business-day.test.ts +++ b/src/is-business-day/is-business-day.test.ts @@ -2,7 +2,8 @@ import * as fc from "fast-check"; import { type StateCode } from "../_internals/constants/states"; import { - businessDayDates, + anyBusinessDayDate, + anyBusinessDayOptions, holidayYears, monthDays, monthIndexes, @@ -19,15 +20,6 @@ function getHolidaysFor(year: number, stateCode: StateCode | null): Holiday[] { return stateCode === null ? getHolidays(year) : getHolidays({ year, stateCode }); } -const anyStateCode = fc.oneof(fc.constantFrom(...PROTOTYPE_KEYS, "SP", "xx"), fc.anything()); -const anyIncludeOptional = fc.oneof(fc.boolean(), fc.anything()); -const hostileOptions = fc.record({ - stateCode: anyStateCode, - includeOptional: anyIncludeOptional, -}); -const anyValueInput = fc.oneof(fc.anything(), businessDayDates); -const anyOptionsInput = fc.oneof(fc.anything(), hostileOptions); - describe("isBusinessDay", () => { it("should return true for a plain weekday that is not a holiday (noon, DST-safe)", () => { expect(isBusinessDay(new Date(2024, 0, 2, 12))).toBe(true); @@ -123,6 +115,54 @@ describe("isBusinessDay", () => { }); }); + describe("includeSaturday", () => { + it("should return true for a plain Saturday when includeSaturday is true (Sat 2024-01-06, IN MTP nº 2/2021 art. 14, I)", () => { + expect(isBusinessDay(new Date(2024, 0, 6, 12), { includeSaturday: true })).toBe(true); + }); + + it("should still return false for a Sunday when includeSaturday is true (Sun 2024-01-07, the article excludes it)", () => { + expect(isBusinessDay(new Date(2024, 0, 7, 12), { includeSaturday: true })).toBe(false); + }); + + it("should still return false for a national holiday that falls on a Saturday (Sat 2024-09-07, Independência)", () => { + expect(isBusinessDay(new Date(2024, 8, 7, 12), { includeSaturday: true })).toBe(false); + }); + + it("should still return false for Finados on Saturday 2024-11-02, and true for the plain Saturday a week later", () => { + expect(isBusinessDay(new Date(2024, 10, 2, 12), { includeSaturday: true })).toBe(false); + expect(isBusinessDay(new Date(2024, 10, 9, 12), { includeSaturday: true })).toBe(true); + }); + + it("should still return false for a state holiday that falls on a Saturday (Sat 2024-11-30, Dia do Evangélico in DF)", () => { + expect( + isBusinessDay(new Date(2024, 10, 30, 12), { stateCode: "DF", includeSaturday: true }), + ).toBe(false); + expect(isBusinessDay(new Date(2024, 10, 30, 12), { includeSaturday: true })).toBe(true); + }); + + it("should leave Monday to Friday untouched (Tue 2024-01-02 is a business day, Mon 2024-01-01 is Ano novo)", () => { + expect(isBusinessDay(new Date(2024, 0, 2, 12), { includeSaturday: true })).toBe(true); + expect(isBusinessDay(new Date(2024, 0, 1, 12), { includeSaturday: true })).toBe(false); + }); + }); + + describe("no breaking change", () => { + it("should keep the Monday to Friday count when includeSaturday is absent, false or undefined (Sat 2024-01-06)", () => { + expect(isBusinessDay(new Date(2024, 0, 6, 12))).toBe(false); + expect(isBusinessDay(new Date(2024, 0, 6, 12), {})).toBe(false); + expect(isBusinessDay(new Date(2024, 0, 6, 12), { includeSaturday: false })).toBe(false); + expect(isBusinessDay(new Date(2024, 0, 6, 12), { includeSaturday: undefined })).toBe(false); + expect(isBusinessDay(new Date(2024, 0, 6, 12), { stateCode: "SP" })).toBe(false); + expect(isBusinessDay(new Date(2024, 0, 6, 12), { includeOptional: false })).toBe(false); + }); + + it("should keep the Monday to Friday count for every Saturday of January 2024 without the option (6, 13, 20 and 27)", () => { + for (const day of [6, 13, 20, 27]) { + expect(isBusinessDay(new Date(2024, 0, day, 12))).toBe(false); + } + }); + }); + describe("year boundaries", () => { it("should return false for 2024-12-31 only if it were a holiday, but treat it as a business day (Tuesday, no holiday)", () => { expect(isBusinessDay(new Date(2024, 11, 31, 12))).toBe(true); @@ -168,9 +208,9 @@ describe("isBusinessDay", () => { }); describe("properties", () => { - const stateAndOptionalArbitrary = fc.tuple(fc.option(stateCodes), fc.boolean()); + const optionsArbitrary = fc.tuple(fc.option(stateCodes), fc.boolean(), fc.boolean()); - test("should return false for every Saturday and Sunday", () => { + test("should return false for every Saturday and Sunday, and still for every Sunday when includeSaturday is true", () => { fc.assert( fc.property(holidayYears, monthIndexes, monthDays, (year, month, day) => { const date = new Date(year, month, day); @@ -178,6 +218,10 @@ describe("isBusinessDay", () => { if (date.getDay() === 0 || date.getDay() === 6) { expect(isBusinessDay(date)).toBe(false); } + + if (date.getDay() === 0) { + expect(isBusinessDay(date, { includeSaturday: true })).toBe(false); + } }), ); }); @@ -188,8 +232,8 @@ describe("isBusinessDay", () => { holidayYears, monthIndexes, monthDays, - stateAndOptionalArbitrary, - (year, month, day, [stateCode, includeOptional]) => { + optionsArbitrary, + (year, month, day, [stateCode, includeOptional, includeSaturday]) => { const date = new Date(year, month, day); const holidays = getHolidaysFor(year, stateCode); const isHolidayMatch = holidays.some( @@ -198,8 +242,12 @@ describe("isBusinessDay", () => { holiday.date.getMonth() === month && holiday.date.getDate() === day, ); - const isWeekend = date.getDay() === 0 || date.getDay() === 6; - const options = { stateCode: stateCode ?? undefined, includeOptional }; + const isWeekend = date.getDay() === 0 || (date.getDay() === 6 && !includeSaturday); + const options = { + stateCode: stateCode ?? undefined, + includeOptional, + includeSaturday, + }; expect(isBusinessDay(date, options)).toBe(!isWeekend && !isHolidayMatch); }, @@ -208,7 +256,7 @@ describe("isBusinessDay", () => { }); test("should never throw, regardless of the input, prototype chain state codes included", () => { - expectNeverThrowsWithOptions(isBusinessDay, anyValueInput, anyOptionsInput); + expectNeverThrowsWithOptions(isBusinessDay, anyBusinessDayDate, anyBusinessDayOptions); }); }); }); @@ -219,6 +267,7 @@ describe("isBusinessDay types", () => { expectTypeOf(isBusinessDay).parameter(1).toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf(isBusinessDay).returns.toEqualTypeOf(); }); }); diff --git a/src/is-business-day/is-business-day.ts b/src/is-business-day/is-business-day.ts index a918a57d3..5a210aeec 100644 --- a/src/is-business-day/is-business-day.ts +++ b/src/is-business-day/is-business-day.ts @@ -7,21 +7,26 @@ export type { StateCode } from "../_internals/constants/states"; /** * Options shared by every business day util (`isBusinessDay`, `addBusinessDays`, - * `subBusinessDays` and `differenceInBusinessDays`): which holidays count as non-business days. + * `subBusinessDays`, `differenceInBusinessDays`, `getNextBusinessDay`, `getNthBusinessDay` and + * `getLastBusinessDayOfMonth`): which days count as business days. */ export type BusinessDayOptions = { /** Two letter state code whose state holidays are also treated as non-business days (default: national holidays only). */ stateCode?: StateCode; /** Whether optional-type holidays (`Holiday.type === "optional"`, e.g. Carnaval, Corpus Christi) count as non-business days (default: `true`). */ includeOptional?: boolean; + /** Whether Saturday counts as a business day, the labour law count of Instrução Normativa MTP nº 2/2021, art. 14, I (default: `false`, the Monday to Friday banking count). */ + includeSaturday?: boolean; }; -const WEEKEND_DAYS = new Set([0, 6]); +const SUNDAY = 0; +const SATURDAY = 6; /** * Checks whether a given date is a Brazilian business day (dia útil). * - * A day is not a business day when it falls on Saturday or Sunday, or when it is a + * A day is not a business day when it falls on Saturday or Sunday (`options.includeSaturday` + * keeps Saturday, the labour law count), or when it is a * Brazilian holiday returned by `getHolidays({ year, stateCode })` for `value`'s **local * calendar day** (its local year/month/day, as read by `Date#getFullYear`/`getMonth`/`getDate`), * the same convention used by `isHoliday`. Build `value` from local components @@ -33,6 +38,19 @@ const WEEKEND_DAYS = new Set([0, 6]); * they are not statutory holidays. Pass `false` to only treat statutory (`"national"` and * `"state"`) holidays as non-business days. * + * `options.includeSaturday` defaults to `false`, the Monday to Friday count banks and courts + * use. Pass `true` for the labour law count of the payroll deadline of CLT art. 459 § 1º ("até o + * quinto dia útil do mês subsequente ao vencido"), which the labour inspection reads through + * Instrução Normativa MTP nº 2/2021, art. 14, I: "na contagem dos dias será incluído o sábado, + * excluindo-se o domingo e o feriado, inclusive o municipal". Sunday and holidays are still + * non-business days with the option on, so a holiday that falls on a Saturday stays a + * non-business day. + * + * What `includeSaturday: true` does **not** cover: the "inclusive o municipal" part of that + * rule. `getHolidays` has national and state holidays only, so a municipal holiday is counted as + * a business day here while the labour inspection would exclude it. A count that must be exact + * for a municipality has to remove its municipal holidays on top of this option. + * * An invalid `options.stateCode` is treated in two different ways, depending on its type, the * same split `isHoliday` makes: * @@ -56,9 +74,10 @@ const WEEKEND_DAYS = new Set([0, 6]); * outside it returns `false` rather than silently treating every weekday as a business day. * * @param {Date} value - The date to check. - * @param {BusinessDayOptions} [options] - Which holidays count as non-business days. + * @param {BusinessDayOptions} [options] - Which days count as business days. * @param {StateCode} [options.stateCode] - Brazilian state code whose state holidays are also considered. * @param {boolean} [options.includeOptional] - Whether optional holidays count as non-business days (default: `true`). + * @param {boolean} [options.includeSaturday] - Whether Saturday counts as a business day (default: `false`). * @returns {boolean} True when `value` is a business day, false otherwise. Bad input also * returns false: a `value` that is not a valid `Date` (including non-`Date` values), a * `value` outside the supported 1900-2099 range, or a `stateCode` that is present and is not a @@ -69,6 +88,9 @@ const WEEKEND_DAYS = new Set([0, 6]); * isBusinessDay(new Date(2024, 0, 2)); // true (Tuesday, not a holiday) * isBusinessDay(new Date(2024, 0, 1)); // false (Ano novo) * isBusinessDay(new Date(2024, 0, 6)); // false (Saturday) + * isBusinessDay(new Date(2024, 0, 6), { includeSaturday: true }); // true (labour law count) + * isBusinessDay(new Date(2024, 8, 7), { includeSaturday: true }); // false (Independência, a holiday on a Saturday) + * isBusinessDay(new Date(2024, 0, 7), { includeSaturday: true }); // false (Sunday is never included) * isBusinessDay(new Date(2024, 1, 13)); // false (Carnaval, optional holiday, counted by default) * isBusinessDay(new Date(2024, 1, 13), { includeOptional: false }); // true * isBusinessDay(new Date(2024, 6, 9), { stateCode: "SP" }); // false (Revolução Constitucionalista) @@ -91,7 +113,17 @@ const WEEKEND_DAYS = new Set([0, 6]); * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2023/lei/l14759.htm * Lei 14.759/2023, nationalized Dia da Consciência Negra from 2024. * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9093.htm - * Lei 9.093/1995, the framework law authorizing state and municipal holidays. + * Lei 9.093/1995, the framework law authorizing state and municipal holidays. Its art. 2º leaves + * the up to four feriados religiosos to each municipality's own law, which is why + * `includeSaturday` cannot cover the municipal part of the labour law count: `getHolidays` does + * not carry them. + * @see Official: https://www.planalto.gov.br/ccivil_03/decreto-lei/del5452.htm + * CLT art. 459 § 1º (wording given by Lei 7.855/1989), the "quinto dia útil do mês subsequente ao + * vencido" payroll deadline that `includeSaturday` exists for. + * @see Official: https://www.gov.br/trabalho-e-emprego/pt-br/assuntos/inspecao-do-trabalho/areas-de-atuacao/in-2-de-8-denovembro-de-2021.pdf + * Instrução Normativa MTP nº 2, de 8 de novembro de 2021, art. 14, I: the rule `includeSaturday` + * implements, verbatim "na contagem dos dias será incluído o sábado, excluindo-se o domingo e o + * feriado, inclusive o municipal". * @see Official: https://www.in.gov.br/web/dou/-/portaria-mgi-n-11.460-de-29-de-dezembro-de-2025-678388627 * Portaria MGI nº 11.460/2025, the federal executive's annual calendar of feriados nacionais and * pontos facultativos: the source of three of the four Easter-derived entries, namely @@ -111,7 +143,13 @@ export const isBusinessDay = (value: Date, options?: BusinessDayOptions): boolea if (!isSupportedHolidayYear(year)) return false; - if (WEEKEND_DAYS.has(value.getDay())) return false; + const day = value.getDay(); + + if (day === SUNDAY) return false; + + const includeSaturday = options?.includeSaturday ?? false; + + if (day === SATURDAY && !includeSaturday) return false; const includeOptional = options?.includeOptional ?? true; diff --git a/src/sub-business-days/sub-business-days.test.ts b/src/sub-business-days/sub-business-days.test.ts index 8de347ae4..783ff7a13 100644 --- a/src/sub-business-days/sub-business-days.test.ts +++ b/src/sub-business-days/sub-business-days.test.ts @@ -113,6 +113,32 @@ describe("subBusinessDays", () => { }); }); + describe("includeSaturday", () => { + it("should stop on Saturday when it counts (Mon 2024-01-08 - 1 -> Sat 2024-01-06)", () => { + const result = subBusinessDays(new Date(2024, 0, 8, 12), 1, { includeSaturday: true }); + + expect(result).toEqual(new Date(2024, 0, 6, 12)); + }); + + it("should keep walking back to Friday without the option (Mon 2024-01-08 - 1 -> Fri 2024-01-05)", () => { + expect(subBusinessDays(new Date(2024, 0, 8, 12), 1, { includeSaturday: false })).toEqual( + new Date(2024, 0, 5, 12), + ); + }); + + it("should still walk back over a holiday that falls on a Saturday (Mon 2024-11-04 - 1 -> Fri 2024-11-01, Finados)", () => { + const result = subBusinessDays(new Date(2024, 10, 4, 12), 1, { includeSaturday: true }); + + expect(result).toEqual(new Date(2024, 10, 1, 12)); + }); + + it("should walk forwards onto Saturday for a negative amount (Fri 2024-01-05 - -1 -> Sat 2024-01-06)", () => { + const result = subBusinessDays(new Date(2024, 0, 5, 12), -1, { includeSaturday: true }); + + expect(result).toEqual(new Date(2024, 0, 6, 12)); + }); + }); + describe("invalid input", () => { for (const [label, call] of NULL_CALLS) { it(`should return null for ${label}`, () => { @@ -125,6 +151,12 @@ describe("subBusinessDays", () => { it("should walk back over 30 December 2011, the local day Samoa skipped to cross the date line", () => { expect(subBusinessDays(new Date(2012, 0, 5, 12), 4)).toEqual(new Date(2011, 11, 29, 12)); }); + + it("should walk back over it with includeSaturday too, counting Sat 2011-12-31 and landing on Thu 2011-12-29", () => { + expect(subBusinessDays(new Date(2012, 0, 2, 12), 2, { includeSaturday: true })).toEqual( + new Date(2011, 11, 29, 12), + ); + }); }); inTimeZone("America/Sao_Paulo", () => { diff --git a/src/sub-business-days/sub-business-days.ts b/src/sub-business-days/sub-business-days.ts index 664c6fbde..e3449218a 100644 --- a/src/sub-business-days/sub-business-days.ts +++ b/src/sub-business-days/sub-business-days.ts @@ -8,9 +8,9 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * * The mirror image of `addBusinessDays`, which it delegates to: `subBusinessDays(date, amount)` * is `addBusinessDays(date, -amount)`, down to the last detail. A business day is a day for - * which `isBusinessDay` returns `true` (not a Saturday, a Sunday, or a Brazilian holiday), - * evaluated with the same `options`, and the walk goes one calendar day at a time, counting only - * business days. + * which `isBusinessDay` returns `true` (not a Saturday, a Sunday, or a Brazilian holiday; + * `options.includeSaturday` keeps Saturday), evaluated with the same `options`, and the walk goes + * one calendar day at a time, counting only business days. * * `amount: 0` returns a **new `Date` equal to `date`, unchanged**, even when `date` itself falls * on a weekend or holiday, and a negative `amount` walks *forwards*, exactly like date-fns' @@ -19,6 +19,12 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * The time-of-day (hours, minutes, seconds, milliseconds) of `date` is preserved in the result, * and `date` itself is never mutated. * + * `options.includeSaturday` defaults to `false`, the Monday to Friday banking count. Pass `true` + * for the labour law count of Instrução Normativa MTP nº 2/2021, art. 14, I, which includes + * Saturday and still excludes Sunday and holidays, so a holiday that falls on a Saturday is never + * counted. See `isBusinessDay` for the law behind it and for what it does not cover: municipal + * holidays, which `getHolidays` does not carry. + * * If `options.stateCode` is provided but is not a valid/known state code, it is ignored and only * national holidays are considered (same behavior as `getHolidays`/`isBusinessDay`), so a * prototype-chain key such as `"__proto__"` is an unknown state code like any other. An `options` @@ -29,9 +35,10 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * * @param {Date} date - The date to count from. Never mutated: a new `Date` is returned. * @param {number} amount - The number of business days to subtract; a negative value walks forwards. - * @param {BusinessDayOptions} [options] - Which holidays count as non-business days. + * @param {BusinessDayOptions} [options] - Which days count as business days. * @param {StateCode} [options.stateCode] - Brazilian state code whose state holidays are also considered. * @param {boolean} [options.includeOptional] - Whether optional holidays count as non-business days (default: `true`). + * @param {boolean} [options.includeSaturday] - Whether Saturday counts as a business day (default: `false`). * @returns {Date | null} A new `Date`, `amount` business days before `date`. `null` on bad input: * a `date` that is not a valid `Date` or is outside 1900-2099, an `amount` that is not a finite * integer, a `stateCode` that is not a string, or a walk that leaves the supported years. @@ -43,6 +50,8 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * subBusinessDays(new Date(2025, 0, 2, 12), 1); // Tue 2024-12-31, 12:00 (Jan 1 is Ano novo, skipped) * subBusinessDays(new Date(2024, 0, 5, 12), -1); // Mon 2024-01-08, 12:00 (walks forwards) * subBusinessDays(new Date(2024, 0, 6, 12), 0); // Sat 2024-01-06, 12:00 (unchanged, even though Saturday is not a business day) + * subBusinessDays(new Date(2024, 0, 8, 12), 1, { includeSaturday: true }); // Sat 2024-01-06, 12:00 (labour count) + * subBusinessDays(new Date(2024, 10, 4, 12), 1, { includeSaturday: true }); // Fri 2024-11-01, 12:00 (Nov 2 is Finados, a holiday on a Saturday) * subBusinessDays(new Date(2024, 6, 10, 12), 1, { stateCode: "SP" }); // Mon 2024-07-08, 12:00 (Jul 9 is a state holiday in SP) * subBusinessDays(new Date("not a date"), 1); // null * subBusinessDays(new Date(2024, 0, 2), 1.5); // null (not an integer)