Contracts
Rental + utility agreements. v1's 28 BO files collapse to ~6 tables. Single status enum with explicit transition functions kills KNOWN_ISSUES #3 desync.
Scope
In
- Contract (one tenant ↔ one or more rooms, fixed-term or open-ended)
- ContractItem (per-month line items: rent, utility, parking, additional services)
- ContractRoom (join — for multi-room contracts)
- Payment schedule (jsonb for fixed schedules; PaymentChartEntry table for variable)
- Lifecycle: draft → active → grace_period → terminated / expired / renewed
- Actions: activate, extend, terminate (early or natural), renegotiate (creates new contract)
Out / merged
- v1's
ContractExtendpopup BO — becomes an action endpoint, not an entity - v1's separate
ContractPaymentCharttable (91K rows) — collapses to jsonb on Contract for fixed schedules
Data model
// packages/db/src/schema/contracts.ts
export const contractStatus = pgEnum("contract_status",
["draft","pending_signature","active","grace_period","terminated","expired","renewed"]);
export const contracts = pgTable("contracts", {
id: uuid().primaryKey().defaultRandom(),
ownerId: uuid("owner_id").notNull().references(() => propertyOwners.id),
code: text().notNull(), // human-readable, e.g. "CT-2026-0042"
propertyId: uuid("property_id").notNull().references(() => properties.id),
tenantUserId: uuid("tenant_user_id").notNull().references(() => users.id),
tenantNameSnapshot: text("tenant_name_snapshot").notNull(), // frozen at signing
startsAt: timestamp("starts_at", { withTimezone: true }).notNull(),
endsAt: timestamp("ends_at", { withTimezone: true }), // null = open-ended
terminatesAt: timestamp("terminates_at", { withTimezone: true }), // set when terminated
status: contractStatus().notNull().default("draft"),
rentAmount: bigint("rent_amount", { mode: "bigint" }).notNull(),
rentCurrency: text("rent_currency").notNull().default("MNT"),
depositAmount: bigint("deposit_amount", { mode: "bigint" }).notNull(),
paymentDayOfMonth: integer("payment_day_of_month").notNull().default(1),
scheduleKind: text("schedule_kind", { enum: ["fixed","custom"] }).notNull().default("fixed"),
scheduleJson: jsonb("schedule_json"), // when kind=fixed; cached materialization
notes: text(),
signedAt: timestamp("signed_at", { withTimezone: true }),
signedDocumentUrl: text("signed_document_url"),
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),
}, (t) => [
unique().on(t.ownerId, t.code),
index("contract_owner_status").on(t.ownerId, t.status),
index("contract_tenant").on(t.tenantUserId),
pgPolicy("contract_owner_or_tenant", {
as: "permissive", for: "all", to: "authenticated",
using: sql`current_setting('app.role', true) = 'admin'
or owner_id::text = (select current_setting('app.owner_id', true))
or tenant_user_id::text = (select current_setting('app.user_id', true))`,
}),
]);
export const contractRooms = pgTable("contract_rooms", {
id: uuid().primaryKey().defaultRandom(),
contractId: uuid("contract_id").notNull().references(() => contracts.id, { onDelete: "cascade" }),
roomId: uuid("room_id").notNull().references(() => rooms.id),
ownerId: uuid("owner_id").notNull(),
}, (t) => [unique().on(t.contractId, t.roomId), index("contract_room_owner").on(t.ownerId)]);
export const contractItems = pgTable("contract_items", {
id: uuid().primaryKey().defaultRandom(),
contractId: uuid("contract_id").notNull().references(() => contracts.id, { onDelete: "cascade" }),
ownerId: uuid("owner_id").notNull(),
kind: text({ enum: ["rent","utility_water","utility_electricity","utility_heating","parking","internet","cleaning","other"] }).notNull(),
description: text(),
amount: bigint({ mode: "bigint" }).notNull(),
currency: text().notNull().default("MNT"),
recurrence: text({ enum: ["monthly","quarterly","yearly","one_time"] }).notNull().default("monthly"),
startsAt: timestamp("starts_at", { withTimezone: true }), // override contract dates
endsAt: timestamp("ends_at", { withTimezone: true }),
});
Explicit state machine
Single enum + transition function. Kills KNOWN_ISSUES #3 (v1 had old + new status enums that drifted).
// apps/api/src/domain/contract-transitions.ts
type Status = typeof contractStatus.enumValues[number];
const ALLOWED: Record<Status, Status[]> = {
draft: ["pending_signature","terminated"],
pending_signature: ["active","terminated"],
active: ["grace_period","terminated","expired","renewed"],
grace_period: ["active","terminated","expired"],
terminated: [], // terminal
expired: ["renewed"],
renewed: [], // terminal (new contract takes over)
};
export function canTransition(from: Status, to: Status): boolean {
return ALLOWED[from].includes(to);
}
export async function transitionContract(
tx: Tx, contractId: string, to: Status, actorId: string, reason?: string
): Promise<void> {
const c = await tx.query.contracts.findFirst({ where: eq(contracts.id, contractId) });
if (!c) throw new HTTPException(404, { message: "Contract not found" });
if (!canTransition(c.status, to)) {
throw new HTTPException(409, { message: `Cannot transition ${c.status} → ${to}` });
}
await tx.update(contracts).set({ status: to, updatedAt: new Date() }).where(eq(contracts.id, contractId));
await tx.insert(contractAuditLog).values({
contractId, actorId, fromStatus: c.status, toStatus: to, reason, at: new Date(),
});
}
Payment schedule
Fixed schedule (most contracts): stored as jsonb on Contract. Cached materialization of the (startsAt, endsAt, paymentDayOfMonth, rentAmount) tuple.
// scheduleJson shape
{
"version": 1,
"entries": [
{ "due": "2026-06-01", "amount": "1500000", "currency": "MNT", "label": "June rent" },
{ "due": "2026-07-01", "amount": "1500000", "currency": "MNT", "label": "July rent" },
/* ... */
]
}
Custom schedule: rows in payment_chart_entries table; the contract just references them. Use when the tenant wants quarterly or non-standard cadence.
API surface
GET /v2/contracts?status=active&cursor=&limit=
POST /v2/contracts # creates draft
GET /v2/contracts/{id}
PATCH /v2/contracts/{id} # only allowed fields per status
DELETE /v2/contracts/{id} # only if draft
# Actions
POST /v2/contracts/{id}/sign # → pending_signature → active
POST /v2/contracts/{id}/extend # body: { newEndsAt, [rentChange?] }
POST /v2/contracts/{id}/terminate # body: { reason, effectiveAt }
POST /v2/contracts/{id}/renew # creates next contract, marks current renewed
# Related
GET /v2/contracts/{id}/rooms
POST /v2/contracts/{id}/rooms
DELETE /v2/contracts/{id}/rooms/{roomId}
GET /v2/contracts/{id}/items
POST /v2/contracts/{id}/items
PATCH /v2/contract-items/{id}
DELETE /v2/contract-items/{id}
GET /v2/contracts/{id}/schedule # materialized schedule
GET /v2/contracts/{id}/invoices # billing history
Build steps (Phase 2, 6 wks)
- Schemas + RLS policies for contracts, contractRooms, contractItems, contractAuditLog.
- Transition functions in
apps/api/src/domain/contract-transitions.ts. - Schedule materialization helper in
@spacehub/shared(input: contract dates + amount + day → entries[]). - Hono routes — CRUD + 4 action endpoints.
- Web: contract list (status filter), detail (tabs: overview, rooms, items, schedule, invoices), create wizard, extend/terminate/renew modals.
- BullMQ scheduler:
contract-expiry-notificationdaily 10:00 Asia/Ulaanbaatar → query contracts ending in 5 days → SMS via 131344 + push. - Backfill: read v1
Contract+ContractItem+ContractRoom+ContractPaymentChart; collapse status enum (v1 had two); materialize schedule into jsonb.
Open questions
- e-signature integration? Mongolia has a national e-signature scheme (ITC). Defer to Phase 6+ — for v2 launch, store signed PDF uploaded by manager.
- Auto-renewal? When a contract reaches
endsAtwithout explicit action, auto-create renewal in draft? Recommend yes — owner reviews and signs. - Grace period length? Default 7 days after expiry before status flips to expired. Configurable per owner.