Public: anyone can read this; the crew can change it.
Each deal carries the agent's commission rate, like 2.5%, and its page shows the commission it pays at closing, kept off the client timeline #145
Check · Review · Land · Live done — Landed and live in production.
mason/deal-clock:mason/142-each-deal-carries-the-agent-s-commission → main 1 commit fork
10 files
changed +250
−27
README.md +9
| @@ -178,6 +178,15 @@ Signed out, `/` shows what deal-clock does, three short points, a still picture | ||
| 178 | 178 | with none shows nothing, and the earnest money reminder says the amount |
| 179 | 179 | ("Your $12,750 earnest money deposit is due Nov 7"). The sample deals carry |
| 180 | 180 | figures. Over MCP, the `update_figures` tool; the terminal does not edit them. |
| 181 | **Commission.** A deal may carry your commission rate, typed as `2.5` or | |
| 182 | `2.5%` (0 to 10, at most two decimals; anything else is refused and keeps | |
| 183 | what you typed), set when you open the deal or under Edit price, earnest | |
| 184 | money and commission. The deal's page then reads "Commission 2.5% · $11,250", | |
| 185 | or "Commission 2.5%" with "add the price to see the amount" while the | |
| 186 | price is missing. It is yours alone: the client timeline, the printed | |
| 187 | handout and every reminder draft never show it. Over the API and MCP it is | |
| 188 | `commission_rate`, in hundredths of a percent (250 is 2.5%). The sample | |
| 189 | deals carry a rate. | |
| 181 | 190 | **Closing time and place.** The closing may carry a time (a quarter hour |
| 182 | 191 | from 7:00 am to 8:00 pm) and a place (up to 200 characters): tap Edit |
| 183 | 192 | closing time and place on the deal's page, and clear either by leaving it |
contract/openapi.json +36
| @@ -442,6 +442,15 @@ | ||
| 442 | 442 | "maximum": 100000000, |
| 443 | 443 | "description": "Optional, whole dollars; the earnest money reminder says it." |
| 444 | 444 | }, |
| 445 | "commission_rate": { | |
| 446 | "type": [ | |
| 447 | "integer", | |
| 448 | "null" | |
| 449 | ], | |
| 450 | "minimum": 0, | |
| 451 | "maximum": 1000, | |
| 452 | "description": "Optional, hundredths of a percent (250 is 2.5%), 0 to 1000." | |
| 453 | }, | |
| 445 | 454 | "side": { |
| 446 | 455 | "enum": [ |
| 447 | 456 | "buyer", |
| @@ -808,6 +817,15 @@ | ||
| 808 | 817 | "maximum": 100000000, |
| 809 | 818 | "description": "The earnest money amount in whole dollars; null while none is set." |
| 810 | 819 | }, |
| 820 | "commission_rate": { | |
| 821 | "type": [ | |
| 822 | "integer", | |
| 823 | "null" | |
| 824 | ], | |
| 825 | "minimum": 0, | |
| 826 | "maximum": 1000, | |
| 827 | "description": "The agent's commission rate in hundredths of a percent (250 is 2.5%), 0 to 1000; null while none is set. Never on a client page." | |
| 828 | }, | |
| 811 | 829 | "closing_time": { |
| 812 | 830 | "type": [ |
| 813 | 831 | "string", |
| @@ -1232,6 +1250,15 @@ | ||
| 1232 | 1250 | "minimum": 0, |
| 1233 | 1251 | "maximum": 100000000 |
| 1234 | 1252 | }, |
| 1253 | "commission_rate": { | |
| 1254 | "type": [ | |
| 1255 | "integer", | |
| 1256 | "null" | |
| 1257 | ], | |
| 1258 | "minimum": 0, | |
| 1259 | "maximum": 1000, | |
| 1260 | "description": "The agent's commission rate in hundredths of a percent (250 is 2.5%), 0 to 1000; null while none is set. Never on a client page." | |
| 1261 | }, | |
| 1235 | 1262 | "closing_time": { |
| 1236 | 1263 | "type": [ |
| 1237 | 1264 | "string", |
| @@ -2778,6 +2805,15 @@ | ||
| 2778 | 2805 | "maximum": 100000000, |
| 2779 | 2806 | "description": "The earnest money amount in whole dollars; null while none is set." |
| 2780 | 2807 | }, |
| 2808 | "commission_rate": { | |
| 2809 | "type": [ | |
| 2810 | "integer", | |
| 2811 | "null" | |
| 2812 | ], | |
| 2813 | "minimum": 0, | |
| 2814 | "maximum": 1000, | |
| 2815 | "description": "The agent's commission rate in hundredths of a percent (250 is 2.5%), 0 to 1000; null while none is set. Never on a client page." | |
| 2816 | }, | |
| 2781 | 2817 | "closing_time": { |
| 2782 | 2818 | "type": [ |
| 2783 | 2819 | "string", |
src/commission.test.ts added +110
| @@ -0,0 +1,110 @@ | ||
| 1 | /** The agent's commission rate: parsed at the boundary, shown on the deal's page as dollars, kept off every client page and draft. */ | |
| 2 | import { afterAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { commission, percent, rate } from "./money.ts"; | |
| 7 | import { createDeal, getDeal, updateDeal } from "./deals.ts"; | |
| 8 | import { openDb } from "./db.ts"; | |
| 9 | import { dealPage } from "./pages/deal.ts"; | |
| 10 | import { timelinePage } from "./pages/timeline.ts"; | |
| 11 | import { drafts } from "./reminders.ts"; | |
| 12 | import { agent } from "./env.ts"; | |
| 13 | import { serve } from "./server.ts"; | |
| 14 | import { sign, type Who } from "./who.ts"; | |
| 15 | ||
| 16 | const alice: Who = { role: "user", id: "u1", name: "alice" }; | |
| 17 | const fields = { address: "5 Rate Way", buyer: "B", seller: "S", contractDate: "2026-10-01", closingDate: "2026-11-01" }; | |
| 18 | let n = 0; | |
| 19 | const open = (extra: object = {}) => { | |
| 20 | const db = openDb(":memory:"); | |
| 21 | const made = createDeal(db, alice, { ...fields, address: `${++n} Rate Way`, openAnyway: true, ...extra }); | |
| 22 | if (!made.ok) throw new Error(made.message); | |
| 23 | return { db, id: made.value.id }; | |
| 24 | }; | |
| 25 | ||
| 26 | test("rate parses 2.5 and 2.5% to hundredths and refuses the rest", () => { | |
| 27 | const v = (x: unknown) => { const r = rate(x, "commission_rate", "commission rate"); return r.ok ? r.value : r.error; }; | |
| 28 | expect(v("2.5")).toBe(250); | |
| 29 | expect(v(" 2.5% ")).toBe(250); | |
| 30 | expect(v("3")).toBe(300); | |
| 31 | expect(v("10")).toBe(1000); | |
| 32 | expect(v("0.25")).toBe(25); | |
| 33 | expect(v(250)).toBe(250); | |
| 34 | expect(v("")).toBeNull(); | |
| 35 | expect(v(undefined)).toBeNull(); | |
| 36 | for (const bad of ["-1", "10.01", "11", "abc", "2.555", "2,5", "1e1", 1001, -5, 2.5, true]) expect(v(bad)).toBe("invalid"); | |
| 37 | }); | |
| 38 | ||
| 39 | test("the amount is rounded once, to whole dollars", () => { | |
| 40 | expect(commission(450_000, 250)).toBe(11_250); | |
| 41 | expect(commission(318_500, 300)).toBe(9_555); | |
| 42 | expect(commission(389_900, 275)).toBe(10_722); | |
| 43 | expect(percent(250)).toBe("2.5%"); | |
| 44 | expect(percent(300)).toBe("3%"); | |
| 45 | expect(percent(275)).toBe("2.75%"); | |
| 46 | }); | |
| 47 | ||
| 48 | test("a deal opens with a rate, edits it, and a bad one is refused keeping the deal", () => { | |
| 49 | const { db, id } = open({ commissionRate: "2.5%" }); | |
| 50 | expect(getDeal(db, alice, { id })).toMatchObject({ ok: true, value: { commission_rate: 250 } }); | |
| 51 | const bad = updateDeal(db, alice, { id, commissionRate: "11" }); | |
| 52 | expect(bad).toMatchObject({ ok: false, error: "invalid", field: "commission_rate" }); | |
| 53 | expect(updateDeal(db, alice, { id, commissionRate: "3" })).toMatchObject({ ok: true, value: { commission_rate: 300 } }); | |
| 54 | expect(updateDeal(db, alice, { id, commissionRate: "" })).toMatchObject({ ok: true, value: { commission_rate: null } }); | |
| 55 | expect(createDeal(db, alice, { ...fields, address: "6 Rate Way", commissionRate: "-1" })).toMatchObject({ ok: false, field: "commission_rate" }); | |
| 56 | }); | |
| 57 | ||
| 58 | const view = (db: ReturnType<typeof openDb>, id: number) => { | |
| 59 | const got = getDeal(db, alice, { id }); | |
| 60 | if (!got.ok) throw new Error(got.message); | |
| 61 | return got.value; | |
| 62 | }; | |
| 63 | ||
| 64 | test("the deal's page shows the rate and the amount, or asks for the price", () => { | |
| 65 | const { db, id } = open({ commissionRate: "2.5", salePrice: "450000" }); | |
| 66 | const page = dealPage({ who: alice, deal: view(db, id), on: "2026-10-05" }); | |
| 67 | expect(page).toContain("Commission <b>2.5%</b> · <b>$11,250</b>"); | |
| 68 | const none = open({ commissionRate: "2.5" }); | |
| 69 | const bare = dealPage({ who: alice, deal: view(none.db, none.id), on: "2026-10-05" }); | |
| 70 | expect(bare).toContain("Commission <b>2.5%</b>"); | |
| 71 | expect(bare).toContain("add the price to see the amount"); | |
| 72 | const unset = open({ salePrice: "450000" }); | |
| 73 | expect(dealPage({ who: alice, deal: view(unset.db, unset.id), on: "2026-10-05" })).not.toContain("Commission <b>"); | |
| 74 | }); | |
| 75 | ||
| 76 | test("the client timeline and every reminder draft leave the rate and the amount out", () => { | |
| 77 | const { db, id } = open({ commissionRate: "2.5", salePrice: "450000", earnestAmount: "9000" }); | |
| 78 | const deal = view(db, id); | |
| 79 | const page = timelinePage({ who: alice, deal, on: "2026-10-05" }); | |
| 80 | expect(page).toContain("$450,000"); | |
| 81 | for (const text of [page, ...drafts(deal, agent).flatMap((d) => [d.subject, d.body])]) { | |
| 82 | expect(text).not.toContain("11,250"); | |
| 83 | expect(text).not.toMatch(/commission/i); | |
| 84 | expect(text).not.toContain("2.5%"); | |
| 85 | } | |
| 86 | }); | |
| 87 | ||
| 88 | const KEY = "a-key-only-the-forge-holds"; | |
| 89 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-rate-")), identityKey: KEY }); | |
| 90 | afterAll(() => server.stop(true)); | |
| 91 | const base = `http://${server.hostname}:${server.port}`; | |
| 92 | const identity = `u1|alice|user|${Date.now() + 600_000}`; | |
| 93 | const auth = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; | |
| 94 | ||
| 95 | test("the figures form answers 422 keeping what was typed, then saves a good rate", async () => { | |
| 96 | const made = await fetch(base + "/api/deals", { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ address: "7 Rate Way", buyer: "B", seller: "S", contract_date: "2026-10-01", closing_date: "2026-11-01", sale_price: 450000, commission_rate: 250 }) }); | |
| 97 | expect(made.status).toBe(201); | |
| 98 | const deal = (await made.json()) as { id: number; commission_rate: number }; | |
| 99 | expect(deal.commission_rate).toBe(250); | |
| 100 | const send = (v: string) => fetch(`${base}/deals/${deal.id}/figures`, { method: "POST", headers: { ...auth, "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ sale_price: "450000", earnest_amount: "", commission_rate: v }) , redirect: "manual" }); | |
| 101 | const bad = await send("12abc"); | |
| 102 | expect(bad.status).toBe(422); | |
| 103 | expect(await bad.text()).toContain('value="12abc"'); | |
| 104 | const good = await send("3%"); | |
| 105 | expect(good.status).toBeLessThan(400); | |
| 106 | const read = (await (await fetch(`${base}/api/deals/${deal.id}`, { headers: auth })).json()) as { commission_rate: number }; | |
| 107 | expect(read.commission_rate).toBe(300); | |
| 108 | const api = await fetch(base + "/api/deals", { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ address: "8 Rate Way", buyer: "B", seller: "S", contract_date: "2026-10-01", closing_date: "2026-11-01", commission_rate: 1001 }) }); | |
| 109 | expect(api.status).toBe(422); | |
| 110 | }); |
src/db.ts +2
| @@ -424,6 +424,8 @@ export const migrations: string[] = [ | ||
| 424 | 424 | `ALTER TABLE deal ADD COLUMN closing_place TEXT CHECK (closing_place IS NULL OR length(closing_place) BETWEEN 1 AND 200)`, |
| 425 | 425 | // Which side the agent represents: the buyer's, a listing, or both. |
| 426 | 426 | `ALTER TABLE deal ADD COLUMN side TEXT NOT NULL DEFAULT 'buyer' CHECK (side IN ('buyer', 'listing', 'both'))`, |
| 427 | // The agent's commission rate on a deal, in hundredths of a percent (250 is 2.5%), 0 to 10%; null while none is set. | |
| 428 | `ALTER TABLE deal ADD COLUMN commission_rate INTEGER CHECK (commission_rate IS NULL OR commission_rate BETWEEN 0 AND 1000)`, | |
| 427 | 429 | ]; |
| 428 | 430 | |
| 429 | 431 | export function openDb(dir = dataDir): Database { |
src/deals.ts +19 −9
| @@ -29,7 +29,7 @@ import { LENGTH_MAX, standard, validEmail, validPhone } from "./env.ts"; | ||
| 29 | 29 | import { agentOf } from "./profile.ts"; |
| 30 | 30 | import { emit } from "./live.ts"; |
| 31 | 31 | import { closingPlace, closingTime } from "./closing.ts"; |
| 32 | import { amount } from "./money.ts"; | |
| 32 | import { amount, rate } from "./money.ts"; | |
| 33 | 33 | import { SIDE_REFUSAL, sideOf, type Side } from "./side.ts"; |
| 34 | 34 | import { outboxes } from "./outbox.ts"; |
| 35 | 35 | import { conflictLine, conflicts, type Conflict } from "./order.ts"; |
| @@ -52,6 +52,8 @@ export interface Deal { | ||
| 52 | 52 | /** The sale price and the earnest money amount, in whole dollars; null while none is set. */ |
| 53 | 53 | readonly sale_price: number | null; |
| 54 | 54 | readonly earnest_amount: number | null; |
| 55 | /** The agent's commission rate in hundredths of a percent (250 is 2.5%); null while none is set. Never on a client page. */ | |
| 56 | readonly commission_rate: number | null; | |
| 55 | 57 | /** When and where it closes beyond the day: "14:00" and a place; null while unset. */ |
| 56 | 58 | readonly closing_time: string | null; |
| 57 | 59 | readonly closing_place: string | null; |
| @@ -496,6 +498,8 @@ export interface NewDeal { | ||
| 496 | 498 | /** Optional whole dollars: nothing, or blank, is none set. */ |
| 497 | 499 | readonly salePrice?: unknown; |
| 498 | 500 | readonly earnestAmount?: unknown; |
| 501 | /** Optional percent ("2.5", "2.5%") or hundredths (250); nothing, or blank, is none set. */ | |
| 502 | readonly commissionRate?: unknown; | |
| 499 | 503 | /** buyer, listing or both; nothing is buyer. */ |
| 500 | 504 | readonly side?: unknown; |
| 501 | 505 | /** `true` (or a ticked box) opens the deal although its address is already open. */ |
| @@ -513,6 +517,7 @@ export interface Plan { | ||
| 513 | 517 | readonly businessDays: boolean; |
| 514 | 518 | readonly salePrice: number | null; |
| 515 | 519 | readonly earnestAmount: number | null; |
| 520 | readonly commissionRate?: number | null; | |
| 516 | 521 | readonly side: Side; |
| 517 | 522 | } |
| 518 | 523 | |
| @@ -554,9 +559,11 @@ export function plan(input: NewDeal): Result<Plan> { | ||
| 554 | 559 | if (!salePrice.ok) return salePrice; |
| 555 | 560 | const earnestAmount = amount(input.earnestAmount, "earnest_amount", "earnest money amount"); |
| 556 | 561 | if (!earnestAmount.ok) return earnestAmount; |
| 562 | const commissionRate = rate(input.commissionRate, "commission_rate", "commission rate"); | |
| 563 | if (!commissionRate.ok) return commissionRate; | |
| 557 | 564 | const side = sideOf(input.side); |
| 558 | 565 | if (side === undefined) return fail("invalid", SIDE_REFUSAL, "side"); |
| 559 | return ok({ address, buyer, seller, contractDate, closingDate, lengths, businessDays, salePrice: salePrice.value, earnestAmount: earnestAmount.value, side }); | |
| 566 | return ok({ address, buyer, seller, contractDate, closingDate, lengths, businessDays, salePrice: salePrice.value, earnestAmount: earnestAmount.value, commissionRate: commissionRate.value, side }); | |
| 560 | 567 | } |
| 561 | 568 | |
| 562 | 569 | /** An address as two typings of one house agree on it: trimmed, spaces collapsed, lower case, no trailing period. */ |
| @@ -612,10 +619,10 @@ export function insertDeal( | ||
| 612 | 619 | ): Deal { |
| 613 | 620 | return db.transaction(() => { |
| 614 | 621 | const deal = db |
| 615 | .query<Deal, [string, string, number, string, string, number | null, number | null, string]>( | |
| 616 | "INSERT INTO deal (address, contract_date, business_days, author_id, author_name, sale_price, earnest_amount, side) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) RETURNING *", | |
| 622 | .query<Deal, [string, string, number, string, string, number | null, number | null, string, number | null]>( | |
| 623 | "INSERT INTO deal (address, contract_date, business_days, author_id, author_name, sale_price, earnest_amount, side, commission_rate) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) RETURNING *", | |
| 617 | 624 | ) |
| 618 | .get(p.address, p.contractDate, p.businessDays ? 1 : 0, author.id, author.name, p.salePrice ?? null, p.earnestAmount ?? null, p.side ?? "buyer")!; | |
| 625 | .get(p.address, p.contractDate, p.businessDays ? 1 : 0, author.id, author.name, p.salePrice ?? null, p.earnestAmount ?? null, p.side ?? "buyer", p.commissionRate ?? null)!; | |
| 619 | 626 | const party = db.query( |
| 620 | 627 | "INSERT INTO party (deal_id, role, name) VALUES (?1, ?2, ?3)", |
| 621 | 628 | ); |
| @@ -664,6 +671,7 @@ export interface DealEdit { | ||
| 664 | 671 | readonly address?: unknown; |
| 665 | 672 | readonly salePrice?: unknown; |
| 666 | 673 | readonly earnestAmount?: unknown; |
| 674 | readonly commissionRate?: unknown; | |
| 667 | 675 | readonly closingTime?: unknown; |
| 668 | 676 | readonly closingPlace?: unknown; |
| 669 | 677 | readonly side?: unknown; |
| @@ -675,7 +683,7 @@ export const updateDeal = (db: Database, who: Who, input: DealEdit): Result<Deal | ||
| 675 | 683 | function update(db: Database, who: Who, input: DealEdit): Result<DealView> { |
| 676 | 684 | const found = dealFor(db, who, "update", input.id); |
| 677 | 685 | if (!found.ok) return found; |
| 678 | const bare = input.address === undefined && input.salePrice === undefined && input.earnestAmount === undefined && input.closingTime === undefined && input.closingPlace === undefined && input.side === undefined; | |
| 686 | const bare = input.address === undefined && input.salePrice === undefined && input.earnestAmount === undefined && input.commissionRate === undefined && input.closingTime === undefined && input.closingPlace === undefined && input.side === undefined; | |
| 679 | 687 | const address = input.address === undefined && !bare ? found.value.address : line(input.address, 3, 200); |
| 680 | 688 | if (address === undefined) |
| 681 | 689 | return fail("invalid", "The address is 3 to 200 characters.", "address"); |
| @@ -683,17 +691,19 @@ function update(db: Database, who: Who, input: DealEdit): Result<DealView> { | ||
| 683 | 691 | if (!price.ok) return price; |
| 684 | 692 | const earnest = input.earnestAmount === undefined ? ok(found.value.earnest_amount) : amount(input.earnestAmount, "earnest_amount", "earnest money amount"); |
| 685 | 693 | if (!earnest.ok) return earnest; |
| 694 | const commissionRate = input.commissionRate === undefined ? ok(found.value.commission_rate) : rate(input.commissionRate, "commission_rate", "commission rate"); | |
| 695 | if (!commissionRate.ok) return commissionRate; | |
| 686 | 696 | const time = input.closingTime === undefined ? ok(found.value.closing_time) : closingTime(input.closingTime); |
| 687 | 697 | if (!time.ok) return time; |
| 688 | 698 | const place = input.closingPlace === undefined ? ok(found.value.closing_place) : closingPlace(input.closingPlace); |
| 689 | 699 | if (!place.ok) return place; |
| 690 | 700 | const side = input.side === undefined ? found.value.side : input.side === "" || input.side === null ? undefined : sideOf(input.side); |
| 691 | 701 | if (side === undefined) return fail("invalid", SIDE_REFUSAL, "side"); |
| 692 | db.query("UPDATE deal SET address = ?1, sale_price = ?2, earnest_amount = ?3, closing_time = ?4, closing_place = ?5, side = ?6 WHERE id = ?7").run( | |
| 693 | address, price.value, earnest.value, time.value, place.value, side, found.value.id, | |
| 702 | db.query("UPDATE deal SET address = ?1, sale_price = ?2, earnest_amount = ?3, closing_time = ?4, closing_place = ?5, side = ?6, commission_rate = ?7 WHERE id = ?8").run( | |
| 703 | address, price.value, earnest.value, time.value, place.value, side, commissionRate.value, found.value.id, | |
| 694 | 704 | ); |
| 695 | 705 | emit("deal.changed", { id: found.value.id }); |
| 696 | return ok(view(db, { ...found.value, address, sale_price: price.value, earnest_amount: earnest.value, closing_time: time.value, closing_place: place.value, side })); | |
| 706 | return ok(view(db, { ...found.value, address, sale_price: price.value, earnest_amount: earnest.value, commission_rate: commissionRate.value, closing_time: time.value, closing_place: place.value, side })); | |
| 697 | 707 | } |
| 698 | 708 | |
| 699 | 709 | /** What the audit trail calls a deal falling through, and its reopening. */ |
src/mcp.ts +6 −3
| @@ -39,7 +39,7 @@ import { | ||
| 39 | 39 | releaseReminder, |
| 40 | 40 | undoDecline, |
| 41 | 41 | } from "./deals.ts"; |
| 42 | import { AMOUNT_MAX } from "./money.ts"; | |
| 42 | import { AMOUNT_MAX, RATE_MAX } from "./money.ts"; | |
| 43 | 43 | import { LENGTH_MAX, standard, title } from "./env.ts"; |
| 44 | 44 | import { dueOn } from "./reminders.ts"; |
| 45 | 45 | import { FIND_REFUSAL, find, query } from "./find.ts"; |
| @@ -342,6 +342,7 @@ export const tools: readonly Tool[] = [ | ||
| 342 | 342 | business_days: { type: "boolean", default: standard.businessDays }, |
| 343 | 343 | sale_price: { type: "integer", minimum: 0, maximum: AMOUNT_MAX, description: "Optional sale price in whole dollars." }, |
| 344 | 344 | earnest_amount: { type: "integer", minimum: 0, maximum: AMOUNT_MAX, description: "Optional earnest money amount in whole dollars; the earnest money reminder says it." }, |
| 345 | commission_rate: { type: "integer", minimum: 0, maximum: RATE_MAX, description: "Optional commission rate in hundredths of a percent (250 is 2.5%), 0 to 1000." }, | |
| 345 | 346 | side: { enum: [...SIDES], default: "buyer", description: "Which side you represent: buyer, listing or both." }, |
| 346 | 347 | open_anyway: { type: "boolean", default: false, description: "Open a second deal although this address already has an open one (otherwise refused as a conflict naming it)." }, |
| 347 | 348 | }, |
| @@ -356,6 +357,7 @@ export const tools: readonly Tool[] = [ | ||
| 356 | 357 | closingDate: a.closing_date, |
| 357 | 358 | salePrice: a.sale_price, |
| 358 | 359 | earnestAmount: a.earnest_amount, |
| 360 | commissionRate: a.commission_rate, | |
| 359 | 361 | side: a.side, |
| 360 | 362 | lengths: { |
| 361 | 363 | earnest: a.earnest, |
| @@ -406,17 +408,18 @@ export const tools: readonly Tool[] = [ | ||
| 406 | 408 | }, |
| 407 | 409 | { |
| 408 | 410 | name: "update_figures", |
| 409 | description: "Set a deal's sale price and earnest money amount, in whole dollars (0 to 100,000,000). A figure left out stays as it is; null clears it. The earnest money reminder drafts say the amount; reminders already sent keep their words.", | |
| 411 | description: "Set a deal's sale price and earnest money amount, in whole dollars (0 to 100,000,000), and the agent's commission rate in hundredths of a percent (250 is 2.5%, 0 to 1000). A figure left out stays as it is; null clears it. The earnest money reminder drafts say the amount; reminders already sent keep their words.", | |
| 410 | 412 | inputSchema: { |
| 411 | 413 | type: "object", |
| 412 | 414 | properties: { |
| 413 | 415 | deal_id: { type: "integer" }, |
| 414 | 416 | sale_price: { type: ["integer", "null"], minimum: 0, maximum: AMOUNT_MAX }, |
| 415 | 417 | earnest_amount: { type: ["integer", "null"], minimum: 0, maximum: AMOUNT_MAX }, |
| 418 | commission_rate: { type: ["integer", "null"], minimum: 0, maximum: RATE_MAX, description: "Hundredths of a percent: 250 is 2.5%." }, | |
| 416 | 419 | }, |
| 417 | 420 | required: ["deal_id"], |
| 418 | 421 | }, |
| 419 | run: (db, who, a) => updateDeal(db, who, { id: Number(a.deal_id), salePrice: a.sale_price, earnestAmount: a.earnest_amount }), | |
| 422 | run: (db, who, a) => updateDeal(db, who, { id: Number(a.deal_id), salePrice: a.sale_price, earnestAmount: a.earnest_amount, commissionRate: a.commission_rate }), | |
| 420 | 423 | }, |
| 421 | 424 | { |
| 422 | 425 | name: "update_closing", |
src/money.ts +29
| @@ -27,3 +27,32 @@ export function amount(value: unknown, field: string, name: string): Result<numb | ||
| 27 | 27 | return fail("invalid", `The ${name} is whole dollars, digits only, 0 to ${COMMAS.format(AMOUNT_MAX)}.`, field); |
| 28 | 28 | return ok(n); |
| 29 | 29 | } |
| 30 | ||
| 31 | /** The most a commission rate may be, in hundredths of a percent: 10%. */ | |
| 32 | export const RATE_MAX = 1000; | |
| 33 | ||
| 34 | /** 250 → "2.5%", 300 → "3%", 275 → "2.75%". */ | |
| 35 | export const percent = (rate: number): string => `${Number((rate / 100).toFixed(2))}%`; | |
| 36 | ||
| 37 | /** | |
| 38 | * A commission rate from a form, a JSON body or a prompt, in hundredths of a | |
| 39 | * percent. Text is a percent, "2.5" or "2.5%" (at most two decimals); a JSON | |
| 40 | * integer is already hundredths (250), as the API reads and writes it. | |
| 41 | * 0 to `RATE_MAX`. Nothing (undefined, null, or blank) is "none set": null. | |
| 42 | */ | |
| 43 | export function rate(value: unknown, field: string, name: string): Result<number | null> { | |
| 44 | if (value === undefined || value === null) return ok(null); | |
| 45 | const refused = (): Result<number | null> => | |
| 46 | fail("invalid", `The ${name} is a percent from 0 to ${RATE_MAX / 100}, like 2.5 or 2.5%.`, field); | |
| 47 | if (typeof value === "number") return Number.isInteger(value) && value >= 0 && value <= RATE_MAX ? ok(value) : refused(); | |
| 48 | if (typeof value !== "string") return refused(); | |
| 49 | const text = value.trim().replace(/%$/, "").trim(); | |
| 50 | if (value.trim() === "") return ok(null); | |
| 51 | const m = /^([0-9]{1,2})(?:\.([0-9]{1,2}))?$/.exec(text); | |
| 52 | if (m === null) return refused(); | |
| 53 | const n = Number(m[1]) * 100 + Number((m[2] ?? "").padEnd(2, "0")); | |
| 54 | return n <= RATE_MAX ? ok(n) : refused(); | |
| 55 | } | |
| 56 | ||
| 57 | /** What a commission pays on a price: whole dollars, rounded once, here. */ | |
| 58 | export const commission = (price: number, rate: number): number => Math.round((price * rate) / 10_000); |
src/pages/deal.ts +23 −10
| @@ -11,7 +11,7 @@ import { agent as defaults, standard } from "../env.ts"; | ||
| 11 | 11 | import { DEFAULT_LENGTHS, PARTY_LABEL, PARTY_ROLES } from "../deadlines.ts"; |
| 12 | 12 | import { escape } from "../http.ts"; |
| 13 | 13 | import { closingLine } from "../closing.ts"; |
| 14 | import { dollars } from "../money.ts"; | |
| 14 | import { commission, dollars, percent } from "../money.ts"; | |
| 15 | 15 | import { drafts, sentLabel, statusDrafts, type Agent } from "../reminders.ts"; |
| 16 | 16 | import type { Who } from "../who.ts"; |
| 17 | 17 | import { amendForm, type AmendState } from "./amend.ts"; |
| @@ -120,27 +120,40 @@ function messages(deal: DealView, may: boolean, agent: Agent, to: string): strin | ||
| 120 | 120 | |
| 121 | 121 | /** What was typed into the price form and why it was refused, to show beside the field. */ |
| 122 | 122 | export interface FiguresProblem { |
| 123 | readonly values: { readonly sale_price: string; readonly earnest_amount: string }; | |
| 123 | readonly values: { readonly sale_price: string; readonly earnest_amount: string; readonly commission_rate: string }; | |
| 124 | 124 | readonly field: string | undefined; |
| 125 | 125 | readonly message: string; |
| 126 | 126 | } |
| 127 | 127 | |
| 128 | /** The two optional figure fields, shared by the new-deal form and the deal's own: `value` is what each holds, `problem` what was refused. */ | |
| 129 | function figureFields(value: (name: "sale_price" | "earnest_amount") => string, problem: { field?: string | undefined; message: string } | undefined): string { | |
| 130 | const field = (name: "sale_price" | "earnest_amount", label: string): string => { | |
| 128 | /** The optional figure fields, shared by the new-deal form and the deal's own: `value` is what each holds, `problem` what was refused. */ | |
| 129 | function figureFields(value: (name: FigureName) => string, problem: { field?: string | undefined; message: string } | undefined): string { | |
| 130 | const field = (name: FigureName, label: string, hint: string, placeholder: string, mode: string): string => { | |
| 131 | 131 | const bad = problem?.field === name; |
| 132 | return `<label>${label} <span class="muted">(optional, whole dollars)</span><input class="input" name="${name}" inputmode="numeric" maxlength="12" autocomplete="off" placeholder="425000" value="${escape(value(name))}"${bad ? ` aria-invalid="true" aria-describedby="${name}-why"` : ""}>${bad ? `<span class="field-error" id="${name}-why" role="alert">${escape(problem.message)}</span>` : ""}</label>`; | |
| 132 | return `<label>${label} <span class="muted">(${hint})</span><input class="input" name="${name}" inputmode="${mode}" maxlength="12" autocomplete="off" placeholder="${placeholder}" value="${escape(value(name))}"${bad ? ` aria-invalid="true" aria-describedby="${name}-why"` : ""}>${bad ? `<span class="field-error" id="${name}-why" role="alert">${escape(problem.message)}</span>` : ""}</label>`; | |
| 133 | 133 | }; |
| 134 | return `<div class="pair">${field("sale_price", "Sale price")}${field("earnest_amount", "Earnest money")}</div>`; | |
| 134 | return `<div class="pair">${field("sale_price", "Sale price", "optional, whole dollars", "425000", "numeric")}${field("earnest_amount", "Earnest money", "optional, whole dollars", "12750", "numeric")}</div> | |
| 135 | ${field("commission_rate", "Commission rate", "optional, like 2.5%, yours alone", "2.5%", "decimal")}`; | |
| 135 | 136 | } |
| 136 | 137 | |
| 138 | type FigureName = "sale_price" | "earnest_amount" | "commission_rate"; | |
| 139 | ||
| 137 | 140 | /** The figures a deal is about, said in the one way: nothing at all when neither is set. */ |
| 138 | 141 | function figuresLine(deal: DealView): string { |
| 139 | 142 | const parts = [ |
| 140 | 143 | deal.sale_price === null ? "" : `Sale price <b>${escape(dollars(deal.sale_price))}</b>`, |
| 141 | 144 | deal.earnest_amount === null ? "" : `Earnest money <b>${escape(dollars(deal.earnest_amount))}</b>`, |
| 142 | 145 | ].filter((p) => p !== ""); |
| 143 | return parts.length === 0 ? "" : `<p class="summary" id="figures">${parts.join(" · ")}</p>`; | |
| 146 | const pays = commissionLine(deal); | |
| 147 | return parts.length === 0 && pays === "" ? "" : `${parts.length === 0 ? "" : `<p class="summary" id="figures">${parts.join(" · ")}</p>`}${pays}`; | |
| 148 | } | |
| 149 | ||
| 150 | /** What the deal pays the agent at closing: for the agent's page only, never the client's. */ | |
| 151 | function commissionLine(deal: DealView): string { | |
| 152 | if (deal.commission_rate === null) return ""; | |
| 153 | const rate = percent(deal.commission_rate); | |
| 154 | return deal.sale_price === null | |
| 155 | ? `<p class="summary" id="commission">Commission <b>${escape(rate)}</b> <span class="muted">· add the price to see the amount</span></p>` | |
| 156 | : `<p class="summary" id="commission">Commission <b>${escape(rate)}</b> · <b>${escape(dollars(commission(deal.sale_price, deal.commission_rate)))}</b></p>`; | |
| 144 | 157 | } |
| 145 | 158 | |
| 146 | 159 | /** What was typed into the closing form and why it was refused, to show beside the field. */ |
| @@ -218,9 +231,9 @@ export function dealPage(input: { | ||
| 218 | 231 | const figures = input.figures; |
| 219 | 232 | const editFigures = may |
| 220 | 233 | ? `<details class="address-edit"${figures === undefined ? "" : " open"}> |
| 221 | <summary class="button button-quiet button-small">Edit price and earnest money</summary> | |
| 234 | <summary class="button button-quiet button-small">Edit price, earnest money and commission</summary> | |
| 222 | 235 | <form class="stack" method="post" action="${path}/figures"> |
| 223 | ${figureFields((n) => figures?.values[n] ?? (deal[n]?.toString() ?? ""), figures)} | |
| 236 | ${figureFields((n) => figures?.values[n] ?? (n === "commission_rate" ? (deal.commission_rate === null ? "" : percent(deal.commission_rate)) : (deal[n]?.toString() ?? "")), figures)} | |
| 224 | 237 | <button class="button">Save</button> |
| 225 | 238 | </form> |
| 226 | 239 | </details>` |
src/routes.ts +9 −5
| @@ -40,7 +40,7 @@ import type { Verb } from "./can.ts"; | ||
| 40 | 40 | import { events } from "./live.ts"; |
| 41 | 41 | import { today } from "./dates.ts"; |
| 42 | 42 | import { tally } from "./book.ts"; |
| 43 | import { AMOUNT_MAX } from "./money.ts"; | |
| 43 | import { AMOUNT_MAX, RATE_MAX } from "./money.ts"; | |
| 44 | 44 | import { progress } from "./progress.ts"; |
| 45 | 45 | import { |
| 46 | 46 | addDeadline, |
| @@ -235,6 +235,7 @@ const PROGRESS: Schema = { | ||
| 235 | 235 | }; |
| 236 | 236 | /** A sale price or earnest money amount: whole dollars, or null for none. */ |
| 237 | 237 | const FIGURE: Schema = { type: ["integer", "null"], minimum: 0, maximum: AMOUNT_MAX }; |
| 238 | const RATE: Schema = { type: ["integer", "null"], minimum: 0, maximum: RATE_MAX, description: "The agent's commission rate in hundredths of a percent (250 is 2.5%), 0 to 1000; null while none is set. Never on a client page." }; | |
| 238 | 239 | const CLOSING_TIME: Schema = { type: ["string", "null"], pattern: "^(0[7-9]|1[0-9]):(00|15|30|45)$|^20:00$", description: "When it closes, HH:MM on a quarter hour from 07:00 to 20:00, local time; null while none is set." }; |
| 239 | 240 | const CLOSING_PLACE: Schema = { type: ["string", "null"], minLength: 1, maxLength: 200, description: "Where it closes; null while none is set." }; |
| 240 | 241 | export const DEAL: Schema = { |
| @@ -252,6 +253,7 @@ export const DEAL: Schema = { | ||
| 252 | 253 | side: { enum: [...SIDES], description: "Which side the agent represents: buyer, listing or both." }, |
| 253 | 254 | sale_price: { ...FIGURE, description: "The sale price in whole dollars; null while none is set." }, |
| 254 | 255 | earnest_amount: { ...FIGURE, description: "The earnest money amount in whole dollars; null while none is set." }, |
| 256 | commission_rate: RATE, | |
| 255 | 257 | closing_time: CLOSING_TIME, |
| 256 | 258 | closing_place: CLOSING_PLACE, |
| 257 | 259 | business_days: { enum: [0, 1] }, |
| @@ -379,6 +381,7 @@ const NEW_DEAL: Schema = { | ||
| 379 | 381 | business_days: { type: "boolean", default: standard.businessDays }, |
| 380 | 382 | sale_price: { ...FIGURE, description: "Optional, whole dollars." }, |
| 381 | 383 | earnest_amount: { ...FIGURE, description: "Optional, whole dollars; the earnest money reminder says it." }, |
| 384 | commission_rate: { ...RATE, description: "Optional, hundredths of a percent (250 is 2.5%), 0 to 1000." }, | |
| 382 | 385 | side: { enum: [...SIDES], default: "buyer", description: "Which side the agent represents." }, |
| 383 | 386 | open_anyway: { type: "boolean", default: false }, |
| 384 | 387 | }, |
| @@ -630,6 +633,7 @@ const newDeal = (f: Record<string, unknown>): NewDeal => ({ | ||
| 630 | 633 | businessDays: f.business_days, |
| 631 | 634 | salePrice: f.sale_price, |
| 632 | 635 | earnestAmount: f.earnest_amount, |
| 636 | commissionRate: f.commission_rate, | |
| 633 | 637 | side: f.side, |
| 634 | 638 | openAnyway: f.open_anyway, |
| 635 | 639 | }); |
| @@ -1034,12 +1038,12 @@ export const routes: readonly Route[] = [ | ||
| 1034 | 1038 | const f = await form(c); |
| 1035 | 1039 | if (f instanceof Response) return f; |
| 1036 | 1040 | const id = Number(c.params.id); |
| 1037 | const saved = updateDeal(c.db, c.who, { id, salePrice: f.sale_price ?? "", earnestAmount: f.earnest_amount ?? "" }); | |
| 1041 | const saved = updateDeal(c.db, c.who, { id, salePrice: f.sale_price ?? "", earnestAmount: f.earnest_amount ?? "", commissionRate: f.commission_rate ?? "" }); | |
| 1038 | 1042 | if (saved.ok) return back("Price and earnest money saved.", `/deals/${id}`); |
| 1039 | 1043 | if (saved.error !== "invalid") return back(saved.message, `/deals/${id}`); |
| 1040 | 1044 | const found = getDeal(c.db, c.who, { id }); |
| 1041 | 1045 | if (!found.ok) return back(found.message, `/deals/${id}`); |
| 1042 | const values = { sale_price: String(f.sale_price ?? ""), earnest_amount: String(f.earnest_amount ?? "") }; | |
| 1046 | const values = { sale_price: String(f.sale_price ?? ""), earnest_amount: String(f.earnest_amount ?? ""), commission_rate: String(f.commission_rate ?? "") }; | |
| 1043 | 1047 | return html(dealPage({ who: c.who, waiting: waitingFor(c), agent: agentFor(c), deal: found.value, on: today(), figures: { values, field: saved.field, message: saved.message } }), STATUS.invalid); |
| 1044 | 1048 | }, |
| 1045 | 1049 | }, |
| @@ -1445,11 +1449,11 @@ export const routes: readonly Route[] = [ | ||
| 1445 | 1449 | const body = await readJson(c.request); |
| 1446 | 1450 | if (body === undefined) return json({ error: "too_large" }, 413); |
| 1447 | 1451 | const f = fields(body); |
| 1448 | return answer(updateDeal(c.db, c.who, { id: Number(c.params.id), address: f.address, salePrice: f.sale_price, earnestAmount: f.earnest_amount, closingTime: f.closing_time, closingPlace: f.closing_place, side: f.side })); | |
| 1452 | return answer(updateDeal(c.db, c.who, { id: Number(c.params.id), address: f.address, salePrice: f.sale_price, earnestAmount: f.earnest_amount, commissionRate: f.commission_rate, closingTime: f.closing_time, closingPlace: f.closing_place, side: f.side })); | |
| 1449 | 1453 | }, |
| 1450 | 1454 | api: { |
| 1451 | 1455 | summary: "Correct a deal's address, sale price, earnest money amount, closing time, closing place or side (buyer, listing or both); a field left out stays as it is, and one sent as null is cleared (not the side). Dates move one at a time, with a reason (POST /deadlines/:id/move).", |
| 1452 | body: { type: "object", properties: { address: { type: "string", minLength: 3, maxLength: 200 }, sale_price: FIGURE, earnest_amount: FIGURE, closing_time: CLOSING_TIME, closing_place: CLOSING_PLACE, side: { enum: [...SIDES] } } }, | |
| 1456 | body: { type: "object", properties: { address: { type: "string", minLength: 3, maxLength: 200 }, sale_price: FIGURE, earnest_amount: FIGURE, commission_rate: RATE, closing_time: CLOSING_TIME, closing_place: CLOSING_PLACE, side: { enum: [...SIDES] } } }, | |
| 1453 | 1457 | answer: DEAL, |
| 1454 | 1458 | }, |
| 1455 | 1459 | }, |
src/sample.ts +7
| @@ -23,6 +23,8 @@ interface Sample { | ||
| 23 | 23 | readonly closing: number; |
| 24 | 24 | /** Whole dollars. */ |
| 25 | 25 | readonly price: number; |
| 26 | /** The agent's commission rate in hundredths of a percent. */ | |
| 27 | readonly rate: number; | |
| 26 | 28 | readonly earnest: number; |
| 27 | 29 | /** Which side the agent is on; buyer unless said. */ |
| 28 | 30 | readonly side?: Side; |
| @@ -50,6 +52,7 @@ const SAMPLES: readonly Sample[] = [ | ||
| 50 | 52 | contract: -26, |
| 51 | 53 | closing: 4, |
| 52 | 54 | price: 425_000, |
| 55 | rate: 250, | |
| 53 | 56 | earnest: 12_750, |
| 54 | 57 | lengths: { financing: 28 }, |
| 55 | 58 | closingAt: ["14:00", "Bayside Title, 400 Main St"], |
| @@ -74,6 +77,7 @@ const SAMPLES: readonly Sample[] = [ | ||
| 74 | 77 | contract: -12, |
| 75 | 78 | closing: 33, |
| 76 | 79 | price: 318_500, |
| 80 | rate: 300, | |
| 77 | 81 | earnest: 6_000, |
| 78 | 82 | parties: [ |
| 79 | 83 | ["lender", "Owen Brandt, First Keel Bank", "[email protected]", "(555) 010-0188"], |
| @@ -92,6 +96,7 @@ const SAMPLES: readonly Sample[] = [ | ||
| 92 | 96 | contract: -20, |
| 93 | 97 | closing: 18, |
| 94 | 98 | price: 560_000, |
| 99 | rate: 275, | |
| 95 | 100 | earnest: 15_000, |
| 96 | 101 | parties: [ |
| 97 | 102 | ["lender", "Priya Nair, Coastline Mortgage", "[email protected]"], |
| @@ -110,6 +115,7 @@ const SAMPLES: readonly Sample[] = [ | ||
| 110 | 115 | contract: -3, |
| 111 | 116 | closing: 40, |
| 112 | 117 | price: 389_900, |
| 118 | rate: 250, | |
| 113 | 119 | earnest: 8_000, |
| 114 | 120 | lengths: { earnest: 5 }, |
| 115 | 121 | parties: [ |
| @@ -252,6 +258,7 @@ function writeSamples(db: Database, on: string): void { | ||
| 252 | 258 | businessDays: false, |
| 253 | 259 | salePrice: s.price, |
| 254 | 260 | earnestAmount: s.earnest, |
| 261 | commissionRate: s.rate, | |
| 255 | 262 | side: s.side ?? "buyer", |
| 256 | 263 | }); |
| 257 | 264 | if (s.closingAt !== undefined) |