diff --git a/apps/mobile/src/app/(app)/project/[id].tsx b/apps/mobile/src/app/(app)/project/[id].tsx index 86f46bd..0c65393 100644 --- a/apps/mobile/src/app/(app)/project/[id].tsx +++ b/apps/mobile/src/app/(app)/project/[id].tsx @@ -5,7 +5,7 @@ import { canManage, companyCurrency, costCumulative, - monthlyRecurringAmount, + recurringRevenue, sourceClientTjm, sourceManualAmount, totalCostForMonth, @@ -174,7 +174,9 @@ export default function ProjectDetail() { const revenueSourcesTotalCents = (sources ?? []).reduce((acc, source) => { const amount = source.type === 'recurring' - ? monthlyRecurringAmount(source) + ? // A recurring source's configured amount is per occurrence, so it is + // only comparable once expanded — take what it recognizes this month. + recurringRevenue(source, todayISO()) : (sourceManualAmount(source) ?? sourceClientTjm(source)); return acc + amount; }, 0); diff --git a/apps/mobile/src/app/(app)/project/[id]/revenue-sources.tsx b/apps/mobile/src/app/(app)/project/[id]/revenue-sources.tsx index 2a5fa2e..45ac02f 100644 --- a/apps/mobile/src/app/(app)/project/[id]/revenue-sources.tsx +++ b/apps/mobile/src/app/(app)/project/[id]/revenue-sources.tsx @@ -6,7 +6,7 @@ import { canManage, companyCurrency, dueRevenue, - fetchRevenueForMonth, + fetchRevenueEntries, markRevenueEntriesPaid, revenueEntryPaid, } from '@chrono/sdk'; @@ -21,7 +21,11 @@ import { useRevenueSourceMutations, useRevenueSources, } from '@/lib/hooks/use-revenue-sources'; -import { useMarkRevenueEntriesPaid, useRecognizeRevenue, useRevenueEntries } from '@/lib/hooks/use-revenue-entries'; +import { + useMarkRevenueEntriesPaid, + useRecognizeRevenueRange, + useRevenueEntries, +} from '@/lib/hooks/use-revenue-entries'; import { RevenueSourceRow } from '@/components/projects/RevenueSourceRow'; import { AddRevenueSourceForm, type AddRevenueSourceValues } from '@/components/projects/AddRevenueSourceForm'; import { ScreenLoader } from '@/components/common/ScreenLoader'; @@ -39,7 +43,7 @@ export default function ProjectRevenueSourcesScreen() { const { data: sources, isLoading, error, refetch } = useRevenueSources(id); const { data: revenueEntries, refetch: refetchEntries } = useRevenueEntries(id); const sourceMut = useRevenueSourceMutations(); - const { mutateAsync: recognizeRevenue, isPending: recognizing } = useRecognizeRevenue(); + const { mutateAsync: recognizeRevenueRange, isPending: recognizing } = useRecognizeRevenueRange(); const { mutateAsync: correctSource, isPending: correcting } = useCorrectRevenueSource(); const { mutateAsync: markPaid, isPending: markingPaid } = useMarkRevenueEntriesPaid(); const [adding, setAdding] = useState(false); @@ -59,19 +63,24 @@ export default function ProjectRevenueSourcesScreen() { content: values.content, external_invoice_id: values.externalInvoiceId ?? null, rem_kind: values.remKind, + starts_on: values.startsOn ?? null, + ends_on: values.endsOn ?? null, }); const today = todayISO(); - // Recognize the current month immediately so the funding pool (available - // balance) reflects the new source right away, instead of only at the - // next invoice settle. - await recognizeRevenue(project.id, today); + // Recognize immediately so the funding pool (available balance) reflects + // the new source right away, instead of only at the next invoice settle. + // A backdated start date has months to catch up on, so recognize the whole + // range rather than just this month. + const from = values.startsOn && values.startsOn < today ? values.startsOn : today; + await recognizeRevenueRange(project.id, from, today); if (values.markPaid) { - // Find the entry recognition just created for this source and flag it - // paid right away (the manager is logging money already received). - const entries = await fetchRevenueForMonth(globalSupabaseClient, project.id, today); - const entry = entries.find((e) => e.revenue_source_id === created.id); - if (entry) { - await markRevenueEntriesPaid(globalSupabaseClient, [entry.id], true); + // Flag everything recognition just created for this source paid (the + // manager is logging money already received) — with a backdated start + // that is every back-filled month, not only the current one. + const entries = await fetchRevenueEntries(globalSupabaseClient, project.id); + const ids = entries.filter((e) => e.revenue_source_id === created.id).map((e) => e.id); + if (ids.length > 0) { + await markRevenueEntriesPaid(globalSupabaseClient, ids, true); } } setAdding(false); diff --git a/apps/mobile/src/components/projects/AddRevenueSourceForm.tsx b/apps/mobile/src/components/projects/AddRevenueSourceForm.tsx index ac52167..802c6fc 100644 --- a/apps/mobile/src/components/projects/AddRevenueSourceForm.tsx +++ b/apps/mobile/src/components/projects/AddRevenueSourceForm.tsx @@ -1,9 +1,18 @@ import { useState } from 'react'; -import { Picker, Segmented, TextField, TitledCard } from '@chrono/ui'; -import { REM_KINDS, revenueSourceLabel, type Json, type RemKind, type RevenueSourceType } from '@chrono/sdk'; +import { DatePicker, Picker, Segmented, TextField, TitledCard } from '@chrono/ui'; +import { + RECURRENCE_FREQUENCIES, + REM_KINDS, + revenueSourceLabel, + type Json, + type RecurrenceFrequency, + type RemKind, + type RevenueSourceType, +} from '@chrono/sdk'; import { FormActions } from '@/components/common/FormActions'; import { InlineError } from '@/components/common/ErrorState'; import { useT } from '@/lib/i18n'; +import { toISODate } from '@/lib/date'; import { resolveDayRateCents, toCents, toNumber } from './AddRevenueSourceForm.lib'; import { remKindRequired } from '@/lib/rem-form.lib'; @@ -16,6 +25,10 @@ export interface AddRevenueSourceValues { /** Mark the recognized amount for this source paid immediately (default: due by client). */ markPaid: boolean; remKind: RemKind | null; + /** Recurring only: when the schedule starts applying (ISO date). */ + startsOn?: string; + /** Recurring only: when it stops (ISO date). Absent = ongoing. */ + endsOn?: string; } const TYPE_OPTIONS = (['time_based', 'recurring', 'self_billing'] as RevenueSourceType[]).map((t) => ({ @@ -54,8 +67,16 @@ export function AddRevenueSourceForm({ const [externalInvoiceId, setExternalInvoiceId] = useState(''); const [paidStatus, setPaidStatus] = useState<'due' | 'paid'>('due'); const [remKind, setRemKind] = useState(''); + const [frequency, setFrequency] = useState('monthly'); + const [startsOn, setStartsOn] = useState(new Date()); + // DatePicker has no empty state, so "ongoing" is modelled as a separate + // choice rather than a null date. + const [bounded, setBounded] = useState(false); + const [endsOn, setEndsOn] = useState(new Date()); const [error, setError] = useState(); + const isRecurring = type === 'recurring'; + const paidOptions = [ { label: t('details.dueByClient'), value: 'due' }, { label: t('details.paid'), value: 'paid' }, @@ -66,11 +87,25 @@ export function AddRevenueSourceForm({ ...REM_KINDS.map((k) => ({ label: t(`rem.kind.${k}`), value: k })), ]; - const amountLabel = type === 'recurring' ? t('comp.revsource.monthlyAmount') : t('comp.revsource.clientDayRate'); - // Recurring's "amount" is a literal monthly figure, no day-rate fallback. - const tjmCents = type === 'recurring' ? toCents(amount) : resolveDayRateCents(amount, defaultTjmCents); + const frequencyOptions = RECURRENCE_FREQUENCIES.map((f) => ({ + label: t(`comp.revsource.freq.${f}`), + value: f, + })); + + const endOptions = [ + { label: t('comp.revsource.ongoing'), value: 'ongoing' }, + { label: t('comp.revsource.untilDate'), value: 'until' }, + ]; + + // Recurring's amount is what ONE occurrence is worth; recognition multiplies + // it by the occurrences the schedule puts in each month. + const amountLabel = isRecurring + ? t('comp.revsource.amountPer', { unit: t(`comp.revsource.freqUnit.${frequency}`) }) + : t('comp.revsource.clientDayRate'); + // Recurring's "amount" is used literally, no day-rate fallback. + const tjmCents = isRecurring ? toCents(amount) : resolveDayRateCents(amount, defaultTjmCents); const amountPlaceholder = - type !== 'recurring' && defaultTjmCents ? String(Math.round(defaultTjmCents / 100)) : '500'; + !isRecurring && defaultTjmCents ? String(Math.round(defaultTjmCents / 100)) : '500'; const onDaysChange = (value: string) => { setDays(value); @@ -105,9 +140,13 @@ export function AddRevenueSourceForm({ setError(t('comp.revsource.errNegative', { label: amountLabel })); return; } + if (isRecurring && bounded && toISODate(endsOn) < toISODate(startsOn)) { + setError(t('comp.revsource.errEndsBeforeStarts')); + return; + } let content: Json; if (type === 'recurring') { - content = { monthly_amount_cents: cents }; + content = { frequency, amount_cents: cents }; } else if (type === 'self_billing') { const markupPct = Number.isFinite(parseFloat(markup.replace(',', '.'))) ? parseFloat(markup.replace(',', '.')) @@ -155,6 +194,8 @@ export function AddRevenueSourceForm({ externalInvoiceId: externalInvoiceId.trim() || undefined, markPaid: paidStatus === 'paid', remKind: remKind ? (remKind as RemKind) : null, + startsOn: isRecurring ? toISODate(startsOn) : undefined, + endsOn: isRecurring && bounded ? toISODate(endsOn) : undefined, }); }; @@ -167,6 +208,14 @@ export function AddRevenueSourceForm({ onValueChange={(v) => setType(v as RevenueSourceType)} options={TYPE_OPTIONS} /> + {isRecurring ? ( + setFrequency(v as RecurrenceFrequency)} + options={frequencyOptions} + /> + ) : null} + {isRecurring ? ( + <> + + setBounded(v === 'until')} + /> + {bounded ? ( + + ) : null} + + ) : null} {type === 'self_billing' ? ( + recognizeRevenueRange(globalSupabaseClient, projectId, from, to), + ); +} + /** Mark one or more revenue entries paid (or back to due). Manager-only (RPC). */ export function useMarkRevenueEntriesPaid() { return useAsyncAction((entryIds: string[], paid: boolean) => diff --git a/apps/mobile/src/lib/i18n/catalogs/componentsA.ts b/apps/mobile/src/lib/i18n/catalogs/componentsA.ts index c8bf2dd..290522a 100644 --- a/apps/mobile/src/lib/i18n/catalogs/componentsA.ts +++ b/apps/mobile/src/lib/i18n/catalogs/componentsA.ts @@ -181,15 +181,32 @@ export const componentsACatalog: CatalogSlice = { 'comp.revsource.title': 'Add revenue source', 'comp.revsource.type': 'Type', 'comp.revsource.markup': 'Markup %', - 'comp.revsource.monthlyAmount': 'Monthly amount', + 'comp.revsource.amountPer': 'Amount per {unit}', 'comp.revsource.clientDayRate': 'Client day rate (TJM)', 'comp.revsource.daysInvoiced': 'Days invoiced (optional)', 'comp.revsource.totalInvoiced': 'Total invoiced (optional)', 'comp.revsource.daysInvoicedSubtitle': '{days} days invoiced', 'comp.revsource.namePlaceholder': 'Monthly retainer', 'comp.revsource.addSource': 'Add source', - 'comp.revsource.monthly': 'monthly', 'comp.revsource.clientTjm': 'client TJM', + 'comp.revsource.frequency': 'Frequency', + 'comp.revsource.freq.daily': 'Daily', + 'comp.revsource.freq.weekly': 'Weekly', + 'comp.revsource.freq.biweekly': 'Every 2 weeks', + 'comp.revsource.freq.monthly': 'Monthly', + 'comp.revsource.freq.quarterly': 'Quarterly', + 'comp.revsource.freq.yearly': 'Yearly', + 'comp.revsource.freqUnit.daily': 'day', + 'comp.revsource.freqUnit.weekly': 'week', + 'comp.revsource.freqUnit.biweekly': '2 weeks', + 'comp.revsource.freqUnit.monthly': 'month', + 'comp.revsource.freqUnit.quarterly': 'quarter', + 'comp.revsource.freqUnit.yearly': 'year', + 'comp.revsource.startsOn': 'Starts on', + 'comp.revsource.endsOn': 'Ends on', + 'comp.revsource.ongoing': 'Ongoing', + 'comp.revsource.untilDate': 'Until a date', + 'comp.revsource.errEndsBeforeStarts': 'The end date cannot be before the start date', 'comp.revsource.errName': 'Enter a name', 'comp.revsource.errNegative': '{label} cannot be negative', 'comp.revsource.errMarkupMin': 'Markup % cannot be below -100', @@ -386,15 +403,32 @@ export const componentsACatalog: CatalogSlice = { 'comp.revsource.title': 'Ajouter une source de revenus', 'comp.revsource.type': 'Type', 'comp.revsource.markup': 'Marge %', - 'comp.revsource.monthlyAmount': 'Montant mensuel', + 'comp.revsource.amountPer': 'Montant par {unit}', 'comp.revsource.clientDayRate': 'TJM client', 'comp.revsource.daysInvoiced': 'Jours facturés (optionnel)', 'comp.revsource.totalInvoiced': 'Total facturé (optionnel)', 'comp.revsource.daysInvoicedSubtitle': '{days} jours facturés', 'comp.revsource.namePlaceholder': 'Forfait mensuel', 'comp.revsource.addSource': 'Ajouter la source', - 'comp.revsource.monthly': 'mensuel', 'comp.revsource.clientTjm': 'TJM client', + 'comp.revsource.frequency': 'Fréquence', + 'comp.revsource.freq.daily': 'Quotidien', + 'comp.revsource.freq.weekly': 'Hebdomadaire', + 'comp.revsource.freq.biweekly': 'Toutes les 2 semaines', + 'comp.revsource.freq.monthly': 'Mensuel', + 'comp.revsource.freq.quarterly': 'Trimestriel', + 'comp.revsource.freq.yearly': 'Annuel', + 'comp.revsource.freqUnit.daily': 'jour', + 'comp.revsource.freqUnit.weekly': 'semaine', + 'comp.revsource.freqUnit.biweekly': '2 semaines', + 'comp.revsource.freqUnit.monthly': 'mois', + 'comp.revsource.freqUnit.quarterly': 'trimestre', + 'comp.revsource.freqUnit.yearly': 'an', + 'comp.revsource.startsOn': 'Date de début', + 'comp.revsource.endsOn': 'Date de fin', + 'comp.revsource.ongoing': 'Sans fin', + 'comp.revsource.untilDate': "Jusqu'à une date", + 'comp.revsource.errEndsBeforeStarts': 'La date de fin doit suivre la date de début', 'comp.revsource.errName': 'Saisissez un nom', 'comp.revsource.errNegative': '{label} ne peut pas être négatif', 'comp.revsource.errMarkupMin': 'La marge % ne peut pas être inférieure à -100', diff --git a/apps/mobile/src/lib/i18n/catalogs/dbErrors.ts b/apps/mobile/src/lib/i18n/catalogs/dbErrors.ts index 68e4639..cd50950 100644 --- a/apps/mobile/src/lib/i18n/catalogs/dbErrors.ts +++ b/apps/mobile/src/lib/i18n/catalogs/dbErrors.ts @@ -39,6 +39,9 @@ export const dbErrorsCatalog: CatalogSlice = { 'db.company-not-found': 'Company not found.', 'db.revenue-source-not-found': 'Revenue source not found.', 'db.revenue-recognize-forbidden': 'Only a manager can recognize revenue.', + 'db.revenue-range-invalid': 'That revenue period range runs backwards.', + 'db.revenue-range-too-wide': + 'That start date is {0} months back. Pick a date within the last 10 years.', 'db.revenue-correct-forbidden': 'Only a manager can correct revenue.', 'db.revenue-paid-forbidden': 'Only a manager can mark revenue as paid.', 'db.revenue-entries-not-found': 'No matching revenue entries.', @@ -106,6 +109,9 @@ export const dbErrorsCatalog: CatalogSlice = { 'db.company-not-found': 'Entreprise introuvable.', 'db.revenue-source-not-found': 'Source de revenu introuvable.', 'db.revenue-recognize-forbidden': 'Seul un manager peut constater du revenu.', + 'db.revenue-range-invalid': 'Cette plage de périodes est inversée.', + 'db.revenue-range-too-wide': + 'Cette date de début remonte à {0} mois. Choisissez une date dans les 10 dernières années.', 'db.revenue-correct-forbidden': 'Seul un manager peut corriger un revenu.', 'db.revenue-paid-forbidden': 'Seul un manager peut marquer un revenu comme payé.', 'db.revenue-entries-not-found': 'Aucune écriture de revenu correspondante.', diff --git a/backend/supabase/migrations/20260815000000_recurring_revenue_frequency.sql b/backend/supabase/migrations/20260815000000_recurring_revenue_frequency.sql new file mode 100644 index 0000000..626a323 --- /dev/null +++ b/backend/supabase/migrations/20260815000000_recurring_revenue_frequency.sql @@ -0,0 +1,319 @@ +-- ============================================================================ +-- Recurring revenue: a real schedule (frequency + start date) +-- +-- Until now a recurring revenue source meant exactly one thing: a flat figure +-- in content.monthly_amount_cents, recognized every month, forever, starting +-- whenever the source happened to be created. revenue_sources.starts_on and +-- ends_on already existed and recognize_project_revenue already filtered on +-- them — the app simply never wrote them. +-- +-- A recurring source now carries a schedule in `content`: +-- { "frequency": "daily|weekly|biweekly|monthly|quarterly|yearly", +-- "amount_cents": } +-- anchored on revenue_sources.starts_on and bounded by revenue_sources.ends_on. +-- +-- The schedule is expanded PER MONTH, not below it. revenue_entries.period_month +-- is a hard monthly grain (partial unique index on (revenue_source_id, +-- period_month)) and invoices, rem_months, rem_lines, referral_earnings and +-- company_fee_reserve_ledger all key off it. So a weekly source does not write +-- weekly rows: recognition counts the occurrences landing inside the month and +-- writes that month's single entry. Weekly 500€ from 2026-03-11 recognizes +-- 1500€ in March (the 11th, 18th, 25th) and 2500€ in April (the 1st, 8th, 15th, +-- 22nd, 29th). `daily` counts calendar days — weekends and holidays included. +-- +-- LEGACY SOURCES ARE UNTOUCHED. No `frequency` key in content means a +-- pre-frequency source: the flat monthly_amount_cents, once a month, exactly as +-- before. Those rows also have starts_on = null, so nothing about them shifts. +-- +-- No column is added to revenue_sources. This migration adds an occurrence +-- helper, swaps the recurring branch of recognize_project_revenue to use it, +-- and adds a range wrapper so a source backdated to a past start date can +-- back-fill every month it missed in one round trip. +-- ============================================================================ + +-- ---------------------------------------------------------------------------- +-- public.recurring_occurrences_in_month +-- +-- MIRRORED IN TYPESCRIPT as `occurrencesInMonth` +-- (packages/sdk/src/revenue-source/revenue-source.lib.ts) — change both +-- together, the way project_cost_cumulative mirrors project-cost.lib.ts. +-- ---------------------------------------------------------------------------- +create or replace function public.recurring_occurrences_in_month( + p_frequency text, + p_starts_on date, + p_ends_on date, + p_period date +) +returns integer +language plpgsql +immutable +set search_path = '' +as $$ +declare + v_month_start date := date_trunc('month', p_period)::date; + v_month_end date := (date_trunc('month', p_period) + interval '1 month - 1 day')::date; + v_win_start date; + v_win_end date; + v_step integer; + v_first_k integer; + v_last_k integer; + v_cycle integer; + v_elapsed integer; + v_occurrence date; +begin + -- A frequency with no anchor cannot be expanded. Callers treat 0 as "this + -- helper does not apply" and fall back to the flat monthly reading. + if p_frequency is null or p_starts_on is null then + return 0; + end if; + + -- Intersect the schedule's own window with the month. + v_win_start := greatest(p_starts_on, v_month_start); + v_win_end := least(coalesce(p_ends_on, v_month_end), v_month_end); + if v_win_start > v_win_end then + return 0; + end if; + + if p_frequency = 'daily' then + return (v_win_end - v_win_start) + 1; + end if; + + if p_frequency in ('weekly', 'biweekly') then + v_step := case p_frequency when 'weekly' then 7 else 14 end; + -- Occurrences are starts_on + step*k; count the k landing in the window. + v_first_k := greatest(0, ceil((v_win_start - p_starts_on)::numeric / v_step)::integer); + v_last_k := floor((v_win_end - p_starts_on)::numeric / v_step)::integer; + return greatest(0, v_last_k - v_first_k + 1); + end if; + + v_cycle := case p_frequency + when 'monthly' then 1 + when 'quarterly' then 3 + when 'yearly' then 12 + else 1 + end; + v_elapsed := + (extract(year from v_month_start)::integer - extract(year from p_starts_on)::integer) * 12 + + (extract(month from v_month_start)::integer - extract(month from p_starts_on)::integer); + if v_elapsed < 0 or v_elapsed % v_cycle <> 0 then + return 0; + end if; + + -- Anchor on the start day, clamped to this month's length: a 31st anchor + -- lands on Feb 28 (29 in a leap year), same clamp as holidayDatesForYear. + v_occurrence := v_month_start + + least( + extract(day from p_starts_on)::integer, + extract(day from v_month_end)::integer + ) - 1; + + if v_occurrence >= v_win_start and v_occurrence <= v_win_end then + return 1; + end if; + return 0; +end; +$$; + +grant execute on function public.recurring_occurrences_in_month(text, date, date, date) to authenticated; + +-- ---------------------------------------------------------------------------- +-- public.recognize_project_revenue +-- +-- Unchanged from 20260813000000_error_message_slugs.sql except the recurring +-- branch, which now expands the schedule instead of reading a flat monthly +-- figure. An off-cycle month yields 0 occurrences -> v_amount = 0 -> the +-- existing "zero means retire any auto row" branch below fires, so a quarterly +-- source correctly writes nothing in its two idle months. +-- ---------------------------------------------------------------------------- +create or replace function public.recognize_project_revenue( + p_project_id uuid, + p_period date +) +returns void +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_company_id uuid; + v_hours_per_day numeric; + v_period date := date_trunc('month', p_period)::date; + v_src record; + v_billable_minutes integer; + v_billable_days numeric; + v_amount bigint; + v_client_tjm integer; + v_markup numeric; + v_freq text; +begin + select company_id, hours_per_day into v_company_id, v_hours_per_day + from public.projects where id = p_project_id; + + if v_company_id is null then + raise exception 'project-not-found'; + end if; + + if not public.is_company_manager(v_company_id) then + raise exception 'revenue-recognize-forbidden'; + end if; + + for v_src in + select * from public.revenue_sources + where project_id = p_project_id + and company_id = v_company_id + and active = true + and deleted = false + and (starts_on is null or starts_on <= (v_period + interval '1 month - 1 day')::date) + and (ends_on is null or ends_on >= v_period) + loop + if v_src.type = 'recurring' then + v_freq := v_src.content ->> 'frequency'; + if v_freq is null then + -- Legacy source: flat monthly figure, no schedule to expand. + v_amount := coalesce((v_src.content ->> 'monthly_amount_cents')::bigint, 0); + elsif v_src.starts_on is null then + -- A frequency with no anchor cannot be expanded. The form always writes + -- starts_on alongside a frequency, so this only guards hand-edited or + -- imported rows; pay one occurrence a month rather than silently paying + -- zero. Mirrors the same guard in `recurringRevenue`. + v_amount := coalesce((v_src.content ->> 'amount_cents')::bigint, 0); + else + v_amount := coalesce((v_src.content ->> 'amount_cents')::bigint, 0) + * public.recurring_occurrences_in_month( + v_freq, v_src.starts_on, v_src.ends_on, v_period + ); + end if; + elsif v_src.type = 'time_based' and (v_src.content ? 'manual_amount_cents') then + v_amount := coalesce((v_src.content ->> 'manual_amount_cents')::bigint, 0); + else + select coalesce(sum(duration_minutes), 0) into v_billable_minutes + from public.time_entries + where project_id = p_project_id + and company_id = v_company_id + and billable = true + and status = 'approved' + and deleted = false + and entry_date >= v_period + and entry_date < (v_period + interval '1 month')::date; + + v_billable_days := v_billable_minutes::numeric / (v_hours_per_day * 60); + v_client_tjm := coalesce((v_src.content ->> 'client_tjm_cents')::integer, 0); + v_amount := round(v_billable_days * v_client_tjm); + + if v_src.type = 'self_billing' then + v_markup := coalesce((v_src.content ->> 'markup_pct')::numeric, 0); + v_amount := round(v_amount * (1 + v_markup / 100)); + end if; + end if; + + -- Auto recognition never invents negatives; zero means retire any auto row. + if v_amount < 0 then v_amount := 0; end if; + + if v_amount = 0 then + update public.revenue_entries + set deleted = true, updated_at = now() + where revenue_source_id = v_src.id + and period_month = v_period + and auto_generated = true + and deleted = false; + else + insert into public.revenue_entries + (project_id, company_id, revenue_source_id, type, period_month, amount_cents, auto_generated) + values + (p_project_id, v_company_id, v_src.id, v_src.type, v_period, v_amount, true) + on conflict (revenue_source_id, period_month) + where (auto_generated and not deleted) + do update set + amount_cents = excluded.amount_cents, + updated_at = now() + where public.revenue_entries.auto_generated = true + and public.revenue_entries.deleted = false; + end if; + end loop; + + -- Retire auto-generated revenue for THIS month whose source is no longer + -- active/in-window. Corrections (auto_generated = false) are left untouched. + -- Skip autos that still have a live correction for the same source×month — + -- soft-deleting only the positive leg would orphan the negative and either + -- understate revenue or trip the net-non-negative trigger. + update public.revenue_entries re + set deleted = true, updated_at = now() + where re.project_id = p_project_id + and re.period_month = v_period + and re.deleted = false + and re.auto_generated = true + and not exists ( + select 1 from public.revenue_sources rs + where rs.id = re.revenue_source_id + and rs.active = true and rs.deleted = false + and (rs.starts_on is null or rs.starts_on <= (v_period + interval '1 month - 1 day')::date) + and (rs.ends_on is null or rs.ends_on >= v_period) + ) + and not exists ( + select 1 from public.revenue_entries corr + where corr.revenue_source_id = re.revenue_source_id + and corr.period_month = re.period_month + and corr.deleted = false + and corr.auto_generated = false + and corr.amount_cents < 0 + ); +end; +$$; + +grant execute on function public.recognize_project_revenue(uuid, date) to authenticated; + +-- ---------------------------------------------------------------------------- +-- public.recognize_project_revenue_range +-- +-- Recognize every month from p_from to p_to inclusive (both snapped to their +-- first day). A source backdated to a past start date has to fill in the months +-- it missed: recognize_project_revenue only ever handles the ONE month it is +-- given, so without this the client would fire N sequential RPCs. One call, +-- one transaction instead. +-- +-- The manager check is inherited from recognize_project_revenue, which raises +-- revenue-recognize-forbidden on the first month. +-- ---------------------------------------------------------------------------- +create or replace function public.recognize_project_revenue_range( + p_project_id uuid, + p_from date, + p_to date +) +returns void +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_from date := date_trunc('month', p_from)::date; + v_to date := date_trunc('month', p_to)::date; + v_months integer; + v_month date; +begin + if p_from is null or p_to is null then + raise exception 'revenue-range-invalid'; + end if; + + if v_to < v_from then + raise exception 'revenue-range-invalid'; + end if; + + v_months := + (extract(year from v_to)::integer - extract(year from v_from)::integer) * 12 + + (extract(month from v_to)::integer - extract(month from v_from)::integer) + 1; + + -- Ten years of back-fill is already far past anything a real contract needs; + -- beyond that the caller has almost certainly mistyped a start date. + if v_months > 120 then + raise exception 'revenue-range-too-wide:%', v_months; + end if; + + v_month := v_from; + while v_month <= v_to loop + perform public.recognize_project_revenue(p_project_id, v_month); + v_month := (v_month + interval '1 month')::date; + end loop; +end; +$$; + +grant execute on function public.recognize_project_revenue_range(uuid, date, date) to authenticated; diff --git a/backend/supabase/tests/database/080-recurring_revenue_frequency.test.sql b/backend/supabase/tests/database/080-recurring_revenue_frequency.test.sql new file mode 100644 index 0000000..1ae28ee --- /dev/null +++ b/backend/supabase/tests/database/080-recurring_revenue_frequency.test.sql @@ -0,0 +1,331 @@ +-- Recurring revenue schedules: occurrence counting, month-by-month recognition, +-- re-recognition, retirement when a schedule shrinks, the range back-fill and +-- its authorization, and the legacy flat-monthly path staying untouched. +begin; +select plan(32); + +create temporary table rev_freq_ctx ( + admin uuid, + outsider uuid, + company uuid, + project uuid, + src_weekly uuid, + src_quarterly uuid, + src_legacy uuid, + src_daily uuid, + src_anchorless uuid, + src_bounded uuid +); +grant all on table rev_freq_ctx to authenticated, anon; + +do $$ +declare + v_admin uuid := gen_random_uuid(); + v_outsider uuid := gen_random_uuid(); + v_company uuid; + v_project uuid; + v_weekly uuid; + v_quarterly uuid; + v_legacy uuid; + v_daily uuid; + v_anchorless uuid; + v_bounded uuid; +begin + perform tests.create_auth_user(v_admin, 'revfreq-admin@example.com'); + -- Belongs to no company: used to prove the range wrapper inherits the + -- manager gate rather than quietly running for anyone. + perform tests.create_auth_user(v_outsider, 'revfreq-outsider@example.com'); + v_company := tests.make_company(v_admin, 'revfreq-co-' || substr(v_admin::text, 1, 8)); + + insert into public.projects (company_id, name, rem_policy, hours_per_day, created_by) + values (v_company, 'Schedules', 'staffing', 8, v_admin) + returning id into v_project; + + -- 500€ every week from Wednesday 2026-03-11. + insert into public.revenue_sources + (company_id, project_id, type, name, content, starts_on, created_by) + values + (v_company, v_project, 'recurring', 'Weekly retainer', + jsonb_build_object('frequency', 'weekly', 'amount_cents', 50000), + '2026-03-11', v_admin) + returning id into v_weekly; + + -- 3000€ every quarter from 2026-03-15. + insert into public.revenue_sources + (company_id, project_id, type, name, content, starts_on, created_by) + values + (v_company, v_project, 'recurring', 'Quarterly licence', + jsonb_build_object('frequency', 'quarterly', 'amount_cents', 300000), + '2026-03-15', v_admin) + returning id into v_quarterly; + + -- Pre-frequency source: flat monthly figure, no schedule, no starts_on. + insert into public.revenue_sources + (company_id, project_id, type, name, content, created_by) + values + (v_company, v_project, 'recurring', 'Legacy retainer', + jsonb_build_object('monthly_amount_cents', 100000), v_admin) + returning id into v_legacy; + + -- 10€ every calendar day from 2026-03-11. + insert into public.revenue_sources + (company_id, project_id, type, name, content, starts_on, created_by) + values + (v_company, v_project, 'recurring', 'Daily retainer', + jsonb_build_object('frequency', 'daily', 'amount_cents', 1000), + '2026-03-11', v_admin) + returning id into v_daily; + + -- Hand-edited / imported shape: a frequency with no anchor to expand it. + insert into public.revenue_sources + (company_id, project_id, type, name, content, created_by) + values + (v_company, v_project, 'recurring', 'Anchorless retainer', + jsonb_build_object('frequency', 'weekly', 'amount_cents', 70000), v_admin) + returning id into v_anchorless; + + -- 200€ weekly for March only. + insert into public.revenue_sources + (company_id, project_id, type, name, content, starts_on, ends_on, created_by) + values + (v_company, v_project, 'recurring', 'Bounded retainer', + jsonb_build_object('frequency', 'weekly', 'amount_cents', 20000), + '2026-03-01', '2026-03-31', v_admin) + returning id into v_bounded; + + insert into rev_freq_ctx values ( + v_admin, v_outsider, v_company, v_project, + v_weekly, v_quarterly, v_legacy, v_daily, v_anchorless, v_bounded + ); +end $$; + +-- --------------------------------------------------------------------------- +-- 1-11: the occurrence helper. Mirrors the `occurrencesInMonth` table in +-- packages/sdk/src/revenue-source/revenue-source.test.ts — keep them in step. +-- --------------------------------------------------------------------------- +select is( + public.recurring_occurrences_in_month('weekly', '2026-03-11', null, '2026-03-01'), + 3, + 'weekly: Mar 11, 18, 25' +); +select is( + public.recurring_occurrences_in_month('weekly', '2026-03-11', null, '2026-04-01'), + 5, + 'weekly: Apr 1, 8, 15, 22, 29' +); +select is( + public.recurring_occurrences_in_month('biweekly', '2026-03-11', null, '2026-03-01'), + 2, + 'biweekly: Mar 11, 25' +); +select is( + public.recurring_occurrences_in_month('daily', '2026-03-11', null, '2026-03-01'), + 21, + 'daily counts calendar days, weekends included' +); +select is( + public.recurring_occurrences_in_month('monthly', '2026-01-31', null, '2026-02-01'), + 1, + 'a 31st anchor clamps to Feb 28 rather than vanishing' +); +select is( + public.recurring_occurrences_in_month('monthly', '2028-01-31', null, '2028-02-01'), + 1, + 'a 31st anchor clamps to Feb 29 in a leap year' +); +select is( + public.recurring_occurrences_in_month('quarterly', '2026-03-15', null, '2026-06-01'), + 1, + 'quarterly lands three months on' +); +select is( + public.recurring_occurrences_in_month('quarterly', '2026-03-15', null, '2026-04-01'), + 0, + 'quarterly is idle off-cycle' +); +select is( + public.recurring_occurrences_in_month('yearly', '2026-03-15', null, '2027-03-01'), + 1, + 'yearly lands twelve months on' +); +select is( + public.recurring_occurrences_in_month('monthly', '2026-05-01', null, '2026-04-01'), + 0, + 'nothing before the schedule starts' +); +select is( + public.recurring_occurrences_in_month('weekly', '2026-03-11', '2026-03-20', '2026-03-01'), + 2, + 'the end date truncates the month' +); + +-- --------------------------------------------------------------------------- +-- 12-22: recognition writes one entry per month, sized by the schedule. +-- --------------------------------------------------------------------------- +select tests.authenticate_as((select admin from rev_freq_ctx)); + +select lives_ok( + $$select public.recognize_project_revenue((select project from rev_freq_ctx), '2026-03-01')$$, + 'recognizing March succeeds' +); +select lives_ok( + $$select public.recognize_project_revenue((select project from rev_freq_ctx), '2026-04-01')$$, + 'recognizing April succeeds' +); + +select is( + (select amount_cents::text from public.revenue_entries + where revenue_source_id = (select src_weekly from rev_freq_ctx) + and period_month = '2026-03-01' and deleted = false), + '150000', + 'weekly source recognizes 3 x 500€ in March' +); +select is( + (select amount_cents::text from public.revenue_entries + where revenue_source_id = (select src_weekly from rev_freq_ctx) + and period_month = '2026-04-01' and deleted = false), + '250000', + 'weekly source recognizes 5 x 500€ in April' +); +select is( + (select amount_cents::text from public.revenue_entries + where revenue_source_id = (select src_daily from rev_freq_ctx) + and period_month = '2026-03-01' and deleted = false), + '21000', + 'daily source bills all 21 calendar days from Mar 11, weekends included' +); +select is( + (select amount_cents::text from public.revenue_entries + where revenue_source_id = (select src_quarterly from rev_freq_ctx) + and period_month = '2026-03-01' and deleted = false), + '300000', + 'quarterly source recognizes its full amount on-cycle' +); +select is( + (select count(*)::text from public.revenue_entries + where revenue_source_id = (select src_quarterly from rev_freq_ctx) + and period_month = '2026-04-01' and deleted = false), + '0', + 'quarterly source writes no entry in an off-cycle month' +); +select is( + (select string_agg(amount_cents::text, ',' order by period_month) from public.revenue_entries + where revenue_source_id = (select src_legacy from rev_freq_ctx) and deleted = false), + '100000,100000', + 'legacy flat-monthly source is untouched by the schedule work' +); +-- The TS mirror (`recurringRevenue`) pays one occurrence here. Reading +-- monthly_amount_cents instead would find nothing and silently pay zero. +select is( + (select string_agg(amount_cents::text, ',' order by period_month) from public.revenue_entries + where revenue_source_id = (select src_anchorless from rev_freq_ctx) and deleted = false), + '70000,70000', + 'a frequency with no anchor pays one occurrence a month, not zero' +); +select is( + (select amount_cents::text from public.revenue_entries + where revenue_source_id = (select src_bounded from rev_freq_ctx) + and period_month = '2026-03-01' and deleted = false), + '100000', + 'bounded source bills its 5 March occurrences' +); +select is( + (select count(*)::text from public.revenue_entries + where revenue_source_id = (select src_bounded from rev_freq_ctx) + and period_month = '2026-04-01' and deleted = false), + '0', + 'bounded source stops at its end date' +); + +-- --------------------------------------------------------------------------- +-- 23-24: re-recognizing a month is idempotent (partial unique index + upsert). +-- --------------------------------------------------------------------------- +select lives_ok( + $$select public.recognize_project_revenue((select project from rev_freq_ctx), '2026-03-01')$$, + 'March can be recognized a second time' +); +select is( + (select count(*)::text || ':' || max(amount_cents)::text from public.revenue_entries + where revenue_source_id = (select src_weekly from rev_freq_ctx) + and period_month = '2026-03-01' and deleted = false), + '1:150000', + 're-recognition updates in place, it does not duplicate' +); + +-- --------------------------------------------------------------------------- +-- 25-27: the range wrapper back-fills every month, with the right amounts. +-- --------------------------------------------------------------------------- +select lives_ok( + $$select public.recognize_project_revenue_range( + (select project from rev_freq_ctx), '2026-03-01', '2026-06-01')$$, + 'range recognition succeeds' +); +select is( + (select string_agg(to_char(period_month, 'YYYY-MM'), ',' order by period_month) + from public.revenue_entries + where revenue_source_id = (select src_weekly from rev_freq_ctx) and deleted = false), + '2026-03,2026-04,2026-05,2026-06', + 'one call back-fills every month in the range' +); +select is( + (select string_agg(amount_cents::text, ',' order by period_month) + from public.revenue_entries + where revenue_source_id = (select src_weekly from rev_freq_ctx) and deleted = false), + '150000,250000,200000,200000', + 'each back-filled month is sized by its own occurrence count' +); + +-- --------------------------------------------------------------------------- +-- 28-29: shrinking a schedule retires the months it no longer covers. +-- This is the branch that makes an off-cycle month safe; without it a source +-- that stops early would keep its already-recognized revenue on the books. +-- --------------------------------------------------------------------------- +reset role; +update public.revenue_sources +set ends_on = '2026-03-31' +where id = (select src_weekly from rev_freq_ctx); + +select tests.authenticate_as((select admin from rev_freq_ctx)); +select lives_ok( + $$select public.recognize_project_revenue_range( + (select project from rev_freq_ctx), '2026-03-01', '2026-06-01')$$, + 're-recognition after tightening the end date succeeds' +); +select is( + (select string_agg(to_char(period_month, 'YYYY-MM'), ',' order by period_month) + from public.revenue_entries + where revenue_source_id = (select src_weekly from rev_freq_ctx) and deleted = false), + '2026-03', + 'months past the new end date are retired, March survives' +); + +-- --------------------------------------------------------------------------- +-- 30-32: authorization and range guards. +-- --------------------------------------------------------------------------- +select tests.authenticate_as((select outsider from rev_freq_ctx)); +select throws_ok( + $$select public.recognize_project_revenue_range( + (select project from rev_freq_ctx), '2026-03-01', '2026-04-01')$$, + 'P0001', + 'revenue-recognize-forbidden', + 'the range wrapper inherits the manager gate' +); + +select tests.authenticate_as((select admin from rev_freq_ctx)); +select throws_ok( + $$select public.recognize_project_revenue_range( + (select project from rev_freq_ctx), '2026-06-01', '2026-03-01')$$, + 'P0001', + 'revenue-range-invalid', + 'a backwards range raises the slug, not prose' +); +select throws_ok( + $$select public.recognize_project_revenue_range( + (select project from rev_freq_ctx), '1990-01-01', '2026-03-01')$$, + 'P0001', + 'revenue-range-too-wide:435', + 'an absurd back-fill raises the slug with its month count' +); + +select * from finish(); +rollback; diff --git a/packages/sdk/src/revenue-entry/revenue-entry.lib.test.ts b/packages/sdk/src/revenue-entry/revenue-entry.lib.test.ts index 99990af..488f7d5 100644 --- a/packages/sdk/src/revenue-entry/revenue-entry.lib.test.ts +++ b/packages/sdk/src/revenue-entry/revenue-entry.lib.test.ts @@ -17,21 +17,89 @@ import { const PAID = '2026-07-01T00:00:00Z'; describe('recurringRevenue', () => { - it('reads monthly_amount_cents for recurring sources', () => { - expect( - recurringRevenue({ - type: 'recurring', - content: { monthly_amount_cents: 300000 }, - }), - ).toBe(300000); + it('multiplies the per-occurrence amount by the occurrences in the month', () => { + const weekly = { + type: 'recurring' as const, + content: { frequency: 'weekly' as const, amount_cents: 50000 }, + starts_on: '2026-03-11', + ends_on: null, + }; + // Mar 11, 18, 25 -> 3 x 500€ + expect(recurringRevenue(weekly, '2026-03-01')).toBe(150000); + // Apr 1, 8, 15, 22, 29 -> 5 x 500€ + expect(recurringRevenue(weekly, '2026-04-01')).toBe(250000); + }); + + it('recognizes nothing in an off-cycle month', () => { + const quarterly = { + type: 'recurring' as const, + content: { frequency: 'quarterly' as const, amount_cents: 300000 }, + starts_on: '2026-03-15', + ends_on: null, + }; + expect(recurringRevenue(quarterly, '2026-03-01')).toBe(300000); + expect(recurringRevenue(quarterly, '2026-04-01')).toBe(0); + expect(recurringRevenue(quarterly, '2026-06-01')).toBe(300000); + }); + + it('pays one occurrence a month when a frequency has no anchor', () => { + // Hand-edited / imported row. Paying zero here would drop the revenue + // silently; the RPC has the same guard. + const anchorless = { + type: 'recurring' as const, + content: { frequency: 'weekly' as const, amount_cents: 70000 }, + starts_on: null, + ends_on: null, + }; + expect(recurringRevenue(anchorless, '2026-03-01')).toBe(70000); + expect(recurringRevenue(anchorless, '2026-04-01')).toBe(70000); + }); + + it('stops at the end date', () => { + const bounded = { + type: 'recurring' as const, + content: { frequency: 'weekly' as const, amount_cents: 20000 }, + starts_on: '2026-03-01', + ends_on: '2026-03-31', + }; + // Mar 1, 8, 15, 22, 29 + expect(recurringRevenue(bounded, '2026-03-01')).toBe(100000); + expect(recurringRevenue(bounded, '2026-04-01')).toBe(0); + }); + + it('bills every calendar day for a daily schedule', () => { + const daily = { + type: 'recurring' as const, + content: { frequency: 'daily' as const, amount_cents: 1000 }, + starts_on: '2026-03-11', + ends_on: null, + }; + // Mar 11..31 inclusive, weekends included + expect(recurringRevenue(daily, '2026-03-01')).toBe(21000); + }); + + it('reads the flat monthly figure for a legacy source, whatever the month', () => { + const legacy = { + type: 'recurring' as const, + content: { monthly_amount_cents: 300000 }, + starts_on: null, + ends_on: null, + }; + expect(recurringRevenue(legacy, '2026-03-01')).toBe(300000); + expect(recurringRevenue(legacy, '2026-04-01')).toBe(300000); }); it('is 0 for non-recurring sources', () => { expect( - recurringRevenue({ - type: 'time_based', - content: { client_tjm_cents: 60000 }, - }), + recurringRevenue( + { + type: 'time_based', + content: { client_tjm_cents: 60000 }, + starts_on: null, + ends_on: null, + }, + '2026-03-01', + ), ).toBe(0); }); }); diff --git a/packages/sdk/src/revenue-entry/revenue-entry.lib.ts b/packages/sdk/src/revenue-entry/revenue-entry.lib.ts index cc07817..0dbc0d2 100644 --- a/packages/sdk/src/revenue-entry/revenue-entry.lib.ts +++ b/packages/sdk/src/revenue-entry/revenue-entry.lib.ts @@ -1,15 +1,34 @@ import type { RevenueSource } from '../revenue-source/revenue-source.entity'; -import { monthlyRecurringAmount } from '../revenue-source/revenue-source.lib'; +import { + occurrencesInMonth, + recurringSchedule, +} from '../revenue-source/revenue-source.lib'; import type { RevenueEntry } from './revenue-entry.entity'; // `minutesToDays` lives in ../time-entry/time-entry.lib (single source of the // hours->days->cents math); import it from there when you need it here. -/** Recognized amount (cents) for a recurring source in one month. */ +/** + * Recognized amount (cents) for a recurring source in one month: the + * per-occurrence amount times the occurrences the schedule puts in that + * month. Matches the recurring branch of the `recognize_project_revenue` RPC. + */ export function recurringRevenue( - source: Pick, + source: Pick, + periodMonthISO: string, ): number { - return monthlyRecurringAmount(source); + if (source.type !== 'recurring') return 0; + const { frequency, amountCents } = recurringSchedule(source); + // Legacy source: a flat monthly figure, recognized once every month. + if (frequency === null) return amountCents; + // A frequency with no anchor cannot be expanded. The form always writes + // `starts_on` alongside a frequency, so this only guards hand-edited rows; + // fall back to the flat monthly reading rather than silently paying zero. + if (!source.starts_on) return amountCents; + return ( + amountCents * + occurrencesInMonth(frequency, source.starts_on, source.ends_on, periodMonthISO) + ); } /** time_based: round(billableDays * clientTjmCents). Matches the DB RPC. */ diff --git a/packages/sdk/src/revenue-entry/revenue-entry.queries.ts b/packages/sdk/src/revenue-entry/revenue-entry.queries.ts index 59e3137..012c029 100644 --- a/packages/sdk/src/revenue-entry/revenue-entry.queries.ts +++ b/packages/sdk/src/revenue-entry/revenue-entry.queries.ts @@ -54,6 +54,26 @@ export async function recognizeRevenue( if (error) throw error; } +/** + * Recognize every month from `from` to `to` inclusive in one round-trip. + * `recognizeRevenue` only ever handles the single month it is given, so a + * source backdated to a past start date needs this to fill in the months it + * missed. + */ +export async function recognizeRevenueRange( + client: Client, + projectId: string, + from: string, + to: string, +): Promise { + const { error } = await client.rpc('recognize_project_revenue_range', { + p_project_id: projectId, + p_from: monthKey(from), + p_to: monthKey(to), + }); + if (error) throw error; +} + /** * Mark one or more revenue entries paid (or back to due). Manager-only, * scoped server-side to the entries' own company. Pass every due entry's id diff --git a/packages/sdk/src/revenue-source/revenue-source.entity.ts b/packages/sdk/src/revenue-source/revenue-source.entity.ts index 426b59c..decd6a4 100644 --- a/packages/sdk/src/revenue-source/revenue-source.entity.ts +++ b/packages/sdk/src/revenue-source/revenue-source.entity.ts @@ -18,9 +18,34 @@ export type TimeBasedContent = { manual_days?: number; }; -/** `type = 'recurring'` */ +export const RECURRENCE_FREQUENCIES = [ + 'daily', + 'weekly', + 'biweekly', + 'monthly', + 'quarterly', + 'yearly', +] as const; + +export type RecurrenceFrequency = (typeof RECURRENCE_FREQUENCIES)[number]; + +/** + * `type = 'recurring'` + * + * The schedule is per-occurrence: `amount_cents` is what ONE occurrence is + * worth, and recognition counts the occurrences landing inside each month + * (see `occurrencesInMonth`) to produce that month's single revenue entry. + * The source's `starts_on` / `ends_on` columns bound the schedule. + */ export type RecurringContent = { - monthly_amount_cents: number; + /** Amount of one occurrence. Written by every new source. */ + amount_cents?: number; + frequency?: RecurrenceFrequency; + /** + * Legacy (pre-frequency) sources only: a flat per-month figure with no + * schedule. Read when `frequency` is absent; never written by new code. + */ + monthly_amount_cents?: number; }; /** `type = 'self_billing'` */ diff --git a/packages/sdk/src/revenue-source/revenue-source.lib.ts b/packages/sdk/src/revenue-source/revenue-source.lib.ts index 0c91d9c..a40384f 100644 --- a/packages/sdk/src/revenue-source/revenue-source.lib.ts +++ b/packages/sdk/src/revenue-source/revenue-source.lib.ts @@ -1,5 +1,6 @@ import type { RevenueSourceType } from '../schema'; import type { + RecurrenceFrequency, RecurringContent, RevenueSource, SelfBillingContent, @@ -27,13 +28,113 @@ export function sourceClientTjm( return content.client_tjm_cents ?? 0; } -/** Fixed monthly amount for a recurring source (0 for other types). */ -export function monthlyRecurringAmount( +export type RecurringSchedule = { + /** + * `null` for legacy sources stored before frequencies existed: a flat + * monthly amount with no schedule to expand. + */ + frequency: RecurrenceFrequency | null; + /** Amount of ONE occurrence (for a legacy source, the flat monthly amount). */ + amountCents: number; +}; + +/** + * The schedule configured on a recurring source, with the legacy fallback + * resolved in one place: no `frequency` in `content` means a pre-frequency + * source that pays `monthly_amount_cents` once every month. + */ +export function recurringSchedule( source: Pick, -): number { - if (source.type !== 'recurring') return 0; +): RecurringSchedule { + if (source.type !== 'recurring') return { frequency: null, amountCents: 0 }; const content = (source.content ?? {}) as RecurringContent; - return content.monthly_amount_cents ?? 0; + if (content.frequency == null) { + return { frequency: null, amountCents: content.monthly_amount_cents ?? 0 }; + } + return { frequency: content.frequency, amountCents: content.amount_cents ?? 0 }; +} + +function parseISO(dateISO: string): Date { + return new Date(`${dateISO.slice(0, 10)}T00:00:00.000Z`); +} + +/** Whole days from `from` to `to` (both UTC midnight). */ +function dayDelta(from: Date, to: Date): number { + return Math.round((to.getTime() - from.getTime()) / 86_400_000); +} + +function monthsBetween(from: Date, to: Date): number { + return ( + (to.getUTCFullYear() - from.getUTCFullYear()) * 12 + + (to.getUTCMonth() - from.getUTCMonth()) + ); +} + +/** + * `day` inside the month starting at `monthStart`, clamped to the month's + * length — a 31st anchor lands on Feb 28 (29 in a leap year). Same clamp as + * `holidayDatesForYear` in ../company-holiday/company-holiday.lib. + */ +function anchorDayInMonth(monthStart: Date, day: number): Date { + const year = monthStart.getUTCFullYear(); + const month = monthStart.getUTCMonth(); + const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + return new Date(Date.UTC(year, month, Math.min(day, lastDay))); +} + +const CYCLE_MONTHS: Partial> = { + monthly: 1, + quarterly: 3, + yearly: 12, +}; + +/** + * How many occurrences of a recurring schedule land inside one month. + * + * The schedule is anchored on `startsOnISO` and bounded by `endsOnISO` + * (null = ongoing); `daily` counts calendar days, weekends and holidays + * included. Multiplying this by the per-occurrence amount gives the month's + * recognized revenue. + * + * MIRRORED IN SQL as `public.recurring_occurrences_in_month` + * (backend/supabase/migrations/20260815000000_recurring_revenue_frequency.sql) + * — change both together. + */ +export function occurrencesInMonth( + frequency: RecurrenceFrequency, + startsOnISO: string, + endsOnISO: string | null | undefined, + periodMonthISO: string, +): number { + const [year, month] = periodMonthISO.slice(0, 7).split('-').map(Number); + const monthStart = new Date(Date.UTC(year, (month ?? 1) - 1, 1)); + const monthEnd = new Date(Date.UTC(year, month ?? 1, 0)); + + const start = parseISO(startsOnISO); + const end = endsOnISO ? parseISO(endsOnISO) : null; + + // Intersect the schedule's own window with the month. + const winStart = start > monthStart ? start : monthStart; + const winEnd = end && end < monthEnd ? end : monthEnd; + if (winStart > winEnd) return 0; + + if (frequency === 'daily') { + return dayDelta(winStart, winEnd) + 1; + } + + if (frequency === 'weekly' || frequency === 'biweekly') { + const step = frequency === 'weekly' ? 7 : 14; + // Occurrences are start + step*k; count the k landing in the window. + const firstK = Math.max(0, Math.ceil(dayDelta(start, winStart) / step)); + const lastK = Math.floor(dayDelta(start, winEnd) / step); + return Math.max(0, lastK - firstK + 1); + } + + const cycle = CYCLE_MONTHS[frequency] ?? 1; + const elapsed = monthsBetween(start, monthStart); + if (elapsed < 0 || elapsed % cycle !== 0) return 0; + const occurrence = anchorDayInMonth(monthStart, start.getUTCDate()); + return occurrence >= winStart && occurrence <= winEnd ? 1 : 0; } /** @@ -58,11 +159,16 @@ export function sourceManualDays( return content.manual_days; } -/** Headline configured amount for a source (content), before entry corrections. */ +/** + * Headline configured amount for a source (content), before entry + * corrections. For a recurring source this is the per-occurrence amount — + * what one occurrence is worth, not what a given month recognizes (that is + * `recurringRevenue`, which needs a period). + */ export function sourceHeadlineAmount( source: Pick, ): number { - if (source.type === 'recurring') return monthlyRecurringAmount(source); + if (source.type === 'recurring') return recurringSchedule(source).amountCents; const manual = sourceManualAmount(source); if (manual != null) return manual; return sourceClientTjm(source); diff --git a/packages/sdk/src/revenue-source/revenue-source.test.ts b/packages/sdk/src/revenue-source/revenue-source.test.ts index 32d986c..a4a916d 100644 --- a/packages/sdk/src/revenue-source/revenue-source.test.ts +++ b/packages/sdk/src/revenue-source/revenue-source.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest'; import type { RevenueSource } from './revenue-source.entity'; import { - monthlyRecurringAmount, + occurrencesInMonth, + recurringSchedule, revenueSourceInactive, revenueSourceLabel, sourceClientTjm, @@ -52,26 +53,93 @@ describe('sourceClientTjm', () => { }); }); -describe('monthlyRecurringAmount', () => { - it('reads monthly_amount_cents for recurring sources', () => { +describe('recurringSchedule', () => { + it('reads frequency + per-occurrence amount from a scheduled source', () => { expect( - monthlyRecurringAmount(src('recurring', { monthly_amount_cents: 300000 })), - ).toBe(300000); + recurringSchedule(src('recurring', { frequency: 'weekly', amount_cents: 50000 })), + ).toEqual({ frequency: 'weekly', amountCents: 50000 }); }); - it('is 0 for non-recurring sources', () => { + it('falls back to the flat monthly figure for a legacy source', () => { expect( - monthlyRecurringAmount(src('time_based', { client_tjm_cents: 60000 })), - ).toBe(0); - expect( - monthlyRecurringAmount(src('self_billing', { client_tjm_cents: 60000 })), - ).toBe(0); + recurringSchedule(src('recurring', { monthly_amount_cents: 300000 })), + ).toEqual({ frequency: null, amountCents: 300000 }); + }); + + it('is empty for non-recurring sources', () => { + expect(recurringSchedule(src('time_based', { client_tjm_cents: 60000 }))).toEqual({ + frequency: null, + amountCents: 0, + }); + expect(recurringSchedule(src('self_billing', { client_tjm_cents: 60000 }))).toEqual({ + frequency: null, + amountCents: 0, + }); }); it('is 0 when content is null / empty / missing the key', () => { - expect(monthlyRecurringAmount(src('recurring', null))).toBe(0); - expect(monthlyRecurringAmount(src('recurring', {}))).toBe(0); - expect(monthlyRecurringAmount(src('recurring', { other: 1 }))).toBe(0); + expect(recurringSchedule(src('recurring', null)).amountCents).toBe(0); + expect(recurringSchedule(src('recurring', {})).amountCents).toBe(0); + expect(recurringSchedule(src('recurring', { other: 1 })).amountCents).toBe(0); + }); +}); + +describe('occurrencesInMonth', () => { + // 2026-03-11 is a Wednesday; March 2026 has 31 days, April 30, and 2026 is + // not a leap year. + it('counts weekly occurrences from the anchor', () => { + // Mar 11, 18, 25 + expect(occurrencesInMonth('weekly', '2026-03-11', null, '2026-03-01')).toBe(3); + // Apr 1, 8, 15, 22, 29 + expect(occurrencesInMonth('weekly', '2026-03-11', null, '2026-04-01')).toBe(5); + }); + + it('counts biweekly occurrences from the anchor', () => { + // Mar 11, 25 + expect(occurrencesInMonth('biweekly', '2026-03-11', null, '2026-03-01')).toBe(2); + // Apr 8, 22 + expect(occurrencesInMonth('biweekly', '2026-03-11', null, '2026-04-01')).toBe(2); + }); + + it('counts calendar days for daily, weekends included', () => { + // Mar 11..31 inclusive + expect(occurrencesInMonth('daily', '2026-03-11', null, '2026-03-01')).toBe(21); + // A whole month once the schedule has started + expect(occurrencesInMonth('daily', '2026-03-11', null, '2026-04-01')).toBe(30); + }); + + it('gives a monthly schedule one occurrence per month from the start', () => { + expect(occurrencesInMonth('monthly', '2026-03-15', null, '2026-03-01')).toBe(1); + expect(occurrencesInMonth('monthly', '2026-03-15', null, '2026-04-01')).toBe(1); + }); + + it('clamps a month-end anchor to shorter months', () => { + // A 31st anchor lands on Feb 28 in a non-leap year, not nowhere. + expect(occurrencesInMonth('monthly', '2026-01-31', null, '2026-02-01')).toBe(1); + // ...and on Feb 29 in a leap year. + expect(occurrencesInMonth('monthly', '2028-01-31', null, '2028-02-01')).toBe(1); + }); + + it('skips off-cycle months for quarterly and yearly', () => { + expect(occurrencesInMonth('quarterly', '2026-03-15', null, '2026-03-01')).toBe(1); + expect(occurrencesInMonth('quarterly', '2026-03-15', null, '2026-04-01')).toBe(0); + expect(occurrencesInMonth('quarterly', '2026-03-15', null, '2026-06-01')).toBe(1); + expect(occurrencesInMonth('yearly', '2026-03-15', null, '2026-03-01')).toBe(1); + expect(occurrencesInMonth('yearly', '2026-03-15', null, '2026-04-01')).toBe(0); + expect(occurrencesInMonth('yearly', '2026-03-15', null, '2027-03-01')).toBe(1); + }); + + it('is 0 before the schedule starts', () => { + expect(occurrencesInMonth('monthly', '2026-05-01', null, '2026-04-01')).toBe(0); + expect(occurrencesInMonth('weekly', '2026-05-01', null, '2026-04-01')).toBe(0); + expect(occurrencesInMonth('daily', '2026-05-01', null, '2026-04-01')).toBe(0); + }); + + it('truncates at the end date', () => { + // Mar 11, 18 — the 25th is past the end. + expect(occurrencesInMonth('weekly', '2026-03-11', '2026-03-20', '2026-03-01')).toBe(2); + expect(occurrencesInMonth('daily', '2026-03-01', '2026-03-10', '2026-03-01')).toBe(10); + expect(occurrencesInMonth('monthly', '2026-03-15', '2026-03-31', '2026-04-01')).toBe(0); }); }); @@ -114,7 +182,13 @@ describe('sourceManualDays', () => { }); describe('sourceHeadlineAmount', () => { - it('uses monthly amount for recurring', () => { + it('uses the per-occurrence amount for recurring', () => { + expect( + sourceHeadlineAmount(src('recurring', { frequency: 'weekly', amount_cents: 50000 })), + ).toBe(50000); + }); + + it('uses the flat monthly amount for a legacy recurring source', () => { expect(sourceHeadlineAmount(src('recurring', { monthly_amount_cents: 300000 }))).toBe(300000); }); diff --git a/packages/sdk/src/schema.ts b/packages/sdk/src/schema.ts index 2bec94f..1746dc4 100644 --- a/packages/sdk/src/schema.ts +++ b/packages/sdk/src/schema.ts @@ -1018,6 +1018,19 @@ export type Database = { Args: { p_project_id: string; p_period: string }; Returns: undefined; }; + recognize_project_revenue_range: { + Args: { p_project_id: string; p_from: string; p_to: string }; + Returns: undefined; + }; + recurring_occurrences_in_month: { + Args: { + p_frequency: string; + p_starts_on: string; + p_ends_on: string | null; + p_period: string; + }; + Returns: number; + }; correct_revenue_source: { Args: { p_source_id: string }; Returns: undefined;