Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions lib/inngest/functions/hospitable/teammate-sync-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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')
Expand Down
8 changes: 7 additions & 1 deletion lib/inngest/functions/ownerrez/incremental-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)),
Expand Down
10 changes: 8 additions & 2 deletions lib/inngest/functions/ownerrez/initial-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
50 changes: 40 additions & 10 deletions lib/integrations/providers/hospitable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,8 +511,15 @@ export async function hospFetchProperties(token: string): Promise<HospitableProp
while (url) {
pageCount++
if (pageCount > 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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -799,18 +810,37 @@ export async function hospFetchTeammates(token: string): Promise<HospitableTeamm
while (url) {
pageCount++
if (pageCount > 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
Expand Down
15 changes: 11 additions & 4 deletions lib/integrations/providers/hostaway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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({
Expand Down
19 changes: 19 additions & 0 deletions lib/integrations/providers/ownerrez-backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 25 additions & 1 deletion lib/integrations/providers/ownerrez.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,10 +642,34 @@ export function partitionMappedBookingRows(rows: OwnerRezBookingRow[]): {
*/
export function selectOwnerRezBookingsToPostRevenue(
rows: OwnerRezBookingRow[],
idByExternalId: Record<string, string>
idByExternalId: Record<string, string>,
/**
* 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,
Expand Down
Loading
Loading