From 46a33ce87551e7ab61f378660f16bfba84181148 Mon Sep 17 00:00:00 2001 From: Kristine White Date: Fri, 24 Jul 2026 16:21:52 -0700 Subject: [PATCH] feat(Payroll): add regular-rate-of-pay workweek distribution helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports gusto-web's evenlyDistributeValue (pro-rata distribution across workweeks, weighted by calendar days, with rounding-remainder reconciliation) and its existence-based needsWorkweekBreakdown / needsWorkweekBreakdownForEarning detection predicates into a pure, framework-agnostic TypeScript module (SDK-1135). Workweek boundaries are accepted as an external input and rounding precision is caller-supplied — the Embedded API does not yet expose per-workweek fields (backend work is landing under EMBPAY-705/709 but is alpha-only and not in this SDK's vendored API version), so this PR covers only the pure math, with no UI or API wiring. Co-Authored-By: Claude Sonnet 5 --- .../Payroll/Rrop/rropHelpers.test.ts | 224 ++++++++++++++++++ src/components/Payroll/Rrop/rropHelpers.ts | 214 +++++++++++++++++ 2 files changed, 438 insertions(+) create mode 100644 src/components/Payroll/Rrop/rropHelpers.test.ts create mode 100644 src/components/Payroll/Rrop/rropHelpers.ts diff --git a/src/components/Payroll/Rrop/rropHelpers.test.ts b/src/components/Payroll/Rrop/rropHelpers.test.ts new file mode 100644 index 000000000..2e61dd4d8 --- /dev/null +++ b/src/components/Payroll/Rrop/rropHelpers.test.ts @@ -0,0 +1,224 @@ +import { expect, describe, it } from 'vitest' +import { + evenlyDistributeValue, + needsWorkweekBreakdown, + needsWorkweekBreakdownForEarning, + type Workweek, +} from './rropHelpers' + +describe('evenlyDistributeValue', () => { + it('splits evenly across two equal 7-day workweeks', () => { + const workweeks: Workweek[] = [ + { startDate: '2023-10-01', endDate: '2023-10-07' }, + { startDate: '2023-10-08', endDate: '2023-10-14' }, + ] + + expect(evenlyDistributeValue(40, 2, workweeks)).toEqual({ + '2023-10-01/2023-10-07': 20, + '2023-10-08/2023-10-14': 20, + }) + }) + + it('splits proportionally across a 5-day and 7-day workweek', () => { + const workweeks: Workweek[] = [ + { startDate: '2023-10-01', endDate: '2023-10-05' }, + { startDate: '2023-10-06', endDate: '2023-10-12' }, + ] + + const result = evenlyDistributeValue(40, 2, workweeks) + + expect(result).toEqual({ + '2023-10-01/2023-10-05': 16.67, + '2023-10-06/2023-10-12': 23.33, + }) + expect(Object.values(result).reduce((sum, v) => sum + v, 0)).toBeCloseTo(40, 2) + }) + + it('splits across three unequal workweeks', () => { + const workweeks: Workweek[] = [ + { startDate: '2023-10-01', endDate: '2023-10-05' }, + { startDate: '2023-10-06', endDate: '2023-10-11' }, + { startDate: '2023-10-12', endDate: '2023-10-14' }, + ] + + expect(evenlyDistributeValue(40, 2, workweeks)).toEqual({ + '2023-10-01/2023-10-05': 14.29, + '2023-10-06/2023-10-11': 17.14, + '2023-10-12/2023-10-14': 8.57, + }) + }) + + it('assigns the entire gap-day shortfall to the first workweek for non-contiguous workweeks', () => { + const workweeks: Workweek[] = [ + { startDate: '2023-01-01', endDate: '2023-01-02' }, // 2 days + { startDate: '2023-01-05', endDate: '2023-01-07' }, // 3 days (gap: Jan 3-4) + ] + + // totalDays spans Jan 1 - Jan 7 inclusive = 7, including the 2 gap days. + // Naive proportional split would be 40.257 * 2/7 ≈ 11.50 and 40.257 * 3/7 ≈ 17.25, + // summing to ~28.76 (5/7 of the total) — but the remainder-correction step forces + // the two rounded shares to sum to the FULL value, with workweek 0 absorbing it all. + expect(evenlyDistributeValue(40.257, 0, workweeks)).toEqual({ + '2023-01-01/2023-01-02': 23, + '2023-01-05/2023-01-07': 17, + }) + }) + + it('returns {} for an empty workweeks array without throwing', () => { + expect(() => evenlyDistributeValue(40, 2, [])).not.toThrow() + expect(evenlyDistributeValue(40, 2, [])).toEqual({}) + }) + + it('returns the whole rounded value for a single workweek', () => { + const workweeks: Workweek[] = [{ startDate: '2023-10-01', endDate: '2023-10-07' }] + + expect(evenlyDistributeValue(100, 2, workweeks)).toEqual({ + '2023-10-01/2023-10-07': 100, + }) + }) + + it('generalizes to more than three workweeks', () => { + const workweeks: Workweek[] = [ + { startDate: '2023-10-01', endDate: '2023-10-07' }, + { startDate: '2023-10-08', endDate: '2023-10-14' }, + { startDate: '2023-10-15', endDate: '2023-10-21' }, + { startDate: '2023-10-22', endDate: '2023-10-28' }, + ] + + const result = evenlyDistributeValue(100, 2, workweeks) + + expect(Object.keys(result)).toHaveLength(4) + expect(Object.values(result).reduce((sum, v) => sum + v, 0)).toBeCloseTo(100, 2) + }) + + it.each([0, 1, 2, 3, 4, 5, 6])('sums back to the original value at precision %i', precision => { + const workweeks: Workweek[] = [ + { startDate: '2023-10-01', endDate: '2023-10-05' }, + { startDate: '2023-10-06', endDate: '2023-10-11' }, + { startDate: '2023-10-12', endDate: '2023-10-14' }, + ] + + for (const valueToDistribute of [40.257, 42.253, 123.4567, 78.91234]) { + const result = evenlyDistributeValue(valueToDistribute, precision, workweeks) + const total = Object.values(result).reduce((sum, v) => sum + v, 0) + expect(total).toBeCloseTo(valueToDistribute, precision) + } + }) + + it('returns {} when the first workweek is missing a startDate', () => { + const workweeks: Workweek[] = [ + { startDate: '', endDate: '2023-10-07' }, + { startDate: '2023-10-08', endDate: '2023-10-14' }, + ] + + expect(evenlyDistributeValue(40, 2, workweeks)).toEqual({}) + }) + + it('returns {} when the last workweek is missing an endDate', () => { + const workweeks: Workweek[] = [ + { startDate: '2023-10-01', endDate: '2023-10-07' }, + { startDate: '2023-10-08', endDate: '' }, + ] + + expect(evenlyDistributeValue(40, 2, workweeks)).toEqual({}) + }) + + it('assigns the rounding remainder to the first workweek, never the last', () => { + const workweeks: Workweek[] = [ + { startDate: '2023-10-01', endDate: '2023-10-01' }, + { startDate: '2023-10-02', endDate: '2023-10-02' }, + { startDate: '2023-10-03', endDate: '2023-10-03' }, + ] + + // 100 / 3 workweeks = 33.333... each; rounded independently that's 33/33/33 + // (summing to 99), so the 1-unit remainder must land on workweek 0. + expect(evenlyDistributeValue(100, 0, workweeks)).toEqual({ + '2023-10-01/2023-10-01': 34, + '2023-10-02/2023-10-02': 33, + '2023-10-03/2023-10-03': 33, + }) + }) + + it('produces keys formatted as "startDate/endDate"', () => { + const workweeks: Workweek[] = [ + { startDate: '2023-10-01', endDate: '2023-10-07' }, + { startDate: '2023-10-08', endDate: '2023-10-14' }, + ] + + expect(Object.keys(evenlyDistributeValue(40, 2, workweeks))).toEqual([ + '2023-10-01/2023-10-07', + '2023-10-08/2023-10-14', + ]) + }) + + it('splits two equal 7-day workweeks evenly even when one spans a US Daylight Saving Time transition', () => { + // 2024-03-10 is the US spring-forward transition (a 23-hour local day). + const workweeks: Workweek[] = [ + { startDate: '2024-03-04', endDate: '2024-03-10' }, + { startDate: '2024-03-11', endDate: '2024-03-17' }, + ] + + expect(evenlyDistributeValue(40, 2, workweeks)).toEqual({ + '2024-03-04/2024-03-10': 20, + '2024-03-11/2024-03-17': 20, + }) + }) +}) + +describe('needsWorkweekBreakdown', () => { + it('returns false when payPeriodTotal is 0', () => { + expect(needsWorkweekBreakdown(0, [])).toBe(false) + }) + + it('returns false when payPeriodTotal is negative', () => { + expect(needsWorkweekBreakdown(-5, [])).toBe(false) + }) + + it('returns true when payPeriodTotal is positive and there are no existing rows', () => { + expect(needsWorkweekBreakdown(10, [])).toBe(true) + }) + + it('returns false when an existing row has a positive value', () => { + expect(needsWorkweekBreakdown(10, [{ value: 5 }])).toBe(false) + }) + + it('returns false even when existing rows sum to the wrong total, as long as one row is positive', () => { + // Existence check, not a correctness check — 3 + 2 = 5, not 10, but this + // still counts as "already has a breakdown" by gusto-web's own design. + expect(needsWorkweekBreakdown(10, [{ value: 3 }, { value: 2 }])).toBe(false) + }) + + it('returns true when all existing rows are zero or negative', () => { + expect(needsWorkweekBreakdown(10, [{ value: 0 }, { value: -3 }])).toBe(true) + }) + + it('returns true when existingBreakdownRows is empty', () => { + expect(needsWorkweekBreakdown(10, [])).toBe(true) + }) +}) + +describe('needsWorkweekBreakdownForEarning', () => { + it('returns false when isIncludedInRegularRateOfPay is false, even with a positive total and no existing rows', () => { + expect(needsWorkweekBreakdownForEarning(10, [], false, 'Commission')).toBe(false) + }) + + it('returns false for "Minimum Wage Adjustment" regardless of isIncludedInRegularRateOfPay', () => { + expect(needsWorkweekBreakdownForEarning(10, [], true, 'Minimum Wage Adjustment')).toBe(false) + }) + + it('returns false for a case-varied "minimum wage adjustment" name', () => { + expect(needsWorkweekBreakdownForEarning(10, [], true, 'minimum WAGE Adjustment')).toBe(false) + }) + + it('returns true when included in RROP, positive total, and no existing breakdown rows', () => { + expect(needsWorkweekBreakdownForEarning(10, [], true, 'Commission')).toBe(true) + }) + + it('returns false when included in RROP but an existing row already has a positive value', () => { + expect(needsWorkweekBreakdownForEarning(10, [{ value: 5 }], true, 'Commission')).toBe(false) + }) + + it('returns false when payPeriodTotal is 0 even if included in RROP', () => { + expect(needsWorkweekBreakdownForEarning(0, [], true, 'Commission')).toBe(false) + }) +}) diff --git a/src/components/Payroll/Rrop/rropHelpers.ts b/src/components/Payroll/Rrop/rropHelpers.ts new file mode 100644 index 000000000..d0e2a7cc3 --- /dev/null +++ b/src/components/Payroll/Rrop/rropHelpers.ts @@ -0,0 +1,214 @@ +import { normalizeToDate, MS_PER_HOUR } from '@/helpers/dateFormatting' + +const MS_PER_DAY = MS_PER_HOUR * 24 + +const MINIMUM_WAGE_ADJUSTMENT_NAME = 'minimum wage adjustment' + +/** + * A workweek date range, as returned by a future Embedded API field once + * Regular Rate of Pay support ships (tracked as EMBPAY-705/EMBPAY-709 in + * zenpayroll; not yet available in any released API version). + * + * @remarks Consumers must never derive these dates client-side. Workweek + * boundary computation (pay-schedule transitions, off-cycle payrolls with + * employees on divergent workweek start days) is entirely owned by the + * backend — gusto-web's equivalent, `Workweek::WorkweekCalculator`, is + * intentionally not ported here. Once the API ships, boundaries are expected + * to arrive as `start_date`/`end_date` pairs on each compensation's + * `breakdowns[]` rows (from `payrolls#prepare`/`#update`/`#create`), not as a + * standalone workweeks list. + * + * @internal + */ +export interface Workweek { + startDate: string + endDate: string +} + +/** + * A value distributed across workweeks, keyed by `${startDate}/${endDate}`. + * + * @remarks The key format matches gusto-web's `evenlyDistributeValue` key + * convention, preserved for parity with the reference implementation. + * + * @internal + */ +export type WorkweekDistribution = Record + +const round = (value: number, precision: number): number => parseFloat(value.toFixed(precision)) + +const calendarDaysInclusive = (startDate: string, endDate: string): number => { + const start = normalizeToDate(startDate) + const end = normalizeToDate(endDate) + if (!start || !end) return 0 + + // Diff via UTC calendar-date components (not raw millisecond subtraction on + // the local-time Date objects normalizeToDate returns) so a Daylight Saving + // Time transition falling inside the range never perturbs the day count. + const utcStart = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate()) + const utcEnd = Date.UTC(end.getFullYear(), end.getMonth(), end.getDate()) + return Math.round((utcEnd - utcStart) / MS_PER_DAY) + 1 +} + +/** + * Evenly distributes a pay-period total across workweeks, weighted by each + * workweek's calendar-day span, then rounds each share and reconciles any + * rounding remainder onto the first workweek. + * + * @remarks + * Ported from gusto-web's `evenlyDistributeValue` + * (`WorkweekBreakdownDrawerContent/utils/helpers.ts`). The distributed + * VALUES are computed here; the regular-rate-of-pay / overtime-premium math + * that consumes them happens server-side and is not part of this SDK. + * + * Two intentional deviations from gusto-web: + * 1. **Empty array**: gusto-web accesses `workweeks[0]` before checking + * `workweeks.length === 0`, which throws on an empty array in production + * today. This port checks length first and returns `{}` instead of + * throwing — there is no well-defined distribution over zero workweeks. + * 2. **Missing bounds**: if the first workweek's `startDate` or the last + * workweek's `endDate` is falsy, this returns `{}` rather than proceeding + * with invalid-date arithmetic. + * + * Everything else below is gusto-web's real, load-bearing behavior — not a + * bug we're fixing: + * - `totalDays` (the distribution denominator) spans from the first + * workweek's `startDate` to the last workweek's `endDate`, inclusive. For + * non-contiguous ("gappy") workweeks, gap days between them count toward + * this denominator without being individually attributed to any workweek. + * - The rounding remainder (`valueToDistribute` minus the sum of each + * workweek's independently-rounded share) is always assigned entirely to + * `workweeks[0]` — the first workweek by array order, never the last, + * never spread proportionally. For gappy workweeks this means + * `workweeks[0]` can absorb a disproportionate share of the total. + * + * @param valueToDistribute - The pay-period total to spread across workweeks. + * @param precision - Decimal places to round each workweek's share to. + * Caller-supplied; this SDK does not hardcode a convention, since + * gusto-web itself uses different precisions in different code paths + * (2 for earnings in its bulk auto-distribute flow, 6 for both hours and + * earnings in its per-employee drawer autosave flow). + * @param workweeks - The workweek date ranges to distribute across, in + * chronological order. Must be supplied externally — never derived here. + * @returns A map from `${startDate}/${endDate}` to the rounded share of + * `valueToDistribute` assigned to that workweek. Returns `{}` when + * `workweeks` is empty or the outer date bounds are missing. + * @internal + */ +export const evenlyDistributeValue = ( + valueToDistribute: number, + precision: number, + workweeks: Workweek[], +): WorkweekDistribution => { + if (workweeks.length === 0) return {} + + const firstStartDate = workweeks[0]!.startDate + const lastEndDate = workweeks[workweeks.length - 1]!.endDate + if (!firstStartDate || !lastEndDate) return {} + + const totalDays = calendarDaysInclusive(firstStartDate, lastEndDate) + + const valuesPerWorkweek = workweeks.map(workweek => { + const daysInWorkweek = + workweek.startDate && workweek.endDate + ? calendarDaysInclusive(workweek.startDate, workweek.endDate) + : 0 + const value = (valueToDistribute * daysInWorkweek) / totalDays + return { value: round(value, precision), key: `${workweek.startDate}/${workweek.endDate}` } + }) + + const total = valuesPerWorkweek.reduce((acc, workweek) => acc + workweek.value, 0) + const difference = valueToDistribute - total + valuesPerWorkweek[0]!.value = round(valuesPerWorkweek[0]!.value + difference, precision) + + return valuesPerWorkweek.reduce((acc, workweek) => { + acc[workweek.key] = workweek.value + return acc + }, {}) +} + +/** + * A single row of existing per-workweek breakdown data for one field (e.g. + * one job's overtime hours, or one earning type), used only to decide + * whether a breakdown has already been started — not to validate its + * correctness. + * + * @internal + */ +export interface WorkweekBreakdownRow { + startDate?: string + endDate?: string + value: number +} + +/** + * Decides whether a pay-period-total field needs to be broken down into + * per-workweek values before it can be submitted. + * + * @remarks + * Ported from gusto-web's `useEmployeesNeedWorkweekBreakdowns`, generalized + * to a framework-agnostic predicate with no React/GraphQL dependency. + * + * This is an EXISTENCE check, not a correctness check, by deliberate + * gusto-web design: "needs a breakdown" is `false` as soon as + * `existingBreakdownRows` contains any row with `value > 0`, even if the + * existing rows don't sum to `payPeriodTotal`. A breakdown that already + * exists but is incomplete or wrong still counts as "already has one." This + * is inherited from gusto-web, not something introduced here — flagging it + * because it's worth reconsidering (sum-based validation instead) once this + * is wired to a real UI, since it currently means a partial/incorrect + * breakdown can never be automatically re-triggered. + * + * @param payPeriodTotal - The pay-period total for this job+field (e.g. total overtime hours). + * @param existingBreakdownRows - Any already-entered per-workweek rows for the same job+field. + * @returns `true` when the field needs a per-workweek breakdown; `false` otherwise. + * @internal + */ +export const needsWorkweekBreakdown = ( + payPeriodTotal: number, + existingBreakdownRows: WorkweekBreakdownRow[], +): boolean => { + if (payPeriodTotal <= 0) return false + return !existingBreakdownRows.some(row => row.value > 0) +} + +/** + * Decides whether an earning type needs a per-workweek breakdown, applying + * the same existence check as {@link needsWorkweekBreakdown} plus two + * earnings-specific gates gusto-web applies before considering an earning + * for RROP breakdown at all. + * + * @remarks + * `isIncludedInRegularRateOfPay` naming note: gusto-web's GraphQL schema + * calls this field `includedInRegularRateOfPay` (a boolean on the + * earning-type definition). An internal TSpec for the Embedded API instead + * proposes `included_in_overtime_pay`. Whether these denote the same + * concept is unconfirmed — "regular rate of pay" (the divisor used to + * compute the overtime premium) and "overtime pay" (the premium itself) are + * related but distinct legal concepts. This parameter is named generically + * to avoid committing to either name before the real API field ships; + * confirm the mapping at the call site once it does. + * + * "Minimum Wage Adjustment" is always structurally excluded from RROP + * breakdown consideration in gusto-web, regardless of + * `isIncludedInRegularRateOfPay` — matched case-insensitively here, mirroring + * this SDK's existing case-insensitive earning-name comparisons in + * `Payroll/helpers.ts`. + * + * @param payPeriodTotal - The pay-period total amount for this earning type. + * @param existingBreakdownRows - Any already-entered per-workweek rows for this earning. + * @param isIncludedInRegularRateOfPay - Whether this earning type counts toward the regular rate of pay. + * @param earningTypeName - The earning type's display name, used only to detect "Minimum Wage Adjustment". + * @returns `true` when the earning needs a per-workweek breakdown; `false` otherwise. + * @internal + */ +export const needsWorkweekBreakdownForEarning = ( + payPeriodTotal: number, + existingBreakdownRows: WorkweekBreakdownRow[], + isIncludedInRegularRateOfPay: boolean, + earningTypeName: string | undefined, +): boolean => { + if (!isIncludedInRegularRateOfPay) return false + if ((earningTypeName ?? '').toLowerCase() === MINIMUM_WAGE_ADJUSTMENT_NAME) return false + return needsWorkweekBreakdown(payPeriodTotal, existingBreakdownRows) +}