diff --git a/.semgrep/baseline-counts.json b/.semgrep/baseline-counts.json index fa4cf540..cc8fb564 100644 --- a/.semgrep/baseline-counts.json +++ b/.semgrep/baseline-counts.json @@ -139,8 +139,8 @@ "", "PROMOTED 2026-08-11: the three Supabase error-handling rules (-discarded-result, -read-without-error, -read-without-error-fan-in) all sat at 0 and moved to chokepoints.yml, where they gate at --error across the whole tree rather than only on findings new vs. the PR base. Their keys are deleted here in the same change, per the promotion rule in .semgrep/README.md. No site was fixed in this change -- the burn-downs that got them to 0 are the 2026-08-07 and 2026-08-08 entries above; this is only the gate upgrade those burn-downs earned and never collected. Fire-checked before promoting, same protocol as -cross-tenant and -global-table: a scratch fixture with one deliberate violation per rule plus a correct control for each produced exactly 3 findings (the violations) and 0 on the controls, then was reverted. What remains in this file is the unbounded-select ladder alone: -in-list 11, -org-scoped 65, -single-parent 16." ], - "measured_at": "2026-08-12", + "measured_at": "2026-08-14", "counts": { - "fieldstay-supabase-unbounded-select-org-scoped": 59 + "fieldstay-supabase-unbounded-select-org-scoped": 58 } } diff --git a/CLAUDE.md b/CLAUDE.md index f2038598..45949a28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1321,6 +1321,18 @@ following them stops being a memory test. Five layers, checked in CI via `information_schema.columns.is_generated`, NOT against `supabase/schema_reference.sql`, which renders generated columns as plain `DEFAULT`s and is exactly why this class shipped twice. + - `absence-reconciliation` — every "delete/cancel/deactivate every local row + missing from the fetched list" site is registered with HOW it survives an + empty fetch, and no provider fetch returns `[]` from an error branch. + Empty is the degenerate input of reconcile-by-absence: it makes every row + absent. On 2026-07-18 one org's entire Hospitable crew roster was + deactivated at the same microsecond because `hospFetchTeammates` returned + `[]` on a non-ok response and the deactivation pass had no guard. The two + valid protections are NOT interchangeable — `fetch-fails-loud` where empty + is a legitimate steady state (no calendar blocks, no assignments; a guard + there would make the LAST one unclearable), `empty-set-guard` where empty + is implausible and the fetch can't be trusted. Carries a self-check that + the scan still fires. - `public-route-rate-limiting` — every prefix in `proxy.ts`'s `TOKEN_ROUTES` has a matching branch in `rateLimiterForPathname()`, and the two guessable-invite-token `BYPASS_ROUTES` entries diff --git a/app/(dashboard)/ops/ops-snapshot.tsx b/app/(dashboard)/ops/ops-snapshot.tsx index bff8d5a5..daf55289 100644 --- a/app/(dashboard)/ops/ops-snapshot.tsx +++ b/app/(dashboard)/ops/ops-snapshot.tsx @@ -17,17 +17,20 @@ const AVG_DRIVE_SPEED_MPH = 30 // ── Types ────────────────────────────────────────────────────── -interface TurnoverAssignment { +export interface TurnoverAssignment { id: string crew_member: { id: string; name: string } | { id: string; name: string }[] | null } -interface Turnover { +export interface OpsTurnover { id: string property_id: string checkout_datetime: string + /** SYNTHETIC when prev_booking_id is null. */ checkin_datetime: string window_minutes: number | null + /** Null on a standalone turnover: nothing is booked after this checkout. */ + prev_booking_id: string | null status: string priority: string notes: string | null @@ -185,7 +188,7 @@ function TurnoverCard({ turnover, propertyName, }: Readonly<{ - turnover: Turnover + turnover: OpsTurnover propertyName: string }>) { const assignments = unwrapJoinArray(turnover.turnover_assignments) @@ -224,12 +227,14 @@ function TurnoverCard({ {checkout.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} - {turnover.window_minutes && ( + {/* Only a REAL next booking makes this a window. A standalone + turnover carries the generator's placeholder checkout + 4h. */} + {turnover.prev_booking_id && turnover.window_minutes ? ( <> · {Math.floor(turnover.window_minutes / 60)}h window - )} + ) : null}
@@ -277,7 +282,7 @@ function DayAccordion({ }: Readonly<{ label: string isToday: boolean - turnovers: Turnover[] + turnovers: OpsTurnover[] propertyMap: Record crewTravel?: CrewTravelSummary[] defaultOpen: boolean @@ -386,7 +391,7 @@ function getDayLabel(day: string, todayDate: string): string { // ── Urgency sort ───────────────────────────────────────────────── -function urgencyRank(t: Turnover): number { +function urgencyRank(t: OpsTurnover): number { const unassigned = t.status === 'pending_assignment' const urgent = t.priority === 'urgent' || t.priority === 'high' if (unassigned && urgent) return 0 @@ -398,10 +403,10 @@ function urgencyRank(t: Turnover): number { // ── Crew travel summary ───────────────────────────────────────── function getCrewTravelSummaries( - turnovers: Turnover[], + turnovers: OpsTurnover[], propertyById: Record ): CrewTravelSummary[] { - const byCrew: Record = {} + const byCrew: Record = {} for (const t of turnovers) { const assignments = unwrapJoinArray(t.turnover_assignments) @@ -447,7 +452,7 @@ export function OpsSnapshot({ metrics, showPmsRevenueNudge = false, }: Readonly<{ - turnovers: Turnover[] + turnovers: OpsTurnover[] properties: Property[] openWorkOrders: WorkOrder[] lowStockItems: LowStockItem[] diff --git a/app/(dashboard)/ops/page.tsx b/app/(dashboard)/ops/page.tsx index 9449b991..360e7893 100644 --- a/app/(dashboard)/ops/page.tsx +++ b/app/(dashboard)/ops/page.tsx @@ -1,7 +1,8 @@ import { requireOrgMember } from '@/lib/auth' import { createServiceClient } from '@/lib/supabase/server' import { unwrapList, type PostgrestResult } from '@/lib/supabase/unwrap' -import { OpsSnapshot } from './ops-snapshot' +import { OpsSnapshot, type OpsTurnover } from './ops-snapshot' +import { fetchAllRows } from '@/lib/inngest/paginate' import { addDays, subDays, startOfDay, endOfDay } from 'date-fns' import type { Metadata } from 'next' @@ -53,19 +54,39 @@ export default async function OpsSnapshotPage() { monthBookingsRes, pmsConnectionsRes, ] = await Promise.all([ - supabase - .from('turnovers') - .select(` - id, property_id, checkout_datetime, checkin_datetime, - window_minutes, status, priority, notes, completed_at, started_at, - checklist_template_id, - turnover_assignments(id, crew_member_id, crew_member:crew_members(id, name)) - `) - .eq('org_id', membership.org_id) - .neq('status', 'cancelled') - .gte('checkout_datetime', rangeStart.toISOString()) - .lte('checkout_datetime', rangeEnd.toISOString()) - .order('checkout_datetime', { ascending: true }), + // DRAINED, not a single .select(), matching turnovers/page.tsx. + // + // The 31-day window looks like it bounds this, and for a small org it does + // — but the ceiling is properties x turnovers-per-property-per-month, and + // at the 50-property target a busy calendar clears PostgREST's + // max_rows = 1000 inside one window. A .limit() would not have helped: + // max_rows caps the response regardless of what the query asks for, so the + // page would silently lose the far end of its own date range and report + // KPIs over a partial month with a 200 and no signal. + // + // .order('id') is the load-bearing half. .range() is OFFSET pagination, so + // the sort must be TOTAL or consecutive pages answer different questions — + // and checkout_datetime is emphatically not unique, since a portfolio + // shares 10am checkouts across every property. Same reasoning as the + // header comment on turnovers/page.tsx. + fetchAllRows( + (from, to) => supabase + .from('turnovers') + .select(` + id, property_id, prev_booking_id, checkout_datetime, checkin_datetime, + window_minutes, status, priority, notes, completed_at, started_at, + checklist_template_id, + turnover_assignments(id, crew_member_id, crew_member:crew_members(id, name)) + `) + .eq('org_id', membership.org_id) + .neq('status', 'cancelled') + .gte('checkout_datetime', rangeStart.toISOString()) + .lte('checkout_datetime', rangeEnd.toISOString()) + .order('checkout_datetime', { ascending: true }) + .order('id') + .range(from, to), + { label: `turnovers(ops-snapshot)[org=${membership.org_id}]` }, + ), supabase .from('properties') @@ -109,7 +130,9 @@ export default async function OpsSnapshotPage() { // app/(dashboard)/ops/error.tsx — a real error state, not a dashboard of // reassuring zeroes. const ctx = { site: 'page.ops', orgId: membership.org_id } - const allTurnovers = unwrapList(turnoversRes, { ...ctx, extra: { query: 'turnovers' } }) + // fetchAllRows already logs, reports and throws on a failed page, so this + // one is a plain array rather than a PostgrestResult to unwrap. + const allTurnovers = turnoversRes const properties = unwrapList(propertiesRes, { ...ctx, extra: { query: 'properties' } }) const openWorkOrders = unwrapList(openWOsRes, { ...ctx, extra: { query: 'work_orders' } }) const lowStockItems = unwrapList(belowParRes as PostgrestResult, { ...ctx, extra: { query: 'below_par' } }) diff --git a/app/(dashboard)/turnovers/[id]/page.tsx b/app/(dashboard)/turnovers/[id]/page.tsx index eda46519..60f36d0b 100644 --- a/app/(dashboard)/turnovers/[id]/page.tsx +++ b/app/(dashboard)/turnovers/[id]/page.tsx @@ -21,7 +21,7 @@ export default async function TurnoverDetailPage({ params }: Props) { const { data: turnover, error: turnoverError } = await supabase .from('turnovers') .select(` - id, property_id, checkout_datetime, checkin_datetime, + id, property_id, prev_booking_id, checkout_datetime, checkin_datetime, window_minutes, status, priority, notes, completion_notes, completed_at, auto_generated, checklist_template_id, bookings!booking_id ( guest_name, checkin_date, checkout_date, source ), @@ -134,9 +134,13 @@ export default async function TurnoverDetailPage({ params }: Props) {

Checkout

{formatDateTime(turnover.checkout_datetime)}

+ {/* Standalone turnover (no outgoing booking): checkin_datetime is + the generator's invented checkout + 4h, not a real arrival. */}

Next Check-in

-

{formatDateTime(turnover.checkin_datetime)}

+

+ {turnover.prev_booking_id ? formatDateTime(turnover.checkin_datetime) : 'No next booking'} +

diff --git a/app/(dashboard)/turnovers/page.tsx b/app/(dashboard)/turnovers/page.tsx index 3d169266..e06119e9 100644 --- a/app/(dashboard)/turnovers/page.tsx +++ b/app/(dashboard)/turnovers/page.tsx @@ -9,7 +9,7 @@ import { reportError } from '@/lib/observability/report-error' export const metadata: Metadata = { title: 'Turnovers' } const TURNOVER_COLUMNS = ` - id, property_id, booking_id, checkout_datetime, checkin_datetime, + id, property_id, booking_id, prev_booking_id, checkout_datetime, checkin_datetime, window_minutes, status, priority, notes, completed_at, started_at, crew_duration_minutes, checklist_template_id, is_same_day_turnover, is_archived, diff --git a/app/(dashboard)/turnovers/turnover-board.tsx b/app/(dashboard)/turnovers/turnover-board.tsx index 2f91f4ab..7da44a10 100644 --- a/app/(dashboard)/turnovers/turnover-board.tsx +++ b/app/(dashboard)/turnovers/turnover-board.tsx @@ -71,7 +71,18 @@ interface Turnover { // `=== 'owner_stay'` check below is unaffected by the wider type. stay_type: string | null checkout_datetime: string + /** + * SYNTHETIC when prev_booking_id is null — see the render below. The + * generator's standalone pass invents checkout + 4h so the turnover has a + * working window; there is no guest arriving at that time. + */ checkin_datetime: string + /** + * The OUTGOING booking on a paired turnover, and null on a standalone one. + * That null is the only signal that checkin_datetime was invented rather + * than read off a real arrival. + */ + prev_booking_id: string | null window_minutes: number | null status: string priority: string @@ -346,6 +357,14 @@ function TurnoverCard({ const isOverdue = isPast(checkout) && turnover.status !== 'completed' && turnover.status !== 'in_progress' const windowMins = turnover.window_minutes ?? 0 const windowColor = windowUrgencyColor(windowMins) + + // A turnover with no OUTGOING booking recorded is a standalone: nobody is + // arriving after this checkout that we know of. generator.ts's standalone + // pass still stores a checkin_datetime of checkout + 4h so the row has a + // usable working window, but that time is INVENTED. Rendering it as "In: + // 2:00 PM — 4h" states an arrival that does not exist, and a PM reading it + // schedules crew against a deadline nothing imposed. + const hasRealCheckin = turnover.prev_booking_id !== null const urgencyTone = turnoverUrgencyTone(isOverdue, turnover.priority) const duration = formatDurationMinutes(turnover.crew_duration_minutes) @@ -437,20 +456,26 @@ function TurnoverCard({ {checkout.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}{' '} {checkout.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} - - - In:{' '} - {checkin.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} - {checkin.toDateString() !== checkout.toDateString() && ( - - ({checkin.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}) + {hasRealCheckin ? ( + <> + + + In:{' '} + {checkin.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} + {checkin.toDateString() !== checkout.toDateString() && ( + + ({checkin.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}) + + )} - )} - - - - {formatWindow(windowMins)} - + + + {formatWindow(windowMins)} + + + ) : ( + No next booking + )}
{/* Auto-assignment suggestion banner */} diff --git a/app/(dashboard)/turnovers/turnover-gantt.tsx b/app/(dashboard)/turnovers/turnover-gantt.tsx index 71683ee5..b3bb655c 100644 --- a/app/(dashboard)/turnovers/turnover-gantt.tsx +++ b/app/(dashboard)/turnovers/turnover-gantt.tsx @@ -10,8 +10,11 @@ interface Turnover { id: string property_id: string checkout_datetime: string + /** SYNTHETIC when prev_booking_id is null — see the bar render below. */ checkin_datetime: string window_minutes: number | null + /** Null on a standalone turnover: nothing is booked after this checkout. */ + prev_booking_id: string | null status: string priority: string } @@ -332,12 +335,27 @@ export function TurnoverGantt({ turnovers, properties, bookings }: Props) { if (colIdx < 0 || colIdx >= TOTAL_DAYS) return null - // Tight window detection + // Tight window detection. A standalone turnover + // (prev_booking_id null) has NO next booking — the + // generator stores checkout + 4h so the row has a working + // window, but printing "4h" on the bar reads as a + // four-hour deadline, and it is the tightest-looking bar + // on the chart precisely when there is no pressure at all. + const hasRealCheckin = turnover.prev_booking_id !== null const windowMinutes = turnover.window_minutes ?? 0 const windowMs = windowMinutes * 60_000 const checkinDT = new Date(turnover.checkin_datetime) const gapMs = checkinDT.getTime() - checkoutDT.getTime() - const isTight = gapMs > 0 && gapMs < windowMs + const isTight = hasRealCheckin && gapMs > 0 && gapMs < windowMs + const windowHours = Math.round(windowMinutes / 60 * 10) / 10 + + // Built here rather than inline: the tight-window prefix + // nests a second ternary inside the has-a-checkin one. + let barTitle = `${turnover.status} · no next booking` + if (hasRealCheckin) { + const prefix = isTight ? 'Tight window — ' : '' + barTitle = `${prefix}${turnover.status} · ${windowHours}h window` + } const colors = turnoverColors(turnover.status, isTight) const leftPx = colIdx * COL_W + 2 @@ -358,9 +376,12 @@ export function TurnoverGantt({ turnovers, properties, bookings }: Props) { color: colors.fg, border: `1px solid ${colors.border}`, }} - title={`${isTight ? 'Tight window — ' : ''}${turnover.status} · ${Math.round(windowMinutes / 60 * 10) / 10}h window`} + title={barTitle} > - {isTight ? : } {Math.round(windowMinutes / 60 * 10) / 10}h + {isTight + ? + : } + {' '}{hasRealCheckin ? `${windowHours}h` : '—'} ) })} diff --git a/app/crew/page.tsx b/app/crew/page.tsx index 0153b2dc..35e25774 100644 --- a/app/crew/page.tsx +++ b/app/crew/page.tsx @@ -24,8 +24,11 @@ type TurnoverRow = { status: string priority: string checkout_datetime: string + /** SYNTHETIC when prev_booking_id is null. */ checkin_datetime: string window_minutes: number | null + /** Null on a standalone turnover: nothing is booked after this checkout. */ + prev_booking_id: string | null property_id: string } @@ -99,13 +102,16 @@ function TurnoverCard({ t, property }: { t: TurnoverRow; property?: PropertyRow {checkout.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} - {t.window_minutes && ( + {/* Only a REAL next booking makes this a window. On a standalone + turnover window_minutes is the generator's placeholder 4h, and + showing it tells a cleaner to hurry for nobody. */} + {t.prev_booking_id && t.window_minutes ? ( {Math.floor(t.window_minutes / 60)}h {t.window_minutes % 60 > 0 ? ` ${t.window_minutes % 60}m` : ''} - )} + ) : null} ) diff --git a/app/crew/turnovers/[id]/page.tsx b/app/crew/turnovers/[id]/page.tsx index bb5b9c19..a455e6f7 100644 --- a/app/crew/turnovers/[id]/page.tsx +++ b/app/crew/turnovers/[id]/page.tsx @@ -142,12 +142,12 @@ export default function CrewTurnoverPage() { > {turnover.priority} priority - {turnover.window_minutes && ( + {turnover.prev_booking_id && turnover.window_minutes ? ( {Math.floor(turnover.window_minutes / 60)}h {turnover.window_minutes % 60 > 0 ? ` ${turnover.window_minutes % 60}m` : ''} window - )} + ) : null}
@@ -157,10 +157,17 @@ export default function CrewTurnoverPage() { {formatPropertyDateTime(turnover.checkout_datetime, property?.timezone ?? 'America/Chicago')}
+ {/* No outgoing booking recorded means this is a standalone turnover: + nobody is arriving after this checkout that we know of. The + generator still stores checkout + 4h as checkin_datetime so the + row has a working window, but showing that as "Next In" tells a + cleaner to hurry for a guest who does not exist. */}
Next In - {formatPropertyDateTime(turnover.checkin_datetime, property?.timezone ?? 'America/Chicago')} + {turnover.prev_booking_id + ? formatPropertyDateTime(turnover.checkin_datetime, property?.timezone ?? 'America/Chicago') + : 'No next booking'}
diff --git a/lib/dexie/schema.ts b/lib/dexie/schema.ts index a1399e03..65465c32 100644 --- a/lib/dexie/schema.ts +++ b/lib/dexie/schema.ts @@ -10,7 +10,19 @@ export interface TurnoverRow { property_id: string org_id: string checkout_datetime: string + /** + * SYNTHETIC when prev_booking_id is null — the generator's standalone pass + * stores checkout + 4h so the turnover has a working window. No guest is + * arriving then. Never render it as an arrival without checking + * prev_booking_id first. + */ checkin_datetime: string + /** + * The outgoing booking on a paired turnover, null on a standalone one. The + * only signal that checkin_datetime above was invented. Non-indexed, so no + * Dexie version bump. + */ + prev_booking_id: string | null window_minutes: number status: string priority: string diff --git a/lib/dexie/sync/turnovers.ts b/lib/dexie/sync/turnovers.ts index fe7b324b..456007d3 100644 --- a/lib/dexie/sync/turnovers.ts +++ b/lib/dexie/sync/turnovers.ts @@ -39,7 +39,7 @@ import { scopeChanged, rememberScope } from './scope' import { reportError } from '@/lib/observability/report-error' const TURNOVER_COLUMNS = - 'id, property_id, org_id, checkout_datetime, checkin_datetime, window_minutes, status, priority, notes, ' + + 'id, property_id, org_id, prev_booking_id, checkout_datetime, checkin_datetime, window_minutes, status, priority, notes, ' + 'inventory_started_at, inventory_confirmed_complete_at, inventory_confirmed_by_crew_id, completion_notes, ' + 'pending_checkout_datetime, pending_checkin_datetime, dates_changed_at, dates_change_acknowledged_at, updated_at' diff --git a/lib/inngest/functions/hospitable/teammate-sync-handler.ts b/lib/inngest/functions/hospitable/teammate-sync-handler.ts index 7e3b03bf..cb927d8b 100644 --- a/lib/inngest/functions/hospitable/teammate-sync-handler.ts +++ b/lib/inngest/functions/hospitable/teammate-sync-handler.ts @@ -21,6 +21,7 @@ import { createServiceClient } from '@/lib/supabase/server' import { getValidHospitableToken } from '@/lib/integrations/providers/hospitable-token' import { hospFetchTeammates, hospitableTeammatesToCrewRows } from '@/lib/integrations/providers/hospitable' import { logAuditEvents } from '@/lib/audit' +import { reportError } from '@/lib/observability/report-error' const PROVIDER = 'hospitable' @@ -67,6 +68,37 @@ export const hospTeammateSyncHandler = inngest.createFunction( const supabase = createServiceClient({ system: 'inngest:teammate-sync-handler' }) const freshExternalIds = new Set(teammates.map((t) => t.id)) + // An EMPTY fresh set is not "every teammate was removed upstream". + // + // This step reconciles by absence, which is the only way a removal is + // ever detectable — but absence-as-signal has one degenerate input, and + // hospFetchTeammates hands it to us readily: it returns [] on ANY non-ok + // response, including the 403 its own doc comment names as expected for a + // connection lacking the teammate:read scope. With an empty set every + // active Hospitable crew member is absent, so this deactivated the org's + // ENTIRE roster and wrote an audit row for each saying they were removed + // from Hospitable. + // + // That is not hypothetical. In production on 2026-07-18 at 09:00 UTC all + // three of one org's Hospitable crew members were deactivated at the same + // microsecond — one batch, the whole roster, from a single cron run. + // + // The asymmetry decides it, exactly as in ownerrez/reconciliation-handler + // and ical-sync: declining to deactivate leaves a stale crew row for one + // more day, while deactivating wrongly removes real people from + // scheduling and assignment. + if (freshExternalIds.size === 0) { + logger.error( + `[Hospitable teammate-sync] org ${org_id}: ZERO teammates returned — ` + + `skipping the deactivation pass rather than deactivating every crew member` + ) + reportError(new Error('Hospitable teammate sync returned zero teammates'), { + site: 'inngest.hospitable-teammate-sync-handler.empty-result-guard', + orgId: org_id, + }) + return 0 + } + const { data: existingActive, error: fetchErr } = await supabase .from('crew_members') .select('id, external_id') diff --git a/lib/inngest/functions/ownerrez/incremental-sync.ts b/lib/inngest/functions/ownerrez/incremental-sync.ts index 1c99a95e..59cab4cf 100644 --- a/lib/inngest/functions/ownerrez/incremental-sync.ts +++ b/lib/inngest/functions/ownerrez/incremental-sync.ts @@ -57,6 +57,7 @@ import { planBackfillWindow, advanceBackfill, readBackfillState, + revenuePostingFloor, } from '@/lib/integrations/providers/ownerrez-backfill' import { getRedis, upstashConfigured } from '@/lib/redis' import { RateLimitError, TokenRevokedError, translateSyncError } from '@/lib/integrations/types' @@ -409,7 +410,12 @@ async function persistBookings( return { affectedPropertyIds: Array.from(new Set(bookingRows.map((b) => b.property_id))), - bookingsToPostRevenue: selectOwnerRezBookingsToPostRevenue(bookingRows, idByExternalId), + // Revenue floor: only stays in the current month or later. A stay that + // predates FieldStay managing the property has no recoverable expense side, + // so posting its revenue alone overstates that month's net income. See + // revenuePostingFloor. + bookingsToPostRevenue: selectOwnerRezBookingsToPostRevenue( + bookingRows, idByExternalId, revenuePostingFloor(new Date())), // Blocks never generate turnovers (filtered at the generator query level), // but a known vacancy window is the best signal for scheduling maintenance. ownerBlocks: bookingRows.filter((r) => Boolean(r.is_block)), diff --git a/lib/inngest/functions/ownerrez/initial-sync.ts b/lib/inngest/functions/ownerrez/initial-sync.ts index 0d7f7b9a..37f293a8 100644 --- a/lib/inngest/functions/ownerrez/initial-sync.ts +++ b/lib/inngest/functions/ownerrez/initial-sync.ts @@ -23,7 +23,7 @@ import { selectOwnerRezBookingsToPostRevenue, } from '@/lib/integrations/providers/ownerrez' import { upsertBookingsReturningIds } from './upsert-bookings' -import { initialHistoryFrom } from '@/lib/integrations/providers/ownerrez-backfill' +import { initialHistoryFrom, revenuePostingFloor } from '@/lib/integrations/providers/ownerrez-backfill' import { logAuditEvent } from '@/lib/audit' import { applyMasterChecklistToProperty, @@ -639,7 +639,13 @@ export const ownerRezInitialSync = inngest.createFunction( bookingRows.map((b) => b.property_id).filter((id): id is string => id !== null) )) - bookingsToPostRevenue = selectOwnerRezBookingsToPostRevenue(bookingRows, idByExternalId) + // Revenue floor: current month onward only. The 90-day initial window + // has the same problem the backfill does — a stay that completed + // before the account connected has no cleaning fee, work order or + // restock recorded against it, because none of those happened in + // FieldStay. Its revenue alone is not a P&L, it is an overstatement. + bookingsToPostRevenue = selectOwnerRezBookingsToPostRevenue( + bookingRows, idByExternalId, revenuePostingFloor(new Date())) } try { diff --git a/lib/integrations/providers/hospitable.ts b/lib/integrations/providers/hospitable.ts index 78284961..7fdbacdf 100644 --- a/lib/integrations/providers/hospitable.ts +++ b/lib/integrations/providers/hospitable.ts @@ -511,8 +511,15 @@ export async function hospFetchProperties(token: string): Promise MAX_PAGES) { - console.error(`[Hospitable] properties pagination exceeded ${MAX_PAGES} pages — aborting`) - break + // THROW rather than break. `break` returned the pages gathered so far as + // if they were the complete set, and callers upsert that list and treat + // anything missing from it as gone — the same silent-truncation class as + // the OwnerRez pager. A loud failure is strictly better than a quietly + // short portfolio. + throw new Error( + `[Hospitable] properties pagination exceeded ${MAX_PAGES} pages ` + + `(${properties.length} so far) — refusing to return a partial result` + ) } const res = await hospitableFetch(url, token) @@ -631,8 +638,10 @@ export async function fetchReservationsWindow( while (page <= lastPage) { pageCount++ if (pageCount > MAX_PAGES) { - console.error(`[Hospitable] reservations pagination exceeded ${MAX_PAGES} pages — aborting`) - break + throw new Error( + `[Hospitable] reservations pagination exceeded ${MAX_PAGES} pages ` + + `(${reservations.length} so far) — refusing to return a partial result` + ) } // Build base params via URLSearchParams (handles encoding for all standard params) @@ -706,8 +715,10 @@ export async function hospFetchReviews( while (url) { pageCount++ if (pageCount > MAX_PAGES) { - console.error(`[Hospitable] reviews pagination exceeded ${MAX_PAGES} pages for property ${propertyId} — aborting`) - break + throw new Error( + `[Hospitable] reviews pagination exceeded ${MAX_PAGES} pages for property ${propertyId} ` + + `(${reviews.length} so far) — refusing to return a partial result` + ) } const res = await hospitableFetch(url, token) @@ -799,18 +810,37 @@ export async function hospFetchTeammates(token: string): Promise MAX_PAGES) { - console.error('[Hospitable] teammates pagination exceeded limit — aborting') - break + throw new Error( + `[Hospitable] teammates pagination exceeded ${MAX_PAGES} pages ` + + `(${teammates.length} so far) — refusing to return a partial result` + ) } const res = await hospitableFetch(url, token) if (!res.ok) { const text = await res.text().catch(() => '') - console.warn( + + // 403 is the ONE expected non-ok: a connection predating the + // teammate:read scope. Nothing is retriable about it, and teammate sync + // is additive, so an empty list is the honest answer. Callers that + // reconcile by absence must still guard an empty set — see + // teammate-sync-handler's deactivation pass. + if (res.status === 403) { + console.warn( + `[Hospitable] GET /teammates forbidden (missing teammate:read scope): ${text.slice(0, 200)}` + ) + return [] + } + + // Everything else THROWS. This returned [] for any status at all, from + // inside the pagination loop — so a 500 on page two discarded page one + // as well and reported "0 teammates" as a successful sync. Combined with + // the caller's absence-based deactivation, that is how an org's entire + // Hospitable crew roster was deactivated in one run. + throw new Error( `[Hospitable] GET /teammates failed (${res.status}): ${text.slice(0, 200)}` ) - return [] } const data = await res.json() as HospitablePagedTeammates diff --git a/lib/integrations/providers/hostaway.ts b/lib/integrations/providers/hostaway.ts index 15e0ba45..b57851c4 100644 --- a/lib/integrations/providers/hostaway.ts +++ b/lib/integrations/providers/hostaway.ts @@ -161,8 +161,13 @@ export async function hostawayFetchListings( while (true) { pageCount++ if (pageCount > MAX_PAGES) { - console.error(`[Hostaway] listings pagination exceeded ${MAX_PAGES} pages — aborting`) - break + // THROW rather than break — see the note on hospFetchProperties. A + // partial listing set returned as complete is indistinguishable from a + // shrunken portfolio to everything downstream. + throw new Error( + `[Hostaway] listings pagination exceeded ${MAX_PAGES} pages ` + + `(${listings.length} so far) — refusing to return a partial result` + ) } const res = await fetch( @@ -206,8 +211,10 @@ export async function hostawayFetchReservations( while (true) { pageCount++ if (pageCount > MAX_PAGES) { - console.error(`[Hostaway] reservations pagination exceeded ${MAX_PAGES} pages — aborting`) - break + throw new Error( + `[Hostaway] reservations pagination exceeded ${MAX_PAGES} pages ` + + `(${reservations.length} so far) — refusing to return a partial result` + ) } const params = new URLSearchParams({ diff --git a/lib/integrations/providers/ownerrez-backfill.ts b/lib/integrations/providers/ownerrez-backfill.ts index 570735dc..f3438bbb 100644 --- a/lib/integrations/providers/ownerrez-backfill.ts +++ b/lib/integrations/providers/ownerrez-backfill.ts @@ -69,6 +69,25 @@ export interface BackfillWindow { to: string } +/** + * First day of the current month — the floor for posting booking revenue to + * owner_transactions. + * + * A stay that ended before FieldStay was managing the property has no + * recoverable expense side: cleaning_fee posts on turnover completion, + * wo_completion on a work order, inventory_purchase on a received PO, and none + * of those exist for work done in another system. Posting its revenue anyway + * produces a month showing full rent against zero costs — an owner-facing P&L + * that is not merely incomplete but overstated. + * + * The current month is the boundary rather than the connection date because it + * is the one an operator can state plainly: "your FieldStay ledger starts this + * month." Anything earlier is visibly absent rather than quietly wrong. + */ +export function revenuePostingFloor(now: Date): string { + return `${now.toISOString().slice(0, 7)}-01` +} + /** Calendar date in UTC, as OwnerRez's date-only bounds expect. */ export function isoDate(d: Date): string { return d.toISOString().slice(0, 10) diff --git a/lib/integrations/providers/ownerrez.ts b/lib/integrations/providers/ownerrez.ts index 9ce26f1a..d5c39bfd 100644 --- a/lib/integrations/providers/ownerrez.ts +++ b/lib/integrations/providers/ownerrez.ts @@ -642,10 +642,34 @@ export function partitionMappedBookingRows(rows: OwnerRezBookingRow[]): { */ export function selectOwnerRezBookingsToPostRevenue( rows: OwnerRezBookingRow[], - idByExternalId: Record + idByExternalId: Record, + /** + * Earliest check-in date eligible for revenue posting (YYYY-MM-DD), or null + * for no floor. + * + * Revenue for a stay that predates FieldStay managing the property is + * REVENUE WITHOUT ITS EXPENSES, and that is worse than no data. All three + * expense sources on owner_transactions post when something COMPLETES inside + * FieldStay — cleaning_fee on turnover completion, wo_completion on a work + * order, inventory_purchase on a received PO — and none of those exist for a + * stay that happened before the account connected. Those jobs were done on + * paper or in another system and cannot be reconstructed. + * + * So a backfilled month would show full rent and zero costs: an inflated net + * income presented to a property owner as their P&L. A missing month is + * visibly missing; a wrong month is not. + * + * The bookings themselves are still imported across the whole backfill — + * they feed stay-length derivation for the par engine and occupancy history, + * neither of which is distorted by the absent expense side. + */ + minCheckinDate: string | null = null, ): { bookingId: string; propertyId: string; actualTotalAmount: number | null }[] { return rows .filter((b) => b.status === 'confirmed' && b.stay_type === 'guest_stay' && b.property_id !== null) + // ISO dates compare correctly as strings: fixed width, zero padded, + // most-significant first. + .filter((b) => minCheckinDate === null || (b.checkin_date ?? '') >= minCheckinDate) .map((b) => ({ bookingId: idByExternalId[b.external_id], propertyId: b.property_id as string, diff --git a/unit/guardrails/absence-reconciliation.test.ts b/unit/guardrails/absence-reconciliation.test.ts new file mode 100644 index 00000000..a2e3d849 --- /dev/null +++ b/unit/guardrails/absence-reconciliation.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { execSync } from 'node:child_process' + +// ============================================================================ +// RECONCILING BY ABSENCE — the destructive-by-default sync shape. +// +// "Fetch the upstream list, then delete/cancel/deactivate every local row that +// is missing from it" is the only way an upstream HARD DELETE is ever +// detectable, so this codebase uses it in five places. It also has one +// degenerate input that turns it into a data-destruction bug: an EMPTY fetched +// list means every local row is absent, so the pass removes all of them. +// +// This is not hypothetical. On 2026-07-18 at 09:00 UTC, hospTeammateSyncHandler +// deactivated all three of one org's Hospitable crew members at the same +// microsecond — one batch, the entire roster, one cron run — because +// hospFetchTeammates returned [] for a non-ok response and the deactivation +// pass had no empty-set guard. Every row got an audit entry claiming the person +// had been removed from Hospitable. +// +// THE INVARIANT: a fetch feeding an absence-based reconciliation must +// distinguish FAILURE from GENUINE EMPTINESS. There are exactly two valid ways, +// and which one is correct depends on whether empty is a legitimate steady +// state for that entity: +// +// fetch-fails-loud — the fetch throws (or returns null) on failure, so [] +// can only mean "upstream really has none". Required +// where empty is NORMAL: a property with no calendar +// blocks, a crew member with no assignments. An empty-set +// guard would be WRONG here — it would make the last +// block or last assignment impossible to ever clear. +// +// empty-set-guard — the pass refuses to act on an empty set at all. +// Required where empty is IMPLAUSIBLE and the fetch +// cannot be trusted to fail loudly. Costs one stale row +// until the next run; the alternative costs the table. +// +// Registering a site is deliberate. Adding a NEW absence-based reconciliation +// fails this test until someone states which protection it has and why. +// ============================================================================ + +const DESTRUCTIVE = /bulkDelete\(|\.delete\(\)|status:\s*'cancelled'|is_active:\s*false|deleted_at:/ +const ABSENCE = /!\s*(\w+)\s*\.has\(/ + +interface Reconciler { + protection: 'fetch-fails-loud' | 'empty-set-guard' + why: string +} + +/** + * Every absence-based reconciliation in the codebase, keyed by file:line. + * + * Shrink-only in spirit: a new entry needs a real justification, not a note + * that the check was noisy. + */ +const RECONCILERS: Record = { + 'lib/inngest/functions/ownerrez/reconciliation-handler.ts:163': { + protection: 'empty-set-guard', + why: 'Cancels FieldStay bookings absent from OwnerRez. An org with connected properties having zero bookings is implausible, and getBookings() can return a 200 with an empty body on an upstream hiccup — indistinguishable from a genuinely emptied account. Cancelling wrongly sends crew home from stays that are still happening.', + }, + 'lib/inngest/functions/hospitable/teammate-sync-handler.ts:112': { + protection: 'empty-set-guard', + why: 'Deactivates crew members absent from Hospitable. THE SITE THAT FIRED: hospFetchTeammates returned [] for any non-ok response, including the 403 expected for a connection without teammate:read, and did so from inside its pagination loop. Both halves are fixed, and the guard stays as the backstop because the caller cannot verify how the fetch failed.', + }, + 'lib/inngest/functions/hospitable/calendar-sync-handler.ts:122': { + protection: 'fetch-fails-loud', + why: 'Cancels blocks absent from the Hospitable calendar. hospFetchCalendar THROWS on any non-ok, so [] can only mean the window genuinely holds no blocks — which is the normal state for most properties. An empty-set guard here would be a bug: the LAST lifted block could never be cleared.', + }, + 'lib/dexie/sync/turnovers.ts:109': { + protection: 'fetch-fails-loud', + why: 'Drops cached turnovers no longer assigned to this crew member. fetchAssignedTurnoverIds returns NULL on failure and syncAssignedTurnovers returns early on null, so [] means the crew member genuinely has no assignments — a normal state, and unassignment-to-zero must still clear the device.', + }, + 'lib/dexie/sync/work-orders.ts:177': { + protection: 'fetch-fails-loud', + why: 'Drops cached work orders outside the crew member\'s current set. Both deltaPull and idSnapshot return NULL on failure and resolveDeltaPull propagates it, so [] means genuinely none assigned.', + }, +} + +/** + * A provider fetch that returns [] from an ERROR branch is the root cause the + * teammate wipe came from: it manufactures the degenerate input above, and no + * caller can tell the difference. Registered exceptions must be cases where + * the empty result is semantically true, not merely convenient. + */ +const FAIL_SOFT_EMPTY_RETURNS: Record = { + 'lib/integrations/providers/hospitable.ts:786': + 'hospFetchReservationMessages: 404 means the reservation has no message thread. Semantically empty, not a failure — and message sync is additive, never reconciled by absence.', + 'lib/integrations/providers/hospitable.ts:833': + 'hospFetchTeammates: 403 is the one expected non-ok — a connection predating the teammate:read scope. Nothing about it is retriable. Every OTHER status now throws, and the sole absence-based consumer carries an empty-set guard.', +} + +function sourceFiles(): string[] { + return execSync('grep -rl "\\.has(" --include=*.ts --include=*.tsx lib app', { encoding: 'utf8' }) + .trim().split('\n') + .filter((f) => f && !/\.test\.|\/stubs\//.test(f)) +} + +/** + * Finds every "absence drives a destructive write" site. + * + * `setName` is the Set being tested for membership — carried out of the scan so + * the empty-set-guard check can tie itself to THAT set rather than to any + * emptiness check anywhere in the file. A file-wide search was the first + * version and it was useless: teammate-sync-handler contains an unrelated + * `if (!rows.length) return 0` in its upsert step, so deleting the real guard + * still passed. + */ +function findReconcilerSites(): { site: string; setName: string }[] { + const found: { site: string; setName: string }[] = [] + + for (const file of sourceFiles()) { + const lines = readFileSync(file, 'utf8').split('\n') + + lines.forEach((line, i) => { + const absence = line.match(ABSENCE) + if (!absence) return + const setName = absence[1]! + + // What the absence check feeds: either an accumulator on the same line + // (`if (!set.has(x)) departed.add(x)`) or the const it is bound to. + let bind: string | null = null + const accumulator = line.match(/(\w+)\.add\(/) + if (accumulator) bind = accumulator[1]! + for (let b = i; !bind && b >= Math.max(0, i - 4); b--) { + const m = lines[b]!.match(/(?:const|let)\s+(\w+)\s*=/) + if (m) bind = m[1]! + } + if (!bind) return + + // A destructive write must follow, and must actually mention that value — + // otherwise this is an additive "create what's missing" filter, which is + // the common and harmless case. + const after = lines.slice(i, i + 45) + const body = after.join('\n') + if (!DESTRUCTIVE.test(body)) return + if (!new RegExp(`\\b${bind}\\b`).test(after.slice(1).join('\n'))) return + + found.push({ site: `${file}:${i + 1}`, setName }) + }) + } + + return found +} + +const findReconcilers = (): string[] => findReconcilerSites().map((r) => r.site) + +function findFailSoftEmptyReturns(): string[] { + const found: string[] = [] + const files = execSync('ls lib/integrations/providers/*.ts', { encoding: 'utf8' }) + .trim().split('\n').filter((f) => f && !/\.test\./.test(f)) + + for (const file of files) { + const lines = readFileSync(file, 'utf8').split('\n') + lines.forEach((line, i) => { + if (!/return \[\]/.test(line)) return + // Is this inside an error/status branch? Look back a few lines. + // 20 lines, not 8: an error branch can carry a long explanatory comment + // between the status check and the return, and an 8-line window silently + // missed exactly that shape when it was canaried. + const before = lines.slice(Math.max(0, i - 20), i + 1).join('\n') + if (!/!res\.ok|res\.status\s*===|catch\s*[({]/.test(before)) return + found.push(`${file}:${i + 1}`) + }) + } + return found +} + +describe('guardrail: reconciling by absence must survive an empty fetch', () => { + it('every absence-driven destructive write is registered with its protection', () => { + const unlisted = findReconcilers().filter((site) => !RECONCILES_KNOWN.has(site)) + + expect( + [ + unlisted.length === 0 ? '' : + 'A destructive write is driven by absence from a fetched set, and this site is not\n' + + 'registered. An EMPTY fetched list makes every local row absent, so this pass would\n' + + 'delete/cancel/deactivate all of them — that is exactly how one org\'s entire\n' + + 'Hospitable crew roster was deactivated in a single cron run on 2026-07-18.\n\n' + + 'Add it to RECONCILERS in this file, stating which protection it has:\n' + + ' fetch-fails-loud — the fetch throws/returns null on failure, so [] is genuinely empty\n' + + ' empty-set-guard — the pass refuses to act on an empty set\n\n' + + 'Unregistered sites:', + ...unlisted, + ].filter(Boolean).join('\n'), + ).toEqual('') + }) + + it('every registered site still exists at that file:line (prune when code moves)', () => { + const live = new Set(findReconcilers()) + const stale = Object.keys(RECONCILERS).filter((site) => !live.has(site)) + + expect( + [ + stale.length === 0 ? '' : + 'A RECONCILERS entry no longer matches any absence-driven destructive write.\n' + + 'Line numbers drift — re-point it, or delete the entry if the code is gone.\n' + + 'A stale entry silently stops protecting anything.\n\nStale entries:', + ...stale, + ].filter(Boolean).join('\n'), + ).toEqual('') + }) + + it('every empty-set-guard site actually contains an empty-set check', () => { + // The registry records intent; this checks the intent was implemented. + const bySite = new Map(findReconcilerSites().map((r) => [r.site, r.setName])) + + const missing = Object.entries(RECONCILERS) + .filter(([, r]) => r.protection === 'empty-set-guard') + .map(([site]) => site) + .filter((site) => { + const setName = bySite.get(site) + if (!setName) return false // staleness is the other test's job + const src = readFileSync(site.split(':')[0]!, 'utf8') + // The guard must name THE SET the absence check uses. + return !new RegExp(`${setName}\\.(size|length)\\s*===\\s*0|!${setName}\\.(size|length)\\b`).test(src) + }) + + expect( + [ + missing.length === 0 ? '' : + 'Registered as empty-set-guard, but the file has no empty-set check:', + ...missing, + ].filter(Boolean).join('\n'), + ).toEqual('') + }) + + it('every registration carries a real justification', () => { + for (const [site, r] of Object.entries(RECONCILERS)) { + expect(r.why.length, `${site} needs a real reason`).toBeGreaterThan(60) + } + }) + + it('no provider fetch returns [] from an error branch unless registered', () => { + const unlisted = findFailSoftEmptyReturns().filter((s) => !(s in FAIL_SOFT_EMPTY_RETURNS)) + + expect( + [ + unlisted.length === 0 ? '' : + 'A provider fetch returns [] from an error branch. That manufactures the degenerate\n' + + 'input the reconcilers above must survive, and no caller can tell it apart from a\n' + + 'genuinely empty upstream. Throw instead — or register it here if the empty result\n' + + 'is semantically TRUE rather than merely convenient.\n\nUnregistered:', + ...unlisted, + ].filter(Boolean).join('\n'), + ).toEqual('') + }) + + it('the scan itself fires — a broken checker looks exactly like a clean tree', () => { + // Self-check: the two shapes this test exists to catch must both be + // recognised. Without this, a regex typo turns the whole guardrail into a + // permanently-passing no-op. + const known = findReconcilers() + expect(known).toContain('lib/inngest/functions/hospitable/teammate-sync-handler.ts:112') + expect(known).toContain('lib/dexie/sync/work-orders.ts:177') // accumulator shape + expect(known.length).toBeGreaterThanOrEqual(5) + }) +}) + +const RECONCILES_KNOWN = new Set(Object.keys(RECONCILERS)) diff --git a/unit/inngest/hospitable-teammate-sync-handler.test.ts b/unit/inngest/hospitable-teammate-sync-handler.test.ts index 1d211d29..c92515e6 100644 --- a/unit/inngest/hospitable-teammate-sync-handler.test.ts +++ b/unit/inngest/hospitable-teammate-sync-handler.test.ts @@ -13,12 +13,16 @@ vi.mock('@/lib/integrations/providers/hospitable', () => ({ vi.mock('@/lib/audit', () => ({ logAuditEvents: vi.fn(), })) +vi.mock('@/lib/observability/report-error', () => ({ + reportError: vi.fn(), +})) import { hospTeammateSyncHandler } from '@/lib/inngest/functions/hospitable/teammate-sync-handler' import { createServiceClient } from '@/lib/supabase/server' import { getValidHospitableToken } from '@/lib/integrations/providers/hospitable-token' import { hospFetchTeammates, hospitableTeammatesToCrewRows } from '@/lib/integrations/providers/hospitable' import { logAuditEvents } from '@/lib/audit' +import { reportError } from '@/lib/observability/report-error' import { invokeHandler } from './test-helpers' function runAllStep() { @@ -182,3 +186,100 @@ describe('hospTeammateSyncHandler', () => { })).rejects.toThrow('Teammates upsert failed: db unavailable') }) }) + +// ========================================================================== +// The empty-fresh-set guard. +// +// This step reconciles by ABSENCE, and hospFetchTeammates used to return [] +// for ANY non-ok response — including the 403 its own doc comment names as +// expected for a connection without the teammate:read scope, and including a +// mid-pagination failure that also discarded the pages already gathered. +// +// With an empty set, every active Hospitable crew member is absent, so the +// deactivation pass removed the org's ENTIRE roster and wrote an audit row for +// each claiming they were removed from Hospitable. +// +// That happened. In production on 2026-07-18 at 09:00 UTC all three of one +// org's Hospitable crew members were deactivated at the SAME microsecond — +// one batch, the whole roster, from a single cron run. +// ========================================================================== +describe('hospTeammateSyncHandler — empty-result guard', () => { + beforeEach(() => { + vi.clearAllMocks() + ;(getValidHospitableToken as ReturnType).mockResolvedValue('token_abc') + }) + + it('deactivates NOBODY when Hospitable returns zero teammates', async () => { + ;(hospFetchTeammates as ReturnType).mockResolvedValue([]) + ;(hospitableTeammatesToCrewRows as ReturnType).mockReturnValue([]) + + const supabase = makeSupabase({ + crew_members: [ + // The read that would have supplied the whole roster as "removed". + { data: [ + { id: 'crew_1', external_id: 'tm_1' }, + { id: 'crew_2', external_id: 'tm_2' }, + { id: 'crew_3', external_id: 'tm_3' }, + ], error: null }, + ], + }) + ;(createServiceClient as ReturnType).mockReturnValue(supabase) + + const result = await invokeHandler(hospTeammateSyncHandler, { + event: { data: EVENT_DATA }, + step: runAllStep(), + logger: makeLogger(), + }) + + expect(result).toEqual({ upserted: 0, deactivated: 0 }) + + // The two things that made this destructive rather than merely wrong. + const update = supabase.calls.find((c) => c.table === 'crew_members' && c.method === 'update') + expect(update).toBeUndefined() + expect(logAuditEvents).not.toHaveBeenCalled() + }) + + it('reports the empty result rather than passing it off as a clean run', async () => { + // Silence here is the whole problem: a wiped roster looked like a + // successful sync in the logs. + ;(hospFetchTeammates as ReturnType).mockResolvedValue([]) + ;(hospitableTeammatesToCrewRows as ReturnType).mockReturnValue([]) + const supabase = makeSupabase({ crew_members: [{ data: [], error: null }] }) + ;(createServiceClient as ReturnType).mockReturnValue(supabase) + + const logger = makeLogger() + await invokeHandler(hospTeammateSyncHandler, { + event: { data: EVENT_DATA }, step: runAllStep(), logger, + }) + + expect(reportError).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ orgId: 'org_1' }), + ) + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('ZERO teammates')) + }) + + it('still deactivates normally when the fresh set is non-empty', async () => { + // The guard must not have disabled reconciliation altogether — a real + // removal still has to be detected. + ;(hospFetchTeammates as ReturnType).mockResolvedValue([{ id: 'tm_1' }]) + ;(hospitableTeammatesToCrewRows as ReturnType).mockReturnValue([ + { org_id: 'org_1', external_id: 'tm_1', external_source: 'hospitable' }, + ]) + const supabase = makeSupabase({ + crew_members: [ + { error: null }, + { data: [{ id: 'crew_gone', external_id: 'tm_old' }], error: null }, + { error: null }, + ], + }) + ;(createServiceClient as ReturnType).mockReturnValue(supabase) + + const result = await invokeHandler(hospTeammateSyncHandler, { + event: { data: EVENT_DATA }, step: runAllStep(), logger: makeLogger(), + }) + + expect(result).toEqual({ upserted: 1, deactivated: 1 }) + }) +}) + diff --git a/unit/integrations/ownerrez-backfill.test.ts b/unit/integrations/ownerrez-backfill.test.ts index c760c588..4a0c11b5 100644 --- a/unit/integrations/ownerrez-backfill.test.ts +++ b/unit/integrations/ownerrez-backfill.test.ts @@ -5,6 +5,7 @@ import { advanceBackfill, initialHistoryFrom, readBackfillState, + revenuePostingFloor, isoDate, INITIAL_HISTORY_DAYS, BACKFILL_WINDOW_DAYS, @@ -177,3 +178,54 @@ describe('readBackfillState', () => { expect(readBackfillState({ backfill_complete: 1 }).complete).toBe(false) }) }) + +// ============================================================================ +// The revenue-posting floor. +// +// Backfilled bookings are imported across the whole two-year walk — they feed +// stay-length derivation and occupancy history. Their REVENUE is not posted, +// because the expense side cannot be reconstructed: cleaning_fee posts on +// turnover completion, wo_completion on a work order, inventory_purchase on a +// received PO, and none of those exist for work done before the account +// connected, on paper or in another system. +// +// A backfilled month would therefore show full rent against zero costs — an +// owner-facing P&L that is overstated rather than merely incomplete. A missing +// month is visibly missing; a wrong month is not. +// ============================================================================ +describe('revenuePostingFloor', () => { + it('is the first of the current month', () => { + expect(revenuePostingFloor(new Date('2026-08-13T12:00:00.000Z'))).toBe('2026-08-01') + }) + + it('holds on the first of the month — that day is inside the window, not before it', () => { + const floor = revenuePostingFloor(new Date('2026-08-01T00:00:00.000Z')) + expect(floor).toBe('2026-08-01') + expect('2026-08-01' >= floor).toBe(true) + }) + + it('holds on the last instant of a month without rolling forward', () => { + expect(revenuePostingFloor(new Date('2026-08-31T23:59:59.999Z'))).toBe('2026-08-01') + }) + + it('zero-pads single-digit months so string comparison stays chronological', () => { + // The floor is compared against checkin_date as a STRING. '2026-9-01' + // would sort after '2026-10-01' and silently admit the wrong bookings. + expect(revenuePostingFloor(new Date('2026-01-15T00:00:00.000Z'))).toBe('2026-01-01') + expect(revenuePostingFloor(new Date('2026-09-15T00:00:00.000Z'))).toBe('2026-09-01') + }) + + it('excludes every window the backfill walks', () => { + // The guarantee that matters: no window the historical walk produces can + // contain a stay eligible for revenue posting. + const now = new Date('2026-08-13T12:00:00.000Z') + const floor = revenuePostingFloor(now) + let state = { oldestCovered: null as string | null, complete: false } + for (let i = 0; i < 100; i++) { + const w = planBackfillWindow(state, now) + if (!w) break + expect(w.to < floor).toBe(true) + state = advanceBackfill(w, now) + } + }) +})