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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/deal-clock:mason/142-each-deal-carries-the-agent-s-commission → main 1 commit fork

  • Merged. mason/142-each-deal-carries-the-agent-s-commission was merged into main as 169dd25.

Review

Sign in to review this change.
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
178178 with none shows nothing, and the earnest money reminder says the amount
179179 ("Your $12,750 earnest money deposit is due Nov 7"). The sample deals carry
180180 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.
181190 **Closing time and place.** The closing may carry a time (a quarter hour
182191 from 7:00 am to 8:00 pm) and a place (up to 200 characters): tap Edit
183192 closing time and place on the deal's page, and clear either by leaving it
contract/openapi.json +36
@@ -442,6 +442,15 @@
442442 "maximum": 100000000,
443443 "description": "Optional, whole dollars; the earnest money reminder says it."
444444 },
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 },
445454 "side": {
446455 "enum": [
447456 "buyer",
@@ -808,6 +817,15 @@
808817 "maximum": 100000000,
809818 "description": "The earnest money amount in whole dollars; null while none is set."
810819 },
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 },
811829 "closing_time": {
812830 "type": [
813831 "string",
@@ -1232,6 +1250,15 @@
12321250 "minimum": 0,
12331251 "maximum": 100000000
12341252 },
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 },
12351262 "closing_time": {
12361263 "type": [
12371264 "string",
@@ -2778,6 +2805,15 @@
27782805 "maximum": 100000000,
27792806 "description": "The earnest money amount in whole dollars; null while none is set."
27802807 },
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 },
27812817 "closing_time": {
27822818 "type": [
27832819 "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[] = [
424424 `ALTER TABLE deal ADD COLUMN closing_place TEXT CHECK (closing_place IS NULL OR length(closing_place) BETWEEN 1 AND 200)`,
425425 // Which side the agent represents: the buyer's, a listing, or both.
426426 `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)`,
427429 ];
428430
429431 export function openDb(dir = dataDir): Database {
src/deals.ts +19 −9
@@ -29,7 +29,7 @@ import { LENGTH_MAX, standard, validEmail, validPhone } from "./env.ts";
2929 import { agentOf } from "./profile.ts";
3030 import { emit } from "./live.ts";
3131 import { closingPlace, closingTime } from "./closing.ts";
32 import { amount } from "./money.ts";
32 import { amount, rate } from "./money.ts";
3333 import { SIDE_REFUSAL, sideOf, type Side } from "./side.ts";
3434 import { outboxes } from "./outbox.ts";
3535 import { conflictLine, conflicts, type Conflict } from "./order.ts";
@@ -52,6 +52,8 @@ export interface Deal {
5252 /** The sale price and the earnest money amount, in whole dollars; null while none is set. */
5353 readonly sale_price: number | null;
5454 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;
5557 /** When and where it closes beyond the day: "14:00" and a place; null while unset. */
5658 readonly closing_time: string | null;
5759 readonly closing_place: string | null;
@@ -496,6 +498,8 @@ export interface NewDeal {
496498 /** Optional whole dollars: nothing, or blank, is none set. */
497499 readonly salePrice?: unknown;
498500 readonly earnestAmount?: unknown;
501 /** Optional percent ("2.5", "2.5%") or hundredths (250); nothing, or blank, is none set. */
502 readonly commissionRate?: unknown;
499503 /** buyer, listing or both; nothing is buyer. */
500504 readonly side?: unknown;
501505 /** `true` (or a ticked box) opens the deal although its address is already open. */
@@ -513,6 +517,7 @@ export interface Plan {
513517 readonly businessDays: boolean;
514518 readonly salePrice: number | null;
515519 readonly earnestAmount: number | null;
520 readonly commissionRate?: number | null;
516521 readonly side: Side;
517522 }
518523
@@ -554,9 +559,11 @@ export function plan(input: NewDeal): Result<Plan> {
554559 if (!salePrice.ok) return salePrice;
555560 const earnestAmount = amount(input.earnestAmount, "earnest_amount", "earnest money amount");
556561 if (!earnestAmount.ok) return earnestAmount;
562 const commissionRate = rate(input.commissionRate, "commission_rate", "commission rate");
563 if (!commissionRate.ok) return commissionRate;
557564 const side = sideOf(input.side);
558565 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 });
560567 }
561568
562569 /** 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(
612619 ): Deal {
613620 return db.transaction(() => {
614621 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 *",
617624 )
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)!;
619626 const party = db.query(
620627 "INSERT INTO party (deal_id, role, name) VALUES (?1, ?2, ?3)",
621628 );
@@ -664,6 +671,7 @@ export interface DealEdit {
664671 readonly address?: unknown;
665672 readonly salePrice?: unknown;
666673 readonly earnestAmount?: unknown;
674 readonly commissionRate?: unknown;
667675 readonly closingTime?: unknown;
668676 readonly closingPlace?: unknown;
669677 readonly side?: unknown;
@@ -675,7 +683,7 @@ export const updateDeal = (db: Database, who: Who, input: DealEdit): Result<Deal
675683 function update(db: Database, who: Who, input: DealEdit): Result<DealView> {
676684 const found = dealFor(db, who, "update", input.id);
677685 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;
679687 const address = input.address === undefined && !bare ? found.value.address : line(input.address, 3, 200);
680688 if (address === undefined)
681689 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> {
683691 if (!price.ok) return price;
684692 const earnest = input.earnestAmount === undefined ? ok(found.value.earnest_amount) : amount(input.earnestAmount, "earnest_amount", "earnest money amount");
685693 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;
686696 const time = input.closingTime === undefined ? ok(found.value.closing_time) : closingTime(input.closingTime);
687697 if (!time.ok) return time;
688698 const place = input.closingPlace === undefined ? ok(found.value.closing_place) : closingPlace(input.closingPlace);
689699 if (!place.ok) return place;
690700 const side = input.side === undefined ? found.value.side : input.side === "" || input.side === null ? undefined : sideOf(input.side);
691701 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,
694704 );
695705 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 }));
697707 }
698708
699709 /** What the audit trail calls a deal falling through, and its reopening. */
src/mcp.ts +6 −3
@@ -39,7 +39,7 @@ import {
3939 releaseReminder,
4040 undoDecline,
4141 } from "./deals.ts";
42 import { AMOUNT_MAX } from "./money.ts";
42 import { AMOUNT_MAX, RATE_MAX } from "./money.ts";
4343 import { LENGTH_MAX, standard, title } from "./env.ts";
4444 import { dueOn } from "./reminders.ts";
4545 import { FIND_REFUSAL, find, query } from "./find.ts";
@@ -342,6 +342,7 @@ export const tools: readonly Tool[] = [
342342 business_days: { type: "boolean", default: standard.businessDays },
343343 sale_price: { type: "integer", minimum: 0, maximum: AMOUNT_MAX, description: "Optional sale price in whole dollars." },
344344 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." },
345346 side: { enum: [...SIDES], default: "buyer", description: "Which side you represent: buyer, listing or both." },
346347 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)." },
347348 },
@@ -356,6 +357,7 @@ export const tools: readonly Tool[] = [
356357 closingDate: a.closing_date,
357358 salePrice: a.sale_price,
358359 earnestAmount: a.earnest_amount,
360 commissionRate: a.commission_rate,
359361 side: a.side,
360362 lengths: {
361363 earnest: a.earnest,
@@ -406,17 +408,18 @@ export const tools: readonly Tool[] = [
406408 },
407409 {
408410 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.",
410412 inputSchema: {
411413 type: "object",
412414 properties: {
413415 deal_id: { type: "integer" },
414416 sale_price: { type: ["integer", "null"], minimum: 0, maximum: AMOUNT_MAX },
415417 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%." },
416419 },
417420 required: ["deal_id"],
418421 },
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 }),
420423 },
421424 {
422425 name: "update_closing",
src/money.ts +29
@@ -27,3 +27,32 @@ export function amount(value: unknown, field: string, name: string): Result<numb
2727 return fail("invalid", `The ${name} is whole dollars, digits only, 0 to ${COMMAS.format(AMOUNT_MAX)}.`, field);
2828 return ok(n);
2929 }
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";
1111 import { DEFAULT_LENGTHS, PARTY_LABEL, PARTY_ROLES } from "../deadlines.ts";
1212 import { escape } from "../http.ts";
1313 import { closingLine } from "../closing.ts";
14 import { dollars } from "../money.ts";
14 import { commission, dollars, percent } from "../money.ts";
1515 import { drafts, sentLabel, statusDrafts, type Agent } from "../reminders.ts";
1616 import type { Who } from "../who.ts";
1717 import { amendForm, type AmendState } from "./amend.ts";
@@ -120,27 +120,40 @@ function messages(deal: DealView, may: boolean, agent: Agent, to: string): strin
120120
121121 /** What was typed into the price form and why it was refused, to show beside the field. */
122122 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 };
124124 readonly field: string | undefined;
125125 readonly message: string;
126126 }
127127
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 => {
131131 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>`;
133133 };
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")}`;
135136 }
136137
138 type FigureName = "sale_price" | "earnest_amount" | "commission_rate";
139
137140 /** The figures a deal is about, said in the one way: nothing at all when neither is set. */
138141 function figuresLine(deal: DealView): string {
139142 const parts = [
140143 deal.sale_price === null ? "" : `Sale price <b>${escape(dollars(deal.sale_price))}</b>`,
141144 deal.earnest_amount === null ? "" : `Earnest money <b>${escape(dollars(deal.earnest_amount))}</b>`,
142145 ].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>`;
144157 }
145158
146159 /** 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: {
218231 const figures = input.figures;
219232 const editFigures = may
220233 ? `<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>
222235 <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)}
224237 <button class="button">Save</button>
225238 </form>
226239 </details>`
src/routes.ts +9 −5
@@ -40,7 +40,7 @@ import type { Verb } from "./can.ts";
4040 import { events } from "./live.ts";
4141 import { today } from "./dates.ts";
4242 import { tally } from "./book.ts";
43 import { AMOUNT_MAX } from "./money.ts";
43 import { AMOUNT_MAX, RATE_MAX } from "./money.ts";
4444 import { progress } from "./progress.ts";
4545 import {
4646 addDeadline,
@@ -235,6 +235,7 @@ const PROGRESS: Schema = {
235235 };
236236 /** A sale price or earnest money amount: whole dollars, or null for none. */
237237 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." };
238239 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." };
239240 const CLOSING_PLACE: Schema = { type: ["string", "null"], minLength: 1, maxLength: 200, description: "Where it closes; null while none is set." };
240241 export const DEAL: Schema = {
@@ -252,6 +253,7 @@ export const DEAL: Schema = {
252253 side: { enum: [...SIDES], description: "Which side the agent represents: buyer, listing or both." },
253254 sale_price: { ...FIGURE, description: "The sale price in whole dollars; null while none is set." },
254255 earnest_amount: { ...FIGURE, description: "The earnest money amount in whole dollars; null while none is set." },
256 commission_rate: RATE,
255257 closing_time: CLOSING_TIME,
256258 closing_place: CLOSING_PLACE,
257259 business_days: { enum: [0, 1] },
@@ -379,6 +381,7 @@ const NEW_DEAL: Schema = {
379381 business_days: { type: "boolean", default: standard.businessDays },
380382 sale_price: { ...FIGURE, description: "Optional, whole dollars." },
381383 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." },
382385 side: { enum: [...SIDES], default: "buyer", description: "Which side the agent represents." },
383386 open_anyway: { type: "boolean", default: false },
384387 },
@@ -630,6 +633,7 @@ const newDeal = (f: Record<string, unknown>): NewDeal => ({
630633 businessDays: f.business_days,
631634 salePrice: f.sale_price,
632635 earnestAmount: f.earnest_amount,
636 commissionRate: f.commission_rate,
633637 side: f.side,
634638 openAnyway: f.open_anyway,
635639 });
@@ -1034,12 +1038,12 @@ export const routes: readonly Route[] = [
10341038 const f = await form(c);
10351039 if (f instanceof Response) return f;
10361040 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 ?? "" });
10381042 if (saved.ok) return back("Price and earnest money saved.", `/deals/${id}`);
10391043 if (saved.error !== "invalid") return back(saved.message, `/deals/${id}`);
10401044 const found = getDeal(c.db, c.who, { id });
10411045 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 ?? "") };
10431047 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);
10441048 },
10451049 },
@@ -1445,11 +1449,11 @@ export const routes: readonly Route[] = [
14451449 const body = await readJson(c.request);
14461450 if (body === undefined) return json({ error: "too_large" }, 413);
14471451 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 }));
14491453 },
14501454 api: {
14511455 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] } } },
14531457 answer: DEAL,
14541458 },
14551459 },
src/sample.ts +7
@@ -23,6 +23,8 @@ interface Sample {
2323 readonly closing: number;
2424 /** Whole dollars. */
2525 readonly price: number;
26 /** The agent's commission rate in hundredths of a percent. */
27 readonly rate: number;
2628 readonly earnest: number;
2729 /** Which side the agent is on; buyer unless said. */
2830 readonly side?: Side;
@@ -50,6 +52,7 @@ const SAMPLES: readonly Sample[] = [
5052 contract: -26,
5153 closing: 4,
5254 price: 425_000,
55 rate: 250,
5356 earnest: 12_750,
5457 lengths: { financing: 28 },
5558 closingAt: ["14:00", "Bayside Title, 400 Main St"],
@@ -74,6 +77,7 @@ const SAMPLES: readonly Sample[] = [
7477 contract: -12,
7578 closing: 33,
7679 price: 318_500,
80 rate: 300,
7781 earnest: 6_000,
7882 parties: [
7983 ["lender", "Owen Brandt, First Keel Bank", "[email protected]", "(555) 010-0188"],
@@ -92,6 +96,7 @@ const SAMPLES: readonly Sample[] = [
9296 contract: -20,
9397 closing: 18,
9498 price: 560_000,
99 rate: 275,
95100 earnest: 15_000,
96101 parties: [
97102 ["lender", "Priya Nair, Coastline Mortgage", "[email protected]"],
@@ -110,6 +115,7 @@ const SAMPLES: readonly Sample[] = [
110115 contract: -3,
111116 closing: 40,
112117 price: 389_900,
118 rate: 250,
113119 earnest: 8_000,
114120 lengths: { earnest: 5 },
115121 parties: [
@@ -252,6 +258,7 @@ function writeSamples(db: Database, on: string): void {
252258 businessDays: false,
253259 salePrice: s.price,
254260 earnestAmount: s.earnest,
261 commissionRate: s.rate,
255262 side: s.side ?? "buyer",
256263 });
257264 if (s.closingAt !== undefined)

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs