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.

Phase 2

Scope

In

Out / merged

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)

  1. Schemas + RLS policies for contracts, contractRooms, contractItems, contractAuditLog.
  2. Transition functions in apps/api/src/domain/contract-transitions.ts.
  3. Schedule materialization helper in @spacehub/shared (input: contract dates + amount + day → entries[]).
  4. Hono routes — CRUD + 4 action endpoints.
  5. Web: contract list (status filter), detail (tabs: overview, rooms, items, schedule, invoices), create wizard, extend/terminate/renew modals.
  6. BullMQ scheduler: contract-expiry-notification daily 10:00 Asia/Ulaanbaatar → query contracts ending in 5 days → SMS via 131344 + push.
  7. Backfill: read v1 Contract + ContractItem + ContractRoom + ContractPaymentChart; collapse status enum (v1 had two); materialize schedule into jsonb.

Open questions