# Value a total loss for a claim


![Ottervale's claim file for a 2020 Ford F-150: the market range adjusted to its km, the comparable listings, and the Cardog calls behind them](https://cardog.app/marketing/guides/claims-valuation/desktop.jpg "2880x1800")

You'll build **Ottervale**, an auto insurer's app: adjusters value a damaged car against live listings and settle with the comparables attached. [See what it does](https://cardog.app/use-cases/claims-valuation).

- **Time:** About 45 minutes, or 5 to clone and run.
- **You need:** Node 20 or later and pnpm, A Cardog API key, Some Next.js
- **Cardog SDK:** `cardog.v2.vin.getByVin`, `cardog.v2.quotes.getByRef`, `cardog.v2.listings.search`, `cardog.v2.recalls.entity`
- **Clone the finished app:** [cardog-ai/example-claims-valuation](https://github.com/cardog-ai/example-claims-valuation), or build it step by step below.

```bash
git clone https://github.com/cardog-ai/example-claims-valuation.git
```

## 1. Get an API key

Every request to Cardog carries a key. Create one under [Account → API](https://cardog.app/account/api), then copy `.env.example` to `.env.local`. The app reads the key on the server; the browser never sees it.

The example values run as shipped. Without a key the app runs on responses stored for three VINs: a Civic with a deep market, an F-150 with mixed trims and an Ioniq 5 with a thin one.

`BETTER_AUTH_SECRET` ships as a development placeholder, so a fresh clone builds and runs. Replace it with your own (`openssl rand -base64 32`) before you deploy: a production server will not start without one, and warns on every start while the placeholder is set.

**`.env.example`**

```ini
# Copy to .env.local. With these values the app runs on stored fixtures
# and a development auth secret.

# Your Cardog API key (https://cardog.app/account/api). Server-side only; it
# never reaches the browser. Leave empty for fixture mode.
CARDOG_API_KEY=

# Signs sessions. This value is a DEVELOPMENT PLACEHOLDER so a fresh clone
# builds and runs; the app warns on every production start while it is set.
# Replace it before deploying: openssl rand -base64 32
BETTER_AUTH_SECRET=ottervale-claims-dev-only-secret-replace-before-deploying

# Where the app is served. Needed when it is not http://localhost:3026.
BETTER_AUTH_URL=http://localhost:3026

# SQLite file. Created on first run. Damage photos go in uploads/ beside it.
DATABASE_PATH=data/claims.db

# Seeded review logins (created by `pnpm db:setup` on an empty database):
#   policyholder  policyholder@ottervale.example / ottervale-policyholder
#   adjuster      adjuster@ottervale.example     / ottervale-adjuster
```

## 2. Create the Next.js app

The claims desk is one Next.js app: the policyholder's screens, the adjuster's desk, and the server code that talks to Cardog. SQLite keeps it to one process, and damage photos sit on disk beside the database. `better-sqlite3` is a native module, so the config loads it at runtime, and server actions accept four photos of up to 8 MB.

**`terminal`**

```bash
pnpm create next-app@latest ottervale-claims --ts --app --tailwind --src-dir
cd ottervale-claims
pnpm add @cardog/api better-auth better-sqlite3 drizzle-orm server-only
pnpm add -D drizzle-kit tsx @types/better-sqlite3
```

**`next.config.ts`**

```ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // better-sqlite3 is a native module: load it from node_modules at runtime.
  serverExternalPackages: ["better-sqlite3"],
  // This app is its own root, even when it sits inside another repository.
  outputFileTracingRoot: process.cwd(),
  // A claim carries up to four damage photos of up to 8 MB each.
  experimental: { serverActions: { bodySizeLimit: "36mb" } },
};

export default nextConfig;
```

## 3. Add sign-in with better-auth

Policyholders and adjusters sign in with email and password, with sessions in the same SQLite file as the claims. `role` is a user field sign-up cannot set, so everyone who signs up is a policyholder and the seed script makes the adjuster.

`authSecret()` picks the secret that signs sessions: yours whenever `BETTER_AUTH_SECRET` is set, and the development placeholder only on your machine and during `next build`. A production server with no secret stops, and one still on the placeholder logs a warning.

**`src/lib/auth.ts`**

```ts
function authSecret(): string {
  const set = process.env.BETTER_AUTH_SECRET?.trim();
  const production = process.env.NODE_ENV === "production" && process.env.NEXT_PHASE !== "phase-production-build";
  if (set) {
    if (production && set === DEV_SECRET) {
      console.warn("[ottervale-claims] BETTER_AUTH_SECRET is the development placeholder. Replace it before deploying.");
    }
    return set;
  }
  if (!production) return DEV_SECRET;
  throw new Error("[ottervale-claims] BETTER_AUTH_SECRET is not set. Generate one with `openssl rand -base64 32`.");
}
```

**`src/lib/auth.ts`**

```ts
export const auth = betterAuth({
  appName: "Ottervale Claims",
  baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3026",
  secret: authSecret(),
  database: drizzleAdapter(db, {
    provider: "sqlite",
    schema: { user: schema.user, session: schema.session, account: schema.account, verification: schema.verification },
  }),
  emailAndPassword: { enabled: true, minPasswordLength: 8, autoSignIn: true },
  user: {
    additionalFields: {
      role: { type: "string", required: false, defaultValue: "policyholder", input: false },
    },
  },
  plugins: [nextCookies()],
});
```

**`src/app/api/auth/[...all]/route.ts`**

```ts
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";

export const { GET, POST } = toNextJsHandler(auth);
```

## 4. Construct the Cardog client on the server

One file holds every Cardog call. The client is built once from the key, and the `server-only` import fails the build if a browser bundle reaches for it. Each call is timed and logged, which is what the app's "What the app called" pane shows.

SDK call: `new CardogClient({ apiKey, appId })`

**`src/lib/cardog.ts`**

```ts
const apiKey = process.env.CARDOG_API_KEY?.trim();

/** The one Cardog client. Server-side only. */
export const cardog: CardogClient | null = apiKey
  ? new CardogClient({ apiKey, appId: "ottervale-claims", baseUrl: process.env.CARDOG_BASE_URL || undefined })
  : null;

export const dataMode: "live" | "fixture" = cardog ? "live" : "fixture";
export { FIXTURE_VINS };

// The v2 response types, read off the client's own method signatures.
type V2 = CardogClient["v2"];
export type VinIdentity = Awaited<ReturnType<V2["vin"]["getByVin"]>>;
export type MarketQuote = Awaited<ReturnType<V2["quotes"]["getByRef"]>>;
export type ListingSearch = Awaited<ReturnType<V2["listings"]["search"]>>;
export type Listing = ListingSearch["listings"][number];
type ListingFiltersV2 = NonNullable<NonNullable<Parameters<V2["listings"]["search"]>[0]>["filters"]>;
export type ModelYearRecalls = Awaited<ReturnType<V2["recalls"]["entity"]>>;

export interface Result<T> {
  data: T | null;
  call: CallRecord;
  error: string | null;
}
```

## 5. Check the VIN as it is typed

Most bad VINs are typos, and the ninth character catches them. Check it in the browser before spending a request: an I or O is almost always a 1 or 0, and a check digit that does not add up means a character is wrong.

**`src/lib/vin.ts`**

```ts
export function checkVin(vin: string): VinCheck {
  if (!vin) return { state: "empty" };
  const bad = vin.match(/[IOQ]/);
  if (bad) {
    const hint = bad[0] === "I" ? "1" : "0";
    return { state: "invalid", message: `VINs never use the letter ${bad[0]}. It's probably the number ${hint}.` };
  }
  if (/[^A-HJ-NPR-Z0-9]/.test(vin)) return { state: "invalid", message: "Only letters and numbers." };
  if (vin.length < 17) return { state: "typing", remaining: 17 - vin.length };
  const expected = expectedCheckDigit(vin);
  if (expected && expected !== vin[8]) {
    return {
      state: "check-mismatch",
      message: `The 9th character should be ${expected} for this VIN. Check for a mistyped character.`,
    };
  }
  return { state: "ok", message: "17 characters, check digit matches." };
}
```

## 6. Decode the VIN

Once the VIN is complete, the form posts it to a route handler, which decodes it on the server and shows the policyholder the vehicle they are claiming for. The decode is held for an hour, so filing the claim does not read it twice.

SDK call: `cardog.v2.vin.getByVin(vin)`

**What comes back.** Year, make, model, trim and body, and the refs: `refs.model` keys the comparable search, `refs.modelYear` the quote and the recalls.

**`src/lib/cardog.ts`**

```ts
export function decodeVin(vin: string, step: string) {
  return run<VinIdentity>({
    step,
    vin,
    key: "decode",
    method: "cardog.v2.vin.getByVin",
    args: [vin],
    http: `GET /v2/vin/${vin}`,
    returns: "Identity card: year, make, model, trim, and the refs every other call takes",
    live: (c) => c.v2.vin.getByVin(vin),
  });
}
```

**`src/lib/vehicle-read.ts`**

```ts
export async function identify(userId: string, vin: string, step = "Report a claim"): Promise<IdentifyResult> {
  const r = await decodeVin(vin, step);
  if (!r.data) return { ok: false, error: r.error, vehicle: null, calls: [r.call] };
  const vehicle = summarize(r.data);
  held.set(keyOf(userId, vin), { vehicle, call: r.call, at: Date.now() });
  return { ok: true, error: null, vehicle, calls: [r.call] };
}
```

**`src/app/api/vehicle/identify/route.ts`**

```ts
import { NextResponse } from "next/server";
import { getViewer } from "@/lib/session";
import { identify } from "@/lib/vehicle-read";
import { normalizeVin } from "@/lib/vin";

/** POST { vin } → the decoded vehicle and the call behind it. Signed-in users only. */
export async function POST(req: Request) {
  const me = await getViewer();
  if (!me) return NextResponse.json({ ok: false, error: "Sign in first." }, { status: 401 });
  const { vin } = (await req.json().catch(() => ({}))) as { vin?: string };
  const v = normalizeVin(vin ?? "");
  if (v.length !== 17) return NextResponse.json({ ok: false, error: "A VIN has 17 characters." }, { status: 400 });
  return NextResponse.json(await identify(me.id, v));
}
```

![Ottervale's report-a-claim form with the VIN decoded to a 2021 Honda Civic LX](https://cardog.app/marketing/guides/claims-valuation/report.jpg "2880x1800")

## 7. File the claim

The claim carries the VIN, the km, the date and city of the loss, what happened and up to four photos. A VIN that does not decode still files: the adjuster sees that and works from the photos. The policyholder then follows the claim on its own page, with the updates the desk shares.

**`src/app/actions.ts`**

```ts
export async function fileClaim(_prev: ActionResult | null, form: FormData): Promise<ActionResult> {
  const me = await requireViewer();
  const vin = normalizeVin(String(form.get("vin") ?? ""));
  const check = checkVin(vin);
  if (check.state !== "ok" && check.state !== "check-mismatch")
    return { ok: false, error: "Enter the 17-character VIN." };
  const odometerKm = Number(String(form.get("odometerKm") ?? "").replace(/[^\d]/g, ""));
  if (!Number.isFinite(odometerKm) || odometerKm < 1 || odometerKm > 1_500_000)
    return { ok: false, error: "Enter the odometer reading in km." };
  const lossDate = String(form.get("lossDate") ?? "");
  if (!/^\d{4}-\d{2}-\d{2}$/.test(lossDate) || new Date(lossDate) > new Date())
    return { ok: false, error: "Enter the date of the loss. It can't be in the future." };
  const lossCity = String(form.get("lossCity") ?? "");
  if (!cityById(lossCity)) return { ok: false, error: "Choose where it happened." };
  const description = String(form.get("description") ?? "").trim();
  if (description.length < 10) return { ok: false, error: "Tell us in a sentence or two what happened." };
  const policyNumber = String(form.get("policyNumber") ?? "").trim() || null;

  const files = PHOTO_SLOTS.map((slot) => [slot, form.get(`photo_${slot}`)] as const).filter(
    (x): x is [(typeof PHOTO_SLOTS)[number], File] => x[1] instanceof File && x[1].size > 0
  );

  // The decode the policyholder already saw, or a fresh one. A VIN that does
  // not decode still files: the adjuster sees that and works from the photos.
  const { vehicle, calls } = await vehicleFor(me.id, vin, "Report a claim");

  const id = crypto.randomUUID();
  db.insert(claim)
    .values({
      id,
      number: newClaimNumber(),
      userId: me.id,
      policyNumber,
      vin,
      odometerKm,
      lossDate,
      lossCity,
      description,
      vehicle,
      calls,
    })
    .run();
  try {
    for (const [slot, file] of files) {
      const path = await savePhoto(id, slot, file);
      db.insert(photo)
        .values({ id: crypto.randomUUID(), claimId: id, slot, path, mime: file.type, bytes: file.size })
        .run();
    }
  } catch (e) {
    db.delete(claim).where(eq(claim.id, id)).run();
    return { ok: false, error: e instanceof Error ? e.message : "A photo could not be saved." };
  }
  logEvent(id, me.name, "filed", "Claim reported", true);
  revalidatePath("/claims");
  revalidatePath("/desk");
  redirect(`/claims/${id}`);
}
```

![The policyholder's view of a reported claim and its status](https://cardog.app/marketing/guides/claims-valuation/follow.jpg "2880x1800")

## 8. Build the adjuster's queue

Adjusters see open, decided or all claims, oldest first. Each row shows the vehicle, the claimant, the loss city and, once valued, the actual cash value.

**`src/lib/claims.ts`**

```ts
export function queue(view: QueueView) {
  const statuses: ClaimStatus[] =
    view === "open"
      ? ["filed", "in_review"]
      : view === "decided"
      ? ["total_loss", "repairable"]
      : ["filed", "in_review", "total_loss", "repairable"];
  return db
    .select({ claim, claimant: { name: user.name } })
    .from(claim)
    .innerJoin(user, eq(claim.userId, user.id))
    .where(inArray(claim.status, statuses))
    .orderBy(view === "decided" ? desc(claim.decidedAt) : asc(claim.createdAt))
    .all();
}
```

![Ottervale's claims desk with three claims and their status](https://cardog.app/marketing/guides/claims-valuation/queue.jpg "2880x1800")

## 9. Show the recalls issued for the model and year

The claim file reads the recalls issued for the model and year when an adjuster opens it. They are listed by model year, not matched to the VIN, and the file says so. The policyholder never sees them.

SDK call: `cardog.v2.recalls.entity(modelYearRef)`

**What comes back.** Transport Canada and NHTSA campaigns issued for the model year, and the total.

**`src/lib/cardog.ts`**

```ts
export function readModelYearRecalls(modelYearRef: string, vin: string, step: string) {
  return run<ModelYearRecalls>({
    step,
    vin,
    key: "recalls",
    method: "cardog.v2.recalls.entity",
    args: [modelYearRef, { limit: 20 }],
    http: `GET /v2/recalls/entity/${modelYearRef}?limit=20`,
    returns: "Recall campaigns issued for the model year (Transport Canada and NHTSA)",
    live: (c) => c.v2.recalls.entity(modelYearRef, { limit: 20 }),
  });
}
```

![The recalls issued for the 2020 Ford F-150 model year on Ottervale's claim file](https://cardog.app/marketing/guides/claims-valuation/recalls.jpg "2880x1800")

## 10. Read the market for the model year

"Value this vehicle" starts with the quote: the asking prices of live Canadian listings for the model year. It covers every trim and every km, so the app adjusts it to the claimed vehicle in a later step.

SDK call: `cardog.v2.quotes.getByRef(modelYearRef)`

**What comes back.** `priceP25`, `priceMedian` and `priceP75`, the live listing count and `asOf`.

**`src/lib/cardog.ts`**

```ts
export function readMarketQuote(modelYearRef: string, vin: string, step: string) {
  return run<MarketQuote>({
    step,
    vin,
    key: "quote",
    method: "cardog.v2.quotes.getByRef",
    args: [modelYearRef],
    http: `GET /v2/quotes/${modelYearRef}`,
    returns: "Asking-price quartiles and the live listing count for the model year",
    live: (c) => c.v2.quotes.getByRef(modelYearRef),
  });
}
```

## 11. Search comparables near the loss

In the same action, search live listings of the same model, one model year either side, within a radius of the loss city. Both reads run in parallel and are saved to the file as one dated snapshot, so the file reads the same after the listings sell.

SDK call: `cardog.v2.listings.search({ filters: { models, year, location }, pagination })`

**What comes back.** Listings with price, odometer, year, trim and the seller's city.

**`src/lib/cardog.ts`**

```ts
export function searchComparables(
  input: {
    modelRef: string;
    year: number;
    band: number;
    near: { lat: number; lng: number } | null;
    radiusKm: number | null;
  },
  vin: string,
  step: string
) {
  const filters: ListingFiltersV2 = {
    models: [input.modelRef as `model:${string}`],
    year: { min: input.year - input.band, max: input.year + input.band },
    ...(input.near && input.radiusKm ? { location: { ...input.near, radius: input.radiusKm } } : {}),
  };
  const pagination = { page: 1, limit: 24 };
  const query = new URLSearchParams({
    model: input.modelRef,
    "year.min": String(filters.year!.min),
    "year.max": String(filters.year!.max),
    ...(filters.location
      ? {
          lat: String(filters.location.lat),
          lng: String(filters.location.lng),
          radius: String(filters.location.radius),
        }
      : {}),
    limit: "24",
  });
  return run<ListingSearch>({
    step,
    vin,
    key: "comparables",
    method: "cardog.v2.listings.search",
    args: [{ filters, pagination }],
    http: `GET /v2/listings/search?${decodeURIComponent(query.toString())}`,
    returns: "Live listings of the same model in the year band, with price, km and trim",
    live: (c) => c.v2.listings.search({ filters, pagination }),
  });
}
```

**`src/app/actions.ts`**

```ts
export async function valueClaim(claimId: string, radiusKm: number): Promise<ActionResult> {
  const me = await requireAdjuster();
  const c = fileFor(claimId);
  if (decided(c.status)) return { ok: false, error: "Reopen the file to value it again." };
  const v = c.vehicle as VehicleSummary | null;
  if (!v?.refs.modelYear || !v.year)
    return { ok: false, error: "The VIN did not decode, so there is no market to read." };
  const radius = (RADII as readonly number[]).includes(radiusKm) ? radiusKm : 250;
  const city = cityById(c.lossCity);
  const step = `Valuation · ${c.number}`;

  const [quote, comps] = await Promise.all([
    readMarketQuote(v.refs.modelYear, c.vin, step),
    v.refs.model
      ? searchComparables(
          {
            modelRef: v.refs.model,
            year: v.year,
            band: 1,
            near: city ? { lat: city.lat, lng: city.lng } : null,
            radiusKm: radius || null,
          },
          c.vin,
          step
        )
      : null,
  ]);
  const valuation = buildValuation({
    subject: { vin: c.vin, year: v.year, km: c.odometerKm },
    modelYearRef: v.refs.modelYear,
    modelRef: v.refs.model,
    quote: quote.data,
    listings: comps?.data?.listings ?? null,
    search: { band: 1, radiusKm: radius, city: city?.name ?? null, error: comps?.error ?? null },
  });
  if (!valuation.quote && valuation.comps.length === 0)
    return { ok: false, error: quote.error ?? "The market read did not return." };

  const newCalls = [quote.call, ...(comps ? [comps.call] : [])];
  const decodeCalls = (c.calls as CallRecord[]).filter((x) => x.method === "cardog.v2.vin.getByVin");
  db.update(claim)
    .set({
      valuation,
      radiusKm: radius,
      status: "in_review",
      adjusterId: c.adjusterId ?? me.id,
      calls: [...decodeCalls, ...newCalls],
      updatedAt: new Date(),
    })
    .where(eq(claim.id, c.id))
    .run();
  if (c.status === "filed") logEvent(c.id, me.name, "opened", "An adjuster is reviewing your claim", true);
  logEvent(
    c.id,
    me.name,
    "valued",
    `Market read: ${
      valuation.quote
        ? `median ${cad(valuation.quote.median)} across ${valuation.quote.liveCount} listings`
        : "no quote"
    }, ${valuation.comps.length} comparables`
  );
  touch(c);
  return { ok: true, calls: newCalls };
}
```

![Comparable listings for a 2020 Ford F-150 within 250 km of Calgary, adjusted to the claimed km and year](https://cardog.app/marketing/guides/claims-valuation/comparables.jpg "2880x1800")

## 12. Adjust every comparable to the claimed vehicle

A least-squares fit of price on km and model year over the comparable rows gives what a kilometre and a year are worth for this vehicle today. With fewer than six rows, or a fit outside sane bounds, the desk's standing rates apply, and the file says which. The adjuster ticks the comparables to keep; each is copied onto the file.

**`src/lib/valuation.ts`**

```ts
export function fitRates(comps: CompRow[], median: number | null): Rates {
  const deskYear = Math.round((median ?? 25000) * DESK_RATES.perYearShare);
  const desk: Rates = { perKm: DESK_RATES.perKm, perYear: deskYear, source: "desk", n: 0 };
  const rows = comps.filter((c) => c.odometerKm !== null) as (CompRow & { odometerKm: number })[];
  if (rows.length < 6) return { ...desk, n: rows.length };

  const n = rows.length;
  const mk = rows.reduce((s, r) => s + r.odometerKm, 0) / n;
  const my = rows.reduce((s, r) => s + r.year, 0) / n;
  const mp = rows.reduce((s, r) => s + r.price, 0) / n;
  let s11 = 0,
    s12 = 0,
    s22 = 0,
    s1p = 0,
    s2p = 0;
  for (const r of rows) {
    const a = r.odometerKm - mk,
      b = r.year - my,
      p = r.price - mp;
    s11 += a * a;
    s12 += a * b;
    s22 += b * b;
    s1p += a * p;
    s2p += b * p;
  }

  let bKm: number, bYear: number;
  const det = s11 * s22 - s12 * s12;
  if (s22 < 1e-9 || Math.abs(det) < 1e-6) {
    // One model year only: fit km alone and keep the desk's year rate.
    if (s11 < 1e-9) return { ...desk, n };
    bKm = s1p / s11;
    bYear = deskYear;
  } else {
    bKm = (s22 * s1p - s12 * s2p) / det;
    bYear = (s11 * s2p - s12 * s1p) / det;
  }

  const perKm = -bKm;
  const yearCap = (median ?? mp) * 0.25;
  // A fit that says km add value, or a year is worth more than a quarter of
  // the car, is noise from a thin or mixed set of rows. Use the desk's rates.
  if (!(perKm >= PER_KM_BOUNDS[0] && perKm <= PER_KM_BOUNDS[1]) || !(bYear >= 0 && bYear <= yearCap)) {
    return { ...desk, n };
  }
  return { perKm: round(perKm, 3), perYear: Math.round(bYear), source: "fit", n };
}
```

**`src/app/actions.ts`**

```ts
export async function setComparable(claimId: string, listingId: string, picked: boolean): Promise<ActionResult> {
  const me = await requireAdjuster();
  const c = fileFor(claimId);
  if (decided(c.status)) return { ok: false, error: "This file is decided. Reopen it to change comparables." };
  if (!picked) {
    db.delete(comparable)
      .where(and(eq(comparable.claimId, c.id), eq(comparable.listingId, listingId)))
      .run();
  } else {
    const row = (c.valuation as Valuation | null)?.comps.find((x) => x.listingId === listingId);
    if (!row) return { ok: false, error: "That listing is not in the latest market read." };
    db.insert(comparable)
      .values({
        id: crypto.randomUUID(),
        claimId: c.id,
        listingId,
        vin: row.vin,
        year: row.year,
        price: row.price,
        odometerKm: row.odometerKm,
        listing: row,
        addedBy: me.name,
      })
      .onConflictDoNothing()
      .run();
  }
  touch(c);
  return { ok: true };
}
```

## 13. Run the total-loss test and record the decision

Actual cash value is the mean of the picked comparables, three or more, or else the adjusted market median. The vehicle is a total loss when the repair estimate reaches the threshold share of that value. The threshold is editable per file, and a decision against the test needs a note.

**`src/lib/valuation.ts`**

```ts
export function actualCashValue(v: Valuation | null, picked: CompRow[]) {
  if (!v) return null;
  if (picked.length >= MIN_COMPARABLES) {
    const adj = picked.map((c) => adjustedPrice(c, v));
    return {
      value: roundTo(adj.reduce((s, x) => s + x, 0) / adj.length, 10),
      basis: "comparables" as AcvBasis,
      n: picked.length,
    };
  }
  const m = marketRange(v);
  if (!m) return null;
  return { value: m.mid, basis: "market" as AcvBasis, n: 0 };
}
```

**`src/lib/valuation.ts`**

```ts
export function totalLossVerdict(acv: number | null, repair: number | null, thresholdPct: number) {
  if (!acv || repair === null || repair === undefined) return null;
  const threshold = roundTo((acv * thresholdPct) / 100, 1);
  return { threshold, ratio: repair / acv, total: repair >= threshold };
}
```

**`src/app/actions.ts`**

```ts
export async function decideClaim(
  claimId: string,
  decision: "total_loss" | "repairable",
  note: string
): Promise<ActionResult> {
  const me = await requireAdjuster();
  const c = fileFor(claimId);
  if (decided(c.status)) return { ok: false, error: "Already decided." };
  const acv = actualCashValue(c.valuation as Valuation | null, comparablesOn(c.id).map(asCompRow));
  if (!acv) return { ok: false, error: "Value the vehicle first." };
  const verdict = totalLossVerdict(acv.value, c.repairEstimate, c.thresholdPct);
  if (!verdict) return { ok: false, error: "Enter the repair estimate first." };
  if ((decision === "total_loss") !== verdict.total && note.trim().length < 10)
    return { ok: false, error: "This goes against the threshold. Say why in the note." };
  db.update(claim)
    .set({ status: decision, decidedAt: new Date(), decisionNote: note.trim() || null, updatedAt: new Date() })
    .where(eq(claim.id, c.id))
    .run();
  logEvent(
    c.id,
    me.name,
    "decided",
    decision === "total_loss" ? `Total loss. Actual cash value ${cad(acv.value)}` : "Repairable. Repair authorized",
    true
  );
  touch(c);
  return { ok: true };
}
```

![Ottervale's total-loss test with the actual cash value, the repair estimate and the verdict](https://cardog.app/marketing/guides/claims-valuation/total-loss.jpg "2880x1800")

## 14. Preview the settlement letter

The letter is generated from the file as it stands: the determination, how the value was reached with each comparable, the actual cash value less the deductible, and what happens next. It prints; the app never sends it.

![Ottervale's settlement letter preview for a 2023 Hyundai Ioniq 5](https://cardog.app/marketing/guides/claims-valuation/letter.jpg "2880x1800")

## 15. Deploy

The claims desk ships as one container with SQLite and the photos on a volume. Build the image, mount the volume, and set the key, the auth secret and the public URL in the host's environment. On a serverless host, swap SQLite for a hosted database and the photo folder for object storage; the Cardog code does not change.

**`Dockerfile`**

```dockerfile
# Ottervale Claims: one container, SQLite and damage photos on a mounted volume.
#   docker build -t ottervale-claims .
#   docker run -p 3026:3026 -v ottervale-claims-data:/app/data \
#     -e CARDOG_API_KEY=... -e BETTER_AUTH_SECRET=... -e BETTER_AUTH_URL=https://your.host ottervale-claims
FROM node:22-bookworm-slim AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
RUN corepack enable
COPY --from=build /app ./
VOLUME /app/data
EXPOSE 3026
CMD ["pnpm", "start"]
```

**`terminal`**

```bash
docker build -t ottervale-claims .
docker run -p 3026:3026 -v ottervale-claims-data:/app/data \
  -e CARDOG_API_KEY=your_cardog_api_key \
  -e BETTER_AUTH_SECRET=$(openssl rand -base64 32) \
  -e BETTER_AUTH_URL=https://claims.example.com \
  ottervale-claims
```

## Next steps

- [Market data](https://cardog.app/docs/market-data): Quotes, comparables and the price history behind them.
- [VIN decode](https://cardog.app/docs/vin): Every field the decode returns.
- [Insurance onboarding](https://cardog.app/docs/guides/insurance-onboarding): Ottervale's other app: a policy prefilled from the VIN.


---

Previous: [Build an insurance onboarding flow](https://cardog.app/docs/guides/insurance-onboarding.md) (Build guides)
Next: [Check loan collateral at origination](https://cardog.app/docs/guides/loan-collateral.md) (Build guides)
