467 lines
12 KiB
TypeScript
467 lines
12 KiB
TypeScript
// services/leaseService.ts
|
|
// Handle lease management, creation, updates, and tenant relationships
|
|
|
|
import { and, eq, inArray } from "drizzle-orm";
|
|
import { db } from "../db/db.ts";
|
|
import { leases, properties, tenant_invites } from "../db/schema.ts";
|
|
import { Lease } from "../db/interfaces.ts";
|
|
import { getPropertyById } from "./propertyService.ts";
|
|
|
|
// export interface Lease {
|
|
// id: string;
|
|
// title: string | null;
|
|
// property_id: string;
|
|
// property_address: string;
|
|
// tenant_id: string | null;
|
|
// owner_id: string;
|
|
// start_date: number;
|
|
// end_date: number;
|
|
// rent: number | null;
|
|
// security_deposit: number | null;
|
|
// lease_status: string | null; // 'draft', 'active', 'expired', 'terminated'
|
|
// lease_document_url: string | null;
|
|
// payment_schedule_id: string | null;
|
|
// late_fee_rules: string | null; // JSON string of late fee rules
|
|
// auto_renewal: boolean | null;
|
|
// encrypted_data: string | null;
|
|
// created_on: number;
|
|
// updated_on: number;
|
|
// }
|
|
|
|
// Create new lease
|
|
export async function createLease(
|
|
title: string,
|
|
property_id: string,
|
|
owner_id: string,
|
|
start_date: number,
|
|
end_date: number,
|
|
rent: number,
|
|
security_deposit: number = 0,
|
|
tenant_id?: string,
|
|
payment_schedule_id?: string,
|
|
) {
|
|
const now = Date.now();
|
|
return await db.insert(leases)
|
|
.values({
|
|
id: crypto.randomUUID(),
|
|
title,
|
|
property_id,
|
|
tenant_id: tenant_id || null,
|
|
owner_id,
|
|
start_date,
|
|
end_date,
|
|
rent,
|
|
security_deposit,
|
|
lease_status: "new",
|
|
lease_document_url: null,
|
|
payment_schedule_id: payment_schedule_id || null,
|
|
late_fee_rules: null,
|
|
auto_renewal: false,
|
|
encrypted_data: null,
|
|
created_on: now,
|
|
updated_on: now,
|
|
})
|
|
.returning();
|
|
}
|
|
|
|
// Get leases by property
|
|
export async function getLeasesByProperty(property_id: string) {
|
|
return await db.select()
|
|
.from(leases)
|
|
.where(eq(leases.property_id, property_id))
|
|
.orderBy(leases.created_on);
|
|
}
|
|
|
|
// Get leases by owner (landlord)
|
|
export async function getLeasesByOwner(owner_id: string) {
|
|
return await db.select()
|
|
.from(leases)
|
|
.where(eq(leases.owner_id, owner_id))
|
|
.orderBy(leases.created_on);
|
|
}
|
|
|
|
// Get leases by tenant
|
|
export async function getLeasesByTenant(tenant_id: string) {
|
|
return await db.select()
|
|
.from(leases)
|
|
.where(eq(leases.tenant_id, tenant_id))
|
|
.orderBy(leases.created_on);
|
|
}
|
|
|
|
// Get active leases by owner
|
|
export async function getActiveLeasesByOwner(owner_id: string) {
|
|
return await db.select()
|
|
.from(leases)
|
|
.where(and(
|
|
eq(leases.owner_id, owner_id),
|
|
eq(leases.lease_status, "active"),
|
|
))
|
|
.orderBy(leases.created_on);
|
|
}
|
|
|
|
// Get current lease for a property
|
|
export async function getCurrentLeaseForProperty(property_id: string) {
|
|
const result = await db.select()
|
|
.from(leases)
|
|
.where(and(
|
|
eq(leases.property_id, property_id),
|
|
eq(leases.lease_status, "active"),
|
|
));
|
|
return result[0] || null;
|
|
}
|
|
|
|
// Verify if tenant has an active lease for a property
|
|
export async function verifyTenantForProperty(
|
|
tenant_id: string,
|
|
property_id: string,
|
|
): Promise<boolean> {
|
|
const result = await db.select()
|
|
.from(leases)
|
|
.where(and(
|
|
eq(leases.property_id, property_id),
|
|
eq(leases.tenant_id, tenant_id),
|
|
eq(leases.lease_status, "active"),
|
|
));
|
|
|
|
return result.length > 0;
|
|
}
|
|
|
|
// Get lease by ID
|
|
export async function getLeaseById(id: string) {
|
|
const result = await db.select()
|
|
.from(leases)
|
|
.where(eq(leases.id, id));
|
|
return result[0] || null;
|
|
}
|
|
|
|
// Update lease
|
|
export async function updateLease(
|
|
id: string,
|
|
updates: Partial<Omit<Lease, "id" | "created_on">>,
|
|
) {
|
|
const now = Date.now();
|
|
return await db.update(leases)
|
|
.set({ ...updates, updated_on: now })
|
|
.where(eq(leases.id, id))
|
|
.returning();
|
|
}
|
|
|
|
// Assign tenant to lease
|
|
export async function assignTenantToLease(lease_id: string, tenant_id: string) {
|
|
const now = Date.now();
|
|
return await db.update(leases)
|
|
.set({
|
|
tenant_id,
|
|
lease_status: "active",
|
|
updated_on: now,
|
|
})
|
|
.where(eq(leases.id, lease_id))
|
|
.returning();
|
|
}
|
|
|
|
// Update lease status
|
|
export async function updateLeaseStatus(
|
|
id: string,
|
|
status: "draft" | "active" | "expired" | "terminated",
|
|
) {
|
|
const now = Date.now();
|
|
return await db.update(leases)
|
|
.set({
|
|
lease_status: status,
|
|
updated_on: now,
|
|
})
|
|
.where(eq(leases.id, id))
|
|
.returning();
|
|
}
|
|
|
|
// Set lease document URL
|
|
export async function setLeaseDocument(lease_id: string, document_url: string) {
|
|
const now = Date.now();
|
|
return await db.update(leases)
|
|
.set({
|
|
lease_document_url: document_url,
|
|
updated_on: now,
|
|
})
|
|
.where(eq(leases.id, lease_id))
|
|
.returning();
|
|
}
|
|
|
|
// Set payment schedule
|
|
export async function setPaymentSchedule(
|
|
lease_id: string,
|
|
payment_schedule_id: string,
|
|
) {
|
|
const now = Date.now();
|
|
return await db.update(leases)
|
|
.set({
|
|
payment_schedule_id,
|
|
updated_on: now,
|
|
})
|
|
.where(eq(leases.id, lease_id))
|
|
.returning();
|
|
}
|
|
|
|
// Set late fee rules
|
|
export async function setLateFeeRules(
|
|
lease_id: string,
|
|
late_fee_rules: object,
|
|
) {
|
|
const now = Date.now();
|
|
return await db.update(leases)
|
|
.set({
|
|
late_fee_rules: JSON.stringify(late_fee_rules),
|
|
updated_on: now,
|
|
})
|
|
.where(eq(leases.id, lease_id))
|
|
.returning();
|
|
}
|
|
|
|
// Enable/disable auto renewal
|
|
export async function setAutoRenewal(lease_id: string, auto_renewal: boolean) {
|
|
const now = Date.now();
|
|
return await db.update(leases)
|
|
.set({
|
|
auto_renewal,
|
|
updated_on: now,
|
|
})
|
|
.where(eq(leases.id, lease_id))
|
|
.returning();
|
|
}
|
|
|
|
// Delete lease
|
|
export async function deleteLease(id: string) {
|
|
return await db.delete(leases)
|
|
.where(eq(leases.id, id))
|
|
.returning();
|
|
}
|
|
|
|
// Sign a lease (mark it as signed by a user)
|
|
export async function signLease(lease_id: string, _user_id: string) {
|
|
const now = Date.now();
|
|
return await db.update(leases)
|
|
.set({
|
|
lease_document_url: `signed_${lease_id}_${now}`,
|
|
updated_on: now,
|
|
})
|
|
.where(eq(leases.id, lease_id))
|
|
.returning();
|
|
}
|
|
|
|
// Get lease statistics for an owner
|
|
export async function getLeaseStatsByOwner(owner_id: string) {
|
|
const allLeases = await getLeasesByOwner(owner_id);
|
|
|
|
return {
|
|
total: allLeases.length,
|
|
draft: allLeases.filter((l) => l.lease_status === "draft").length,
|
|
active: allLeases.filter((l) => l.lease_status === "active").length,
|
|
expired: allLeases.filter((l) => l.lease_status === "expired").length,
|
|
terminated: allLeases.filter((l) => l.lease_status === "terminated").length,
|
|
with_tenant: allLeases.filter((l) => l.tenant_id !== null).length,
|
|
without_tenant: allLeases.filter((l) => l.tenant_id === null).length,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Link a lease to a tenant invite. If an inviteId is provided, update that
|
|
* tenant_invites row to reference this lease. Otherwise attempt to find an
|
|
* existing invite by: lease's property and owner and attach lease_id
|
|
* there; if none exists, create a new tenant_invites row linking to: lease.
|
|
*/
|
|
export async function inviteLease(lease_id: string, inviteId?: string) {
|
|
// If an explicit inviteId was provided, update that invite record
|
|
if (inviteId) {
|
|
const now = Date.now();
|
|
return await db.update(tenant_invites)
|
|
.set({ lease_id, updated_on: now })
|
|
.where(eq(tenant_invites.id, inviteId))
|
|
.returning();
|
|
}
|
|
|
|
// Otherwise derive of property and landlord from lease
|
|
const lease = await getLeaseById(lease_id);
|
|
if (!lease) return null;
|
|
|
|
const property_id = lease.property_id;
|
|
const landlord_id = lease.owner_id;
|
|
|
|
if (!property_id || !landlord_id) {
|
|
return null;
|
|
}
|
|
|
|
const existing = await db.select().from(tenant_invites).where(
|
|
and(
|
|
eq(tenant_invites.property_id, property_id),
|
|
eq(tenant_invites.landlord_id, landlord_id),
|
|
),
|
|
);
|
|
|
|
const now = Date.now();
|
|
if (existing.length > 0) {
|
|
return await db.update(tenant_invites)
|
|
.set({ lease_id, updated_on: now })
|
|
.where(eq(tenant_invites.id, existing[0].id))
|
|
.returning();
|
|
}
|
|
|
|
// Create a new invite linking to this lease
|
|
return await db.insert(tenant_invites).values({
|
|
id: crypto.randomUUID(),
|
|
landlord_id,
|
|
property_id,
|
|
lease_id,
|
|
available_on: null,
|
|
invited_emails: JSON.stringify([]),
|
|
created_on: now,
|
|
updated_on: now,
|
|
}).returning();
|
|
}
|
|
|
|
// Request lease approval from tenant
|
|
export async function requestLeaseApproval(
|
|
tenant_id: string,
|
|
property_id: string,
|
|
_invite_id: string,
|
|
) {
|
|
const now = Date.now();
|
|
|
|
// Fetch property to populate lease details
|
|
const property = await getPropertyById(property_id);
|
|
|
|
// Build property address from components
|
|
const propertyAddress = property
|
|
? [
|
|
property.address_1,
|
|
property.address_2,
|
|
property.city,
|
|
property.state,
|
|
property.zip,
|
|
]
|
|
.filter(Boolean)
|
|
.join(", ")
|
|
: "";
|
|
|
|
return await db.insert(leases)
|
|
.values({
|
|
id: crypto.randomUUID(),
|
|
title: property?.title || `Lease for Property ${property_id}`,
|
|
property_id,
|
|
property_address: propertyAddress,
|
|
tenant_id,
|
|
owner_id: null,
|
|
start_date: now,
|
|
end_date: now + 365 * 24 * 60 * 60 * 1000,
|
|
rent: property?.monthly_rent || null,
|
|
security_deposit: property?.security_deposit || null,
|
|
lease_status: "draft",
|
|
approval_status: "pending_approval",
|
|
approval_requested_at: now,
|
|
approved_at: null,
|
|
approved_by: null,
|
|
rejection_reason: null,
|
|
lease_document_url: null,
|
|
payment_schedule_id: null,
|
|
late_fee_rules: null,
|
|
auto_renewal: false,
|
|
encrypted_data: null,
|
|
created_on: now,
|
|
updated_on: now,
|
|
})
|
|
.returning();
|
|
}
|
|
|
|
// Approve a lease request
|
|
export async function approveLease(
|
|
lease_id: string,
|
|
landlord_id: string,
|
|
property_id?: string,
|
|
) {
|
|
const now = Date.now();
|
|
|
|
// If property_id provided, verify landlord owns the property
|
|
// (pending leases have owner_id=null, so we check property ownership)
|
|
if (property_id) {
|
|
const property = await db.select({ owner_id: properties.owner_id })
|
|
.from(properties)
|
|
.where(eq(properties.id, property_id))
|
|
.limit(1);
|
|
|
|
if (!property || property[0]?.owner_id !== landlord_id) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
return await db.update(leases)
|
|
.set({
|
|
approval_status: "approved",
|
|
approved_at: now,
|
|
approved_by: landlord_id,
|
|
owner_id: landlord_id, // Set owner_id on approval
|
|
lease_status: "active",
|
|
updated_on: now,
|
|
})
|
|
.where(eq(leases.id, lease_id))
|
|
.returning();
|
|
}
|
|
|
|
// Reject a lease request
|
|
export async function rejectLease(
|
|
lease_id: string,
|
|
landlord_id: string,
|
|
rejection_reason?: string,
|
|
property_id?: string,
|
|
) {
|
|
const now = Date.now();
|
|
|
|
// If property_id provided, verify landlord owns the property
|
|
// (pending leases have owner_id=null, so we check property ownership)
|
|
if (property_id) {
|
|
const property = await db.select({ owner_id: properties.owner_id })
|
|
.from(properties)
|
|
.where(eq(properties.id, property_id))
|
|
.limit(1);
|
|
|
|
if (!property || property[0]?.owner_id !== landlord_id) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
return await db.update(leases)
|
|
.set({
|
|
approval_status: "rejected",
|
|
rejection_reason: rejection_reason || null,
|
|
updated_on: now,
|
|
})
|
|
.where(eq(leases.id, lease_id))
|
|
.returning();
|
|
}
|
|
|
|
// Get pending lease approvals for a landlord
|
|
// Queries by property ownership since pending leases have owner_id=null
|
|
export async function getPendingApprovals(landlord_id: string) {
|
|
// First get property IDs owned by this landlord
|
|
const landlordProperties = await db.select({ id: properties.id })
|
|
.from(properties)
|
|
.where(eq(properties.owner_id, landlord_id));
|
|
|
|
const propertyIds = landlordProperties.map((p) => p.id);
|
|
|
|
if (propertyIds.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Then get pending leases for those properties
|
|
const pendingLeases = await db.select()
|
|
.from(leases)
|
|
.where(
|
|
and(
|
|
eq(leases.approval_status, "pending_approval"),
|
|
// Only return leases for properties owned by this landlord
|
|
inArray(leases.property_id, propertyIds),
|
|
),
|
|
)
|
|
.orderBy(leases.approval_requested_at);
|
|
|
|
return pendingLeases;
|
|
}
|