Skip to content

Commit 31403ba

Browse files
Herkloscursoragent
andcommitted
Fix rem review findings: jungle recompute, vacation null, fee estimates.
Restore jungle backlog remaining before period re-enqueue, treat null vacation max as unlimited while enforcing monthly capacity on leave, avoid rewriting intentional company/project settings, settle only product_pool null-project lines onto the pool project, and align fee report estimates with fee-after-costs. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7ec1c17 commit 31403ba

9 files changed

Lines changed: 1021 additions & 59 deletions

File tree

apps/mobile/src/app/(app)/reports/company-fee.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ export default function CompanyFeeReportScreen() {
4848
}, [reserve, period]);
4949

5050
const estimatedFee = useMemo(
51-
() => estimateCompanyFeeCents(revenue ?? [], projectRefs, feePct, period),
52-
[revenue, projectRefs, feePct, period],
51+
() => estimateCompanyFeeCents(revenue ?? [], projectRefs, feePct, period, costs ?? []),
52+
[revenue, projectRefs, feePct, period, costs],
5353
);
5454
const feeCents = resolveCompanyFeeTotal({
5555
reserveCents: reserveForPeriod,

apps/mobile/src/components/settings/WorkingDaysCard.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ export function WorkingDaysCard({ company }: WorkingDaysCardProps) {
4141
company.max_holidays_per_year != null ? String(company.max_holidays_per_year) : '',
4242
);
4343
const [maxVacationDays, setMaxVacationDays] = useState(
44-
String(company.max_vacation_days_per_year ?? DEFAULT_PAID_VACATION_DAYS),
44+
company.max_vacation_days_per_year != null
45+
? String(company.max_vacation_days_per_year)
46+
: String(DEFAULT_PAID_VACATION_DAYS),
4547
);
4648

4749
const onWorkingWeekdaysChange = (value: number[]) => {
@@ -57,8 +59,8 @@ export function WorkingDaysCard({ company }: WorkingDaysCardProps) {
5759

5860
const saveMaxVacationDays = () => {
5961
const trimmed = maxVacationDays.trim();
60-
const parsed = trimmed === '' ? DEFAULT_PAID_VACATION_DAYS : parseInt(trimmed, 10);
61-
if (!Number.isFinite(parsed) || parsed < 0) return;
62+
const parsed = trimmed === '' ? null : parseInt(trimmed, 10);
63+
if (parsed != null && (!Number.isFinite(parsed) || parsed < 0)) return;
6264
void update(company.id, { max_vacation_days_per_year: parsed });
6365
};
6466

apps/mobile/src/lib/company-rem-reports.test.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const projects = [
1616
];
1717

1818
describe('estimateCompanyFeeCents', () => {
19-
it('fees paid revenue on pool/service/staffing/jungle projects', () => {
19+
it('fees paid revenue on service/staffing; pool fee is after costs', () => {
2020
const fee = estimateCompanyFeeCents(
2121
[
2222
{ project_id: 'p1', period_month: '2026-07-01', amount_cents: 100_000, paid_at: '2026-07-10' },
@@ -27,9 +27,21 @@ describe('estimateCompanyFeeCents', () => {
2727
projects,
2828
5,
2929
'2026-07',
30+
[
31+
{
32+
kind: 'one_off',
33+
amount_cents: 20_000,
34+
active: true,
35+
paid_at: '2026-07-01',
36+
auto_deduct: false,
37+
period_month: '2026-07-01',
38+
starts_on: null,
39+
ends_on: null,
40+
},
41+
],
3042
);
31-
// 300_000 × 5% = 15_000 (unpaid excluded; staffing included)
32-
expect(fee).toBe(15_000);
43+
// service+staffing 200_000 × 5% = 10_000; pool (100_000 − 20_000) × 5% = 4_000
44+
expect(fee).toBe(14_000);
3345
});
3446
});
3547

apps/mobile/src/lib/company-rem-reports.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ import { lastMonths } from '@/lib/reports';
55
import { matchesPeriodMonth, type StatsPeriod } from '@/lib/period-month';
66
import { todayISO } from '@/lib/date';
77

8-
/** Policies that accrue company fee % into the reserve (global fee). */
9-
const FEE_POLICIES: RemPolicy[] = ['product_pool', 'product_service', 'staffing', 'jungle'];
8+
/** Policies that accrue company fee % on gross paid revenue. */
9+
const GROSS_FEE_POLICIES: RemPolicy[] = ['product_service', 'staffing', 'jungle'];
1010

11-
/** Policies that carve license % for rem partners. */
11+
/** Product pool fees apply after eligible costs. */
12+
const POOL_FEE_POLICIES: RemPolicy[] = ['product_pool'];
13+
14+
/** Policies that carve license % for license recipients. */
1215
const LICENSE_POLICIES: RemPolicy[] = ['product_service'];
1316

1417
export type ProjectRemRef = Pick<Project, 'id' | 'rem_policy' | 'name'>;
@@ -41,17 +44,22 @@ function scopedRevenue(
4144

4245
/**
4346
* Estimated company fee for the period from fee-eligible project revenue
44-
* (paid), matching rem: fee = R × company_fee_pct across all rem policies.
47+
* (paid). Product-pool fee is on (gross − costs); other policies fee on gross.
4548
*/
4649
export function estimateCompanyFeeCents(
4750
entries: Rev[],
4851
projects: ProjectRemRef[],
4952
companyFeePct: number,
5053
period: StatsPeriod,
54+
costs: Cost[] = [],
5155
): number {
5256
const byProject = policyMap(projects);
53-
const R = scopedRevenue(entries, period, new Set(FEE_POLICIES), byProject, true);
54-
return companyFeeCents(R, companyFeePct);
57+
const grossR = scopedRevenue(entries, period, new Set(GROSS_FEE_POLICIES), byProject, true);
58+
const poolR = scopedRevenue(entries, period, new Set(POOL_FEE_POLICIES), byProject, true);
59+
const poolCosts =
60+
period === 'all' ? companyPoolCostsCents(costs, 'all') : totalCostForMonth(costs, period);
61+
const poolBase = Math.max(0, poolR - poolCosts);
62+
return companyFeeCents(grossR, companyFeePct) + companyFeeCents(poolBase, companyFeePct);
5563
}
5664

5765
/**
@@ -111,7 +119,13 @@ export function feeVsCostsTrend(input: {
111119
const feeCents =
112120
fromLedger != null
113121
? fromLedger
114-
: estimateCompanyFeeCents(input.revenueEntries, input.projects, input.companyFeePct, month);
122+
: estimateCompanyFeeCents(
123+
input.revenueEntries,
124+
input.projects,
125+
input.companyFeePct,
126+
month,
127+
input.costs,
128+
);
115129
return { month, feeCents, costsCents, netCents: feeCents - costsCents };
116130
});
117131
}

apps/mobile/src/lib/i18n/catalogs/rem.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export const remCatalog: CatalogSlice = {
2121
'rem.policy.desc.external_tjm':
2222
'Days × TJM via classic invoices. When referrers are set, rem shows the referral carve-out; referrals are paid from the funding pool.',
2323
'rem.policy.desc.product_pool': 'Paid product revenue (after costs & company fee) shared by time, with max share caps.',
24-
'rem.policy.desc.product_service': 'Service revenue: company fee, then license % to rem partners, rest by time.',
24+
'rem.policy.desc.product_service': 'Service revenue: company fee, then license % to license recipients, rest by time.',
2525
'rem.policy.desc.jungle':
2626
'A day-rate credit is accrued each month and paid later, in order, when cash is available.',
2727
'rem.kind.label': 'Revenue kind (rem)',
@@ -80,7 +80,7 @@ export const remCatalog: CatalogSlice = {
8080
'rem.policy.desc.external_tjm':
8181
'Jours × TJM via factures classiques. Si des apporteurs sont définis, la rem affiche la part referral ; les apporteurs sont payés sur le pool de financement.',
8282
'rem.policy.desc.product_pool': 'Revenus produits payés (après coûts & frais société) partagés au temps, avec plafond de part.',
83-
'rem.policy.desc.product_service': 'Prestation : frais société, puis % licence aux associés rem, reste au temps.',
83+
'rem.policy.desc.product_service': 'Prestation : frais société, puis % licence aux bénéficiaires licence, reste au temps.',
8484
'rem.policy.desc.jungle':
8585
'Un crédit de TJM est accumulé chaque mois et payé plus tard, dans l’ordre, quand la trésorerie le permet.',
8686
'rem.kind.label': 'Type de revenu (rem)',

apps/mobile/src/lib/reports.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ function member(userId: string, weeklyCapacityDays: number): CompanyMemberWithPr
141141
weekly_capacity_days: weeklyCapacityDays,
142142
working_weekdays: null,
143143
rem_partner: false,
144+
rem_license_recipient: false,
144145
rem_max_percent: null,
145146
created_at: '',
146147
updated_at: '',

backend/supabase/migrations/20260808000000_canonical_remuneration.sql

Lines changed: 99 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -22,30 +22,21 @@ alter table public.companies
2222
alter table public.companies
2323
alter column default_hours_per_day set default 8;
2424

25+
-- Only fill null hours; do not rewrite intentional fee/max/license/vacation values.
2526
update public.companies
26-
set
27-
company_fee_pct = case when company_fee_pct = 0 then 5 else company_fee_pct end,
28-
rem_max_percent = case when rem_max_percent = 100 then 75 else rem_max_percent end,
29-
default_license_pct = case when default_license_pct = 0 then 30 else default_license_pct end,
30-
default_hours_per_day = coalesce(default_hours_per_day, 8),
31-
max_vacation_days_per_year = coalesce(max_vacation_days_per_year, 15)
32-
where deleted = false;
27+
set default_hours_per_day = 8
28+
where deleted = false and default_hours_per_day is null;
3329

3430
alter table public.projects
3531
alter column hours_per_day set default 8;
3632

37-
update public.projects
38-
set hours_per_day = 8
39-
where hours_per_day = 7 and deleted = false;
40-
4133
alter table public.company_members
4234
add column if not exists rem_license_recipient boolean not null default false;
4335

4436
comment on column public.company_members.rem_license_recipient is
4537
'Receives product-service / standalone license splits (exactly two active recipients required).';
4638

47-
-- Seed: if a company has rem partners but no license recipients, promote the two
48-
-- oldest rem_partner members (or all rem_partners when fewer than two).
39+
-- Seed license recipients only when a company has at least two rem partners and none set yet.
4940
with ranked as (
5041
select
5142
id,
@@ -59,7 +50,8 @@ update public.company_members cm
5950
set rem_license_recipient = true
6051
from ranked r
6152
where cm.id = r.id
62-
and r.rn <= least(2, r.n)
53+
and r.n >= 2
54+
and r.rn <= 2
6355
and not exists (
6456
select 1 from public.company_members x
6557
where x.company_id = r.company_id
@@ -203,6 +195,39 @@ as $$
203195
);
204196
$$;
205197

198+
revoke all on function public.company_hours_per_day(uuid) from public;
199+
revoke all on function public.company_hours_per_day(uuid) from anon, authenticated;
200+
201+
create or replace function public._chrono_restore_jungle_period(
202+
p_company_id uuid,
203+
p_period date
204+
)
205+
returns void
206+
language plpgsql
207+
security definer
208+
set search_path = ''
209+
as $$
210+
begin
211+
update public.jungle_tjm_queue_entries e
212+
set remaining_cents = remaining_cents + s.amount_cents, updated_at = now()
213+
from public.jungle_tjm_queue_settlements s
214+
where s.queue_entry_id = e.id
215+
and s.company_id = p_company_id
216+
and s.period_month = p_period
217+
and e.deleted = false;
218+
219+
delete from public.jungle_tjm_queue_settlements
220+
where company_id = p_company_id and period_month = p_period;
221+
222+
update public.jungle_tjm_queue_entries
223+
set deleted = true, updated_at = now()
224+
where company_id = p_company_id and period_month = p_period and deleted = false;
225+
end;
226+
$$;
227+
228+
revoke all on function public._chrono_restore_jungle_period(uuid, date) from public;
229+
revoke all on function public._chrono_restore_jungle_period(uuid, date) from anon, authenticated;
230+
206231
create or replace function public.enforce_vacation_allowance()
207232
returns trigger
208233
language plpgsql
@@ -215,6 +240,11 @@ declare
215240
v_year integer;
216241
v_used numeric;
217242
v_adding numeric;
243+
v_period date;
244+
v_cap_minutes numeric;
245+
v_work_minutes numeric;
246+
v_leave_minutes numeric;
247+
v_new_minutes numeric;
218248
begin
219249
if tg_op = 'DELETE' then
220250
return old;
@@ -225,31 +255,68 @@ begin
225255

226256
select max_vacation_days_per_year into v_max
227257
from public.companies where id = new.company_id;
228-
v_max := coalesce(v_max, 15);
258+
-- null = unlimited (documented company policy)
259+
if v_max is not null then
260+
v_hpd := public.company_hours_per_day(new.company_id);
261+
v_year := extract(year from new.off_date)::integer;
262+
263+
select coalesce(sum(
264+
case
265+
when t.duration_minutes is null then 1
266+
else t.duration_minutes::numeric / nullif(v_hpd * 60, 0)
267+
end
268+
), 0) into v_used
269+
from public.time_off t
270+
where t.company_id = new.company_id
271+
and t.user_id = new.user_id
272+
and t.kind = 'vacation'
273+
and extract(year from t.off_date) = v_year
274+
and t.id is distinct from new.id;
275+
276+
v_adding := case
277+
when new.duration_minutes is null then 1
278+
else new.duration_minutes::numeric / nullif(v_hpd * 60, 0)
279+
end;
280+
281+
if v_used + v_adding > v_max + 1e-9 then
282+
raise exception 'Vacation allowance exceeded (% days/year)', v_max;
283+
end if;
284+
end if;
285+
286+
-- Monthly capacity: work + leave ≤ 22 × company hours/day
229287
v_hpd := public.company_hours_per_day(new.company_id);
230-
v_year := extract(year from new.off_date)::integer;
288+
v_cap_minutes := 22 * v_hpd * 60;
289+
v_period := date_trunc('month', new.off_date)::date;
290+
v_new_minutes := case
291+
when new.duration_minutes is null then v_hpd * 60
292+
else greatest(0, new.duration_minutes)
293+
end;
294+
295+
select coalesce(sum(greatest(0, te.duration_minutes)), 0) into v_work_minutes
296+
from public.time_entries te
297+
where te.company_id = new.company_id
298+
and te.user_id = new.user_id
299+
and te.deleted = false
300+
and te.status is distinct from 'rejected'
301+
and date_trunc('month', te.entry_date)::date = v_period;
231302

232303
select coalesce(sum(
233304
case
234-
when t.duration_minutes is null then 1
235-
else t.duration_minutes::numeric / nullif(v_hpd * 60, 0)
305+
when t.duration_minutes is null then v_hpd * 60
306+
else greatest(0, t.duration_minutes)
236307
end
237-
), 0) into v_used
308+
), 0) into v_leave_minutes
238309
from public.time_off t
239310
where t.company_id = new.company_id
240311
and t.user_id = new.user_id
241312
and t.kind = 'vacation'
242-
and extract(year from t.off_date) = v_year
313+
and date_trunc('month', t.off_date)::date = v_period
243314
and t.id is distinct from new.id;
244315

245-
v_adding := case
246-
when new.duration_minutes is null then 1
247-
else new.duration_minutes::numeric / nullif(v_hpd * 60, 0)
248-
end;
249-
250-
if v_used + v_adding > v_max + 1e-9 then
251-
raise exception 'Vacation allowance exceeded (% days/year)', v_max;
316+
if v_work_minutes + v_leave_minutes + v_new_minutes > v_cap_minutes + 1e-9 then
317+
raise exception 'Monthly capacity exceeded (22 × %h)', v_hpd;
252318
end if;
319+
253320
return new;
254321
end;
255322
$$;
@@ -280,10 +347,7 @@ begin
280347
return new;
281348
end if;
282349

283-
v_hpd := coalesce(
284-
(select hours_per_day from public.projects where id = new.project_id),
285-
public.company_hours_per_day(new.company_id)
286-
);
350+
v_hpd := public.company_hours_per_day(new.company_id);
287351
v_cap_minutes := 22 * v_hpd * 60;
288352
v_period := date_trunc('month', new.entry_date)::date;
289353
v_new_minutes := greatest(0, coalesce(new.duration_minutes, 0));
@@ -860,15 +924,8 @@ begin
860924
end loop;
861925
end loop;
862926

863-
-- ---- jungle: enqueue period, fee on paid revenue, FIFO dequeue post-fee ----
864-
delete from public.jungle_tjm_queue_settlements s
865-
using public.jungle_tjm_queue_entries e
866-
where s.queue_entry_id = e.id
867-
and e.company_id = p_company_id
868-
and e.period_month = v_period;
869-
update public.jungle_tjm_queue_entries
870-
set deleted = true, updated_at = now()
871-
where company_id = p_company_id and period_month = v_period and deleted = false;
927+
-- ---- jungle: restore prior settlements for this period, then re-enqueue ----
928+
perform public._chrono_restore_jungle_period(p_company_id, v_period);
872929

873930
for v_proj in
874931
select id, jungle_fictitious_tjm_cents, hours_per_day, company_id
@@ -1017,9 +1074,10 @@ begin
10171074
and user_id is not null
10181075
and bucket not in ('company_fee', 'staffing_tjm')
10191076
and (
1020-
project_id = p_project_id
1077+
(project_id = p_project_id)
10211078
or (
10221079
project_id is null
1080+
and bucket = 'product_pool'
10231081
and v_policy = 'product_pool'
10241082
and p_project_id = v_pool_project
10251083
)

0 commit comments

Comments
 (0)