Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
dad56a8
test(quota): pin the D1 semantics the inspection cap depends on
important-new Aug 5, 2026
3fc7377
fix(quota): count inspections that exist, not creates that happened
important-new Aug 5, 2026
95cb509
test(quota): verify the inspection cap on real D1 under genuine concu…
important-new Aug 5, 2026
c14c420
docs(quota)+fix(usage): the counter is a cache, and /api/usage says so
important-new Aug 5, 2026
97635a6
fix(usage): route the inspection count through getDrizzle
important-new Aug 5, 2026
0cb37ae
fix(qbo): book payments on the date the money moved, in the tenant zone
important-new Aug 5, 2026
b0e0041
fix(services): refuse to remove a service line that a report delivers
important-new Aug 5, 2026
44cd429
feat(ai): expose GET /api/integration/ai-provisioning for portal's ti…
important-new Aug 5, 2026
4a8cde1
gate(idempotency): ledger every mutating route's retry safety
important-new Aug 5, 2026
ebd6260
chore(gates): wire lint:idempotency into both lint chains
important-new Aug 6, 2026
95f7943
feat(ai): receive delivered AI allowances, without a migration
important-new Aug 6, 2026
dfa9e6d
feat(ai): resolve delivered AI caps per check, at all seven guard sites
important-new Aug 6, 2026
b73c5f6
Merge branch 'w/managed-ai' into batch6/remaining-programme
important-new Aug 6, 2026
77651c2
test(idempotency): verify POST /api/invoices replay writes one row
important-new Aug 6, 2026
a6fc7b9
test(idempotency): verify payment-capture replay does not book cash t…
important-new Aug 6, 2026
05c0de5
test(idempotency): verify agreement send does not re-mail on replay
important-new Aug 6, 2026
063e5b7
test(idempotency): verify manual SMS send does not text twice on replay
important-new Aug 6, 2026
de802f5
test(idempotency): verify workspace agreement send does not re-mail o…
important-new Aug 6, 2026
5d4d88c
test(idempotency): verify request-payment replay does not re-bill the…
important-new Aug 6, 2026
5ff07c3
test(idempotency): order the in-flight overlap instead of racing for it
important-new Aug 6, 2026
7011a1e
feat(pay-splits): pay rules and split records (#278)
important-new Aug 6, 2026
5a01405
feat(pay-splits): populate splits from rules, once (#278)
important-new Aug 6, 2026
a6c6502
fix(pay-splits): stop exporting a helper nothing outside the module c…
important-new Aug 6, 2026
cef0737
feat(pay-splits): the API surface, and visibility as query scoping (#…
important-new Aug 6, 2026
8363774
fix(qbo): require owner or manager on the QuickBooks router
important-new Aug 6, 2026
1959495
feat(metrics): pay, attributed revenue, and a turnaround that says it…
important-new Aug 6, 2026
ea9b9eb
fix(qbo): make the OAuth pair reachable, and authorize the callback b…
important-new Aug 6, 2026
e4d2922
feat(qbo): choose the Intuit API host by QBO_ENV, with no default
important-new Aug 6, 2026
3eefddb
fix(qbo): stop disconnecting a customer over a transient refresh failure
important-new Aug 6, 2026
f5a5890
fix(tests): let the demand-signal doc parser survive a Windows checkout
important-new Aug 6, 2026
40f13a7
fix(gate): the idempotency gate could not see inline route registrations
important-new Aug 6, 2026
f5f5354
feat(pay-splits): give service pay rules a write face (#278)
important-new Aug 6, 2026
c9f1d20
fix(pay-splits): the pay-rule union blew tsc's heap — same contract, …
important-new Aug 6, 2026
541ba55
feat(pay-splits): a tenant can now switch pay splits on from Settings…
important-new Aug 6, 2026
6488e22
refactor(invoices): the payment ledger becomes its own sub-router
important-new Aug 6, 2026
fafbff9
refactor(invoices): split the ledger off InvoiceService, and export t…
important-new Aug 6, 2026
d6ec4ec
refactor(invoices): the row's status and its verbs become components
important-new Aug 6, 2026
aebf90e
refactor(inspections): one snapshot writer for inspection_services, n…
important-new Aug 6, 2026
3f0b231
refactor(booking): four things were living in booking.service.ts; now…
important-new Aug 6, 2026
f45f623
refactor(requests): reading a request and writing one stop sharing a …
important-new Aug 6, 2026
ec8400c
refactor(inspections): the primitive keeps the file; its four callers…
important-new Aug 6, 2026
d203b2a
refactor(settings): the workspace route keeps the form; two report pa…
important-new Aug 6, 2026
ff8792a
refactor(report): the report body becomes one file per section and on…
important-new Aug 6, 2026
f2cfd04
refactor(report): masthead, cover, summary row and export bar leave R…
important-new Aug 6, 2026
2099f49
refactor(report): the props contract and the dead-link page are not t…
important-new Aug 6, 2026
b6700c3
build: raise the app type-check heap — 8G no longer fits a cold run
important-new Aug 6, 2026
bd17615
test(settings): the catalog spec never passed the prop #278 made requ…
important-new Aug 6, 2026
a270f49
chore(gate): re-key one tenant-scope baseline entry after the move
important-new Aug 6, 2026
7716fec
schema(cancellation): the policy column and an attestation that can e…
important-new Aug 6, 2026
2583a63
feat(cancellation): refuse fees the agreement has not been said to cover
important-new Aug 6, 2026
9e49013
feat(cancellation): the outcome resolver, pure and case-by-case
important-new Aug 6, 2026
78e963d
feat(invoices): a partial refund writer, and both refund writers in o…
important-new Aug 6, 2026
402a773
feat(cancellation): apply the ladder on cancel, and quote it first
important-new Aug 6, 2026
7880b25
chore(cancellation): three exports nothing imports
important-new Aug 6, 2026
519fc89
chore(gate): tighten the ratchet on inspection-core, which I left loose
important-new Aug 6, 2026
2300e0b
schema(deposit): three tiers, and a flag so tier 3 survives a re-resolve
important-new Aug 6, 2026
cdb1534
feat(deposit): the webhook could not tell a deposit from a stray event
important-new Aug 6, 2026
bbbc214
feat(deposit): the deposit lands on the invoice, and the report stays…
important-new Aug 6, 2026
624aa97
feat(cancellation): a held deposit is collected money, and needs its …
important-new Aug 6, 2026
11ed379
feat(deposit): say the number before they commit, and ask for it after
important-new Aug 6, 2026
2603b06
chore(gate): three gates caught the deposit work, and all three were …
important-new Aug 6, 2026
15d10d0
feat(deposit): the company default was settable only by hand
important-new Aug 6, 2026
b0049be
feat(deposit): a service needs a way to say "not this one"
important-new Aug 6, 2026
af4562f
feat(booking): the anchors routing needs, none of which existed
important-new Aug 6, 2026
12406fe
feat(booking): three routing strategies, and a way to tell when one d…
important-new Aug 6, 2026
cacc17e
feat(settings): the surfaces that make a routing strategy choosable
important-new Aug 6, 2026
f569064
fix(tests): the drift guard did not cover the table that broke
important-new Aug 6, 2026
2397b7c
feat(calendar): the map from an OI entity to the event it created
important-new Aug 6, 2026
c9ef83c
feat(calendar): call the push primitives that had no callers
important-new Aug 6, 2026
19fbae2
feat(calendar): the import rules that need per-event identity
important-new Aug 6, 2026
7f47d11
feat(calendar): the inspector iCal feeds, and the two live bugs in them
important-new Aug 6, 2026
b44e275
feat(calendar): sync on a schedule, and say why when it does not
important-new Aug 6, 2026
a259f25
test(calendar): what only a running worker can prove
important-new Aug 6, 2026
3276f50
fix(calendar): four things the full gate found that pre-commit does n…
important-new Aug 6, 2026
6bb6197
fix(security): park a command fingerprint, not the payload
important-new Aug 6, 2026
b8ec83a
fix(privacy): redact audit metadata at write and scrub it on erasure
important-new Aug 6, 2026
c5d1b47
docs(compliance): the delivery-confirmation LIA, written before the code
important-new Aug 6, 2026
225d33e
docs(erasure): state what the PII heuristic cannot see, with the live…
important-new Aug 6, 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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ OpenInspection runs as ONE Cloudflare Worker (cloudflare/react-router-hono-fulls
| `SYNC_QUEUE` | No | Cloudflare Queue producer for the SaaS user-sync seam (SaaS only; absent in standalone). The outbox publishes CloudEvents envelopes here; a cron sweeper republishes stragglers; this worker also consumes the matching DLQ to mark failed rows. The same queue carries command REPLIES (`reply.tenant.updated`) from the cmd consumer. (The former `PORTAL_SERVICE` Service Binding was RETIRED 2026-06-04 — core holds no binding to portal; inbound M2M is guarded by the `x-portal-m2m` HMAC.) Inbound portal→core commands arrive on a separate queue this worker consumes (`server/portal/cmd-consumer.ts`): dedup (`processed_cmd_events`) → per-tenant stale guard (`tenants.applied_cmd_seq`) + credential-stream guard (`tenants.applied_cred_seq`) → apply → optional reply; unknown types park (`parked_cmd_events`). |
| `STRIPE_SECRET_KEY` | No | Stripe Connect (each tenant's OWN account; the platform never collects payments). Resolution is tenant-DB-preferred: a tenant's stored key always beats this env, so a platform-level binding can never hijack tenant payments. |
| `STRIPE_WEBHOOK_SECRET` | No | Stripe webhook HMAC verification |
| `QBO_ENV` | No | Which Intuit host the QuickBooks Online integration calls: `sandbox` (`https://sandbox-quickbooks.api.intuit.com`) or `production` (`https://quickbooks.api.intuit.com`). **No default and no fallback** — when unset, every QuickBooks API call throws and `GET /api/integrations/qbo/callback` refuses to store a connection. That is deliberate: Intuit Development keys authenticate only against sandbox companies and Production keys only against real ones, so a guessed host is wrong for one of them and fails in a way that reads like a bad credential. Required (together with `QBO_CLIENT_ID` / `QBO_CLIENT_SECRET`, which may instead be set per tenant in Settings → Integrations) for any QuickBooks sync. The OAuth authorize, token, and revoke endpoints are shared by both environments and are not affected by this setting. |
| `GOOGLE_PLACES_API_KEY` | No | Google Places API key powering address autocomplete on the dashboard new-inspection wizard and the public `/book` page (proxied via `/api/places/*` and `/public/geocode`). When unset, both endpoints return `{ data: [], reason: 'NO_API_KEY' }` and the address inputs degrade gracefully to plain text — the customer can still type a free-form address and submit. |
| `ESTATED_API_KEY` | No | Estated.io public-records key for the `POST /api/inspections/:id/property-facts/autofill` endpoint. Resolves year built / sqft / foundation / lot size / bedrooms / bathrooms by address. When unset, returns `{ data: null, reason: 'NO_API_KEY' }` and the Property Facts card shows a polite "auto-fill not configured" hint while still accepting manual entry. Same graceful-degrade pattern as `GOOGLE_PLACES_API_KEY`. |
| `STREAM` | No | Cloudflare Stream binding (binding name `STREAM`). Required only when the video backend is set to Stream (self-host: Settings → Integrations → Video; SaaS: paid tier). Absent in the default R2 configuration. |
Expand Down
167 changes: 167 additions & 0 deletions app/components/booking/BookingDepositPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* The deposit step, shown AFTER the booking exists.
*
* The ordering is the design, not an implementation detail. The appointment is
* already saved by the time this renders, so a declined card leaves a real
* booking with an unpaid deposit that the tenant can see and chase — never a
* silent drop. That is why there is no "pay to confirm" wording anywhere here.
*
* Nothing this component observes is trusted as payment either. Stripe redirects
* back to the booking page on success, and the ledger row is written by the
* webhook; the confirmation this shows is about the CARD, and it says so.
*
* Modelled on `portal/sections/StripePayPanel` — same lazy `loadStripe` after a
* click, same Elements-in-a-card shape — but not shared with it: that one is
* keyed on an invoice and gated on a portal grant, and this one exists
* precisely for the case where neither is true.
* lint:ds — only `ih-*` tokens.
*/
import { useState } from "react";
import { loadStripe, type Stripe as StripeJs } from "@stripe/stripe-js";
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
import { formatCurrency } from "~/lib/format";
import { useDisplayLocale } from "~/hooks/useSessionContext";
import { buildStripeElementsOptions } from "~/lib/stripe-elements-options";
import { m } from "~/paraglide/messages";

type Phase = "idle" | "loading" | "ready" | "settled" | "unavailable";

export function BookingDepositPanel({
inspectionId,
depositCents,
currency,
companyName,
}: {
inspectionId: string;
depositCents: number;
currency: string;
companyName: string;
}) {
// No session on a public booking page, so this resolves to the default —
// which is correct here: the visitor is anonymous and we have no preference
// of theirs to honour.
const locale = useDisplayLocale();
const [phase, setPhase] = useState<Phase>("idle");
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [stripePromise, setStripePromise] = useState<Promise<StripeJs | null> | null>(null);
const [returnUrl, setReturnUrl] = useState("");

const amount = formatCurrency(depositCents, { locale, currency });

async function startPayment() {
setReturnUrl(typeof window !== "undefined" ? window.location.href : "");
setPhase("loading");
try {
const res = await fetch(`/api/public/inspections/${inspectionId}/deposit-intent`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const body = (await res.json().catch(() => ({}))) as {
data?: { clientSecret?: string; publishableKey?: string };
};
if (res.ok && body.data?.clientSecret && body.data?.publishableKey) {
setStripePromise(loadStripe(body.data.publishableKey));
setClientSecret(body.data.clientSecret);
setPhase("ready");
return;
}
// 404 here means the deposit is already settled — most often because the
// webhook landed while this page was open. Nothing is owed, and saying
// "unavailable" would be a lie the client would phone about.
setPhase(res.status === 404 ? "settled" : "unavailable");
} catch {
setPhase("unavailable");
}
}

return (
<div className="mt-6 rounded-lg border border-ih-border bg-ih-bg-muted p-4 text-left">
<div className="flex items-center justify-between gap-3 mb-1">
<span className="text-[13px] font-semibold text-ih-fg-1">{m.booking_deposit_pay_heading()}</span>
<span className="text-[18px] font-semibold text-ih-fg-1 tabular-nums">{amount}</span>
</div>
<p className="text-[12px] text-ih-fg-3 leading-relaxed mb-3">
{m.booking_deposit_pay_body({ company: companyName })}
</p>

{(phase === "idle" || phase === "loading") && (
<>
<button
type="button"
onClick={startPayment}
disabled={phase === "loading"}
className="w-full h-11 rounded-lg bg-ih-primary text-ih-primary-fg font-bold text-sm hover:bg-ih-primary-600 transition-colors disabled:opacity-60 disabled:cursor-wait"
>
{phase === "loading" ? m.booking_deposit_starting() : m.booking_deposit_pay_button({ amount })}
</button>
{/* The appointment is already made. Say so beside the button, or the
client reads the deposit as the thing that confirms it. */}
<p className="mt-2 text-center text-[11px] text-ih-fg-4">{m.booking_deposit_already_booked()}</p>
</>
)}

{phase === "ready" && clientSecret && stripePromise && (
<Elements
stripe={stripePromise}
options={buildStripeElementsOptions({ clientSecret, brandColor: null, displayLocale: locale })}
>
<DepositForm amount={amount} returnUrl={returnUrl} />
</Elements>
)}

{phase === "settled" && (
<p className="text-[12px] text-ih-fg-3 leading-relaxed">{m.booking_deposit_already_paid()}</p>
)}

{phase === "unavailable" && (
<p className="text-[12px] text-ih-fg-3 leading-relaxed">
{m.booking_deposit_unavailable({ company: companyName })}
</p>
)}
</div>
);
}

function DepositForm({ amount, returnUrl }: { amount: string; returnUrl: string }) {
const stripe = useStripe();
const elements = useElements();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

async function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!stripe || !elements) return;
setSubmitting(true);
setError(null);
const { error: payErr } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: returnUrl || (typeof window !== "undefined" ? window.location.href : "") },
});
// On success Stripe redirects; we only reach here on error. A decline is
// NOT a failed booking — the copy has to keep those apart.
if (payErr) {
setError(payErr.message ?? m.booking_deposit_error_generic());
setSubmitting(false);
}
}

return (
<form onSubmit={onSubmit} className="space-y-3">
<PaymentElement />
<button
type="submit"
disabled={!stripe || submitting}
className="w-full h-11 rounded-lg bg-ih-primary text-ih-primary-fg font-bold text-sm hover:bg-ih-primary-600 transition-colors disabled:opacity-60 disabled:cursor-wait"
>
{submitting ? m.booking_deposit_processing() : m.booking_deposit_pay_button({ amount })}
</button>
{error && (
<div className="rounded-md bg-ih-bad-bg px-3 py-2">
<p className="text-[12px] font-semibold text-ih-bad-fg">{error}</p>
<p className="mt-0.5 text-[11px] text-ih-bad-fg">{m.booking_deposit_decline_keeps_booking()}</p>
</div>
)}
</form>
);
}
66 changes: 61 additions & 5 deletions app/components/booking/BookingSteps.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { timeWindows, type CompanyProfile } from "./booking-constants";
import { PublicAddressAutocomplete, type PublicAddressSuggestion } from "./PublicAddressAutocomplete";
import { BookingDepositPanel } from "./BookingDepositPanel";
import { formatCurrency } from "~/lib/format";
import { useDisplayLocale } from "~/hooks/useSessionContext";
import { m } from "~/paraglide/messages";

export function PropertyStep({
address,
setAddress,
onSelectAddress,
}: {
address: string;
setAddress: (v: string) => void;
/** Carries the ZIP + placeId of a picked suggestion up to the form state. */
onSelectAddress: (sel: PublicAddressSuggestion | null) => void;
}) {
return (
<section className="space-y-5">
Expand All @@ -16,14 +23,12 @@ export function PropertyStep({
</div>
<label className="block">
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-ih-fg-3">{m.booking_field_address_label()}</span>
<input
type="text"
<PublicAddressAutocomplete
value={address}
onChange={(e) => setAddress(e.target.value)}
onValueChange={setAddress}
onSelect={onSelectAddress}
placeholder={m.booking_step_property_address_placeholder()}
autoComplete="street-address"
autoFocus
className="mt-1 w-full h-10 px-3 rounded-md border border-ih-border bg-ih-bg-card focus:border-ih-primary focus:shadow-ih-focus outline-none text-[14px] font-medium transition-colors"
/>
</label>
</section>
Expand All @@ -35,12 +40,18 @@ export function ServicesStep({
selectedServices,
toggleService,
totalPrice,
depositQuoteCents,
currency,
}: {
profile: CompanyProfile;
selectedServices: Set<string>;
toggleService: (id: string) => void;
totalPrice: number;
/** Quoted, not charged. 0 renders nothing at all. */
depositQuoteCents: number;
currency: string;
}) {
const locale = useDisplayLocale();
return (
<section className="space-y-5">
<div className="space-y-1">
Expand Down Expand Up @@ -92,6 +103,13 @@ export function ServicesStep({
</span>
</div>
)}
{selectedServices.size > 0 && depositQuoteCents > 0 && (
<p className="px-4 text-[12px] text-ih-fg-3 leading-relaxed">
{m.booking_deposit_quote_note({
amount: formatCurrency(depositQuoteCents, { locale, currency }),
})}
</p>
)}
</section>
);
}
Expand All @@ -109,6 +127,11 @@ export function ConfirmStep({
totalPrice,
clientName,
clientEmail,
depositQuoteCents,
depositDueCents,
bookedInspectionId,
currency,
companyName,
}: {
message: { text: string; ok: boolean } | null;
address: string;
Expand All @@ -121,7 +144,15 @@ export function ConfirmStep({
totalPrice: number;
clientName: string;
clientEmail: string;
/** What the form expects to be asked for, before submitting. */
depositQuoteCents: number;
/** What the SERVER froze, once the booking exists. Null before then. */
depositDueCents: number | null;
bookedInspectionId: string | null;
currency: string;
companyName: string;
}) {
const locale = useDisplayLocale();
return (
<section className="space-y-5">
{message?.ok ? (
Expand All @@ -133,6 +164,16 @@ export function ConfirmStep({
</div>
<h2 className="text-xl font-bold text-ih-fg-1 mb-2">{m.booking_confirm_submitted_heading()}</h2>
<p className="text-[14px] text-ih-fg-3">{message.text}</p>
{/* Only once the server has said what it froze, and only if it froze
anything. A workspace with no deposit sees no payment step. */}
{bookedInspectionId && depositDueCents != null && depositDueCents > 0 && (
<BookingDepositPanel
inspectionId={bookedInspectionId}
depositCents={depositDueCents}
currency={currency}
companyName={companyName}
/>
)}
</div>
) : (
<>
Expand Down Expand Up @@ -169,6 +210,14 @@ export function ConfirmStep({
<span className="font-bold text-ih-fg-2">{m.booking_confirm_row_total()}</span>
<span className="font-bold text-ih-fg-1">${totalPrice.toFixed(2)}</span>
</div>
{depositQuoteCents > 0 && (
<div className="flex justify-between">
<span className="text-ih-fg-3">{m.booking_confirm_row_deposit()}</span>
<span className="font-medium text-ih-fg-1 tabular-nums">
{formatCurrency(depositQuoteCents, { locale, currency })}
</span>
</div>
)}
<div className="flex justify-between">
<span className="text-ih-fg-3">{m.booking_confirm_row_name()}</span>
<span className="font-medium text-ih-fg-1">{clientName}</span>
Expand All @@ -178,6 +227,13 @@ export function ConfirmStep({
<span className="font-medium text-ih-fg-1">{clientEmail}</span>
</div>
</div>
{depositQuoteCents > 0 && (
<p className="text-[12px] text-ih-fg-3 leading-relaxed">
{m.booking_deposit_confirm_note({
amount: formatCurrency(depositQuoteCents, { locale, currency }),
})}
</p>
)}
</>
)}
</section>
Expand Down
15 changes: 13 additions & 2 deletions app/components/booking/BookingWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function BookingWizard({
}) {
const {
step, setStep,
address, setAddress,
address, setAddress, setAddressPick,
selectedServices,
inspectionDate, setInspectionDate,
timeWindow, setTimeWindow,
Expand All @@ -38,6 +38,10 @@ export function BookingWizard({
turnstileRef,
toggleService,
totalPrice,
depositQuoteCents,
depositDueCents,
bookedInspectionId,
currency,
needsTurnstile,
canNext,
inspectorOptions,
Expand Down Expand Up @@ -97,7 +101,7 @@ export function BookingWizard({

{/* Step 0: Property */}
{step === 0 && (
<PropertyStep address={address} setAddress={setAddress} />
<PropertyStep address={address} setAddress={setAddress} onSelectAddress={setAddressPick} />
)}

{/* Step 1: Services */}
Expand All @@ -107,6 +111,8 @@ export function BookingWizard({
selectedServices={selectedServices}
toggleService={toggleService}
totalPrice={totalPrice}
depositQuoteCents={depositQuoteCents}
currency={currency}
/>
)}

Expand Down Expand Up @@ -157,6 +163,11 @@ export function BookingWizard({
totalPrice={totalPrice}
clientName={clientName}
clientEmail={clientEmail}
depositQuoteCents={depositQuoteCents}
depositDueCents={depositDueCents}
bookedInspectionId={bookedInspectionId}
currency={currency}
companyName={profile.company}
/>
)}

Expand Down
Loading
Loading