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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/mobile/src/app/(app)/project/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
canManage,
companyCurrency,
costCumulative,
monthlyRecurringAmount,
recurringRevenue,
sourceClientTjm,
sourceManualAmount,
totalCostForMonth,
Expand Down Expand Up @@ -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);
Expand Down
35 changes: 22 additions & 13 deletions apps/mobile/src/app/(app)/project/[id]/revenue-sources.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
canManage,
companyCurrency,
dueRevenue,
fetchRevenueForMonth,
fetchRevenueEntries,
markRevenueEntriesPaid,
revenueEntryPaid,
} from '@chrono/sdk';
Expand All @@ -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';
Expand All @@ -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);
Expand All @@ -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);
Expand Down
81 changes: 74 additions & 7 deletions apps/mobile/src/components/projects/AddRevenueSourceForm.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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) => ({
Expand Down Expand Up @@ -54,8 +67,16 @@ export function AddRevenueSourceForm({
const [externalInvoiceId, setExternalInvoiceId] = useState('');
const [paidStatus, setPaidStatus] = useState<'due' | 'paid'>('due');
const [remKind, setRemKind] = useState<string>('');
const [frequency, setFrequency] = useState<RecurrenceFrequency>('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<string | undefined>();

const isRecurring = type === 'recurring';

const paidOptions = [
{ label: t('details.dueByClient'), value: 'due' },
{ label: t('details.paid'), value: 'paid' },
Expand All @@ -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);
Expand Down Expand Up @@ -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(',', '.'))
Expand Down Expand Up @@ -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,
});
};

Expand All @@ -167,6 +208,14 @@ export function AddRevenueSourceForm({
onValueChange={(v) => setType(v as RevenueSourceType)}
options={TYPE_OPTIONS}
/>
{isRecurring ? (
<Picker
label={t('comp.revsource.frequency')}
value={frequency}
onValueChange={(v) => setFrequency(v as RecurrenceFrequency)}
options={frequencyOptions}
/>
) : null}
<Picker
label={t('rem.kind.label')}
value={remKind}
Expand All @@ -180,6 +229,24 @@ export function AddRevenueSourceForm({
placeholder={amountPlaceholder}
keyboardType="decimal-pad"
/>
{isRecurring ? (
<>
<DatePicker label={t('comp.revsource.startsOn')} value={startsOn} onChange={setStartsOn} />
<Segmented
options={endOptions}
value={bounded ? 'until' : 'ongoing'}
onValueChange={(v) => setBounded(v === 'until')}
/>
{bounded ? (
<DatePicker
label={t('comp.revsource.endsOn')}
value={endsOn}
onChange={setEndsOn}
minimumDate={startsOn}
/>
) : null}
</>
) : null}
{type === 'self_billing' ? (
<TextField
label={t('comp.revsource.markup')}
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/components/projects/RevenueSourceRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { StyleSheet, View } from 'react-native';
import { Badge, ListItem, Money, spacing } from '@chrono/ui';
import {
netRevenueForSource,
recurringSchedule,
revenueSourceInactive,
revenueSourceLabel,
sourceHeadlineAmount,
Expand Down Expand Up @@ -39,8 +40,10 @@ export function RevenueSourceRow({
const isRecurring = source.type === 'recurring';
const manualAmount = sourceManualAmount(source);
const manualDays = sourceManualDays(source);
// Legacy sources have no frequency stored; they were monthly by definition.
const frequency = recurringSchedule(source).frequency ?? 'monthly';
const subtitle = isRecurring
? `${revenueSourceLabel(source.type)} · ${t('comp.revsource.monthly')}`
? `${revenueSourceLabel(source.type)} · ${t(`comp.revsource.freq.${frequency}`)}`
: manualAmount != null
? `${revenueSourceLabel(source.type)} · ${t('comp.revsource.daysInvoicedSubtitle', { days: manualDays ?? 0 })}`
: `${revenueSourceLabel(source.type)} · ${t('comp.revsource.clientTjm')}`;
Expand Down
14 changes: 13 additions & 1 deletion apps/mobile/src/lib/hooks/use-revenue-entries.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { linkedQuery } from './linked-query';
import { stores } from '@/lib/supabase-stores';
import { globalSupabaseClient } from '@/lib/supabase';
import { fetchRevenueEntries, markRevenueEntriesPaid, recognizeRevenue } from '@chrono/sdk';
import {
fetchRevenueEntries,
markRevenueEntriesPaid,
recognizeRevenue,
recognizeRevenueRange,
} from '@chrono/sdk';
import type { RevenueEntry, RevenueEntryFilters } from '@chrono/sdk';
import { useAsyncAction } from './use-async-action';

Expand Down Expand Up @@ -52,6 +57,13 @@ export function useRecognizeRevenue() {
);
}

/** Recognize a project's revenue across a month range, inclusive (RPC). */
export function useRecognizeRevenueRange() {
return useAsyncAction((projectId: string, from: string, to: string) =>
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) =>
Expand Down
42 changes: 38 additions & 4 deletions apps/mobile/src/lib/i18n/catalogs/componentsA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/lib/i18n/catalogs/dbErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -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.',
Expand Down
Loading
Loading