Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
007d4c0
Messaging lands at its reserved 0475/0476; calendar dates, DST and pa…
claude Oct 5, 2026
39f958f
0475 leaves family-media's public flag to 0459
claude Oct 5, 2026
e540cc2
An all-day reminder names the date it is stored on
claude Oct 5, 2026
9da0736
Record the goal checkpoint: #970 live in production, #971's holds ans…
claude Oct 5, 2026
05ab40b
0475 leaves an empty allow-list alone, and the bucket cases are executed
claude Oct 5, 2026
7ee2e4a
Calendar navigation fixture loads the real day helpers
claude Oct 5, 2026
6ca54ea
Restart this branch on main 36a516d7c; keep superseded #971 history (…
claude Oct 9, 2026
04d1863
Family messaging works on a database without 0475/0476
claude Oct 9, 2026
4fd0b9d
Messaging without 0475: reuse direct chats, keep old retries, no archive
claude Oct 9, 2026
59bef1e
Keep the Free plan's 10 monthly AI requests working before migration …
claude Oct 9, 2026
0805b75
Point to a schema-cache reload when the 0493 quota fallback is active
claude Oct 9, 2026
fd50865
Keep Google/Microsoft/iCloud sync working before held 0494 is applied
claude Oct 9, 2026
95dfe56
Recognise a missing 0494 sync RPC only from its message and stop re-a…
claude Oct 9, 2026
b21b7cd
Calendar subscriptions refresh and can be added again before migratio…
claude Oct 9, 2026
2073db0
Before 0490, cancelled calendar events are written as production wrot…
claude Oct 9, 2026
c9067a0
Recurring bills work as before 0488 until bills.due_day exists
claude Oct 9, 2026
3ebca6a
Pre-0488 bills keep their stored cadence, ask before adding a day the…
claude Oct 9, 2026
4300411
Feed fallback order checks use at(), which fails when a needle is absent
claude Oct 9, 2026
7ad8c33
Keep family presence private unless the inbox read shows 0475 wholly …
claude Oct 9, 2026
f45accf
Messages: a legacy read receipt that did not land is not shown as read
claude Oct 9, 2026
f9fb196
Fail closed when the pre-0493 AI allowance count returns no count
claude Oct 9, 2026
1bf5647
Bills before 0488: an unknown cadence asks for its schedule, never mo…
claude Oct 9, 2026
d943047
Scope the pre-0494 sync mirror fallback to the provider account
claude Oct 9, 2026
07e025a
Keep the item when a pre-0494 sync mapping write reports an error
claude Oct 10, 2026
1d1b90c
E2E: assert the pre-0488 bill fallback instead of the reversed refusal
claude Oct 10, 2026
d7d2da0
Sync: adopt a pre-0494 orphan claim-first, fenced on owner and update…
claude Oct 10, 2026
6166881
Sync: publish a pre-0494 adoption claim pending and complete it condi…
claude Oct 10, 2026
8b78130
Sync: fence every pre-0494 adoption generation on its own token
claude Oct 10, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/messaging-verification.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
- name: Participant boundaries and independent-session races
run: node scripts/verify-messaging-database.mjs --bin /usr/lib/postgresql/16/bin
- name: Messaging service and thread state contracts
run: npx vitest run tests/messages-overview.test.ts tests/messages-thread-state.test.ts tests/messaging-schema-compat.test.ts tests/service-messages.test.ts
run: npx vitest run tests/messages-overview.test.ts tests/messages-thread-state.test.ts tests/messaging-schema-compat.test.ts tests/messaging-without-0475.test.ts tests/service-messages.test.ts
- name: Install Chromium
run: npx playwright install --with-deps chromium
# Real React components with deterministic transport boundaries. Database
Expand Down
16 changes: 15 additions & 1 deletion app/(app)/dashboard/family-cfo/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { getFormat } from '@/lib/utils/format-server';
import { loadMoneyTimelineInput } from '@/lib/finance/timeline-load';
import { isCfoMissingTable, loadCfoSummaryRows } from '@/lib/finance/cfo-load';
import { buildCashflowTimeline, DEFAULT_BUFFER, money as moneyIn, pretty as prettyIn, type BuildTimelineInput, type PlanSource } from '@/lib/finance/timeline';
import { BillScheduleConfirmationRequired } from '@/lib/finance/bill-schedule';
import { EXPLAIN_MONTH_REQUEST } from '@/lib/finance/cfo-prompts';
import { addDaysToDayKey, dayKeyInTz } from '@/lib/services/scope';

Expand Down Expand Up @@ -82,7 +83,20 @@ export default async function FamilyCfoPage() {
console.error('[dashboard/family-cfo] forecast read failed', err);
return <ErrorState message={tr('familyCfo.couldNotLoadYourFamily')} />;
}
const forecast = buildCashflowTimeline(forecastInput);
// A recurring bill without a named cadence or day is not stepped on a
// guessed schedule: the page says which confirmation it needs instead.
let forecast: ReturnType<typeof buildCashflowTimeline>;
try {
forecast = buildCashflowTimeline(forecastInput);
} catch (err) {
if (!(err instanceof BillScheduleConfirmationRequired)) throw err;
return (
<div className="space-y-4">
<ErrorState message={tr('bills.scheduleForecastNeedsConfirmation')} />
<Link href="/dashboard/bills" className="inline-block rounded-xl border border-border px-4 py-3 font-semibold">{tr('bills.reviewSchedules')}</Link>
</div>
);
}
const buffer = forecastInput.buffer ?? DEFAULT_BUFFER;

const accounts = accountsRes.data;
Expand Down
30 changes: 24 additions & 6 deletions components/finance/bills-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ import { SkeletonList, EmptyState, ErrorState } from '@/components/ui/states';
import { cn } from '@/lib/utils/cn';
import type { Tables } from '@/lib/database.types';
import { usd as usdIn, billDueStatus, billPaidPatch, billDateForAnchorDay, newBillDueDay, BILL_CADENCES, DUE_META, fmtDueDate as fmtDueDateIn } from '@/lib/finance/hub';
import { BILL_READ_CONTRACT, readCompleteBills, isMissingBillDueDay, saveBillPayment } from '@/lib/finance/bills';
import { writeBillPatch } from '@/lib/finance/recurring';
import { BILL_READ_CONTRACT, readCompleteBills, isMissingBillDueDay, saveBillPayment, saveBillPaymentBefore0488 } from '@/lib/finance/bills';
import { dueDayNotKeptQuestion, isDueDayNotKept, writeBillPatch, type DueDayNotKept } from '@/lib/finance/recurring';
import { useConfirm } from '@/components/ui/confirm';
import { BillPaymentModal } from '@/components/finance/bill-payment-modal';
import { useLocale, useTranslations } from '@/components/i18n/locale-provider';
import { describeDbError, wroteNoRows } from '@/lib/supabase/errors';
Expand Down Expand Up @@ -46,6 +47,7 @@ export function BillsView({ mode }: { mode: BillsMode }) {
const fmtDueDate = (iso: string) => fmtDueDateIn(iso, locale.code);
const { familyId, userId, role } = useApp();
const { success, error: toastError } = useToast();
const askConfirm = useConfirm();
const meta = MODE_META[mode];

const { data: rows, loading, error: readError, stale, refresh } = useRealtimeQuery<Bill>({
Expand Down Expand Up @@ -97,15 +99,25 @@ export function BillsView({ mode }: { mode: BillsMode }) {
async function markPaid(b: Bill) {
if (!canWrite() || b.family_id !== familyId) return;
const reopen = b.status === 'paid';
// Without bills.due_day (0488) a clamped roll moves only if the person says yes.
const confirmClampedDay = (refusal: DueDayNotKept) => askConfirm(dueDayNotKeptQuestion(refusal, t, fmtDueDate, locale.code));
let before0488: Awaited<ReturnType<typeof saveBillPaymentBefore0488>> = null;
if (!reopen && !billPaidPatch(b, clock.todayKey())) {
const selection = { bill: b, owner: paymentOwner, ticket: ++paymentTicket.current };
currentPayment.current = selection; setPaymentSelection(selection); return;
// A row read without due_day is paid as before 0488 once the database
// confirms the column is missing; otherwise the person confirms its schedule.
before0488 = b.due_day === undefined ? await saveBillPaymentBefore0488(createClient(), familyId, b, clock.todayKey(), canWrite, { confirmClampedDay }) : null;
if (!before0488) {
if (!canWrite()) return;
const selection = { bill: b, owner: paymentOwner, ticket: ++paymentTicket.current };
currentPayment.current = selection; setPaymentSelection(selection); return;
}
}
// A restrictive RLS policy FILTERS an update/delete rather than raising, so
// a refused write returns zero rows and no error. `.select('id')` is what
// makes the difference visible — without it `data` is null either way.
const { data: rows, error } = await saveBillPayment(createClient(), familyId, b, clock.todayKey(), undefined, reopen, canWrite);
const { data: rows, error } = before0488 ?? await saveBillPayment(createClient(), familyId, b, clock.todayKey(), undefined, reopen, canWrite, { confirmClampedDay });
if (!canWrite()) return;
if (isDueDayNotKept(error)) { toastError(t('bills.dueDayNeedsDatabaseUpdate', { day: error.day })); return; }
if (error) { toastError(isMissingBillDueDay(error) ? t('bills.scheduleUnavailable') : describeDbError(error)); return; }
if (wroteNoRows(rows)) { toastError(t('errors.thatChangeWasNotSaved')); void refresh(); return; }
success(reopen ? 'Reopened' : 'Marked paid');
Expand Down Expand Up @@ -219,6 +231,8 @@ export function BillsView({ mode }: { mode: BillsMode }) {

function BillModal({ familyId, userId, defaultAutopay, onClose, isCurrent }: { familyId: string; userId: string; defaultAutopay: boolean; onClose: () => void; isCurrent: () => boolean }) {
const t = useTranslations();
const locale = useLocale();
const askConfirm = useConfirm();
const { family } = useApp();
const { success, error: toastError } = useToast();
const alive = useRef(true);
Expand All @@ -243,8 +257,12 @@ function BillModal({ familyId, userId, defaultAutopay, onClose, isCurrent }: { f
due_date: v.due_date, category: v.category, is_recurring: v.is_recurring, autopay: v.autopay,
recurrence: v.is_recurring ? v.recurrence : null, ...(dueDay !== null ? { due_day: dueDay } : {}),
status: 'upcoming' as const, created_by: userId,
}, p => alive.current && isCurrent() ? createClient().from('bills').insert(p) : Promise.resolve({ data: null, error: new Error('Bill view changed') }));
}, p => alive.current && isCurrent() ? createClient().from('bills').insert(p) : Promise.resolve({ data: null, error: new Error('Bill view changed') }), {
// Without bills.due_day (0488) a day the first month lacks is added on its last day only if the person says yes.
confirmClampedDay: (refusal) => askConfirm(dueDayNotKeptQuestion(refusal, t, (iso) => fmtDueDateIn(iso, locale.code), locale.code, 'add')),
});
if (!alive.current || !isCurrent()) return;
if (isDueDayNotKept(error)) return;
if (error) return toastError(isMissingBillDueDay(error) ? t('bills.scheduleUnavailable') : describeDbError(error));
success(t('billsView.billAdded'));
onClose();
Expand Down
29 changes: 23 additions & 6 deletions components/modules/billing-module.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ import { cn } from '@/lib/utils/cn';
import type { Tables, SubscriptionStatus, AccountType, TransactionType, BudgetPeriod, BillStatus } from '@/lib/database.types';
import { categoryLabel } from '@/lib/finance/category-label';
import { billPaidPatch, billDateForAnchorDay, newBillDueDay } from '@/lib/finance/hub';
import { BILL_READ_CONTRACT, readCompleteBills, isMissingBillDueDay, saveBillPayment } from '@/lib/finance/bills';
import { BILL_READ_CONTRACT, readCompleteBills, isMissingBillDueDay, saveBillPayment, saveBillPaymentBefore0488 } from '@/lib/finance/bills';
import { BillScheduleModal } from '@/components/finance/bill-schedule-modal';
import { writeBillPatch } from '@/lib/finance/recurring';
import { dueDayNotKeptQuestion, isDueDayNotKept, writeBillPatch, type DueDayNotKept } from '@/lib/finance/recurring';
import { BillPaymentModal } from '@/components/finance/bill-payment-modal';
import { useLocale, useTranslations } from '@/components/i18n/locale-provider';
import { FamilyDeliveredValue } from '@/components/billing/family-delivered-value';
Expand Down Expand Up @@ -449,6 +449,9 @@ function AddBillModal({ open, onClose, familyId, userId, onDone, isCurrent }: {
open: boolean; onClose: () => void; familyId: string; userId: string; onDone: () => void; isCurrent: () => boolean;
}) {
const tr = useTranslations();
const { fmtDate } = useFormat();
const locale = useLocale();
const askConfirm = useConfirm();
const { success, error: toastError } = useToast();
const alive = useRef(true);
const inFlight = useRef(false);
Expand Down Expand Up @@ -479,8 +482,12 @@ function AddBillModal({ open, onClose, familyId, userId, onDone, isCurrent }: {
is_recurring: isRecurring, recurrence: isRecurring ? recurrence : null,
...(dueDay !== null ? { due_day: dueDay } : {}),
status: 'upcoming' as const, category,
}, p => alive.current && isCurrent() ? supabase.from('bills').insert(p) : Promise.resolve({ data: null, error: new Error('Bill view changed') }));
}, p => alive.current && isCurrent() ? supabase.from('bills').insert(p) : Promise.resolve({ data: null, error: new Error('Bill view changed') }), {
// Without bills.due_day (0488) a day the first month lacks is added on its last day only if the person says yes.
confirmClampedDay: (refusal) => askConfirm(dueDayNotKeptQuestion(refusal, tr, fmtDate, locale.code, 'add')),
});
if (!alive.current || !isCurrent()) return;
if (isDueDayNotKept(error)) return;
if (error) return toastError(isMissingBillDueDay(error) ? tr('bills.scheduleUnavailable') : describeDbError(error));
success(tr('billingModule.billAdded'));
reset(); onDone(); onClose();
Expand Down Expand Up @@ -1025,13 +1032,23 @@ export function BillingModule({ serviceFeeNotice = null }: { serviceFeeNotice?:

async function markBillPaid(bill: Bill) {
if (!canWrite() || bill.family_id !== familyId) return;
// Without bills.due_day (0488) a clamped roll moves only if the person says yes.
const confirmClampedDay = (refusal: DueDayNotKept) => askConfirm(dueDayNotKeptQuestion(refusal, tr, fmtDate, locale.code));
let before0488: Awaited<ReturnType<typeof saveBillPaymentBefore0488>> = null;
if (!billPaidPatch(bill, clock.todayKey())) {
const selection = { bill, owner: paymentOwner, ticket: ++paymentTicket.current };
currentPayment.current = selection; setPaymentSelection(selection); return;
// A row read without due_day is paid as before 0488 once the database
// confirms the column is missing; otherwise the person confirms its schedule.
before0488 = bill.due_day === undefined ? await saveBillPaymentBefore0488(createClient(), familyId, bill, clock.todayKey(), canWrite, { confirmClampedDay }) : null;
if (!before0488) {
if (!canWrite()) return;
const selection = { bill, owner: paymentOwner, ticket: ++paymentTicket.current };
currentPayment.current = selection; setPaymentSelection(selection); return;
}
}
const supabase = createClient();
const { data: rows, error } = await saveBillPayment(supabase, familyId, bill, clock.todayKey(), undefined, false, canWrite);
const { data: rows, error } = before0488 ?? await saveBillPayment(supabase, familyId, bill, clock.todayKey(), undefined, false, canWrite, { confirmClampedDay });
if (!canWrite()) return;
if (isDueDayNotKept(error)) return toastError(tr('bills.dueDayNeedsDatabaseUpdate', { day: error.day }));
if (error) return toastError(isMissingBillDueDay(error) ? tr('bills.scheduleUnavailable') : describeDbError(error));
if (wroteNoRows(rows)) { toastError(tr('errors.thatChangeWasNotSaved')); void refreshBills(); return; }
success(tr('billingModule.billMarkedAsPaid'));
Expand Down
Loading
Loading